11 KiB
Pico W (CYW43439) Wi-Fi emulation — what we built and how
Status: Full stack shipped 2026-04-29. Frontend chip emulator + backend pure-Python network stack (TCP/UDP NAT + ARP + DHCP + DNS + ICMP). No QEMU subprocess, no libslirp binding, no closed firmware blob. All 73 tests pass end-to-end including a real TCP round-trip against an in-process HTTP server.
This wiki entry is the post-mortem for how Velxio gained Wi-Fi on the Raspberry Pi Pico W. The user-facing docs live at docs/PICO_W_WIFI_EMULATION.md. This page is for the next maintainer who picks the work up.
What we knew going in
rp2040js — Velxio's RP2040 emulator — had zero Wi-Fi support.
The single relevant upstream issue, wokwi/rp2040js#134
("Is there a way to emulate cyw43 using nodejs?"), was open and
unanswered for years. No community fork, no design draft. Wokwi's
own Pico W simulation was closed-source and lived server-side.
Every other CYW43 codebase we found pointed the other direction — host-side drivers running on a real RP2040 talking to real silicon (pico-sdk pico_cyw43_driver, embassy-rs/cyw43, soypat/cyw43439, jbentham/picowi). Useful for understanding what the host writes, useless for what the chip should answer.
What we did
We stubbed the chip on the bus, not in silicon. The driver gets exactly what it expects byte-for-byte; the firmware blob never runs; the radio MAC never executes. Three insights made this work in days rather than quarters:
Insight 1 — the gSPI protocol is fully documented
The 32-bit command word (write/inc/func2/addr17/len11), the function
numbers (F0=bus, F1=backplane, F2=radio data), the magic
0xFEEDBEAD test register, the SDPCM framing — all in Infineon's
public datasheet and re-implemented identically in three
permissively-licensed open drivers.
Insight 2 — the firmware blob doesn't have to be loaded
The driver writes 224 KB of firmware into the chip's RAM at boot, but
never reads any of it back. After the stream it polls
SDIO_CHIP_CLOCK_CSR for the HT_AVAIL bit, asks for the MAC via
cur_etheraddr, and proceeds. The emulator can:
- Discard firmware writes (just track the auto-increment cursor so length math is right).
- Lie about
HT_AVAILgoing high. - Hand back a synthetic MAC.
This sidesteps the licensing question entirely (Infineon's blob is restricted to "use with CYW43xxx silicon products" — a JS emulator isn't silicon). It also sidesteps the engineering nightmare of emulating the Cortex-R4 and 802.11 radio inside the chip package.
Insight 3 — the Velxio architecture already had the seam
RP2040Simulator.ts was already monkey-patching rp2040js's
pio.run for unrelated reasons. A second hook on txFIFO.push —
the call site every PIO state machine uses to publish words on the
wire — gives us byte-level visibility into the gSPI bus without
modifying rp2040js.
The shape that emerged
gSPI word (PIO TX FIFO)
↓ PioBusSniffer
32-bit command + payload
↓ Cyw43Emulator.onCommand()
F0/F1 register read/write OR SDPCM frame
↓ (if F2)
SDPCM frame
↓ decodeSdpcm()
control / event / data channel
↓ (control)
CDC IOCTL (cmd, payload)
↓ handleIoctl()
synthesised reply + queued events
↓ encodeSdpcm() / encodeEventFrame()
chip → host RX FIFO
↓ rp2040js PIO 'pull' instruction
back to the host driver
The same pattern accommodates WLC_SET_VAR gpioout (the on-board LED
IOCTL — fires onLed listener), WLC_SCAN (synthesises a single
WLC_E_ESCAN_RESULT event for Velxio-GUEST then WLC_E_SCAN_COMPLETE),
and WLC_SET_SSID (drives the documented event sequence
JOIN_START → AUTH → ASSOC_START → ASSOC → SET_SSID → LINK(reason=1)).
Verification — what we actually tested
Eight test files. 55 tests. All green.
tests/01_pio_decoder.test.ts (9 tests) ← bit decoder
tests/02_handshake.test.ts (6 tests) ← Tier-0 handshake
tests/03_pico_w_blink.test.ts (1 skip) ← e2e w/ real UF2
tests/04_sdpcm.test.ts (7 tests) ← SDPCM/CDC codec
tests/05_ioctl.test.ts (5 tests) ← IOCTL surface
tests/06_full_lifecycle.test.ts (3 tests) ← bus→scan→connect→packet→disconnect
tests/07_picow_iot_projects.test.ts (10 tests) ← real 100-days projects
tests/08_viability.test.ts (9 tests) ← perf + IOCTL coverage budgets
frontend/src/__tests__/picow-cyw43-integration.test.ts (6 tests)
The viability suite measures the chip-side throughput at ~138 000 frames/s for 1500-byte payloads. The chip can run inside a single 60 fps frame and consume <0.05% of the budget.
The 07_picow_iot_projects.test.ts suite drives the emulator with
the exact workflows of every Pico W project in
third-party/100_Days_100_IoT_Projects/:
- HTTP server with on-board LED toggle (Pico_W_Async_LED_Control)
- HTTP server flipping a relay (IoT_Relay_Control_Web_Server)
urequests.postwith JSON body (Pico_2_W_Dht11_Http_Csv_Logger)- MQTT CONNECT/PUBLISH (Raspberry_Pi_Pico_2_W_ThingsBoard_IoT)
- WebSocket upgrade + masked frames (WebSocket_LED_Control)
- Servo over HTTP (Pico_W_Web_Servo_Controller)
- Bare GPIO without WiFi (PIR_Motion_Detector, Servo_Motor_Control)
- LED-only OTA (OTA_Update_Pico2W)
wlan.scan()semantics
Each test asserts the chip emits the events it should and the bridge sees the frames it should.
What we deliberately did NOT do
| Out of scope | Why |
|---|---|
| Run the closed firmware blob | Would need a Cortex-R4 emulator + virtual radio. Same result as the stub. |
| Ship the firmware blob | Infineon license restricts redistribution to CYW43xxx silicon; an emulator isn't silicon. |
| Bluetooth | Wokwi doesn't either. None of the 100-days projects use it. |
| ESP-NOW / raw 802.11 between two Pico Ws | Would need a virtual MAC layer hub. Slirp only carries TCP/UDP. |
| WPA2/WPA3 verification | Local sim, no real WiFi spectrum — passwords are accepted as-is. |
| Bit-perfect timing | Behavioural model, not cycle-accurate. Real-world time.sleep_us() quirks may differ. |
| Backend slirp TCP/UDP fan-out | Scoped for follow-up PR — chip-side contract is done; the network half is mechanical. |
Files of interest, in load order
frontend/src/simulation/cyw43/constants.ts— every numeric constant, BSD/MIT-derived.frontend/src/simulation/cyw43/sdpcm.ts— framing codec.frontend/src/simulation/cyw43/virtual-ap.ts—Velxio-GUESTsingle source of truth.frontend/src/simulation/cyw43/PioBusSniffer.ts— 32-bit command decoder.frontend/src/simulation/cyw43/Cyw43Emulator.ts— chip-side state machine (~470 LOC).frontend/src/simulation/cyw43/Cyw43Bridge.ts— WS bridge twin ofEsp32Bridge.frontend/src/simulation/RP2040Simulator.ts—attachCyw43()plumbing.frontend/src/store/useSimulatorStore.ts— auto-detect and lifecycle.backend/app/services/picow_net_bridge.py— backend network manager.backend/app/api/routes/simulation.py—start_picow/stop_picow/picow_packet_out.
Things that bit us along the way
- Pyright vs underscore-prefixed unused params. Pyright still
warns on
_inst/_ethereven though that's the standard Python convention. We left the warnings as★(info) since the function signatures are part of the public bridge contract. - Strict TS
Uint8Array<ArrayBuffer>vsUint8Array<ArrayBufferLike>. TS 5.7 tightened these. The fix was to widen the IOCTL response variable's type explicitly (Uint8Array<ArrayBufferLike>). - Mocking
WebSocketin vitest. A simplevi.fn()for the constructor isn't enough — the bridge checksWebSocket.OPENstatically, so the fake class needsstatic OPEN = 1. - PIO halfword swap. The
cyw43_bus_pio_spi.pioprogram swaps 16-bit halves before pushing words on the wire. Our sniffer has to un-swap before decoding. This was the single longest debug session.
The backend network stack — why pure Python?
Two routes existed: spawn QEMU just for libslirp, or write the stack ourselves. We picked option 4 from the conversation — pure Python — because:
- A QEMU subprocess sized for "just slirp" still costs ~30 MB RSS per simulated chip. With many tabs that adds up.
- libslirp doesn't have stable Python bindings.
- The protocols are stable and well-documented. RFC 793 (TCP) is from 1981; RFC 2131 (DHCP) is from 1997. We're not chasing a moving target.
- Pure-Python keeps the test surface honest — every test exercises real bytes through a real state machine, not "QEMU said it worked".
The whole stack landed in ~1300 LOC across 8 files:
backend/app/services/picow_net/
├── consts.py ~50 LOC network parameters
├── checksums.py ~30 LOC RFC 1071 + pseudo-header
├── protocols.py ~400 LOC parsers + encoders for L2..L7
├── arp.py ~50 LOC
├── dhcp.py ~140 LOC
├── dns.py ~150 LOC
├── icmp.py ~30 LOC
├── tcp_nat.py ~300 LOC RFC 793 state machine
├── udp_nat.py ~150 LOC
└── bridge.py ~150 LOC orchestrator
Every numeric constant traces back to a public RFC or datasheet —
nothing copied from cyw43-driver (RP-noncommercial). Worst quirk
during implementation: TCP sequence-number wrap-around. RFC 1323's
modular comparison (the _seq_lt helper in tcp_nat.py) is the
correct way; naive subtraction breaks at 2³² roll-over.
What's left for someone else to take next
The path of least resistance:
- WS reconnect/backoff —
Cyw43Bridgedoesn't retry on close. MirrorEsp32Bridgeif/when users hit it. - Window scaling (RFC 1323) — current TCP NAT advertises a fixed 65535 window. For high-bandwidth use cases (firmware OTA, streaming sensor logs) we'd want window scaling.
- TCP retransmit timer — we don't time out unacked data; we trust the chip to retransmit. Real slirp does both.
- Bluetooth — only worth it if a user files an issue. No 100-days project uses BT.
- TCP server-mode (chip listens, host connects) — currently the
chip is always the active opener. To accept inbound connections
from the host we'd need a hostfwd-style port-forward layer like
QEMU's
hostfwd=.
If an upstream rp2040js maintainer ever responds to issue #134, this
work is small and modular enough to extract into a separate
rp2040js-cyw43 package.