loadUserFiles passed content.length (UTF-16 code units) as the byte count
to lfs_write_file, but cwrap marshals the content to the heap as UTF-8. A
file with multi-byte chars (e.g. an em-dash in a comment) is then written
short by the multi-byte overhead, truncating the tail. The async-LED Wi-Fi
example (2 em-dashes) lost its last 4 bytes, turning the final
'asyncio.run(main())' into 'asyncio.run(main' -> SyntaxError at EOF. Use
the UTF-8 byte length so the whole file lands; ASCII files are unaffected.
For the first deploy, keep the chip emulator's built-in virtual DHCP/ARP
net ON and leave the backend internet bridge dormant (not validated end
to end yet). A Pico W board now associates and gets a link-local IP
locally (isconnected True); outbound internet (MQTT/HTTP) has no route
until the picow_net bridge is wired. Revert is a one-liner in the store
(cyw43.wifiEnabled = hasWifi; cyw43.connect()) + setVirtualNet(null).
Headless test that drives the REAL RP2040Simulator (attachCyw43 +
installCyw43PioHooks + lockstep PIO stepping in runFrameForTime), boots
the Pico W firmware, injects a WiFi-connect snippet over the raw REPL, and
asserts isconnected(). Result:
PYBOOT
ACTIVE False (this fw's active() getter reports link status)
CONN_OK 192.168.4.2 (DHCP-leased IP, isconnected() == True)
MAINPY_DONE
Reaches link-up in ~31s wall — the production lockstep PIO stepping is
faster than the harness's setTimeout-cranked PIO.
Also fixes a real production bug: the RP2040 logger was
ConsoleLogger(LogLevel.Error) which THROWS on rp2040js unaligned-read
warnings — lwIP reads the IPv4 header at ethernet offset 14 on every
received packet, so WiFi would have crashed on the first DHCP reply.
Now constructed with throwOnError=false.
Gated behind CYW43_PROD_HARNESS=1 (boots real firmware, ~30s).
Port the gSPI wiring proven in the boot harness into the real simulator
so WiFi works in the browser, not just the test:
- Non-dropping TX FIFO (head-pointer queue) in installCyw43PioHooks, so
the 260-word F2 IOCTL writes aren't truncated, with the firmware/
backplane bulk-write fast-path (inDiscardableWriteData) keeping the
~224 KB download cheap. Fully restorable on detach.
- Drive WL_HOST_WAKE (GPIO24) from emu.onHostWake, and re-sync the pin
level after installCyw43PioHooks (loadMicroPython resets GPIO while the
chip's queue persists).
- With a backend bridge attached, disable the built-in DHCP/ARP net so
the bridge owns the network.
Production steps the PIO in lockstep with the CPU (pioStepAccum), so no
PIO-rate crank is needed (that was harness-only). The emulator-side fixes
(host-wake, F2 byte order, join events, BDC header, virtual DHCP/ARP) are
already shared. Not yet exercised in a browser e2e — the headless harness
is the verification today.
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.
The Pico W now joins the virtual AP end to end: active(True) returns,
connect() runs the full WPA/SET_SSID sequence, and the link comes up.
Root causes fixed (each blocked the join):
- mcast_list GET returned empty, so the driver read its own request bytes
as the address count (ASCII 'mcas' ~1.9e9) and looped ~2e9 times,
hanging wifi_on. GETs now return a zero-filled buffer of the asked-for
length (count 0 / status 0), never empty.
- Async event frames lacked the 4-byte BDC header the driver expects at
SDPCM header_length, so it read the broadcast-MAC byte as data_offset
and the payload pointed out of bounds (WRONG_PAYLOAD_TYPE). Prepend BDC.
- WLC_E_LINK signalled link-up via the reason field, but the driver
checks ev->flags & 1. encodeEventFrame now takes a flags arg; LINK uses
flags=1.
- Join needs WIFI_JOIN_STATE_KEYED, which only a WLC_E_PSK_SUP(status=6)
event sets (connect(ssid, "") still configures the WPA supplicant).
Emit it on a successful join.
- Join events were raised synchronously during the SET_SSID ioctl, so the
driver processed them before cyw43_wifi_join set wifi_join_state=ACTIVE,
wiping the bits. Defer events until just after the ioctl reply.
- Event-mask stored 4 bytes misaligned vs queueEvent's read offset.
- SET/GET kind bit is 0x2 (SDPCM_SET), not 0x1.
Remaining for status UP / isconnected: DHCP (needs the packet-transport
bridge or an emulator-side DHCP responder).
Add PioBusSniffer.inDiscardableWriteData(): true while framing a large
non-F2 write (firmware/backplane bulk write the chip discards). The boot
harness drops those data words (keeping ~4 so the PIO raises TXSTALL,
which is all the driver's write path waits for) instead of bit-banging
the full ~224 KB through the PIO. F2/SDPCM IOCTL writes and every
count/command word are retained in full, so the bring-up still completes
the 23-IOCTL wifi_on sequence (F1 framing 3613 -> 97, F2 unchanged).
Also adds IPSR + PC-histogram sampling: confirmed the post-mcast_list
stall is thread-mode (no GPIO IRQ storm) inside MicroPython's host-side
cyw43_cb_tcpip_init (lwIP), above the chip emulation.
Unblocks the full wifi_on IOCTL sequence in the boot harness (clm_load
through the 23-IOCTL bring-up, no crash):
- Drive WL_HOST_WAKE (GPIO24, active-high): the driver gates poll_device
on this pin until its first packet (had_successful_packet), so without
it the first IOCTL response is never read. Emulator now exposes
onHostWake(level) and toggles it with the inbound-frame queue.
- Encode F2/SDPCM frame reads per 32-bit word (encodeFrameWords), same
as register reads: the DMA-in sets channel bswap=true, so an un-encoded
frame landed byte-reversed -> header_length read back as garbage and
the driver dereferenced ioctl_header at an unaligned address (crash).
Guarded to boot mode pass-through (no F2 traffic there; keeps unit tests).
- Fix SET/GET detection: SDPCM_SET is bit 1 (0x2), not 0x1; echo the
kind bit in IOCTL responses.
- Add IOCTL/SDPCM debug counters + sequence log for the harness.
Harness (investigation, CYW43_HARNESS=1 only): non-dropping TX FIFO so
large F2 writes are not truncated, crank PIO steps/tick so the firmware
drains in wall-clock, GPIO24 host-wake wiring, CPU-fault + PC-histogram
+ PIO-state instrumentation.
Remaining: stall after mcast_list (#22) inside cyw43_cb_tcpip_init.
cyw43_spi_transfer calls pio_sm_restart before each transfer's count words, so
hooking restart() to reset the sniffer makes framing deterministic across the
firmware-stream fast-path (no phantom-transfer carryover). Verified: restarts
fire 3625x (once per transfer), F1 phantom count drops, and the CLM IOCTL write
now frames correctly (cmd decodes to F2, 'clmload' payload). Wired into
RP2040Simulator + the harness.
Remaining (next session): the CLM/IOCTL write doesn't complete its payload and
wifi_on still fails (active()=False) — bus_init stalls at/around clm_load with
only 2 STATUS reads and goes idle. Next: trace the CLM write's DMA/PIO drain and
the SDPCM IOCTL response path. See findings.md F-13.
debugInboundCount + STATUS-read tracking show initInbound=1, statusReads=2,
statusReadsWithPkt=2, finalInbound=1: the credit frame IS visible at both STATUS
reads (not a credit tight-loop). The driver reaches clm_load's F2-ready check
(passes) but the F2 IOCTL write never appears on the bus and bus_init returns.
Next: instrument the F2-write path. See findings.md F-13.
Pins the connect blocker: zero F2 transfers (F0=11 F1=97 F2=0), so the host
never sends an IOCTL — it stalls on SDPCM bus credits in clm_load (STATUS shows
no F2_PACKET_AVAILABLE) and times out, so wifi_on fails and active() stays
False. Next: make the credit-granting frame visible in SPI_STATUS during the
stall. See project/picow-wifi-emulation/findings.md F-13.
Brings the Pico W CYW43439 gSPI emulation from "fails at the first register
read" to "the chip boots fully and MicroPython's network.WLAN().active(True)
returns" — validated end-to-end against the real RPI_PICO_W firmware via a
headless boot harness.
What now works (Phases 1-2):
- PioBusSniffer rewritten to the real cyw43_bus_pio_spi framing
[out_bits][in_bits][cmd][write_data], skipping the two PIO loop-counter
words. Self-healing: validates count1 (= tx_length*8-1, 4-aligned, <=2052)
and skips non-conforming words — re-syncs after the extra word rp2040js
pushes on large writes AND fast-paths the ~224 KB firmware stream.
- Dual word-order regime: boot 16-bit-LE (swap16x2 / swap16) flips to 32-bit
big-endian (bswap32) at the SPI_BUS_CONTROL write. Calibrated empirically
against the firmware. Sniffer reads the mode via setModeProvider().
- Cyw43Emulator: encodeReadWord (per-regime), readBytes-sized backplane reads
with the value in the last word (response-delay pad), ALP+HT clocks and F2
always ready, AI core registers (IOCTRL/RESETCTRL), interrupt register
reports no errors, f1Mem echo store, SDPCM bus-credit granting + initial
frame.
- RP2040Simulator: serves chip responses on rxFIFO.pull (on-demand) instead of
racing the async DMA/PIO; passes readBytes through.
Not done yet (Phase 3+): connect() runs but stalls in the power-management /
save-restore phase before any F2/IOCTL traffic; packet transport (Tier 2) and
firmware-clocking perf are open. See project/picow-wifi-emulation/ for the full
research, phases, and findings.
The boot harness (picow-cyw43-boot-harness.investigate.test.ts) is gated behind
CYW43_HARNESS=1 so it stays out of the normal test run.
The RP2040 MicroPython loader always fetched the plain RPI_PICO build, which
ships no `network` module and no CYW43 WiFi driver. Every Pico W WiFi/MQTT
example therefore failed at `import network` ("no module named 'network'"),
which surfaced as a compile/run error in the editor.
- getFirmware()/loadUserFiles() are now variant-aware. pi-pico-w boards load
RPI_PICO_W-20230426-v1.20.0 (network/socket/ssl + the CYW43439 driver) and
write the LittleFS at the W board's flash offset (0x12c000, 212 blocks)
instead of the plain Pico's 0xa0000/352. The W firmware spans flash to
~0xab000 and would otherwise be clobbered by the filesystem. Each variant
gets its own IndexedDB cache key.
- The variant is selected by the presence of the already-wired CYW43 emulator
(attachCyw43 runs for pi-pico-w boards only).
- loadMicroPython swaps in a fresh RP2040 each run, so the CYW43 PIO-FIFO hooks
are re-installed on the new instance; otherwise the driver's gSPI traffic
never reaches the emulator and WiFi never comes up.
- Bundle micropython-rp2040w.uf2 as the offline fallback.
- Point the ThingsBoard example at the simulator's Velxio-GUEST network.
8 MicroPython examples that use `import network` (Blynk IoT relay, ThingsBoard
IoT, OTA update, DHT11 HTTP CSV logger, async LED control, web servo, websocket
LED, IoT relay web server) had boardType "raspberry-pi-pico". A plain Pico
(RP2040) has no WiFi and no `network` module, so they failed at runtime with
`ImportError: no module named 'network'` (the banner even shows "Raspberry Pi
Pico with RP2040"). Move them all to "pi-pico-w", which has WiFi + network.
Adds a self-contained ESP32 networking example for the /examples gallery
(addresses feature request #115). The sketch joins the emulator AP
"Velxio-GUEST", connects to a public MQTT broker (broker.hivemq.com:1883),
then publishes to its own topic and subscribes to it so each message
round-trips through the broker and toggles GPIO2 -- no external client or
local broker needed; just open the Serial Monitor.
Verified end to end in QEMU: WiFi associates (IP 192.168.4.15), DNS resolves
and outbound TCP to :1883 succeeds via slirp NAT. PubSubClient is auto-
installed via the example's `libraries` field.
Follow-up audit after the ESP32 fix: classifyPin() was run for every board
against the protocol pin labels its element actually exposes. One real gap
remained -- Arduino Mega. Its dedicated SDA/SCL pins are only labelled (not
numbered), so I2C links drawn on them came back 'digital' and never bridged.
Map every Mega function label (TX/RX, TX0-3/RX0-3, SDA/SCL) to its pin number.
Audit result for the rest (added as board-protocols-audit.test.ts):
- Arduino Uno/Nano, Pico/Pico-W, STM32 Blue Pill: already OK.
- ESP32 / ESP32-C3: fixed earlier (esp32-uart-pin-classify).
- Raspberry Pi 3/4/5: OK -- the element labels pins by physical number (1..40)
which normalize to BCM, so no function-label gap exists there.
Wiring two ESP32s TX2->RX2 (Serial2) or TX->RX for board-to-board serial
produced no data on the receiver: classifyPin() returned 'digital' for the
UART pins, so the Interconnect never installed the byte-level UART bridge.
Two causes in boardProtocols.ts normalizePinName:
- TX/RX aliases only matched boardKind === 'esp32' exactly, missing every
variant (esp32-devkit-c-v4, esp32-cam, esp32-s3), and TX2/RX2 were not
handled at all. Resolve them via startsWith('esp32') (esp32-c3 kept
separate) and map TX2/RX2 -> GPIO17/16.
- 'GPIO17'-style labels fell into the 'GP' (RP2040) branch first, where
parseInt('IO17') = NaN swallowed them to null. Exclude 'GPIO' from the
'GP' branch so the ESP32 GPIO-prefix handling runs.
Adds esp32-uart-classify.test.ts (6 cases, green).
GitHub does not expose repository secrets to workflow runs triggered by
pull_request from a fork, so VELXIO_BUILD_LICENSE_KEY arrives empty and
the download step's `[ -z ] && exit 1` guard hard-fails every external
contributor's PR for a reason unrelated to their change (e.g. #220 from
ciegovolador, the buzzer audio fix, which only touches frontend).
Add a lightweight `gate` job that checks whether the key is present and
gates the real `e2e` job on it (needs + if). Fork PRs now SKIP e2e
(neutral) instead of going red; maintainer pushes and same-repo branches,
which do receive the secret, still run the full simulation suite.
The 5 binary downloads used a bare `curl -fSL` with no retries. The
assets are served from velxio.dev, which has brief unavailable windows
during a deploy (the app container is recreated -> a few seconds of
502). A single blip mid-download failed the whole Backend E2E job even
though nothing was wrong with the change under test.
Add a shared retry policy (--retry 5 --retry-delay 10 --retry-all-errors
--retry-connrefused --connect-timeout 15) so the step rides out a
container recreate (~50s of headroom) instead of failing hard.
- Reorder: send Discord FIRST; commit CHANGELOG + version bump ONLY on
a successful announce, so a failed send never consumes a version.
- Raise max_tokens for the deepseek-v4-flash reasoning model (6000/4000)
so reasoning+content fit and content is never empty.
- Guard against empty content (don't POST an empty Discord message).
- Add workflow_dispatch (manual re-fire) with an optional base ref input.
Seed the release branch with the corrected discord-release-notify workflow
and version baseline BEFORE the next master->release merge, so that merge
announces v3.0.0 and the workflow bumps the patch (3.0.0 -> 3.0.1 -> ...)
on every subsequent merge instead of repeating the same version.
A direct push (not a pull_request) does not trigger the announce workflow,
so this is safe to land here ahead of the merge.
The Discord release-notify workflow read the version from
frontend/package.json but never wrote it back, so every merge to release
announced the SAME version (the CHANGELOG ended up with two "[2.0.1]"
entries). Now, after generating the CHANGELOG and before announcing, the
workflow bumps the PATCH in frontend/package.json and commits it alongside
the CHANGELOG to release. Each merge advances the counter:
3.0.0 -> 3.0.1 -> 3.0.2 ...
Also sets the baseline to 3.0.0 so the next release is announced as v3.0.0.
To jump the major/minor, edit frontend/package.json on the release branch
(e.g. "version": "3.1.0") and the next merge continues from there.
Revert the earlier approach of widening the existing PWM-callback assertions to
accept the new timeMs arg — that masked a contract change rather than fixing it.
Instead, updatePwm now hands the optional timeMs only to listeners that declare
a 3rd parameter (cb.length >= 3) — i.e. the buzzer, which needs the precise
onset time. Plain (pin, dutyCycle) listeners, and the existing
toHaveBeenCalledWith(pin, dutyCycle) tests, see an unchanged 2-arg call, so the
original PwmCallback contract is preserved.
Add a PinManager test locking the dispatch: a 2-param listener stays 2-arg; a
3-param listener receives timeMs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sample-accurate scheduling (06526c7) added an optional 3rd `timeMs`
argument to PwmCallback / updatePwm, which broke 9 existing strict
toHaveBeenCalledWith(pin, duty) assertions (PinManager, AVRSimulator,
mega-emulation, attiny85). Match the real signature: PinManager drives
updatePwm directly with no timeMs (assert `undefined`); the AVR OCR-poll path
computes timeMs = cpu.cycles / 16000 (assert `expect.anything()`).
Leaves one pre-existing red — component-to-spice "custom-chip missing fixture"
— which fails on master too and is unrelated to this PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A melody / continuous tone (consecutive tone() with no noTone() between) is
back-to-back nonzero-OCR PWM writes with no note-off, so startTone() overwrote
activeOsc without stopping the previous node — oscillators stacked and were
never stopped (reported: created 6, started 6, never stopped 6).
Add a monophonic guard at the top of startTone(): release the live note
(gain ramp + stop) before starting the new one, so a pitch change REPLACES
rather than STACKS. Extract a shared releaseActive(off) helper (also used by
stopTone). Add two melody tests: one asserts starts === stops (no orphans),
monotonic onsets and per-note pitch; one asserts a melody ending without a
trailing noTone() leaves only the final note ringing (stops === starts - 1).
The metronome path is unaffected (each click is an onset→note-off pair, so the
guard never fires there); the three existing metronome tests stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The panel was styled with light-theme CSS-var fallbacks that render wrong on
the editor's dark (#2d2d2d) property dialog:
- "Add files" button used `var(--surface, #f6f6f6)` + light border, so it
rendered a washed-out light-gray box that looked broken. Restyle it as a
primary action like `.rotate-button` (solid #007acc, white text, hover lift).
- Section divider and secondary text used light fallbacks (#e2e2e2 / #777);
switch to the dialog's dark values (#444 border, #aaa text).
Cosmetic only.
The `custom-chip` SPICE mapper emits its sources from getChipDrivenPins()
(the chip's live driven output pins), so a static pin/property fixture can
never exercise it -- it always returns null. The "every mapped metadataId
has a test fixture" check flagged it as missing a fixture, failing the
suite. Exclude it via a RUNTIME_STATE_MAPPERS set; custom-chip SPICE
behaviour is covered by the chip-bus integration tests.
Pre-existing since 4cb5748 (custom-chip first-class circuit nodes).
Add a working microSD card part backed by a FAT16 image, following the
Wokwi storage model: the project's own workspace files are auto-copied
onto the card (free), and an optional "SD Card" panel uploads extra
files (gated as a paid feature by the velxio.dev overlay; OSS default
allows it).
Frontend (in-browser AVR / RP2040):
- ProtocolParts.ts: rewrite the microsd-card part from a handshake stub
into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte
addressing, single/multi-block read+write, CSD/CID, full CMD set).
- utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN).
- utils/sdCardFiles.ts: assemble the card image from workspace files plus
uploaded files; base64 helpers.
- components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI.
- DynamicComponent + useSimulatorStore: build and inject the image on run.
- lib/proSdCardGate.ts: overlay-installable gate for the upload action.
- data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples.
Backend (ESP32 via QEMU):
- services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of
the browser part) with a sparse backing store, idle-state R1 tracking and
real CRC16 on data blocks when the host enables CRC (CMD59) -- both
required by ESP-IDF's sdspi driver.
- esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously)
and feed write-only bulk transfers.
- esp32_lib_manager.py + routes/simulation.py: forward the FAT image
(sd_card.image_b64) from the start config into the worker.
Tested:
- frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware
(real Arduino SD.h on avr8js) -- 86 passing.
- backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and
CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa
(mount, directory listing, read and write-readback).
arduino-esp32's WiFiClientSecure/ssl_client.cpp wraps its ENTIRE body
(start_ssl_client, ssl_init, send_ssl_data, ...) in
#if !defined(MBEDTLS_KEY_EXCHANGE_SOME_PSK_ENABLED) ... #else <body> #endif
ESP-IDF's mbedtls defaults the PSK key-exchange modes OFF, so the object
compiled empty and any sketch using WiFiClientSecure — including
HTTPClient.begin(url), which links the secure client even for http:// —
failed to link with "undefined reference to start_ssl_client". Commenting
out begin() let the optimizer drop the unused client, which is why it
"compiled when commented".
- sdkconfig.defaults.in: enable the PSK key-exchange ciphersuites
(CONFIG_MBEDTLS_PSK_MODES + the four KEY_EXCHANGE_*PSK), matching
arduino-esp32's own sdkconfig.
- espidf_compiler: ESP-IDF only seeds sdkconfig from sdkconfig.defaults when
sdkconfig is ABSENT. Persistent build dirs live in the build volume and
keep a stale sdkconfig across image rebuilds, so the new CONFIG_* would
never reach kconfig. Drop the generated sdkconfig when the rendered
defaults change so it re-seeds on configure.
- test: assert the rendered sdkconfig enables PSK.
Verified end-to-end: the reported WiFi+HTTPClient sketch now compiles to a
1.1 MB binary (was a hard link error before).
The SPIFFS upload feature (GH #162) is wired end-to-end — Board Options
uploads -> spiffs_files -> espidf_compiler._build_spiffs_image() ->
_merge_flash_image() at the partition offset — but the mkspiffs binary it
shells out to was never bundled in the image. _locate_mkspiffs() returns
None, _build_spiffs_image() logs "mkspiffs not found ... files will be
ignored" and returns None, and the merged flash ships with a blank SPIFFS
region. The firmware's SPIFFS.begin() then fails to mount, auto-formats,
and the uploaded files never appear.
Install the arduino-esp32 0.2.3 flavor of mkspiffs (the exact build
referenced by the arduino-esp32 2.0.17 package index, matching the SPIFFS
on-disk format SPIFFS.begin() expects) into the espidf-builder stage at the
path the runtime already searches (IDF_TOOLS_PATH/tools/mkspiffs/*/mkspiffs/).
No code change needed — _build_spiffs_image() works once the binary exists.
LittleFS is a separate follow-up (arduino-esp32 2.0.17's index does not ship
mklittlefs; needs sourcing the matching tool + FS-type selection in the
compiler).