Merge pull request #240 from davidmonterocrespo24/wip/picow-cyw43-emulation

Wip/picow cyw43 emulation
This commit is contained in:
David Montero Crespo 2026-06-12 22:42:13 -03:00 committed by GitHub
commit 2f4e205a59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 895 additions and 50 deletions

View File

@ -0,0 +1,115 @@
/**
* cyw43-virtualnet.test.ts
*
* Unit tests for the self-contained virtual network (DHCP + ARP responder)
* that lets a joined Pico W STA reach CYW43_LINK_UP with no backend.
*/
import { describe, it, expect } from 'vitest';
import { DEFAULT_VNET, virtualNetReply } from '../simulation/cyw43/virtualNet';
const CLIENT_MAC = new Uint8Array([0x28, 0xcd, 0xc1, 0x01, 0x02, 0x03]);
function ip16(sum: number): number {
while (sum >> 16) sum = (sum & 0xffff) + (sum >>> 16);
return (~sum) & 0xffff;
}
/** Minimal DHCP DISCOVER/REQUEST the way lwIP frames it. */
function buildDhcpRequest(msgType: number): Uint8Array {
const ETH = 14, IP = 20, UDP = 8;
const dhcp = new Uint8Array(240 + 7);
dhcp[0] = 1; // BOOTREQUEST
dhcp[1] = 1; dhcp[2] = 6;
dhcp.set([0x11, 0x22, 0x33, 0x44], 4); // xid
dhcp.set(CLIENT_MAC, 28); // chaddr
dhcp.set([0x63, 0x82, 0x53, 0x63], 236); // magic cookie
dhcp.set([53, 1, msgType, 0xff], 240); // option 53 + end
const total = ETH + IP + UDP + dhcp.length;
const buf = new Uint8Array(total);
const dv = new DataView(buf.buffer);
for (let i = 0; i < 6; i++) buf[i] = 0xff; // dst broadcast
buf.set(CLIENT_MAC, 6);
dv.setUint16(12, 0x0800, false);
buf[ETH] = 0x45;
dv.setUint16(ETH + 2, IP + UDP + dhcp.length, false);
buf[ETH + 9] = 17; // UDP
let s = 0;
for (let i = 0; i < IP; i += 2) s += dv.getUint16(ETH + i, false);
dv.setUint16(ETH + 10, ip16(s), false);
dv.setUint16(ETH + IP + 0, 68, false); // src port
dv.setUint16(ETH + IP + 2, 67, false); // dst port (DHCP server)
dv.setUint16(ETH + IP + 4, UDP + dhcp.length, false);
buf.set(dhcp, ETH + IP + UDP);
return buf;
}
function dhcpReplyType(reply: Uint8Array): number {
// reply = ETH(14)+IP(20)+UDP(8)+DHCP; option 53 is right after the magic cookie.
const dhcp = 14 + 20 + 8;
let i = dhcp + 236 + 4;
while (i < reply.length) {
if (reply[i] === 0xff) break;
if (reply[i] === 0) { i++; continue; }
if (reply[i] === 53) return reply[i + 2];
i += 2 + reply[i + 1];
}
return 0;
}
describe('cyw43 virtual network', () => {
it('answers DHCP DISCOVER with an OFFER carrying the client IP', () => {
const reply = virtualNetReply(DEFAULT_VNET, buildDhcpRequest(1))!;
expect(reply).not.toBeNull();
expect(dhcpReplyType(reply)).toBe(2); // DHCPOFFER
// yiaddr (offered address) sits at DHCP offset 16.
const yi = reply.subarray(14 + 20 + 8 + 16, 14 + 20 + 8 + 20);
expect(Array.from(yi)).toEqual([...DEFAULT_VNET.clientIp]);
// Unicast back to the requesting STA.
expect(Array.from(reply.subarray(0, 6))).toEqual([...CLIENT_MAC]);
});
it('answers DHCP REQUEST with an ACK', () => {
const reply = virtualNetReply(DEFAULT_VNET, buildDhcpRequest(3))!;
expect(dhcpReplyType(reply)).toBe(5); // DHCPACK
});
it('answers an ARP who-has the gateway with the AP MAC', () => {
const a = 14;
const req = new Uint8Array(a + 28);
const dv = new DataView(req.buffer);
for (let i = 0; i < 6; i++) req[i] = 0xff;
req.set(CLIENT_MAC, 6);
dv.setUint16(12, 0x0806, false); // ARP
dv.setUint16(a + 0, 1, false); // htype
dv.setUint16(a + 2, 0x0800, false);
req[a + 4] = 6; req[a + 5] = 4;
dv.setUint16(a + 6, 1, false); // request
req.set(CLIENT_MAC, a + 8);
req.set(DEFAULT_VNET.clientIp, a + 14);
req.set(DEFAULT_VNET.serverIp, a + 24); // who-has the gateway
const reply = virtualNetReply(DEFAULT_VNET, req)!;
expect(reply).not.toBeNull();
expect(dv.getUint16(12, false)).toBe(0x0806);
const rdv = new DataView(reply.buffer);
expect(rdv.getUint16(a + 6, false)).toBe(2); // ARP reply
// sender HW = AP MAC, sender IP = gateway
expect(Array.from(reply.subarray(a + 8, a + 14))).toEqual([...DEFAULT_VNET.apMac]);
expect(Array.from(reply.subarray(a + 14, a + 18))).toEqual([...DEFAULT_VNET.serverIp]);
});
it('ignores non-DHCP UDP and ARP for other addresses', () => {
// ARP for some other IP -> no reply.
const a = 14;
const req = new Uint8Array(a + 28);
const dv = new DataView(req.buffer);
dv.setUint16(12, 0x0806, false);
dv.setUint16(a + 0, 1, false);
dv.setUint16(a + 2, 0x0800, false);
req[a + 4] = 6; req[a + 5] = 4;
dv.setUint16(a + 6, 1, false);
req.set([8, 8, 8, 8], a + 24); // not the gateway
expect(virtualNetReply(DEFAULT_VNET, req)).toBeNull();
});
});

View File

