feat(cyw43): bridge Pico W DNS/TCP/UDP to the backend for real internet

Wi-Fi sketches on the emulated Pico W associate via the chip's built-in
virtual net (DHCP/ARP answered locally), but outbound traffic had no
route, so DNS/MQTT/HTTP failed with OSError -2.

Wire the emulator's outbound DATA path to the backend picow_net bridge:

- Cyw43Emulator forwards every outbound Ethernet frame EXCEPT DHCP/ARP
  (still answered locally) to firePacketOut -> the WS bridge, which NATs
  DNS/TCP/UDP to the real internet and injects replies back.
- The virtual net stays ON unconditionally and shares the backend's
  subnet, gateway and gateway MAC (10.13.37.0/24, gw 10.13.37.1). Nothing
  is mutually exclusive, so an absent or flaky bridge can never break the
  Wi-Fi association -- it just falls back to no-internet, as before.
- useSimulatorStore opens the bridge (cyw43.connect()) for Wi-Fi sketches.

Validated end to end against a running backend: WiFi connect + DHCP, DNS
resolves example.com, TCP connect + HTTP GET returns 200 OK. Gated e2e in
picow-bridge-e2e.investigate.test.ts (CYW43_BRIDGE_E2E=1).
This commit is contained in:
David Montero 2026-06-13 05:27:49 +02:00
parent 2639f80a22
commit fd0edd8c6d
5 changed files with 148 additions and 25 deletions

View File

@ -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<string, unknown>;
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);
});

View File

@ -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;

View File

@ -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.
}

View File

@ -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,
};

View File

@ -1821,15 +1821,13 @@ export const useSimulatorStore = create<SimulatorState>((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();
}
}
}