Merge pull request #242 from davidmonterocrespo24/feat/picow-internet-bridge
Feat/picow internet bridge
This commit is contained in:
commit
b3558a4c42
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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