@ -86,7 +86,21 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
}>((resolve) => {
const sim = new Simulator();
sim.rp2040.loadBootrom(bootromB1);
sim.rp2040.logger = new ConsoleLogger(LogLevel.Error);
// Custom non-throwing logger: capture CPU faults (e.g. unaligned reads)
// with the PC so we can locate corruption WITHOUT aborting the run.
void LogLevel; void ConsoleLogger;
let faultCount = 0;
const faultLog: string[] = [];
sim.rp2040.logger = {
debug() {}, info() {}, warn() {},
error: (_comp: string, msg: string) => {
faultCount++;
if (faultLog.length < 25) {
const pc = ((sim.rp2040 as any).core?.PC ?? 0) >>> 0;
faultLog.push(`PC=0x${pc.toString(16)} ${msg}`);
}
},
} as any;
const fwBlocks = loadUF2(new Uint8Array(readFileSync(FW_PATH)), sim.rp2040.flash);
let usbConnected = false;
@ -104,11 +118,24 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
const rawWords: number[] = [];
const trace: string[] = [];
const f2Log: string[] = []; // F2/IOCTL transfers, NOT subject to the ring
const postF2: string[] = []; // verbatim trace from the first F2 write onward
let f2Seen = false;
let restartsAtClm = -1; // restartCount at the moment clm_load goes out
const funcHist = [0, 0, 0, 0]; // count of decoded gSPI functions F0..F3
const cmdCounts = new Map<string, number>();
let ledOn = false;
let packetsOut = 0;
const packetLog: string[] = [];
chip.onLed((e) => { ledOn = e.on; trace.push(`** LED ${e.on ? 'ON' : 'OFF'} **`); });
chip.onConnect((e) => trace.push(`** WIFI CONNECT ssid=${e.ssid} **`));
chip.onPacketOut((e) => {
packetsOut++;
if (packetLog.length < 12) {
const et = e.ether.length >= 14 ? ((e.ether[12] << 8) | e.ether[13]) : 0;
packetLog.push(`pkt ${e.ether.length}B ethertype=0x${et.toString(16)}` +
(et === 0x0806 ? ' ARP' : et === 0x0800 ? ' IPv4' : ''));
}
});
const key = (c: Cyw43Cmd) =>
`${c.write ? 'WR' : 'RD'} F${c.function} 0x${c.address.toString(16)} len=${c.length}`;
@ -131,6 +158,7 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
if (ev.kind === 'header') {
const k = key(ev.cmd);
cmdCounts.set(k, (cmdCounts.get(k) ?? 0) + 1);
if (f2Seen && postF2.length < 400) postF2.push(`HDR ${formatCmd(ev.cmd)}`);
} else if (ev.kind === 'payload') {
// Histogram of decoded functions + capture EVERY F2 transfer.
funcHist[ev.cmd.function & 3]++;
@ -140,7 +168,19 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
if (chip.debugInboundCount() > 0) statusReadsWithPkt++;
}
if (ev.cmd.function === 2 && f2Log.length < 60) {
f2Log.push(`${ev.cmd.write ? 'WR' : 'RD'} F2 a=0x${ev.cmd.address.toString(16)} len=${ev.payload.length || ev.readBytes}`);
const head = Array.from(ev.payload.slice(0, 32))
.map((b) => b.toString(16).padStart(2, '0')).join(' ');
f2Log.push(`${ev.cmd.write ? 'WR' : 'RD'} F2 a=0x${ev.cmd.address.toString(16)} len=${ev.payload.length || ev.readBytes}` +
(ev.cmd.write ? `\n bytes: ${head}` : ''));
}
if (ev.cmd.function === 2 && ev.cmd.write && !f2Seen) { f2Seen = true; restartsAtClm = restartCount; }
// Once clm_load (first F2 write) goes out, capture EVERYTHING verbatim
// (no ring) so we can see exactly where the driver stalls afterward.
if (f2Seen && postF2.length < 400) {
postF2.push(`${formatCmd(ev.cmd)} rx=${ev.readBytes}` +
(ev.cmd.write && ev.payload.length > 0 && ev.payload.length < 8
? ` =0x${((ev.payload[0] | (ev.payload[1] << 8) | (ev.payload[2] << 16) | (ev.payload[3] << 24)) >>> 0).toString(16)}`
: ''));
}
// Skip the ~3500 firmware-block writes (len>=64) AND the backplane
// window-address writes (0x1000a/b/c) that bracket each block —
@ -164,6 +204,11 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
.map((b) => b.toString(16).padStart(2, '0')).join(' ');
trace.push(`<- (${reply.length}B) ${hex}${reply.length > 8 ? ' ...' : ''}`);
}
if (f2Seen && postF2.length < 400) {
const hex = Array.from(reply.slice(0, 16))
.map((b) => b.toString(16).padStart(2, '0')).join(' ');
postF2.push(` <- (${reply.length}B) ${hex}${reply.length > 16 ? ' ...' : ''}`);
}
}
}
}
@ -171,17 +216,52 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
let rxPulled = 0;
let restartCount = 0;
let txPulls = 0;
let smSteps = 0;
for (const pio of (sim.rp2040 as any).pio) {
for (const sm of pio.machines) {
const tx = sm.txFIFO;
if (!tx) continue;
const orig = tx.push.bind(tx);
// Make the TX FIFO NON-DROPPING by swapping its fixed 4-deep backing
// for an unbounded queue. rp2040js's FIFO.push silently drops when
// `used === length` (the DMA outruns the PIO drain on large bursts).
// Real hardware paces the DMA with DREQ so it never drops; the dropped
// words corrupt the 260-word F2 IOCTL writes (clm_load, ioctls), which
// is why no F2 payload ever frames. An unbounded queue retains every
// word so the PIO drains the complete stream — keeping the sniffer
// (hooked on push) and the driver's view identical (no phantom reads).
// Head-pointer queue: pull() is O(1) amortized (NO Array.shift, which is
// O(n) and turned the whole thing O(n^2) — the firmware download stalled).
const q: number[] = [];
let head = 0;
Object.defineProperty(tx, 'full', { get: () => false, configurable: true });
Object.defineProperty(tx, 'empty', { get: () => head >= q.length, configurable: true });
Object.defineProperty(tx, 'itemCount', { get: () => q.length - head, configurable: true });
tx.peek = () => (head < q.length ? q[head] : 0);
tx.reset = () => { q.length = 0; head = 0; };
tx.push = (v: number) => {
pushCount++;
rawWords.push(v >>> 0);
if (rawWords.length > 600) rawWords.splice(0, rawWords.length - 400); // ring
// Firmware/backplane bulk-write data: discard most of it (keep up to
// 4 words so the PIO drains a little then raises TXSTALL, which is all
// the driver's write path waits for). This fast-paths the ~224 KB
// download instead of bit-banging every word through the PIO. F2
// (IOCTL) writes and all count/command words go through feedWord and
// are retained in full.
if (sniffer.inDiscardableWriteData()) {
if (q.length - head < 4) q.push(v >>> 0);
return;
}
feedWord(v, sm);
return orig(v);
q.push(v >>> 0);
};
tx.pull = () => {
txPulls++;
if (head >= q.length) return 0;
const v = q[head++];
if (head > 8192 && head * 2 > q.length) { q.splice(0, head); head = 0; } // compact
return v;
};
// Reset the sniffer at each transfer boundary: cyw43_spi_transfer
// calls pio_sm_restart before pushing the count words, so this keeps
@ -203,8 +283,48 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
};
}
}
// Crank the PIO step-rate. rp2040js runs the PIO on its own setTimeout
// loop at only 1000 steps/tick (pio.run) while the CPU's execute() burns
// 1,000,000 instructions/tick — so a CPU tick (~hundreds of ms wall)
// starves the PIO, which only gets 1000 steps between ticks. With a
// non-dropping FIFO the PIO must bit-bang every firmware word, making the
// ~224KB download take 1000x too long. The gSPI PIO runs near the system
// clock (clkdiv ~1-2), so let it do up to 1M steps/tick, breaking early
// once all TX FIFOs are drained to avoid spinning when idle.
if (typeof pio.run === 'function') {
pio.run = () => {
// Cap sized for the largest real transfer (260-word F2 IOCTL write =
// ~16k steps); firmware data is dropped so it never needs deep drain.
// Break out fast once all TX FIFOs are empty (check every 64 steps,
// require a couple of empty checks so the PIO still raises TXSTALL).
let idleChecks = 0;
for (let i = 0; i < 64_000 && !pio.stopped; i++) {
pio.step();
if ((i & 63) === 0) {
let pending = false;
for (const m of pio.machines) {
if (m.txFIFO && !m.txFIFO.empty) { pending = true; break; }
}
if (pending) { idleChecks = 0; } else if (++idleChecks >= 2) break;
}
}
if (!pio.stopped) pio.runTimer = setTimeout(() => pio.run(), 0);
};
}
}
// Drive the WL_HOST_WAKE line (GPIO24 on Pico W). The driver's
// poll_device gates ALL bus access on this pin being high until it has
// received its first packet (cyw43_ll.c had_successful_packet). Without
// it, the first IOCTL (clm_load) is sent but the driver then polls
// forever getting -1 and never reads the response. Active-high.
let hostWakeHigh = false;
chip.onHostWake((active: boolean) => {
hostWakeHigh = active;
try { (sim.rp2040 as any).gpio[24].setInputValue(active); } catch { /* noop */ }
});
void hostWakeHigh;
// ── raw REPL injection (mirrors test_micropython_pico.mjs) ──
const cdc = new USBCDC(sim.rp2040.usbCtrl);
let serial = '';
@ -253,11 +373,40 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
};
let finished = false;
const deadline = setTimeout(finish, 60_000);
// Sample the CPU PC between sim ticks to find busy-wait loops (the driver
// blocks in active(True) with no bus activity, so a PC histogram localizes
// the stuck loop — DMA wait, TXSTALL wait, or a delay).
const pcHist = new Map<number, number>();
const ipsrHist = new Map<number, number>();
const pcSampler = setInterval(() => {
const core = (sim.rp2040 as any).core;
const pc = (core?.PC ?? 0) >>> 0;
pcHist.set(pc, (pcHist.get(pc) ?? 0) + 1);
const ipsr = (core?.IPSR ?? 0) & 0x3f; // 0=thread, else exception number
ipsrHist.set(ipsr, (ipsrHist.get(ipsr) ?? 0) + 1);
}, 20);
const deadline = setTimeout(finish, 165_000);
function finish() {
if (finished) return;
finished = true;
clearTimeout(deadline);
clearInterval(pcSampler);
// Snapshot the PIO/SM state BEFORE stopping — this is the deadlock state.
let pioState = '';
try {
let pi = 0;
for (const pio of (sim.rp2040 as any).pio) {
pioState += `PIO${pi} stopped=${pio.stopped} fdebug=0x${(pio.fdebug >>> 0).toString(16)} txStall=0x${(pio.txStall >>> 0).toString(16)}\n`;
let si = 0;
for (const sm of pio.machines) {
const tx = sm.txFIFO;
pioState += ` SM${si} en=${sm.enabled} waiting=${sm.waiting} waitType=${sm.waitType} ` +
`pc=${sm.pc ?? '?'} txDepth=${tx ? tx.itemCount : '?'} rxDepth=${sm.rxFIFO ? sm.rxFIFO.itemCount : '?'}\n`;
si++;
}
pi++;
}
} catch (e) { pioState = `pioState err: ${String(e)}`; }
try { sim.stop(); } catch { /* noop */ }
const polls = [...cmdCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12);
const report =
@ -268,9 +417,23 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
'===== TOP COMMAND COUNTS (poll loops) =====\n' +
polls.map(([k, n]) => ` ${String(n).padStart(6)} ${k}`).join('\n') + '\n\n' +
`===== FUNCTION HISTOGRAM F0..F3 = ${funcHist.join(',')} =====\n` +
`===== initInbound=${initInbound} statusReads=${statusReads} statusReadsWithPkt=${statusReadsWithPkt} finalInbound=${chip.debugInboundCount()} restarts=${restartCount} =====\n\n` +
`===== initInbound=${initInbound} statusReads=${statusReads} statusReadsWithPkt=${statusReadsWithPkt} finalInbound=${chip.debugInboundCount()} restarts=${restartCount} =====\n` +
`===== pushCount=${pushCount} txPulls=${txPulls} smSteps=${smSteps} =====\n` +
`===== IOCTL ${chip.debugIoctlStats()} =====\n` +
'===== IOCTL SEQUENCE =====\n' + chip.debugIoctlLog().map((s, i) => ` ${i}: ${s}`).join('\n') + '\n' +
`===== restartsAtClm=${restartsAtClm} restartsFinal=${restartCount} rxPulled=${rxPulled} =====\n` +
`===== packetsOut=${packetsOut} =====\n` + packetLog.map((s) => ` ${s}`).join('\n') + '\n' +
`===== CPU FAULTS faultCount=${faultCount} =====\n` + faultLog.join('\n') + '\n' +
'===== HOT PCs (busy-wait localization) =====\n' +
[...pcHist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)
.map(([pc, n]) => ` ${String(n).padStart(6)} PC=0x${pc.toString(16)}`).join('\n') + '\n' +
'===== IPSR (0=thread, else exception#) =====\n' +
[...ipsrHist.entries()].sort((a, b) => b[1] - a[1])
.map(([ipsr, n]) => ` ${String(n).padStart(6)} IPSR=${ipsr}`).join('\n') + '\n\n' +
'===== PIO/SM STATE AT DEADLINE (deadlock) =====\n' + pioState + '\n' +
'===== F2/IOCTL TRANSFERS (total seen, non-ring) =====\n' +
`count=${f2Log.length}\n` + f2Log.join('\n') + '\n\n' +
'===== POST-CLM_LOAD (verbatim from first F2 write) =====\n' + postF2.join('\n') + '\n\n' +
'===== TRACE TAIL (post-firmware) =====\n' + trace.slice(-120).join('\n') + '\n\n' +
'===== LAST RAW TX WORDS (hex) — the end of the run =====\n' +
rawWords.slice(-70).map((w) => w.toString(16).padStart(8, '0')).join(' ') + '\n';
@ -297,5 +460,5 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
console.log('\n===== reachedImport=' + result.reachedImport + ' reachedActive=' + result.reachedActive + ' =====');
expect(result.trace.length).toBeGreaterThan(0);
}, 120_000);
}, 185_000);
});

