Merge pull request #242 from davidmonterocrespo24/feat/picow-internet-bridge

Feat/picow internet bridge
This commit is contained in:
David Montero Crespo 2026-06-13 03:00:13 -03:00 committed by GitHub
commit b3558a4c42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 236 additions and 28 deletions

View File

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

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

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

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