Merge pull request #240 from davidmonterocrespo24/wip/picow-cyw43-emulation
Wip/picow cyw43 emulation
This commit is contained in:
commit
2f4e205a59
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue