diff --git a/frontend/src/__tests__/cyw43-sdpcm-align.test.ts b/frontend/src/__tests__/cyw43-sdpcm-align.test.ts new file mode 100644 index 00000000..47c85c72 --- /dev/null +++ b/frontend/src/__tests__/cyw43-sdpcm-align.test.ts @@ -0,0 +1,75 @@ +/** + * cyw43-sdpcm-align.test.ts + * + * Regression test for the gSPI word-alignment bug that silently dropped + * DNS/TCP replies on the emulated Pico W. + * + * The CYW43439 F2 (radio frame) channel is word-oriented: the chip always + * drives frames padded up to a 4-byte boundary and the host reads that + * word-aligned length, byte-swapping every 32-bit word on the way in. If the + * emulator emits an SDPCM frame whose backing buffer length is NOT a multiple + * of 4, the host's symmetric per-word swap mangles the final partial word — + * corrupting the last 1-3 bytes of the Ethernet frame. + * + * That went unnoticed for DHCP/ARP (UDP checksum 0 -> lwIP skips the check, + * and the damage lands in trailing option padding) but quietly killed every + * DNS answer and TCP segment (real checksum -> lwIP discards the frame -> + * getaddrinfo()/connect() retry forever). `encodeSdpcm` now pads the buffer to + * a word boundary while keeping the `size` header at the true length. + */ + +import { describe, it, expect } from 'vitest'; +import { encodeSdpcm, decodeSdpcm, SDPCM_HEADER_LEN } from '../simulation/cyw43/sdpcm'; +import { SdpcmChannel } from '../simulation/cyw43/constants'; + +describe('SDPCM word alignment', () => { + // Payload lengths that drive every total-length residue mod 4 once the + // 12-byte header is added (12 is itself a multiple of 4). + for (const payloadLen of [1, 2, 3, 4, 113, 125, 129, 314]) { + it(`pads a ${payloadLen}-byte payload to a 4-byte boundary`, () => { + const payload = new Uint8Array(payloadLen); + for (let i = 0; i < payloadLen; i++) payload[i] = (i * 7 + 1) & 0xff; + const frame = encodeSdpcm({ channel: SdpcmChannel.DATA, sequence: 5, payload }); + + // The backing buffer the chip drives MUST be word-aligned. + expect(frame.length % 4).toBe(0); + + // The `size` header stays the TRUE (unpadded) length so the driver + // parses exactly the real frame and ignores the pad bytes. + const trueSize = SDPCM_HEADER_LEN + payloadLen; + const size = frame[0] | (frame[1] << 8); + expect(size).toBe(trueSize); + expect(frame[2] | (frame[3] << 8)).toBe(~trueSize & 0xffff); + + // decode recovers the exact payload, last byte intact. + const decoded = decodeSdpcm(frame); + expect(decoded).not.toBeNull(); + expect(Array.from(decoded!.payload)).toEqual(Array.from(payload)); + }); + } + + it('survives a full per-word byte-swap round-trip with the last byte intact', () => { + // The real DNS reply payload length the bug bit on: 4-byte BDC + 125-byte + // Ethernet -> total 141, residue 1 mod 4. The last byte (0xf3, tail of the + // second A-record IP) used to be lost. + const payload = new Uint8Array(129); + payload[128] = 0xf3; + const frame = encodeSdpcm({ channel: SdpcmChannel.DATA, sequence: 0x2d, payload }); + expect(frame.length).toBe(144); // 141 padded up to 144 + + // Model the gSPI path: the chip byte-swaps every 32-bit word, the host + // byte-swaps them back. With a word-aligned buffer this is lossless. + const swap = (b: Uint8Array) => { + const out = new Uint8Array(b.length); + for (let i = 0; i < b.length; i += 4) { + out[i] = b[i + 3]; out[i + 1] = b[i + 2]; out[i + 2] = b[i + 1]; out[i + 3] = b[i]; + } + return out; + }; + const roundTripped = swap(swap(frame)); + const decoded = decodeSdpcm(roundTripped); + expect(decoded).not.toBeNull(); + expect(decoded!.payload[128]).toBe(0xf3); + expect(Array.from(decoded!.payload)).toEqual(Array.from(payload)); + }); +}); diff --git a/frontend/src/__tests__/picow-bridge-e2e.investigate.test.ts b/frontend/src/__tests__/picow-bridge-e2e.investigate.test.ts new file mode 100644 index 00000000..c12fbaa5 --- /dev/null +++ b/frontend/src/__tests__/picow-bridge-e2e.investigate.test.ts @@ -0,0 +1,118 @@ +/** + * picow-bridge-e2e.investigate.test.ts (gated: CYW43_BRIDGE_E2E=1) + * Real RP2040Simulator + Cyw43Bridge over a real WebSocket to the RUNNING + * backend picow_net -> real internet. main.py auto-runs from a real LittleFS. + * Logs every chip<->backend packet so a DNS/TCP hang is diagnosable. + */ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { WebSocket as NodeWS } from 'ws'; + +const FW_PATH = '/home/dave/velxio-prod/velxio/frontend/public/firmware/micropython-rp2040w.uf2'; +const WASM_PATH = '/home/dave/velxio-prod/velxio/frontend/node_modules/littlefs/dist/littlefs.wasm'; + +vi.mock('../simulation/MicroPythonLoader', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, getFirmware: async () => new Uint8Array(readFileSync(FW_PATH)) }; +}); +vi.mock('littlefs', async (orig) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actual = (await orig()) as any; + const create = actual.default; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { ...actual, default: (cfg: any = {}) => create({ ...cfg, wasmBinary: new Uint8Array(readFileSync(WASM_PATH)) }) }; +}); + +const MAIN_PY = [ + 'import network, socket, time', + 'w = network.WLAN(network.STA_IF)', + 'w.active(True)', + 'w.connect("Velxio-GUEST", "")', + 'for i in range(80):', + ' if w.isconnected(): break', + ' time.sleep_ms(150)', + 'print("PYIP", w.ifconfig())', + 'try:', + ' ai = socket.getaddrinfo("example.com", 80)[0][-1]', + ' print("PYDNS", ai)', + ' s = socket.socket(); s.connect(ai)', + ' s.send(b"GET / HTTP/1.0\\r\\nHost: example.com\\r\\n\\r\\n")', + ' print("PYHTTP", s.recv(16)); s.close()', + 'except Exception as e:', + ' print("PYNETERR", repr(e))', + 'print("PYDONE")', +].join('\n'); + +function pktDesc(b: Uint8Array): string { + if (b.length < 14) return 'short'; + const et = (b[12] << 8) | b[13]; + if (et === 0x0806) return 'ARP'; + if (et !== 0x0800) return 'eth0x' + et.toString(16); + const proto = b[23], ihl = (b[14] & 0xf) * 4, l4 = 14 + ihl; + if (proto === 1) return 'ICMP'; + if (proto === 17) return `UDP ${(b[l4] << 8) | b[l4 + 1]}->${(b[l4 + 2] << 8) | b[l4 + 3]}`; + if (proto === 6) { const fl = b[l4 + 13]; return `TCP ${(b[l4] << 8) | b[l4 + 1]}->${(b[l4 + 2] << 8) | b[l4 + 3]} fl${fl.toString(16)}`; } + return 'ip-proto' + proto; +} + +describe.skipIf(!process.env.CYW43_BRIDGE_E2E)('Pico W bridge e2e', () => { + it('connects via bridge and fetches example.com', async () => { + const { RP2040Simulator } = await import('../simulation/RP2040Simulator'); + const { PinManager } = await import('../simulation/PinManager'); + const { Cyw43Bridge } = await import('../simulation/cyw43/Cyw43Bridge'); + + const sim = new RP2040Simulator(new PinManager()); + let serial = ''; + const pktlog: string[] = []; + sim.onSerialData = (ch: string) => { serial += ch; if (serial.length > 80000) serial = serial.slice(-40000); }; + + const bridge = new Cyw43Bridge('e2e-board'); + // window only around connect() (computes the WS URL); removed before the + // sim runs so it doesn't accidentally take any browser code path. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).WebSocket = NodeWS; + // Point at a running backend with picow_net enabled (VELXIO_PICOW_NET=1). + // Default = the OSS dev backend (uvicorn --port 8001); override with + // VELXIO_E2E_API_BASE (e.g. the in-container backend exposed on another port). + const apiBase = process.env.VELXIO_E2E_API_BASE || 'http://127.0.0.1:8001/api'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).window = { __VELXIO_API_BASE__: apiBase }; + sim.attachCyw43(bridge); + bridge.wifiEnabled = true; + // wrap send + onPacketIn for logging (after attach set onPacketIn). + const origSend = bridge.sendPacket.bind(bridge); + bridge.sendPacket = (e: Uint8Array) => { + if (pktlog.length < 200) pktlog.push('OUT ' + pktDesc(e)); + return origSend(e); + }; + const innerIn = bridge.onPacketIn!; + bridge.onPacketIn = (p) => { + if (pktlog.length < 200) pktlog.push('IN ' + pktDesc(p.ether)); + return innerIn(p); + }; + bridge.connect(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).window; + + await sim.loadMicroPython([{ name: 'main.py', content: MAIN_PY }]); + const end = Date.now() + 120_000; + while (Date.now() < end) { + // Big chunks during the bring-up; once Wi-Fi is up and the sketch is + // doing DNS/TCP, yield to the WS far more often so the bridge round-trips + // (real-time) keep up with lwIP's emulated DNS/connect timers. + const n = serial.includes('PYIP') ? 1 : 16; + for (let i = 0; i < n; i++) sim.runFrameForTime(n === 1 ? 10 : 50); + if (serial.includes('PYDONE')) break; + await new Promise((r) => setTimeout(r, 0)); + } + try { bridge.disconnect(); } catch { /* noop */ } + try { sim.stop(); } catch { /* noop */ } + writeFileSync('/tmp/bridge-e2e-serial.txt', serial + '\n\n=== PKTLOG ===\n' + pktlog.join('\n')); + console.log('\n===== E2E =====\n' + + serial.split('\n').filter((l) => l.startsWith('PY')).join('\n') + + '\n--- packets ---\n' + pktlog.slice(0, 40).join('\n')); + + expect(serial).toMatch(/PYIP .*10\.13\.37\.42/); + expect(serial).toContain('PYHTTP'); + }, 170_000); +}); diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index 3570f179..da4cac84 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -427,11 +427,11 @@ export class RP2040Simulator { if (bridge) { bridge.onPacketIn = (p) => emu.injectPacket(p.ether); } - // NOTE: the built-in virtual DHCP/ARP net stays ON. The backend bridge is - // deferred (not validated end to end yet) and is left dormant by the store, - // so the virtual net is the only network responder — the STA associates and - // gets a link-local IP locally (no outbound internet). When the bridge is - // wired and connected, switch with emu.setVirtualNet(null) to cede the net. + // The built-in virtual net stays ON and answers DHCP/ARP locally so Wi-Fi + // always associates (10.13.37.42), with or without a backend. The emulator + // forwards only non-DHCP/ARP traffic (DNS/TCP/UDP) to the bridge — addressed + // to the same gateway — for real-internet NAT. No mutually-exclusive switch, + // so a flaky/absent bridge can never break the Wi-Fi association. this.installCyw43PioHooks(); return emu; diff --git a/frontend/src/simulation/cyw43/Cyw43Emulator.ts b/frontend/src/simulation/cyw43/Cyw43Emulator.ts index 7fe2c9f1..6a55bc8c 100644 --- a/frontend/src/simulation/cyw43/Cyw43Emulator.ts +++ b/frontend/src/simulation/cyw43/Cyw43Emulator.ts @@ -499,18 +499,20 @@ export class Cyw43Emulator { if (channel === SdpcmChannel.CONTROL) { this.handleIoctl(payload); } else if (channel === SdpcmChannel.DATA) { - // Outbound Ethernet frame. Strip the 4-byte BDC header the driver - // prepends, then forward to any external bridge. + // Outbound Ethernet frame. Strip the 4-byte BDC header the driver prepends. const BDC = 4; const ether = payload.length >= BDC ? new Uint8Array(payload.subarray(BDC)) : payload; - this.firePacketOut(ether); - // Self-contained virtual network: answer DHCP / ARP so a freshly-joined - // STA gets an IP and the link advances NOIP -> UP (isconnected == True). - // Disabled when an external bridge owns the network. + // DHCP/ARP are answered LOCALLY by the virtual net so the STA always + // associates and gets an IP (10.13.37.42) even with no backend — never + // forwarded, to avoid a double response if a bridge is also attached. + // Everything else (DNS/TCP/UDP, addressed to the same gateway) goes to the + // external bridge for real-internet NAT. With no bridge it's a no-op and + // those just fail, exactly like the pre-bridge self-contained net. if (this.virtualNet) { const reply = virtualNetReply(this.virtualNet, ether); - if (reply) this.injectPacket(reply); + if (reply) { this.injectPacket(reply); return; } } + this.firePacketOut(ether); } // Channel 1 (events) is chip → host only. } diff --git a/frontend/src/simulation/cyw43/sdpcm.ts b/frontend/src/simulation/cyw43/sdpcm.ts index eab4df51..03c588a6 100644 --- a/frontend/src/simulation/cyw43/sdpcm.ts +++ b/frontend/src/simulation/cyw43/sdpcm.ts @@ -42,11 +42,21 @@ export interface SdpcmFrame { /** Build an SDPCM frame for a given channel. */ export function encodeSdpcm(opts: SdpcmFrame): Uint8Array { - const total = SDPCM_HEADER_LEN + opts.payload.length; + const size = SDPCM_HEADER_LEN + opts.payload.length; + // gSPI / F2 is word-oriented: the real CYW43439 always drives frames padded + // up to a 4-byte boundary, and the host reads that word-aligned length. The + // emulator's F2 read path byte-swaps every 32-bit word (encodeFrameWords); + // if the buffer length is NOT a multiple of 4 the final partial word gets + // mangled by the host's symmetric swap, corrupting the last 1-3 bytes of the + // frame. That goes unnoticed for DHCP/ARP (UDP checksum 0, trailing pad) but + // silently drops DNS/TCP replies (real checksum -> lwIP discards). Pad the + // backing buffer to a word boundary; the `size` field stays the true length + // so the driver still parses exactly the real frame and ignores the pad. + const total = (size + 3) & ~3; const buf = new Uint8Array(total); const dv = new DataView(buf.buffer); - dv.setUint16(0, total, true); - dv.setUint16(2, ~total & 0xffff, true); + dv.setUint16(0, size, true); + dv.setUint16(2, ~size & 0xffff, true); buf[4] = opts.sequence & 0xff; buf[5] = opts.channel & 0xff; buf[6] = 0; // next_length diff --git a/frontend/src/simulation/cyw43/virtualNet.ts b/frontend/src/simulation/cyw43/virtualNet.ts index 786e555b..93f41e46 100644 --- a/frontend/src/simulation/cyw43/virtualNet.ts +++ b/frontend/src/simulation/cyw43/virtualNet.ts @@ -25,12 +25,17 @@ export interface VirtualNetConfig { leaseSecs: number; } +// Aligned with the backend picow_net stack (consts.py): same subnet, gateway +// and gateway MAC. That way DHCP/ARP can be answered LOCALLY (so Wi-Fi always +// associates, even with no backend) while DNS/TCP/UDP — addressed to this same +// gateway 10.13.37.1 — are forwarded to the backend NAT for real internet. The +// backend NATs by the chip's source IP, so the addresses must match. export const DEFAULT_VNET: VirtualNetConfig = { - serverIp: [192, 168, 4, 1], - clientIp: [192, 168, 4, 2], + serverIp: [10, 13, 37, 1], + clientIp: [10, 13, 37, 42], netmask: [255, 255, 255, 0], - dnsIp: [192, 168, 4, 1], - apMac: new Uint8Array([0x02, 0x56, 0x45, 0x4c, 0x58, 0x00]), // locally-administered "VELX" + dnsIp: [10, 13, 37, 1], + apMac: new Uint8Array([0x02, 0x42, 0xda, 0x42, 0xff, 0xff]), // backend GATEWAY_MAC leaseSecs: 86400, }; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index fa8de92d..149fc841 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -1821,15 +1821,13 @@ export const useSimulatorStore = create((set, get) => { /#include\s*[<"]WiFi\.h[>"]/.test(f.content) || /WiFi\.begin\(/.test(f.content), ); - // Backend internet bridge (picow_net: DHCP + NAT to the real - // internet) is deferred until validated end to end. Leaving the - // bridge dormant means the chip emulator's built-in virtual DHCP/ARP - // net handles the association locally: WiFi connects + gets a - // link-local IP (isconnected True), but outbound traffic (MQTT/HTTP) - // has no route yet. Re-enable when the bridge is wired: - // cyw43.wifiEnabled = hasWifi; cyw43.connect(); - void hasWifi; - void cyw43; + // Open the backend internet bridge (picow_net: DNS + TCP/UDP NAT to + // the real internet) for Wi-Fi sketches. The chip's built-in virtual + // net always answers DHCP/ARP locally so Wi-Fi associates even if the + // bridge is absent/flaky; only DNS/TCP/UDP ride the bridge. Nothing + // is mutually exclusive, so the bridge can never break association. + cyw43.wifiEnabled = hasWifi; + cyw43.connect(); } } }