feat(cyw43): virtual DHCP/ARP net — WiFi reaches LINK_UP (isconnected)

The Pico W now connects end to end with NO backend: status reaches
CYW43_LINK_UP (3) and network.WLAN().isconnected() returns True.

After association the STA's lwIP broadcasts DHCP DISCOVER and ARPs the
gateway over the cyw43 DATA channel. A self-contained virtual network
(new virtualNet.ts) answers them:
  - DHCP DISCOVER -> OFFER, REQUEST -> ACK (Ethernet+IPv4+UDP+BOOTP, valid
    IPv4 header checksum, UDP checksum 0), leasing 192.168.4.2 with gateway
    192.168.4.1.
  - ARP who-has the gateway -> is-at the AP MAC.
On by default (Cyw43EmulatorOptions.virtualNet); pass null when an
external packet bridge owns the network.

Also fixes injectPacket to prepend the 4-byte BDC header that chip->host
DATA frames need (same as the event-frame fix), so injected packets parse.

Boot harness now reports: STEP_CONNECT_CALLED status=3 / POLL 0 status 3
conn True / HARNESS_DONE.

Known: receiving packets triggers ~30 rp2040js unaligned-read warnings
(lwIP reads the IPv4 header at ethernet offset 14); non-fatal here, but
the production RP2040Simulator must use a non-throwing logger.
This commit is contained in:
David Montero 2026-06-12 23:43:14 +02:00
parent 74365d4ae9
commit 4d631b3a98
4 changed files with 369 additions and 4 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

@ -124,8 +124,18 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
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}`;
@ -412,6 +422,7 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig
`===== 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)

View File

@ -55,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 ────────────────────────────────────────────
@ -80,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;
@ -130,6 +137,7 @@ 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
@ -172,6 +180,7 @@ export class Cyw43Emulator {
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
@ -263,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);
}
@ -477,11 +491,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.
}

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