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/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(); } } }