View File

@ -114,7 +114,9 @@ describe('Cyw43Emulator IOCTL — SET_SSID Velxio-GUEST', () => {
const events = pushIoctl(chip, WLC.SET_SSID, payload, 1);
expect(chip.getLinkState()).toBe('up');
expect(events.some((e) => e.type === WLC_E.LINK && e.reason === 1)).toBe(true);
// Link-up is signalled by the LINK event's flags bit 0 (the driver checks
// ev->flags & 1, not the reason), plus a WLC_SUP_KEYED supplicant event.
expect(events.some((e) => e.type === WLC_E.LINK && (e.flags & 1) === 1)).toBe(true);
expect(events.some((e) => e.type === WLC_E.SET_SSID && e.status === 0)).toBe(true);
});
});
@ -187,10 +189,10 @@ function pushIoctl(
cmd: number,
payload: Uint8Array,
isSet: number,
): Array<{ type: number; status: number; reason: number; data: Uint8Array }> {
): Array<{ type: number; status: number; reason: number; flags: number; data: Uint8Array }> {
const sdpcm = encodeIoctlRequest(0, cmd, isSet, 0, payload);
chip.onCommand(makeHdr({ write: true, func: 2, addr: 0, length: sdpcm.length, inc: true }), sdpcm);
const events: Array<{ type: number; status: number; reason: number; data: Uint8Array }> = [];
const events: Array<{ type: number; status: number; reason: number; flags: number; data: Uint8Array }> = [];
for (let i = 0; i < 32; i++) {
const out = chip.onCommand(makeHdr({ write: false, func: 2, addr: 0, length: 1600, inc: true }), new Uint8Array(0))!;
if (!out || out.every((b) => b === 0)) break;
@ -198,7 +200,7 @@ function pushIoctl(
if (!f) break;
if (f.channel === SdpcmChannel.EVENT) {
const ev = decodeEventBody(f.payload);
if (ev) events.push({ type: ev.eventType, status: ev.status, reason: ev.reason, data: ev.data });
if (ev) events.push({ type: ev.eventType, status: ev.status, reason: ev.reason, flags: ev.flags, data: ev.data });
} else if (f.channel === SdpcmChannel.CONTROL) {
decodeCdc(f.payload); // ignore — we just care about events
}

View File

@ -0,0 +1,103 @@
/**
* picow-cyw43-prod-sim.investigate.test.ts
*
* PRODUCTION-PATH validation (not CI — run explicitly).
*
* Unlike picow-cyw43-boot-harness.investigate.test.ts (which wires the cyw43
* emulator to a raw rp2040js Simulator with its own FIFO/PIO plumbing), this
* drives the REAL `RP2040Simulator` — the exact code that runs in the browser:
* attachCyw43() + installCyw43PioHooks() (non-dropping FIFO, firmware fast-path,
* host-wake GPIO24) + the lockstep PIO stepping in runFrameForTime(). It boots
* the real Pico W firmware, injects a WiFi-connect snippet via the raw REPL, and
* asserts the link reaches isconnected()==True.
*
* Run:
* CYW43_PROD_HARNESS=1 npx vitest run \
* src/__tests__/picow-cyw43-prod-sim.investigate.test.ts
*/
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'node:fs';
const FW_PATH =
'/home/dave/velxio-prod/velxio/frontend/public/firmware/micropython-rp2040w.uf2';
// getFirmware = IndexedDB/fetch (browser only) -> serve from disk.
// loadUserFiles = LittleFS WASM fetch (browser only) -> no-op; we inject the
// WiFi snippet over the raw REPL instead of writing main.py to the filesystem.
vi.mock('../simulation/MicroPythonLoader', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
getFirmware: async () => new Uint8Array(readFileSync(FW_PATH)),
loadUserFiles: async () => { /* skip LittleFS */ },
};
});
const INJECT_CODE = [
'import network, time',
'print("PYBOOT")',
'w = network.WLAN(network.STA_IF)',
'w.active(True)',
'print("ACTIVE", w.active())',
'w.connect("Velxio-GUEST", "")',
'ok = False',
'for i in range(100):',
' if w.isconnected():',
' ok = True',
' print("CONN_OK", w.ifconfig()[0])',
' break',
' time.sleep_ms(150)',
'if not ok:',
' print("CONN_TIMEOUT status", w.status())',
'print("MAINPY_DONE")',
].join('\n');
describe.skipIf(!process.env.CYW43_PROD_HARNESS)('Pico W cyw43 — production RP2040Simulator path', () => {
it('boots, connects WiFi, and reaches isconnected()', async () => {
const { RP2040Simulator } = await import('../simulation/RP2040Simulator');
const { PinManager } = await import('../simulation/PinManager');
const sim = new RP2040Simulator(new PinManager());
let serial = '';
let buf = '';
sim.attachCyw43(); // selects Pico W firmware + registers host-wake listener
await sim.loadMicroPython([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cdc = (sim as any).usbCDC as { sendSerialByte: (b: number) => void } | null;
expect(cdc).toBeTruthy();
const send = (s: string) => { for (const c of s) cdc!.sendSerialByte(c.charCodeAt(0)); };
// Raw-REPL injection state machine, fed by the serial stream.
let state: 'idle' | 'prompt' | 'raw' | 'sent' = 'idle';
sim.onSerialData = (ch: string) => {
serial += ch; buf += ch;
if (serial.length > 60000) serial = serial.slice(-20000);
if (state === 'idle' && buf.includes('>>>')) {
state = 'prompt'; buf = '';
cdc!.sendSerialByte(0x01); // Ctrl-A -> raw REPL
} else if (state === 'prompt' && buf.includes('raw REPL')) {
state = 'raw'; buf = '';
send(INJECT_CODE);
cdc!.sendSerialByte(0x04); // Ctrl-D -> execute
state = 'sent';
}
};
const deadline = Date.now() + 175_000;
let frames = 0;
while (Date.now() < deadline) {
for (let i = 0; i < 16; i++) { sim.runFrameForTime(50); frames++; }
if (serial.includes('MAINPY_DONE') || serial.includes('CONN_OK')) break;
await new Promise((r) => setTimeout(r, 0));
}
try { sim.stop(); } catch { /* noop */ }
// eslint-disable-next-line no-console
console.log(`\n===== PROD-SIM SERIAL (frames=${frames}, state=${state}) =====\n` + serial.slice(-1500));
expect(serial).toContain('PYBOOT');
expect(serial).toContain('CONN_OK');
}, 200_000);
});

View File

@ -291,7 +291,7 @@ export class RP2040Simulator {
// 2. Create fresh RP2040 instance
this.rp2040 = new RP2040();
this.rp2040.logger = new ConsoleLogger(LogLevel.Error);
this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false);
this.rp2040.loadBootrom(bootromB1);
// 3. Load UF2 firmware into flash
@ -416,10 +416,22 @@ export class RP2040Simulator {
emu.onPacketOut((ev: PacketOutEvent) => {
this.cyw43Bridge?.sendPacket(ev.ether);
});
// Drive WL_HOST_WAKE (GPIO24, active-high). The driver gates poll_device on
// this pin until it has received its first packet, so without it the first
// IOCTL response is never read and wifi_on stalls.
emu.onHostWake((active: boolean) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
try { (this.rp2040 as any)?.gpio?.[24]?.setInputValue(active); } catch { /* noop */ }
});
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.
this.installCyw43PioHooks();
return emu;
@ -453,13 +465,55 @@ export class RP2040Simulator {
const tx = sm.txFIFO;
const rx = sm.rxFIFO;
if (!tx || !rx) continue;
const sniffer = this.cyw43Sniffer;
// Make the TX FIFO NON-DROPPING (head-pointer queue). rp2040js's 4-deep
// FIFO silently drops words once full, which truncates the 260-word F2
// IOCTL writes (clm_load, the connect ioctls) so the chip never sees a
// complete frame. Real hardware paces the DMA with DREQ and never drops.
// To keep the ~224 KB firmware download cheap we still discard the bulk
// of each firmware/backplane write (inDiscardableWriteData): the PIO
// drains the few kept words, raises TXSTALL, and the driver moves on.
const q: number[] = [];
let head = 0;
const origFull = Object.getOwnPropertyDescriptor(tx, 'full');
const origEmpty = Object.getOwnPropertyDescriptor(tx, 'empty');
const origItem = Object.getOwnPropertyDescriptor(tx, 'itemCount');
const origPush: (v: number) => void = tx.push.bind(tx);
const origPull: () => number = tx.pull.bind(tx);
const origPeek = tx.peek?.bind(tx);
const origReset = tx.reset?.bind(tx);
Object.defineProperty(tx, 'full', { get: () => false, configurable: true });
Object.defineProperty(tx, 'empty', { get: () => head >= q.length, configurable: true });
Object.defineProperty(tx, 'itemCount', { get: () => q.length - head, configurable: true });
tx.peek = () => (head < q.length ? q[head] : 0);
tx.reset = () => { q.length = 0; head = 0; };
tx.push = (value: number) => {
if (sniffer.inDiscardableWriteData()) {
if (q.length - head < 4) q.push(value >>> 0); // keep a few so the PIO TXSTALLs
return;
}
// Feed the gSPI sniffer; commands that produce a response queue it
// for on-demand delivery (see the rxFIFO.pull hook below).
this.feedCyw43Word(value);
return origPush(value);
q.push(value >>> 0);
};
tx.pull = () => {
if (head >= q.length) return 0;
const v = q[head++];
if (head > 8192 && head * 2 > q.length) { q.splice(0, head); head = 0; } // compact
return v;
};
this.cyw43HookedFifos.push({
restore: () => {
if (origFull) Object.defineProperty(tx, 'full', origFull); else delete tx.full;
if (origEmpty) Object.defineProperty(tx, 'empty', origEmpty); else delete tx.empty;
if (origItem) Object.defineProperty(tx, 'itemCount', origItem); else delete tx.itemCount;
tx.push = origPush;
tx.pull = origPull;
if (origPeek) tx.peek = origPeek;
if (origReset) tx.reset = origReset;
},
});
// Reset the gSPI framing at each transfer boundary. cyw43_spi_transfer
// does pio_sm_restart before pushing the count words, so this keeps the
// sniffer deterministic even across the firmware-stream fast-path.
@ -479,19 +533,25 @@ export class RP2040Simulator {
// the data arrived late or was lost; serving on pull keeps it in lock
// step with the driver.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const origPull: () => number = (rx as any).pull.bind(rx);
const origRxPull: () => number = (rx as any).pull.bind(rx);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(rx as any).pull = () =>
this.cyw43RxQueue.length > 0 ? (this.cyw43RxQueue.shift() as number) : origPull();
this.cyw43RxQueue.length > 0 ? (this.cyw43RxQueue.shift() as number) : origRxPull();
this.cyw43HookedFifos.push({
restore: () => {
tx.push = origPush;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(rx as any).pull = origPull;
(rx as any).pull = origRxPull;
},
});
}
}
// Re-sync WL_HOST_WAKE: loadMicroPython swaps in a fresh RP2040 (GPIO reset
// to low) while the chip's frame queue — and thus its host-wake level —
// persists. onHostWake only fires on changes, so push the current level now.
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.rp2040 as any)?.gpio?.[24]?.setInputValue(this.cyw43.hostWakeLevel());
} catch { /* noop */ }
}
private cyw43RxQueue: number[] = [];
@ -540,7 +600,7 @@ export class RP2040Simulator {
this.rp2040 = new RP2040();
// Suppress noisy internal logs (only show errors)
this.rp2040.logger = new ConsoleLogger(LogLevel.Error);
this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false);
// Load RP2040 B1 bootrom — needed for proper boot sequence
this.rp2040.loadBootrom(bootromB1);
@ -921,7 +981,7 @@ export class RP2040Simulator {
if (this.micropythonMode) {
// In MicroPython mode, restore the full flash snapshot (UF2 + LittleFS)
this.rp2040 = new RP2040();
this.rp2040.logger = new ConsoleLogger(LogLevel.Error);
this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false);
this.rp2040.loadBootrom(bootromB1);
this.rp2040.flash.set(this.flashCopy);
this.rp2040.core.PC = 0x10000000;

View File

@ -38,6 +38,8 @@ import {
WLC,
WLC_E,
WLC_E_STATUS,
WLC_E_LINK_UP_FLAG,
WLC_SUP,
u32le,
} from './constants';
import {
@ -53,6 +55,7 @@ import {
DEFAULT_STA_MAC,
type VirtualAp,
} from './virtual-ap';
import { DEFAULT_VNET, virtualNetReply, type VirtualNetConfig } from './virtualNet';
import type { Cyw43Cmd } from './PioBusSniffer';
// ── Public event surface ────────────────────────────────────────────
@ -78,6 +81,12 @@ export interface Cyw43EmulatorOptions {
staMac?: Uint8Array;
/** Optional clock for tests. */
now?: () => number;
/**
* Self-contained virtual network (DHCP/ARP responder). Defaults to on so a
* joined STA gets an IP with no backend. Pass `null` when an external bridge
* owns the network, or an object to override the addressing.
*/
virtualNet?: VirtualNetConfig | null;
}
type Listener<T> = (ev: T) => void;
@ -128,8 +137,31 @@ export class Cyw43Emulator {
private currentSsid = '';
private staMac: Uint8Array;
private ap: VirtualAp;
private virtualNet: VirtualNetConfig | null;
private eventMask = new Uint8Array(32); // up to 256 event types
private inboundEvents: Uint8Array[] = [];
// Async events raised WHILE handling an IOCTL are deferred until just after
// the IOCTL response is queued, mirroring the real chip: it answers the
// ioctl, THEN emits async events. Critical for SET_SSID — the driver only
// sets wifi_join_state=ACTIVE after cyw43_ll_wifi_join returns, so join
// events delivered before that would be wiped out (link never comes up).
private deferEvents = false;
private pendingEvents: Uint8Array[] = [];
// ── Debug counters (investigation harness only) ─────────────────
private _dbgF2Writes = 0; // F2 write transfers received
private _dbgFramesDecoded = 0; // SDPCM decode succeeded
private _dbgSdpcmFail = 0; // SDPCM decode returned null
private _dbgIoctls = 0; // handleIoctl invoked
private _dbgIoctlFail = 0; // CDC decode returned null
private _dbgLastIoctl = -1; // last cdc.cmd seen
private _dbgIoctlLog: string[] = []; // human-readable IOCTL sequence
debugIoctlStats(): string {
return `f2w=${this._dbgF2Writes} sdpcmOk=${this._dbgFramesDecoded} ` +
`sdpcmFail=${this._dbgSdpcmFail} ioctls=${this._dbgIoctls} ` +
`ioctlFail=${this._dbgIoctlFail} lastCmd=${this._dbgLastIoctl}`;
}
debugIoctlLog(): string[] { return this._dbgIoctlLog; }
// ── Listeners ───────────────────────────────────────────────────
private ledListeners: Listener<LedEvent>[] = [];
@ -137,12 +169,18 @@ export class Cyw43Emulator {
private connectListeners: Listener<ConnectEvent>[] = [];
private disconnectListeners: Listener<DisconnectEvent>[] = [];
private packetOutListeners: Listener<PacketOutEvent>[] = [];
// Host-wake (WL_HOST_WAKE / GPIO24) level listeners. The chip drives this
// pin high when it has a frame for the host; the driver gates poll_device on
// it until the first packet is received (cyw43_ll.c had_successful_packet).
private hostWakeListeners: Listener<boolean>[] = [];
private hostWakeAsserted = false;
constructor(opts: Cyw43EmulatorOptions = {}) {
this.now = opts.now ?? (() => Date.now());
this.bootMs = this.now();
this.ap = opts.ap ?? DEFAULT_AP;
this.staMac = opts.staMac ?? DEFAULT_STA_MAC;
this.virtualNet = opts.virtualNet === undefined ? DEFAULT_VNET : opts.virtualNet;
// F2 ready at boot — driver tolerates this being true early.
this.f0Regs[F0.F2_INFO >> 2] = 0x01;
// Send an initial SDPCM control frame so the host's first F2 poll grants it
@ -166,6 +204,7 @@ export class Cyw43Emulator {
}
this.inboundEvents.push(sdpcm);
this.f0Regs[F0.INTERRUPT >> 2] |= 0x40;
this.updateHostWake();
}
// ── Listener registration ───────────────────────────────────────
@ -174,6 +213,23 @@ export class Cyw43Emulator {
onConnect = (cb: Listener<ConnectEvent>) => this.add(this.connectListeners, cb);
onDisconnect = (cb: Listener<DisconnectEvent>) => this.add(this.disconnectListeners, cb);
onPacketOut = (cb: Listener<PacketOutEvent>) => this.add(this.packetOutListeners, cb);
/** Fires with the WL_HOST_WAKE pin level (true=high) whenever it changes. */
onHostWake = (cb: Listener<boolean>) => {
const off = this.add(this.hostWakeListeners, cb);
cb(this.hostWakeAsserted); // deliver current level on subscribe
return off;
};
/**
* Recompute the host-wake (GPIO24) level from the inbound-frame queue and
* notify listeners on a change. Active-high: high while a frame is pending.
*/
private updateHostWake(): void {
const want = this.inboundEvents.length > 0;
if (want === this.hostWakeAsserted) return;
this.hostWakeAsserted = want;
for (const cb of this.hostWakeListeners) cb(want);
}
private add<T>(arr: Listener<T>[], cb: Listener<T>): () => void {
arr.push(cb);
@ -216,10 +272,15 @@ export class Cyw43Emulator {
* receives bytes destined for the simulated STA's IP address.
*/
injectPacket(ether: Uint8Array): void {
// Chip -> host DATA frames carry the same 4-byte BDC header as events: the
// driver reads bdc at SDPCM header_length, then the ethernet at bdc + 4 +
// (data_offset<<2). data_offset 0 => ethernet immediately follows.
const bdc = new Uint8Array(4 + ether.length);
bdc.set(ether, 4);
const sdpcm = encodeSdpcm({
channel: SdpcmChannel.DATA,
sequence: this.chipToHostSeq++ & 0xff,
payload: ether,
payload: bdc,
});
this.pushFrame(sdpcm);
}
@ -240,9 +301,38 @@ export class Cyw43Emulator {
return this.bigEndian ? bswap32(value >>> 0) : swap16(value >>> 0);
}
/**
* Encode an SDPCM frame for an F2 read. The frame rides the SAME byte-swapped
* DMA-in path as register reads (cyw43_spi_transfer sets channel bswap=true),
* so every 32-bit word must be run through encodeReadWord. Without this the
* frame lands byte-reversed in spid_buf: header_length reads back as garbage
* and the driver dereferences ioctl_header at an unaligned address (crash).
*/
private encodeFrameWords(frame: Uint8Array): Uint8Array {
// F2/SDPCM traffic only happens after the bus switches to 32-bit big-endian;
// in the 16-bit boot regime (and the unit tests that exercise IOCTLs there)
// the frame is consumed raw, so pass it through unchanged.
if (!this.bigEndian) return frame;
const out = new Uint8Array(frame.length);
const whole = frame.length & ~3;
for (let i = 0; i < whole; i += 4) {
writeU32LE(out, i, this.encodeReadWord(readU32LE(frame, i)));
}
for (let i = whole; i < frame.length; i++) out[i] = frame[i]; // tail (gSPI is word-aligned)
return out;
}
/** True once the driver has switched the bus to 32-bit big-endian. */
isBigEndian(): boolean { return this.bigEndian; }
/** Current WL_HOST_WAKE level (true=high). Lets the host re-sync GPIO24
* after the RP2040 (and its GPIO state) is recreated by a firmware reload. */
hostWakeLevel(): boolean { return this.hostWakeAsserted; }
/** Enable/replace (or disable with null) the self-contained DHCP/ARP net.
* Disable it when an external packet bridge owns the network. */
setVirtualNet(cfg: VirtualNetConfig | null): void { this.virtualNet = cfg; }
/** Debug: queued chip→host frame count (for harness instrumentation). */
debugInboundCount(): number { return this.inboundEvents.length; }
@ -378,8 +468,10 @@ export class Cyw43Emulator {
private handleF2(cmd: Cyw43Cmd, _payload: Uint8Array, _rxBytes: number): Uint8Array | null {
if (cmd.write) {
this._dbgF2Writes++;
const frame = decodeSdpcm(_payload);
if (frame) this.handleHostFrame(frame.channel, frame.payload);
if (frame) { this._dbgFramesDecoded++; this.handleHostFrame(frame.channel, frame.payload); }
else this._dbgSdpcmFail++;
return null;
}
@ -395,7 +487,8 @@ export class Cyw43Emulator {
if (this.inboundEvents.length === 0) {
this.f0Regs[F0.INTERRUPT >> 2] &= ~0x40;
}
return out;
this.updateHostWake();
return this.encodeFrameWords(out);
}
// ── Host → chip frame dispatch ──────────────────────────────────
@ -406,11 +499,18 @@ export class Cyw43Emulator {
if (channel === SdpcmChannel.CONTROL) {
this.handleIoctl(payload);
} else if (channel === SdpcmChannel.DATA) {
// Outbound Ethernet frame. Strip BDC header (4 bytes) if present;
// for the test harness we forward raw payload.
// Outbound Ethernet frame. Strip the 4-byte BDC header the driver
// prepends, then forward to any external bridge.
const BDC = 4;
const ether = payload.length >= BDC ? payload.subarray(BDC) : payload;
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.
if (this.virtualNet) {
const reply = virtualNetReply(this.virtualNet, ether);
if (reply) this.injectPacket(reply);
}
}
// Channel 1 (events) is chip → host only.
}
@ -418,9 +518,15 @@ export class Cyw43Emulator {
// ── IOCTL handler ───────────────────────────────────────────────
private handleIoctl(cdcBytes: Uint8Array): void {
this._dbgIoctls++;
const cdc = decodeCdc(cdcBytes);
if (!cdc) return;
const isGet = (cdc.flags & 0x1) === 0; // bit 0 clear = GET
if (!cdc) { this._dbgIoctlFail++; return; }
this._dbgLastIoctl = cdc.cmd;
// Defer any async events the handler raises until after the ioctl reply.
this.deferEvents = true;
// SDPCM_SET is 0x2 (bit 1) in the CDC flags; SDPCM_GET is 0. (cyw43_ll.c)
const ioctlKind = cdc.flags & 0x2;
const isGet = ioctlKind === 0;
const data = cdc.payload;
// For SET_VAR/GET_VAR, name is a NUL-terminated string at start of payload.
let varName = '';
@ -429,6 +535,9 @@ export class Cyw43Emulator {
varName = readCString(data, 0);
varOff = varName.length + 1;
}
if (this._dbgIoctlLog.length < 60) {
this._dbgIoctlLog.push(`${isGet ? 'GET' : 'SET'} cmd=${cdc.cmd}${varName ? ' ' + varName : ''} dlen=${data.length} outlen=${cdc.outlen}`);
}
const reqId = (cdc.flags >>> 16) & 0xffff;
let response: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
const status = 0;
@ -510,8 +619,8 @@ export class Cyw43Emulator {
dv.setUint32(0, cdc.cmd, true);
dv.setUint16(4, response.length, true);
dv.setUint16(6, 0, true);
// Mirror the request-id in the upper 16 bits, set bit 0 to mark "response"
dv.setUint32(8, ((reqId & 0xffff) << 16) | (isGet ? 0 : 1), true);
// Mirror the request-id in the upper 16 bits and echo the SET/GET kind bit.
dv.setUint32(8, ((reqId & 0xffff) << 16) | ioctlKind, true);
dv.setUint32(12, status >>> 0, true);
replyCdc.set(response, CDC_HEADER_LEN);
const sdpcm = encodeSdpcm({
@ -520,6 +629,15 @@ export class Cyw43Emulator {
payload: replyCdc,
});
this.pushFrame(sdpcm);
// Now release any deferred async events AFTER the ioctl response, so the
// driver reads the reply (do_ioctl returns) before processing them.
this.deferEvents = false;
if (this.pendingEvents.length > 0) {
const evs = this.pendingEvents;
this.pendingEvents = [];
for (const f of evs) this.pushFrame(f);
}
}
// ── Concrete IOCTL handlers ─────────────────────────────────────
@ -542,7 +660,15 @@ export class Cyw43Emulator {
this.queueEvent(WLC_E.ASSOC, WLC_E_STATUS.SUCCESS, 0);
this.queueEvent(WLC_E.SET_SSID, WLC_E_STATUS.SUCCESS, 0,
encodeSetSsidPayload(ssid));
this.queueEvent(WLC_E.LINK, WLC_E_STATUS.SUCCESS, 1 /* link up flag */);
// LINK up: the driver checks ev->flags & 1 (NOT the reason) to mark the
// link up, and only sets WIFI_JOIN_STATE_LINK when that bit is set.
this.queueEvent(WLC_E.LINK, WLC_E_STATUS.SUCCESS, 0, new Uint8Array(0),
WLC_E_LINK_UP_FLAG);
// Supplicant "keyed": for a secured join the driver needs
// WIFI_JOIN_STATE_KEYED before it declares the link up (open networks
// preset it, but MicroPython's connect(ssid, "") still configures the
// WPA supplicant). Emit WLC_SUP_KEYED so the 4-bit join state completes.
this.queueEvent(WLC_E.PSK_SUP, WLC_SUP.KEYED, 0);
this.linkState = 'up';
this.fireConnect(ssid);
} else {
@ -569,16 +695,20 @@ export class Cyw43Emulator {
const val = dv.getUint32(4, true);
if (mask & 0x1) this.fireLed((val & 0x1) === 0x1);
}
// bsscfg:event_msgs payload: 4-byte cfg index + 16-byte mask
if (name === 'bsscfg:event_msgs' && value.length >= 4 + 16) {
const mask = value.slice(4, 4 + 16);
// bsscfg:event_msgs (and plain event_msgs) payload: 4-byte bsscfg index
// followed by the event bitmask. Store the buffer VERBATIM (index included)
// so the bitmask lines up at offset 4 — exactly where queueEvent and
// hasAnyMaskBitsSet read it (eventMask[4 + (eventType>>3)]). Slicing the
// index off here mis-aligned the mask by 4 bytes, so the join events
// (AUTH/LINK/PSK_SUP) were dropped and the link never came up.
if ((name === 'bsscfg:event_msgs' || name === 'event_msgs') && value.length >= 4 + 4) {
this.eventMask = new Uint8Array(32);
this.eventMask.set(mask);
this.eventMask.set(value.subarray(0, Math.min(value.length, 32)));
}
// sup_wpa_psk / wsec_pmk / passphrase — accept silently.
}
private handleGetVar(name: string, _outlen: number): Uint8Array {
private handleGetVar(name: string, outlen: number): Uint8Array {
if (name === 'cur_etheraddr') {
return new Uint8Array(this.staMac);
}
@ -586,7 +716,15 @@ export class Cyw43Emulator {
// Synthetic firmware version banner; the driver only uses the prefix.
return new TextEncoder().encode('velxio-cyw43-emu 1.0\0');
}
return new Uint8Array(0);
// Every other GET must return a ZERO-FILLED buffer of the size the driver
// asked for — NEVER an empty response. cyw43_do_ioctl only memmoves the
// bytes we return over the driver's request buffer, so an empty reply
// leaves the request bytes in place. Several GETs then parse a leading u32
// as a count/length and run away: e.g. cyw43_ll_wifi_update_multicast_filter
// reads mcast_list's first word as the address count and loops that many
// times (the ASCII "mcas" => ~1.9e9 iterations, which read out of bounds
// and hang wifi_on). Zeros => count 0 / status 0 (success) everywhere.
return new Uint8Array(Math.max(4, Math.min(outlen, 1536)));
}
// ── Event queueing ─────────────────────────────────────────────
@ -596,6 +734,7 @@ export class Cyw43Emulator {
status: number,
reason: number,
payload: Uint8Array = new Uint8Array(0),
flags = 0,
): void {
// Honour the host's event mask if it's been set; events outside the
// mask are dropped on the floor (the chip wouldn't deliver them).
@ -615,8 +754,10 @@ export class Cyw43Emulator {
reason,
payload,
this.staMac,
flags,
);
this.pushFrame(frame);
if (this.deferEvents) this.pendingEvents.push(frame);
else this.pushFrame(frame);
}
private hasAnyMaskBitsSet(): boolean {

View File

@ -146,6 +146,20 @@ export class PioBusSniffer {
/** Debug hook (tests): fired with each count word's raw value + decode. */
onCount: ((kind: 'out' | 'in', raw: number, bytes: number) => void) | null = null;
/**
* True while the in-flight transfer is a large non-F2 write — i.e. a firmware
* download block (or any backplane bulk write the chip discards). The host
* pushes ~224 KB of these; their data words carry no information the emulator
* needs (firmware lands in chip SRAM we don't model), so a non-dropping FIFO
* forces the PIO to bit-bang every one and the emulation crawls. The harness
* uses this to drop those data words while keeping F2/SDPCM writes (IOCTLs)
* and every command/count word intact. 36 bytes = count(0) + cmd(4) + a few
* config words; firmware blocks are 64-byte payloads, well above it.
*/
inDiscardableWriteData(): boolean {
return this.state === 'writeData' && this.cmd?.function !== 2 && this.outBytes > 36;
}
*feedWord(rawWord: number): Generator<SnifferEvent> {
const raw = rawWord >>> 0;

View File

@ -101,9 +101,19 @@ export const WLC_E = {
SCAN_COMPLETE: 26,
JOIN_START: 36,
ASSOC_START: 38,
PSK_SUP: 46, // WPA supplicant state; status 6 (WLC_SUP_KEYED) => key exchange done
ESCAN_RESULT: 69,
} as const;
// WLC_E_PSK_SUP status values (the WPA supplicant state machine). The driver
// only marks WIFI_JOIN_STATE_KEYED when it sees status == KEYED.
export const WLC_SUP = {
KEYED: 6, // WLC_SUP_KEYED — 4-way handshake complete, link may come up
} as const;
// LINK event flag: bit 0 set means "link up" (cyw43_ll.c checks ev->flags & 1).
export const WLC_E_LINK_UP_FLAG = 1;
// ── Status codes used in event payloads ───────────────────────────
export const WLC_E_STATUS = {
SUCCESS: 0,

View File

@ -141,28 +141,37 @@ export function encodeEventFrame(
reason: number,
payload: Uint8Array = new Uint8Array(0),
srcMac: Uint8Array = new Uint8Array([0, 0, 0, 0, 0, 0]),
flags = 0,
): Uint8Array {
// Event SDPCM payload layout used by Broadcom firmware:
// BDC_HDR (4 bytes) flags, priority, interface, data_offset
// ETHER_HDR (14 bytes) dest+src+ethertype
// BCMETH_HDR (10 bytes) Broadcom OUI tag
// EVENT_HDR (48 bytes) event_type, status, …
// <payload>
// The driver reads the BDC header at the SDPCM header_length, then takes the
// ethernet payload at bdc + 4 + (data_offset<<2). Without the BDC it reads
// the broadcast-MAC byte as data_offset and the payload points out of bounds.
const ETHERTYPE_BRCM = 0x886c;
const BDC_LEN = 4;
const ETH_LEN = 14;
const BCMETH_LEN = 10;
const EVENT_LEN = 48;
const total = ETH_LEN + BCMETH_LEN + EVENT_LEN + payload.length;
const total = BDC_LEN + ETH_LEN + BCMETH_LEN + EVENT_LEN + payload.length;
const buf = new Uint8Array(total);
const dv = new DataView(buf.buffer);
// BDC header: data_offset = 0 (ethernet immediately follows), interface 0.
// buf[0..3] stay zero.
const eth = BDC_LEN;
// Ethernet header: dst = broadcast, src = chip MAC, ethertype = BRCM
for (let i = 0; i < 6; i++) buf[i] = 0xff;
buf.set(srcMac, 6);
dv.setUint16(12, ETHERTYPE_BRCM, false);
for (let i = 0; i < 6; i++) buf[eth + i] = 0xff;
buf.set(srcMac, eth + 6);
dv.setUint16(eth + 12, ETHERTYPE_BRCM, false);
// Broadcom Ethernet header (subtype/len/ver/oui/usr_subtype)
let off = ETH_LEN;
let off = eth + ETH_LEN;
dv.setUint16(off + 0, 0, false);
dv.setUint16(off + 2, total - ETH_LEN, false);
dv.setUint16(off + 2, total - BDC_LEN - ETH_LEN, false);
buf[off + 4] = 0x02; // ver
buf[off + 5] = 0x00; // oui[0]
buf[off + 6] = 0x10; // oui[1]
@ -172,7 +181,7 @@ export function encodeEventFrame(
// EVENT_HDR (big-endian — Broadcom firmware writes this BE)
off += BCMETH_LEN;
dv.setUint16(off + 0, 1, false); // ver
dv.setUint16(off + 2, 0, false); // flags
dv.setUint16(off + 2, flags & 0xffff, false); // flags (bit 0 = link up for WLC_E_LINK)
dv.setUint32(off + 4, eventType >>> 0, false);
dv.setUint32(off + 8, status >>> 0, false);
dv.setUint32(off + 12, reason >>> 0, false);
@ -191,24 +200,27 @@ export function encodeEventFrame(
});
}
/** Decode an event payload — returns the event_type / status / reason. */
/** Decode an event payload — returns the flags / event_type / status / reason. */
export function decodeEventBody(payload: Uint8Array): {
flags: number;
eventType: number;
status: number;
reason: number;
datalen: number;
data: Uint8Array;
} | null {
const BDC_LEN = 4;
const ETH_LEN = 14;
const BCMETH_LEN = 10;
const EVENT_LEN = 48;
if (payload.length < ETH_LEN + BCMETH_LEN + EVENT_LEN) return null;
if (payload.length < BDC_LEN + ETH_LEN + BCMETH_LEN + EVENT_LEN) return null;
const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
const off = ETH_LEN + BCMETH_LEN;
const off = BDC_LEN + ETH_LEN + BCMETH_LEN;
const flags = dv.getUint16(off + 2, false);
const eventType = dv.getUint32(off + 4, false);
const status = dv.getUint32(off + 8, false);
const reason = dv.getUint32(off + 12, false);
const datalen = dv.getUint32(off + 20, false);
const data = payload.slice(off + EVENT_LEN, off + EVENT_LEN + datalen);
return { eventType, status, reason, datalen, data };
return { flags, eventType, status, reason, datalen, data };
}

View File

@ -0,0 +1,218 @@
/**
* virtualNet.ts
*
* A tiny self-contained network for the CYW43 emulator. The real chip just
* bridges Ethernet frames to/from the air; for a fully local emulation (no
* backend) the virtual AP also has to answer the handful of L2/L3 exchanges a
* freshly-joined STA needs before its `network.WLAN` reports a usable link:
*
* - DHCP (DISCOVER -> OFFER, REQUEST -> ACK) so lwIP gets an IP and the link
* advances CYW43_LINK_NOIP -> CYW43_LINK_UP (isconnected() == True).
* - ARP (who-has the gateway -> is-at) so the stack can resolve the router.
*
* Everything is built by hand (Ethernet + IPv4 + UDP + DHCP/BOOTP, or ARP) with
* a correct IPv4 header checksum; the UDP checksum is left 0 (legal, and what
* most embedded DHCP servers emit). All addresses are link-local to the virtual
* AP and never leave the browser.
*/
export interface VirtualNetConfig {
serverIp: readonly [number, number, number, number]; // AP / DHCP server / gateway
clientIp: readonly [number, number, number, number]; // address leased to the STA
netmask: readonly [number, number, number, number];
dnsIp: readonly [number, number, number, number];
apMac: Uint8Array; // 6 bytes — the virtual AP's MAC
leaseSecs: number;
}
export const DEFAULT_VNET: VirtualNetConfig = {
serverIp: [192, 168, 4, 1],
clientIp: [192, 168, 4, 2],
netmask: [255, 255, 255, 0],
dnsIp: [192, 168, 4, 1],
apMac: new Uint8Array([0x02, 0x56, 0x45, 0x4c, 0x58, 0x00]), // locally-administered "VELX"
leaseSecs: 86400,
};
const ETH_IPV4 = 0x0800;
const ETH_ARP = 0x0806;
const IP_PROTO_UDP = 17;
function ip16(sum: number): number {
while (sum >> 16) sum = (sum & 0xffff) + (sum >>> 16);
return (~sum) & 0xffff;
}
/** Build an Ethernet+IPv4+UDP frame carrying `udpPayload` (server -> client). */
function buildUdp(
cfg: VirtualNetConfig,
dstMac: Uint8Array,
srcPort: number,
dstPort: number,
udpPayload: Uint8Array,
): Uint8Array {
const ETH = 14;
const IP = 20;
const UDP = 8;
const total = ETH + IP + UDP + udpPayload.length;
const buf = new Uint8Array(total);
const dv = new DataView(buf.buffer);
// Ethernet
buf.set(dstMac.subarray(0, 6), 0);
buf.set(cfg.apMac.subarray(0, 6), 6);
dv.setUint16(12, ETH_IPV4, false);
// IPv4
let o = ETH;
buf[o] = 0x45; // version 4, IHL 5
buf[o + 1] = 0; // DSCP/ECN
dv.setUint16(o + 2, IP + UDP + udpPayload.length, false); // total length
dv.setUint16(o + 4, 0, false); // id
dv.setUint16(o + 6, 0, false); // flags/frag
buf[o + 8] = 64; // TTL
buf[o + 9] = IP_PROTO_UDP;
dv.setUint16(o + 10, 0, false); // checksum (filled below)
buf.set(cfg.serverIp, o + 12);
buf.set(dstMac === BROADCAST_MAC ? [255, 255, 255, 255] : cfg.clientIp, o + 16);
// IPv4 header checksum
let sum = 0;
for (let i = 0; i < IP; i += 2) sum += dv.getUint16(o + i, false);
dv.setUint16(o + 10, ip16(sum), false);
// UDP (checksum 0 = not computed, legal for IPv4)
o += IP;
dv.setUint16(o + 0, srcPort, false);
dv.setUint16(o + 2, dstPort, false);
dv.setUint16(o + 4, UDP + udpPayload.length, false);
dv.setUint16(o + 6, 0, false);
buf.set(udpPayload, o + UDP);
return buf;
}
const BROADCAST_MAC = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
const DHCP_MAGIC = [0x63, 0x82, 0x53, 0x63];
interface DhcpReq {
xid: Uint8Array; // 4 bytes
chaddr: Uint8Array; // 6 bytes (client MAC)
msgType: number; // 1 = DISCOVER, 3 = REQUEST
broadcast: boolean; // client wants the reply broadcast
}
/** Parse a DHCP request out of an Ethernet frame; null if it isn't one. */
function parseDhcp(ether: Uint8Array): DhcpReq | null {
if (ether.length < 14 + 20 + 8 + 240) return null;
const dv = new DataView(ether.buffer, ether.byteOffset, ether.byteLength);
if (dv.getUint16(12, false) !== ETH_IPV4) return null;
const ihl = (ether[14] & 0x0f) * 4;
if (ether[14 + 9] !== IP_PROTO_UDP) return null;
const udp = 14 + ihl;
const dstPort = dv.getUint16(udp + 2, false);
if (dstPort !== 67) return null; // DHCP server port
const dhcp = udp + 8;
if (ether[dhcp] !== 1) return null; // BOOTREQUEST
const flags = dv.getUint16(dhcp + 10, false);
const xid = ether.slice(dhcp + 4, dhcp + 8);
const chaddr = ether.slice(dhcp + 28, dhcp + 34);
// Verify + skip the magic cookie, then walk options for type 53 (msg type).
const optStart = dhcp + 236;
if (
ether[optStart] !== DHCP_MAGIC[0] || ether[optStart + 1] !== DHCP_MAGIC[1] ||
ether[optStart + 2] !== DHCP_MAGIC[2] || ether[optStart + 3] !== DHCP_MAGIC[3]
) return null;
let msgType = 0;
let i = optStart + 4;
while (i < ether.length) {
const opt = ether[i];
if (opt === 0xff) break; // end
if (opt === 0x00) { i++; continue; } // pad
const len = ether[i + 1];
if (opt === 53 && len >= 1) msgType = ether[i + 2];
i += 2 + len;
}
if (msgType !== 1 && msgType !== 3) return null; // only DISCOVER / REQUEST
return { xid, chaddr, msgType, broadcast: (flags & 0x8000) !== 0 };
}
/** Build the DHCP OFFER/ACK reply Ethernet frame for a parsed request. */
function buildDhcpReply(cfg: VirtualNetConfig, req: DhcpReq): Uint8Array {
// BOOTP fixed area (236) + magic (4) + options.
const replyType = req.msgType === 1 ? 2 : 5; // DISCOVER->OFFER, REQUEST->ACK
const fixed = new Uint8Array(236);
const dv = new DataView(fixed.buffer);
fixed[0] = 2; // BOOTREPLY
fixed[1] = 1; // htype ethernet
fixed[2] = 6; // hlen
fixed.set(req.xid, 4);
dv.setUint16(10, req.broadcast ? 0x8000 : 0, false); // flags
fixed.set(cfg.clientIp, 16); // yiaddr (leased address)
fixed.set(cfg.serverIp, 20); // siaddr (server)
fixed.set(req.chaddr.subarray(0, 6), 28); // chaddr
const opts: number[] = [...DHCP_MAGIC];
opts.push(53, 1, replyType); // DHCP message type
opts.push(54, 4, ...cfg.serverIp); // server identifier
opts.push(51, 4, ...le32be(cfg.leaseSecs)); // lease time
opts.push(1, 4, ...cfg.netmask); // subnet mask
opts.push(3, 4, ...cfg.serverIp); // router (gateway)
opts.push(6, 4, ...cfg.dnsIp); // DNS server
opts.push(0xff); // end
while (opts.length < 60) opts.push(0); // pad to a sane minimum
const dhcp = new Uint8Array(fixed.length + opts.length);
dhcp.set(fixed, 0);
dhcp.set(opts, fixed.length);
const dstMac = req.broadcast ? BROADCAST_MAC : req.chaddr;
return buildUdp(cfg, dstMac, 67, 68, dhcp);
}
function le32be(v: number): number[] {
return [(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff];
}
/** Parse an ARP request for the gateway; build the is-at reply, else null. */
function buildArpReply(cfg: VirtualNetConfig, ether: Uint8Array): Uint8Array | null {
if (ether.length < 14 + 28) return null;
const dv = new DataView(ether.buffer, ether.byteOffset, ether.byteLength);
if (dv.getUint16(12, false) !== ETH_ARP) return null;
const a = 14;
if (dv.getUint16(a + 0, false) !== 1) return null; // htype ethernet
if (dv.getUint16(a + 2, false) !== ETH_IPV4) return null; // ptype IPv4
if (dv.getUint16(a + 6, false) !== 1) return null; // opcode request
const targetIp = ether.slice(a + 24, a + 28);
// Only answer for the gateway/server address.
if (!ipEq(targetIp, cfg.serverIp)) return null;
const senderMac = ether.slice(a + 8, a + 14);
const senderIp = ether.slice(a + 14, a + 18);
const out = new Uint8Array(14 + 28);
const odv = new DataView(out.buffer);
out.set(senderMac, 0); // dst = requester
out.set(cfg.apMac.subarray(0, 6), 6); // src = AP
odv.setUint16(12, ETH_ARP, false);
odv.setUint16(a + 0, 1, false); // htype
odv.setUint16(a + 2, ETH_IPV4, false);
out[a + 4] = 6; out[a + 5] = 4;
odv.setUint16(a + 6, 2, false); // opcode reply
out.set(cfg.apMac.subarray(0, 6), a + 8); // sender HW = AP
out.set(cfg.serverIp, a + 14); // sender IP = gateway
out.set(senderMac, a + 18); // target HW = requester
out.set(senderIp, a + 24); // target IP = requester
return out;
}
function ipEq(a: Uint8Array, b: readonly number[]): boolean {
return a.length >= 4 && a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
}
/**
* Given an Ethernet frame the STA transmitted, return the AP's reply frame to
* inject back (DHCP OFFER/ACK or ARP is-at), or null if no reply is warranted.
*/
export function virtualNetReply(cfg: VirtualNetConfig, ether: Uint8Array): Uint8Array | null {
const dhcpReq = parseDhcp(ether);
if (dhcpReq) return buildDhcpReply(cfg, dhcpReq);
return buildArpReply(cfg, ether);
}

View File

@ -1821,8 +1821,15 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
/#include\s*[<"]WiFi\.h[>"]/.test(f.content) ||
/WiFi\.begin\(/.test(f.content),
);
cyw43.wifiEnabled = hasWifi;
cyw43.connect();
// 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;
}
}
}