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:
parent
2639f80a22
commit
fd0edd8c6d
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue