8.8 KiB
Pico W (CYW43439) Wi-Fi emulation — what we built and how
Status: Tier 0/1 + chip-side Tier 2 shipped 2026-04-29. Backend slirp/TCP fan-out scoped for a follow-up PR. All 55 tests pass.
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
wokwi-libs/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.
Where to take it next
The path of least resistance:
- Backend slirp — terminate TCP locally in
picow_net_bridge.pyand proxy streams to the host. Python'sasyncio.open_connectionis enough; mirror whatesp32_worker.pygets from QEMU's slirp. - DHCP/DNS stubs — already partially synthesised in
virtual-ap.ts. Wire the responses back throughinjectPacket()so MicroPython's lwIP gets a happy path. - WS reconnect/backoff —
Cyw43Bridgedoesn't retry on close. MirrorEsp32Bridgeif/when users hit it. - Bluetooth — only worth it if a user files an issue. No 100-days project uses BT.
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.