The Pi bridge onPinChange was a no-op, so guest GPIO writes never reached the
PinManager / SPICE solver and wired LEDs stayed dark even though user scripts
printed 'LED on'. Mirror the ESP32 branch: forward to pm.triggerPinChange so
GPIO drives the canvas. Interconnect still preserves and calls this before its
own cross-board routing.
makePinPullHandler drove the post-boot INPUT_PULLUP seed through
getBoardBridge(), which only indexes the Pi bridge map (bridgeMap) — for
an ESP32 it returned undefined and the sendPinEvent seed silently no-op'd,
so the digital input stayed LOW even though the pull config was read and
the SPICE net showed the pulled voltage. ESP32 bridges live in
esp32BridgeMap; use getEsp32Bridge().
Completes the internal-pull emulation for the common case (a button on an
RTC-capable GPIO like 4/15/25/... with INPUT_PULLUP):
- Backend reads the RTC_IO pad RUE/RDE bits via the new
get_internals(QEMU_INTERNAL_RTCIO) and emits gpio_pull for RTC pins, so
pull-up/down on those pads is finally visible (it lives in RTC_IO, not
IO_MUX). IO_MUX path still covers non-RTC pins.
- The digitalRead path is driven by seeding the GPIO input level, not by
SPICE. The part-level INPUT_PULLUP seed (BasicParts) is sent at attach,
before the multi-second QEMU boot finishes, so it is lost and the pin
reads LOW. makePinPullHandler now drives the pin to the pull's idle level
via sendPinEvent when the guest programs the pull (post-boot), so it
sticks. A real button press/release still overrides it.
INPUT_PULLUP / INPUT_PULLDOWN had no effect in simulation: the ESP32's
internal pull resistors live inside QEMU and were invisible to the SPICE
solver, so an input wired to a button-to-GND floated to 0 V and read LOW
even at idle. The canonical active-low button never worked.
Read the pull config straight out of the running guest: the IO_MUX
register (FUN_PU bit 8 / FUN_PD bit 7) is already exposed read-only via
qemu_picsimlab_get_internals(3), so no QEMU rebuild is needed. The worker
scans it on the 100 ms poll thread and emits gpio_pull; the bridge feeds
it to PinManager; the netlist stamps a weak 45k resistor to the rail so
idle inputs read the correct level. 45k matches the real internal pull
and is weak enough that any external driver/pull dominates.
Verified with ngspice: idle ~3.3 V (HIGH), pressed ~0 V (LOW).
- boot_images manifest: bump arm64 rootfs (gpiozero/colorzero baked in,
hostname applied at boot, reworded MOTD)
- RaspberryPi3Bridge: onBooted shell-ready detector + sendAndWaitForPrompt
flow control (resets on disconnect)
- RaspberryPiWorkspace: distinct Booting overlay + piBooted-driven status,
inline SVG icons replacing emoji glyphs
- SerialMonitor: strip CSI/DSR escapes so the dumb console no longer shows
a literal [6n next to the prompt
- VirtualFileSystem: upload auto-starts the Pi and waits for the shell, then
flow-controls each command (no more dropped lines on large files)
- i18n: bootingTitle/bootingNote + reworded offlineNote2 across 9 locales
recordUpdateWire pushed its command with { applyNow: false }, so it recorded the
change for undo but never executed it. Its only callers (the wire colour palette
and the new right-click menu) pass the new colour and expect it applied — neither
pre-applies via the raw updateWire mutator. Net result: changing a wire colour
from the UI did nothing (only the 0-9/c/l/m/p/y keyboard shortcut, which calls
updateWire directly, worked). Drop applyNow:false so it applies like every other
record* command (recordRemoveWire etc.). Adds an undo/redo regression test.
Generalizes the LED's burnout to passive parts via a centralized monitor that
watches the live electrical solve. When a part is stressed past its rating for
a sustained moment it's marked "destroyed": the canvas renders it charred with a
smoke badge and a fault is logged to the output console. Clears on Reset.
Follows the Fritzing-simulator precedent (smoke-on-component) wrapped in a
first-order thermal delay so a brief inrush spike doesn't destroy a part — only
sustained overload (or a catastrophic >=3x overload, instant) does.
- runtimeBurnout.ts: pure stress (resistor power, cap voltage / reverse) + a
thermal-delay burn decision, plus a monitor subscribed to the electrical +
simulator stores. Resistor burns past 2x rated (the verifier already warns at
1x for intentional teaching over-power); a cap bursts over its voltage rating
or on reverse polarity.
- useSimulatorStore: burntComponents set + mark/clear actions; cleared on
Reset / restartParts.
- DynamicComponent + SimulatorCanvas.css: charred filter + smoke badge.
Tests: thermal-delay decision (instant / sustained / spike / cooldown) + stress
computation (resistor power, cap over-voltage, reverse, unwired -> null).
The pre-flight circuit verifier reads branch currents via runNetlist ->
readAllCurrentVectors() (ngSpice_AllVecs enumeration). The production
Web-Worker ngspice WASM build does not surface voltage-source #branch
vectors through that enumeration for an .op plot, so branchCurrents came
back empty and every current rule (short-circuit, LED over-current) read
?? 0 -> no fault. The live solver avoided this by requesting each current
explicitly by name; the Node test build enumerates them, so the gap was
invisible to the suite. Net effect: a 9V battery wired straight to an LED
ran with no warning (reported on project 2840fd12).
- runNetlist: request every V_* source branch current explicitly by name
and merge with the enumeration, so source/LED currents are always present
regardless of the worker WASM's AllVecs behaviour.
- circuitVerifier: non-finite source/LED current -> blocking unstable-solve
fault ("could not solve a stable current - likely a short or a part with
no current limit, e.g. an LED with no series resistor").
- LED runtime (BasicParts): burn out on a non-finite current instead of
falling through to the digital fallback and glowing; raise burnout
threshold 20mA -> 100mA so high-power/RGB channels are not falsely
destroyed; clear the burnt latch on Reset (resetBoard bumps hexEpoch).
Tests: real-data repro, mocked non-finite verifier test, runtime
non-finite / high-power / latch-recovery tests.
All three bugs are rotated components whose pin geometry is computed in a
path that ignores the rotation, so pins/wire-starts land tens of pixels off
the visual pin tips. The live rotate action already recalculates correctly;
these are the paths that didn't.
#231 (context-menu 'Tap a pin to wire'): both onPinSelect handlers in
SimulatorCanvas computed the wire start as getBoundingClientRect().left +
pin.x — adding the UNROTATED pin offset to the ROTATED bounding-box corner.
On a 90-deg HC-SR04 that put the start ~70-100px off (measured). Replaced
with calculatePinPosition(id, x+6, y+6, rotation), the same rotation-aware
helper wires and the pin overlay use.
#232 (rotate -> delete -> undo): recordRemoveComponent's undo restored the
component + wires but never recalculated wire endpoints, so a rotated part's
wires kept the unrotated coords captured at delete time. Added a
requestAnimationFrame updateWirePositions(id) after restore.
#230 + #232 (pin boxes wrong after import / undo / load, 'fixes if rotated
again'): PinOverlay captured the wrapper's layout box (the rotation pivot)
once at mount. On import/undo/load the component mounts already-rotated and
its wokwi-element may not be sized on the mount tick, baking a wrong pivot
that only refreshed when rotation changed. PinOverlay now re-measures after
layout (rAF) and whenever it is about to become visible (showPins dep).
The NTC breakout's SPICE topology was inverted relative to the example
sketch's decode formula (rNtc = R_PULL * v / (5 - v)), which assumes a 10k
pull-up from VCC to OUT and the NTC from OUT to GND. The mapper had the NTC
on top (VCC->OUT) and the pull-down on the bottom, so the recovered
temperature ran backwards: dragging the slider to 100C made the sketch
print -25C. Swap the two resistors so V_OUT = 5 * Rntc / (Rntc + Rpull),
matching the sketch and the hand-built reference netlist in
spice-avr-mixed.test.ts (T=0 -> ADC 789, T=25 -> 511, T=50 -> 270).
Also replace the SensorParts linear approximation (2.5 - (t-25)*0.02) with
the same beta-model divider so the non-SPICE ADC injection decodes back to
the slider value, and drop the dead onInput path that treated the element's
value as a raw ADC count.
Reset now restores interactive sensors (temperature/lux/gas sliders) to
their configured defaults: resetBoard re-dispatches each sensor's default
into the running sim and bumps sensorResetNonce so the open
SensorControlPanel remounts and the slider snaps back. Previously a restart
left the NTC frozen at the last dragged temperature.
Updated the examples netlist snapshot for the swapped NTC cards.
Commit 9360f95 deleted lib/proWifiGate.ts but left useSimulatorStore importing
it (the store edits weren't staged), so a clean checkout of master failed to
build (import of a deleted module). velxio.dev was unaffected — deploy.sh builds
from the working tree, which had the removal applied. Commit the removal so HEAD
is consistent.
Two robustness fixes for the paid-WiFi open-core split:
1. A pi-pico-w board now boots the RPI_PICO_W firmware variant (which has the
`network` module) based on its BOARD KIND, not on whether the WiFi
peripheral happens to be attached. Previously the variant was
`pioPeripheral ? 'pico-w' : 'pico'`, so any moment the peripheral was
absent (see #2) booted the plain Pico firmware and a Pico W sketch crashed
with "ImportError: no module named 'network'". Store boardKind in
attachPioPeripheral and pick the variant from it. (OSS: 'pico-w' isn't
registered, so firmwareConfig falls back to 'pico' — a self-hosted Pico W
has no WiFi engine anyway.)
2. Re-attach the PIO peripheral in loadMicroPythonProgram before loading
firmware. An example deep-link adds the board during render, which races the
pro overlay's async mountPro that installs the CYW43 factory — so the
board-add attach returned null and a PAID user's Pico W booted plain
firmware too. attachPioPeripheral is idempotent; by run time the factory is
installed, so a paid user gets the W peripheral and real WiFi.
Pico W WiFi is a paid overlay feature. A free/web user running a Pico W sketch
that uses WiFi had no peripheral attached -> the simulator picked the plain Pico
firmware (no `network` module) -> the run crashed on `import network` with a
raw Python traceback (on a public example page, no less).
Add lib/proWifiGate.ts (mirrors proBoardGate): a stable doorbell the overlay
fills in. useSimulatorStore gates both loadMicroPythonProgram (before loading
firmware) and startBoard (run backstop for example/loaded boards): if the gate
blocks, fire the upgrade prompt and skip the run. Non-WiFi Pico W sketches still
run for free. No-op in OSS (default 'allow' -> a Pico W runs as a plain Pico).
Move the CYW43439 (Pico W) WiFi emulation out of the open-source tree so it
can ship as a paid feature in a private overlay. OSS keeps a plain Pico W
(no WiFi); the overlay registers the cyw43 protocol + backend network stack
at runtime via generic seams.
Frontend:
- Add simulation/PioPeripheral.ts: a generic "PIO bus peripheral" seam
(feedWord / inDiscardableWriteData / resetFraming / hostWakeLevel /
onHostWake / onSimulationStart). No factory is installed in OSS, so
createPioPeripheral() returns null and a Pico W simulates as a plain Pico.
- RP2040Simulator: keep the fragile PIO-FIFO plumbing (it must re-run after
loadMicroPython swaps the chip) but drive it through PioPeripheral instead
of an inlined cyw43 import (attachCyw43 -> attachPioPeripheral, etc.).
- useSimulatorStore: generic attach/detach + setBoardWifiStatus; drop the
cyw43 bridge map.
- MicroPythonLoader: add registerFirmwareVariant() so an overlay can add the
RPI_PICO_W build; remove the OSS pico-w config + bundled .uf2.
- Delete simulation/cyw43/ (moved to the overlay).
Backend:
- core/hooks.py: add generic register_ws_sim_handler / dispatch_ws_sim_message
and register_gateway_proxy / dispatch_gateway_proxy seams.
- simulation.py: route start_picow / stop_picow / picow_packet_out through the
ws_sim_handler hook (the overlay handles + gates them).
- iot_gateway.py: resolve the Pico W gateway through the gateway_proxy hook.
- Delete services/picow_net/ + picow_net_bridge.py (moved to the overlay).
Tests: move the cyw43/picow suites to the overlay; update RP2040Simulator
mock stubs to attachPioPeripheral.
Wi-Fi sketches on the emulated Pico W associate via the chip's built-in
virtual net (DHCP/ARP answered locally), but outbound traffic had no
route, so DNS/MQTT/HTTP failed with OSError -2.
Wire the emulator's outbound DATA path to the backend picow_net bridge:
- Cyw43Emulator forwards every outbound Ethernet frame EXCEPT DHCP/ARP
(still answered locally) to firePacketOut -> the WS bridge, which NATs
DNS/TCP/UDP to the real internet and injects replies back.
- The virtual net stays ON unconditionally and shares the backend's
subnet, gateway and gateway MAC (10.13.37.0/24, gw 10.13.37.1). Nothing
is mutually exclusive, so an absent or flaky bridge can never break the
Wi-Fi association -- it just falls back to no-internet, as before.
- useSimulatorStore opens the bridge (cyw43.connect()) for Wi-Fi sketches.
Validated end to end against a running backend: WiFi connect + DHCP, DNS
resolves example.com, TCP connect + HTTP GET returns 200 OK. Gated e2e in
picow-bridge-e2e.investigate.test.ts (CYW43_BRIDGE_E2E=1).
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).
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).
(1) The explorer's per-board manifest entry is renamed velxio.json -> libraries.json
and clicking it now opens a READ-ONLY JSON view of that board's declared libraries
(board.libraries) in the editor, instead of the modal. New editor state
manifestViewBoardId: when set, CodeEditor renders a read-only Monaco showing
{libraries:[...]} live; opening/activating any real file clears it. No file is
added to the workspace, so nothing touches compile or save. Library actions are
done in the Library Manager modal (toolbar button).
(2) Drop the 'Uninstall' button for shared index/cache libraries — you can't
uninstall a copy everyone shares (content-addressed cache). Only your own custom
.zip uploads keep a 'Remove' (per-user store). Index libs: just Add to / In project.
Library manifests are now PER-BOARD (each board carries its own velxio.json),
so two boards in one project can use different (even conflicting) libraries
without clashing — the multi-board extension of the no-clash guarantee.
- board.libraries on BoardInstance + serialisableBoard: rides in boards_json,
so it round-trips, dirty-checks, autosaves and restores natively. This also
removes the load-restore hacks (useLibraryManifestStore + applyProjectManifest
deleted): the manifest is plain board state.
- loadProjectState now restores per-board boardOptions/spiffsFiles/libraries
(it previously dropped them).
- EditorToolbar single + compile-all send the COMPILING board's libraries.
- Backend compile.py prefers the client's per-board request.libraries; the
project-level libraries_json (now the union of all boards) is the fallback.
- buildLoadPayload migrates pre-per-board projects: seed each board with the
project union so they keep compiling scoped.
- Library Manager 'In project' tab edits the ACTIVE board's velxio.json (shows
the board name) and the add field is now an autocomplete (installed libs +
index search) so users pick from a list instead of typing names.
Deletes useLibraryManifestStore.ts + applyProjectManifest.ts.
Activates manifest-scoped ESP-IDF resolution for the gallery. loadExample now
records the example's declared libraries in useLibraryManifestStore; EditorToolbar
passes them to compileCode, which sends them as `libraries` in the compile
request. The backend then merges exactly those libraries (P2.0 scope) instead of
picking a stray same-named lib from the shared dir.
Safe: a core-only example sends null (legacy scan-all); a stale/incomplete
manifest degrades to scan-all via the backend graceful fallback, never a wrong
build. Ignored by the backend for non-ESP32 (arduino-cli) boards. Example
manifests were completed (incl. transitive deps) in c671c9b.
Phase 2 of the run-system/UX work.
- BoardInstance gains an optional user ; boardDisplayName(board) resolver
(name || kind label) routes every INSTANCE-label surface: file-explorer
section header, compile console (EditorToolbar), canvas selector/tooltip/
context-menu, Serial Monitor tabs, Oscilloscope board picker, Board Options
subtitle. Board/component pickers keep the KIND label (they pick new boards).
- Inline rename on board AND chip section headers (double-click the name, or a
hover pencil button). Board -> updateBoard(id,{name}); chip -> chipName in
properties. Enter commits, Escape cancels (cancel-flag ref guards the
unmount-fires-onBlur footgun), empty clears to the kind / 'Custom Chip'.
- FileTabs shows an owner badge naming the board/chip whose files are shown
(resolved as a selector so it doesn't re-render on every sim pin toggle).
- CustomChipDialog no longer clobbers a user-given chipName: chip.json's name
only seeds the blank defaults (My Chip / Custom Chip); loading an example
relabels explicitly.
- Persistence: board name round-trips via projectPayload (+ dirty hash),
vlxFile, ProjectByIdPage load + loadProjectState; chipName rides components_json.
- Drive-by: fixed a pre-existing rules-of-hooks violation in BoardOptionsModal
(early return before a useCallback).
Reviewed by a 3-agent adversarial pass (completeness / persistence / correctness);
all major findings folded in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A programmable custom-chip (a CPU emulator that runs a ROM/program, e.g. the
Z80 or 8080) now keeps its program (larson.s, chaser.c, ...) in a dedicated
editor file group — group-chip-<chipId> — rendered as its own collapsible
section in the file explorer, exactly like each board owns its sketch group.
Behaviour/driver chips and predefined chips carry no programFile and get no
group; they stay editable only in the chip designer.
Fixes two reported issues on the Z80 examples:
- /example/z80-larson-no-board: the board-less chip example now opens its
program (larson.s) as the active group, editable on the left — previously
the editor showed but no file appeared.
- /example/z80-led-chaser-c: the chip program (chaser.c) no longer shows as
a sibling tab inside the Arduino sketch group; it sits in its own chip
section instead. The board group shows only sketch.ino.
Details:
- useEditorStore: chipFileGroupId()/CHIP_GROUP_PREFIX helpers.
- loadExample: seedChipProgramGroups() routes each chip's programFile into its
own group (seeded from the example files), sweeps stale chip groups, keeps
the program OUT of the board group, and for a board-less chip example makes
the chip group active so the program is the editable file shown.
- EditorToolbar.prepareCustomChips: resolves the program from the chip's own
group (falls back to board files for older projects) before assembling ROM.
- FileExplorer: renders one collapsible section per programmable chip with an
IC icon; clicking switches the editor to the chip group. Lazy-creates a
group for chips dropped on the canvas.
- projectPayload + vlxFile: serialise chip groups alongside board groups and
include them in the dirty-check hash, so chip-program edits persist on
save / autosave / .vlx export and round-trip via replaceFileGroups on load.
- Regression tests for board-less + board+chip routing and stale-group sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Velxio can now simulate one or more custom-chip CPUs with NO Arduino/ESP32
board on the canvas — a general-purpose electronics simulator, not an
MCU-only one.
- DynamicComponent: board-less parts get the real shared flat PinManager
(instead of a no-op stub) so a custom chip's digital pin writes/reads reach
the LEDs/inputs wired to it.
- CustomChipPart: the rAF tick respects board-less Run/Stop (freezes while
the electrical sim is paused); board behaviour is unchanged.
- EditorToolbar.handleRun: board-less Run compiles each chip's WASM/ROM and
re-attaches the parts (restartParts) so they pick up the fresh WASM, then
resumes the solver.
- useSimulatorStore.restartParts(): bump hexEpoch to force part re-attach.
- New example "Z80 Larson Scanner (no board)": a programmable Z80 + 8 LEDs +
the adjustable power-supply component, no MCU. The chip drives the LEDs
through the synthetic-pin + ngspice path added earlier.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three reported circuit bugs:
- Deleting the active/running board left the global `running` flag stale
at true. That flag mirrors the active board, but removeBoard reassigned
activeBoardId without re-deriving running, so the circuit looked
"running" (toolbar stuck on Stop, canvas locked) and SimulatorCanvas's
master-switch effect auto-started sibling remote boards. New Project
hits the same path (it removes every board in a loop). removeBoard now
re-derives running from the new active board (false if none remain).
- loadExample's single-board path called setBoardType when boards already
existed but never dropped the extra boards a previous multi-board
example had added, so they lingered as residue. It now removes every
board past the first before retyping, matching the multi-board and
board-less paths.
Adds board-removal-running-reconcile.test.ts (6 regression tests; full
suite 1917 passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds stm32-f4-discovery, stm32-olimex-h405, stm32-netduino-plus2, stm32-netduino2, stm32-blackpill-f401 and stm32-bluepill-f103cb, mapped to existing qemu-lcgamboa machines (netduinoplus2, olimex-stm32-h405, netduino2, stm32vldiscovery). A generic inline board renderer (no SVG) draws the Discovery/Olimex/Netduino boards from a header pin layout; the Pill variants reuse the Blue/Black Pill SVGs. Per-board onboard-LED pin and polarity via STM32_LED. One blink+serial example per board.
tsc --noEmit clean; all new FQBN pnum variants present in STM32 core 2.12.0; worker smoke tests pass for the new machines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
STM32 emulation (open-core, runs via libqemu-arm in the backend worker):
- backend: stm32_lib_manager + stm32_worker (GPIO, USART, I2C/SPI device models
reusing the ESP32 slaves, live sensor updates), arduino_cli STM32 branch,
start_stm32 simulation route.
- frontend: Stm32Bridge + Stm32BluePill(/BlackPill) web components (Wokwi SVGs),
board kinds, Interconnect/boardPinMapping/boardProtocols wiring, example
projects (blink, serial, I2C BMP280/MPU6050/DS1307/SSD1306/weather, 7-seg,
RGB, button, switch, stepper, cross-board interconnect).
- Raspberry Pi 4/5 board elements + thumbnails.
Pro board gating (generic OSS->Pro seam; entitlement logic lives in the overlay):
- lib/proBoardGate.ts: isProBoardKind (STM32 + every QEMU Raspberry Pi),
installBoardGateImpl/boardGateDecision, triggerProUpgradePrompt.
- PRO badge on those boards in the component picker; gate at the picker add +
the run backstop (startBoard).
- backend/app/services/board_access.py: server-side enforcement seam for the
simulation WebSocket; STM32/Pi unavailable -> Pro-framed message.
- desktop: generic QemuDownloadPrompt + Stm32QemuPrompt (download-behind-license,
mirrors the ESP32 prompt).
- .gitignore: never ship libqemu-* binaries in the public image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 D1.4 — replaces the binary public/private toggle in ShareModal
with three radio-button-styled options. Optimistic UI: every option
renders for every user; the backend's 403 (with structured
visibility_not_allowed detail) redirects to /pricing?from=visibility_X
so the pricing page can lead the right pitch.
Why optimistic-then-redirect instead of hiding/locking options:
1. Discovery — Free / Maker users SEE Pro unlocks Private. That's the
exact conversion signal the pricing page is trying to surface.
2. Discovery without surprise — the locked click goes to /pricing
with a hint, not a dead modal.
3. Less plan-coupling — this upstream component doesn't need to know
about the pro overlay's plan store. Backend is the only source of
truth for what's allowed.
Touched:
- ShareModal.tsx: full rewrite as a 3-option picker with badges
(Maker / Pro) on the gated options.
- projectService.ts: ProjectResponse / ProjectSaveData now declare
`visibility?: 'public' | 'unlisted' | 'private'`. is_public stays
declared for backward compat with old callers.
- useProjectStore.ts: CurrentProject gains `visibility?`; setVisibility
accepts EITHER the legacy boolean OR the new enum and keeps both
fields coherent.
- common.json (4 locales): new editor.share.visibility.{publicLabel,
publicHint, unlistedLabel, unlistedHint, privateLabel, privateHint}
+ editor.share.updateFailed.
Backend gating + DB migration are in the velxio-prod pro overlay
(commit referencing this submodule pointer).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous fix preserved display state on Stop so Resume could pick
up the multiplexed frame seamlessly — but that's Pause semantics, not
Stop. On a real Arduino, hitting the physical Stop is cutting power:
the next Run must boot from setup(), not continue at the saved PC.
User report on https://velxio.dev/example/uno-7segment :
> empieza a contar, le doy stop en el 6, le doy run y sigue desde 6
stopBoard now:
- calls sim.reset() (was sim.stop()) — CPU back to PC=0
- calls hardResetPinStates() (was the soft resetPinStates) — clears
cached states AND notifies listeners so 7-seg / NeoPixel / LCD
blank out instead of freezing on whatever was lit.
Reset and Stop are now the same cold-boot semantics; Reset still
additionally clears serial output + baud rate. The soft
resetPinStates() helper stays for internal SPICE-classification-only
paths that don't want listener fan-out.
Reporter feedback after 7aca3db: pressing Stop on the uno-7segment
example turned the 7-segment off, and pressing Start again left
random segments lit / no number at all. The previous fix made
resetPinStates() notify every listener with (pin, false) on both
Stop and Reset, which was right for Reset (full reboot) but wrong
for Stop:
- On Stop the AVR CPU is just paused. Internally it still has
PORTD=0xFF (or whatever the last drive was).
- resetPinStates blanked the pinStates cache + fan-out LOW
notifications. Display turns off, fine.
- On Start the CPU resumes from where it paused. avr8js's port
listener fires only for bits that CHANGED relative to its OWN
oldValue (which still holds the pre-stop value). If oldValue
matches the live register, no pinChange event fires for that
bit, and the display has no signal telling it to come back on.
Split the API into two methods:
resetPinStates() — soft cleanup, drops outputPins only. Used by
stopBoard. Cached pinStates and visual state
stay so the resume picks up where it left off.
hardResetPinStates() — full cleanup, drops outputPins + pinStates
and fan-outs (pin, false) to listeners.
Used by resetBoard (CPU starts at PC=0,
firmware re-drives every pin from setup()).
Updated the test helper clearAllPinManagerState to call
hardResetPinStates between tests so the same-state short-circuit in
triggerPinChange doesn't suppress fresh events.
All 32 vitest tests pass (AVRSimulator, interconnect-routing,
dual-arduino-software-serial, pin-position-rotation).
Two paired bugs that surfaced on the Reset button.
(1) 7-segment / NeoPixel / LCD freeze on last pattern after Reset.
resetPinStates() was wiping the pinStates cache + outputPins set
silently — no listener notifications fired, so visual components
that update on pinChange kept rendering whatever segments were
lit at the instant the user pressed Reset. Now we snapshot every
pin that was HIGH before clearing and fan out a synthetic
(pin, false) to each registered listener. Stateful displays
redraw cleanly to all-off; passive listeners (analog sensors,
debounce-only buttons) ignore the synthetic LOW and recover on
their next real write.
(2) Cross-board serial silently dies after pressing Reset. resetBoard
was unconditionally reassigning:
sim.onSerialData = (ch) => appendSerial(boardId, ch);
immediately after sim.reset(). The comment said "re-wire after
reset" but reset() does NOT clear that property — the new USART's
onByteTransmit chains through `this.onSerialData` which IS the
Interconnect wrapper. The reassignment destroyed that wrapper and
sibling-board UART forwarding (Uno TX → Nano RX) stopped working
until a full page reload. Same root pattern as the initSimulator
bug fixed in 5480052 — Interconnect's __icSerialHookInstalled
flag is on the live sim, so once the wrapper is blown away
nothing reinstalls it. Removed the reassignment and left a NOTE
so the next person doesn't reintroduce it.
Verified the AVRSimulator + dual-arduino-software-serial +
interconnect-routing test suites still pass (26 tests).
Cross-board UART forwarding silently broke for any project loaded
with > 1 board. User report: Arduino Uno → Arduino Nano serial echo
test where the Uno transmits fine but the Nano's Serial.available()
is never true.
Root cause traced live with chrome-devtools-mcp + temporary debug
logs in AVRSimulator.onSerialData setter and Interconnect:
1. loadProjectState → addBoard(uno) → createSimulator → sim.onSerialData = appendSerial
2. addBoard(nano) → same
3. setWires → Interconnect.updateWires → ensureSerialHook(uno)
wraps sim.onSerialData with a fan-out callback that ALSO pushes
to the Nano's RX queue. __icSerialHookInstalled flag set.
4. SimulatorCanvas mounts → useEffect calls store.initSimulator()
5. initSimulator unconditionally did:
simulatorMap.delete(boardId);
const sim = createSimulator(...); // ← brand-new sim
simulatorMap.set(boardId, sim); // ← Interconnect's wrapper is gone
The new sim's onSerialData is just appendSerial. The old sim
(where the wrapper lived) has been orphaned; Interconnect never
re-installs because its flag was on the discarded sim.
6. Run all boards → Uno.usart.onByteTransmit → this.onSerialData →
appendSerial (Uno's monitor shows TX) but no fan-out call →
Nano never receives anything.
initSimulator is a legacy single-board helper from the days when the
store only knew about one MCU. Multi-board flows already create
their sims in addBoard. Bail out early if a sim for the active
boardId already exists, so the legacy helper becomes a no-op when
the multi-board path has already done the work.
Verified the 3 related test suites still pass (AVRSimulator,
dual-arduino-software-serial, interconnect-routing).
User report: "rotating components messes up their connections" — pressing R
on a placed component visibly slid every wire endpoint off its pin tip.
Root cause: the DynamicComponent wrapper has padding:4px + border:2px on
EVERY side, so the inner web-component element sits 6 px in from the
wrapper top-left on BOTH axes. The wire layer assumed an asymmetric
(4, 6) offset, baked into:
* useSimulatorStore.updateWirePositions — store.x + 4, store.y + 6
* useSimulatorStore.recalculateAllWirePositions
— start (startComp.x + 4, startComp.y + 6)
— end (endComp.x + 4, endComp.y + 6)
* pinPositionCalculator.calculatePinPosition — inverse: (componentX - 4, componentY - 6)
Unrotated the 2 px X bias was visible only as a very-slightly-off wire,
which nobody filed. When the user rotated the component, the bias
rotated WITH it — at 90° it became a 2 px Y offset (wires hanging below
the pin), at 180° a 2 px X offset on the other side, at 270° upward. UX
read as "wires disconnected".
Verified the real CSS box via chrome-devtools-mcp against several live
components on velxio.dev (RGB LED + 3 resistors + analog joystick): all
report padding-left/top = 4 px, border-left/top = 2 px, inner offset = 6
on both axes.
Fix: use (+6, +6) at every site, single source of truth in a comment
explaining padding+border arithmetic. Updated the rotation regression
test to match the corrected math (numbers shift by 2 px on every
expectation that referenced the old offset).
Pin position math, pivot derivation and the rotate-N×90° round trip
unchanged — only the offset constant moved.
Phase 7.7 follow-up. Previously the WiFi stub returned wlan.isconnected()=False
and ntptime.settime() raised OSError — sketches degraded gracefully but
features like the TIME and WEATHER screens in the smart-ui-eyes example
showed "Sync Failed" / "API Error" instead of real-looking data.
Smart stub now:
- wlan.isconnected() returns True after the first ~2 calls (simulates a
~1 second connection ramp)
- ntptime.settime() pre-loads machine.RTC() with the host's UTC datetime
(captured at code-injection time), so localtime() returns real time
- urequests.get(url) returns a stubbed Response whose .json() decodes a
payload routed by URL substring:
"openweathermap"/"weather" → fake weather dict (temp/humidity/desc)
"ipify"/"myip" → fake public IP
"worldtimeapi" → fake ISO datetime
everything else → {}
- urequests.post/head also stubbed (return {"ok": True} / {})
- Both `urequests` and `requests` aliases registered
End result: smart-ui-eyes example shows real-looking time on TIME
screen and plausible weather data on WEATHER screen, no crashes.
Still no real internet (would need Phase 7 QEMU WiFi emulation), but
visually the example demos correctly.
Inject a compat shim into the raw-REPL prelude that replaces
sys.modules["network"] and sys.modules["ntptime"] with no-op stubs
BEFORE user main.py runs.
Why: the picsimlab QEMU fork's esp32_wifi NIC emulation handles
Arduino's lightweight WiFi.h but not MicroPython's full esp_wifi_init
path. Calling network.WLAN(STA_IF) (which is what every
network-using MP sketch does) drives the firmware to wait on
peripheral status bits QEMU never sets, eventually tripping the
FreeRTOS task watchdog (TG1WDT_SYS_RESET ~26s after boot, or
TG0WDT ~14s if the NIC is partially attached).
With the stub:
network.WLAN(STA_IF).isconnected() -> False
network.WLAN(STA_IF).connect(...) -> no-op
ntptime.settime() -> raises OSError
Sketches that already have try/except around sync_time (which is
most of the 100-days examples) now degrade gracefully: WELCOME +
EYES screens run, TIME and WEATHER screens show their fallback
behaviour, no panic, no reboot.
Doesn't affect Arduino C++ — sketches that #include <WiFi.h> use
real WiFi.begin() and the existing esp32_wifi NIC handles those fine.
A proper fix is to extend the picsimlab WiFi emulation to support
the full ESP-IDF API, but that's a multi-day project. This stub
unblocks the 31 MicroPython examples shipping with network imports.
The hasWifi auto-detection in useSimulatorStore.startBoard only matched
Arduino C++ patterns (#include <WiFi.h>, WiFi.begin). MicroPython
sketches that call `import network` or `network.WLAN(STA_IF)` were
not detected, so wifi_enabled stayed false and the backend never
attached the esp32_wifi NIC model to QEMU.
Symptom: any MicroPython ESP32 example that touches the network
module hangs in network.WLAN(STA_IF) (the constructor that triggers
esp_wifi_init internally) and the FreeRTOS task watchdog trips with
TG1WDT_SYS_RESET ~26 seconds after boot. The chip then reboot-loops.
Mirror the Pico W detector right below this one — it already handles
both Arduino and MicroPython patterns. Now ESP32 does too.
Affects 31 examples in examples-100-days.ts that use network.WLAN.
Real digital storage scopes have a trigger that pins the visible window
around a detected edge — without it, sparse activity (UART bytes once
per loop, an interrupt firing every few seconds) scrolls off the screen
faster than the eye can catch. Velxio's scope was free-running only,
which made the recent UART TX waveform work effectively invisible at
fine time/div settings: the byte burst was 87 µs but the window only
showed the most recent 1 ms.
Three trigger modes, matching what you'd find on a Rigol / Tektronix:
* Auto — current free-running behaviour, window's right edge
tracks the most recent sample. Default.
* Normal — window pins around each triggering edge so the event
lands at `triggerPosition * windowMs` from the left
(default centred at 0.5). Keeps re-pinning on every
new triggering edge.
* Single — arms once, freezes the trace on the first triggering
edge by flipping `running = false`. User clicks
"Re-arm" to capture again.
Three knobs configurable per mode:
- source: which channel produces the trigger event
- edge: rising (↑) / falling (↓) / either (⇅)
- position: trigger lands at this fraction of the window
(UI hard-codes centre 0.5 for now; the store field
accepts any value if we want a draggable handle later)
UI additions in the scope header (only shown when mode != auto):
- source / edge dropdowns
- status badge (Armed / Triggered / Captured) with pulse animation
on Armed so the user knows the scope is waiting for an event
- Re-arm button in Single mode after capture
Canvas changes:
- Dashed orange "T" marker drawn at the trigger position when an
edge is latched and within the visible window.
Store changes:
- pushSample peeks at the trigger channel's previous state, detects
a matching edge, sets triggeredAtMs (and stops `running` for
Single mode). matchesTriggerEdge() exported for unit testing.
- clearSamples / setTriggerMode / setTriggerChannel / setTriggerEdge
all re-arm the trigger; rearmTrigger() explicitly resets and resumes
capture (used by the Re-arm button after a single-shot).
Covered by 11 new vitest cases (oscilloscope-trigger.test.ts) plus the
existing 1892 tests still pass.
Closes the "I set 0.1 ms/div on a Serial.print sketch and see a flat
line" UX trap reported on the Discord follow-up — at 0.1 ms/div the
window is 1 ms but bytes fire every 2 s, so without a trigger the
chance of catching the burst is < 0.05 %. With Normal trigger on
rising D1 the burst pins in the middle of the window and the user can
zoom down to bit level (8.68 µs each) without losing it.
Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART
transmits the byte over the WebSocket as a 'serial_output' event with no
GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while
real silicon would render the 8N1 frame at the configured baud rate.
Two changes inside Esp32Bridge:
* New `onPinChangeWithTime: (pin, state, timeMs) => void` callback
that hooks the oscilloscope at parity with AVRSimulator /
RP2040Simulator. The 'gpio_change' event now also flows through it
(timestamped with `performance.now()` — QEMU virtual time isn't
surfaced across the wire, but at 1× sim speed the wall-clock skew
is invisible on any practical sweep). This also fixes the broader
issue that ESP32 boards previously couldn't show ANY digital GPIO
activity on the scope.
* `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first
+ stop transitions at `this.uartBaudRate` (default 115200) on the
UART0 TX pin, mapped per board variant:
esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1
esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43
esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21
Backend doesn't expose the live baud rate so we default to 115200
(the Arduino default). Override path: bridge.uartBaudRate = N
once we surface Serial.begin's argument via a backend event.
Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)`
inside the three Esp32Bridge construction sites in useSimulatorStore
(setBoardType, addBoard, changeBoard).
The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3
deploys without regressions, so the temporary fallback shipped in
commit 77bf897 can come out. Closes#101.
Backend (esp32_worker.py + esp32_lib_manager.py):
- Stop emitting `ledc_update` from the 0x5000 LEDC callback and from
the polling thread. Only `ledc_duty` (channel + duty_pct) and the
GPIO matrix routing events ship now.
- Drop the channel→gpio reverse-lookup that fed the legacy event.
Frontend:
- Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`.
- Delete `makeLedcUpdateHandler` + its `channelGpioMemo`.
- Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':`
message handler + the `LedcUpdate` type.
- Strip `this.onLedcUpdate = null` from 14 test mocks.
- Rewrite the `does not call broadcastPwm` guard in
esp32-multi-servo-gpio-matrix.test.ts to assert the method itself
no longer exists on PinManager (stronger regression guard than the
spy version, and doesn't need vi).
- Remove the `PinManager.broadcastPwm fallback` describe block from
esp32-servo-pot.test.ts — every test in it exercised the deleted
fallback path.
Docs (ESP32_EMULATION.md):
- Replace `ledc_update` rows in the events / implementation tables
with the SignalRouter trio (`ledc_duty`, `gpio_routing`,
`gpio_routing_clear`).
- Update the visual flow diagram + the "why this matters" paragraph
to past-tense the broadcastPwm bug.
Tests: 1886 frontend tests pass (the previously-failing
board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is
also green). Backend unit suite: 279 pass, the 11 espidf_real_paths
prereq failures are environment-dependent (need arduino-cli libs in
the local shell) and unrelated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.
Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
`outputPins` only if the DDR bit is set, so the PORTx write that
enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
`digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
INPUT_PULLUP — without this the firmware reads LOW from boot and
thinks the button is permanently pressed (the "LED is always on,
pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
with active PWM, AND subscribe to onPwmChange to re-tick the netlist
on duty changes. Fade-LED now produces a true gradient (6 distinct
brightness levels across a fade cycle) instead of a binary 0/full
toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
same in-flight tick used to overwrite each other; now every pin's
most-recent edge replays after the tick. Fixes Traffic-Light RED→
YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
underscores in V-source names. ngspice's interactive `alter` command
treats `-` as an operator and silently no-ops on hyphenated source
names, so mid-simulation MCU pin transitions stopped propagating
after the first solve. MixedModeScheduler.onMcuPinChange and
CircuitSimulationService self-heal use the same sanitizer so names
stay consistent across emit/alter/lookup. Also added a regex-based
fallback in step 2 so any board pin matching `GND.\d+` canonicalises
to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
per-board `groundPinNames` list missed several, leaving wires
floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
every wired board pin. Leaves INPUT pins (analog sensors on A0,
pull-down dividers, etc.) free for the SPICE solver instead of being
shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
triggerPinChange / setPinState so the new outputPins tracking fires
on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
outputPins clears between runs; Esp32Bridge.onPinChange passes the
`'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
`clock=16pll` (ATTinyCore 1.5.2 renamed the option).
Backend
- esp-idf-template/main/CMakeLists.txt: skip the
`-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
variants already define LED_BUILTIN in pins_arduino.h via a
self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
command line expanded the static-const declaration to
`static const uint8_t 2 = ...;` — a syntax error that broke every
ESP32-C3 / S3 build (`expected unqualified-id before numeric
constant`).
Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
`componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
exist on the canvas). Replaced with `'arduino-uno'` (the canvas
board-id convention) and converted `D<n>` pin names to `GP<n>` for
Pico-style boards. Affects pico-blink, pico-i2c-scanner,
pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.
Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
Blink (single-pin), Button (idle-OFF invariant — catches the
INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
driven). Run via `npm --prefix frontend run test:visual` against a
Chrome on `:9222` + vite on `:5174` + backend on `:8001`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a new BoardOptionsModal accessible from the EditorToolbar that exposes
per-board options (currently used for board-specific compile flags). Wires
the modal through:
- types/boardOptions.ts new BoardOptions shape
- types/board.ts BoardInstance gains `boardOptions` + `spiffsFiles`
- store/useSimulatorStore.ts boardOptions persisted in loadProjectState
- components/editor/EditorToolbar.tsx button to open the modal
- components/simulator/BoardOptionsModal.{tsx,css} the modal itself
- components/simulator/SimulatorCanvas.tsx passes the options through
- utils/projectPayload.ts board options serialised in saved projects
- pages/ProjectByIdPage.tsx re-includes the by-id loader needed for
project URLs that reference boards with
their persisted options.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to
kill init" panic as `mount /dev/vda` failing with EINVAL — Debian
armmp does not have ext4 builtin (only fuseblk in /proc/filesystems).
- qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries.
All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio
virtio transport (arm-32 virt PCI fails -75 due to missing reg DT
property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile;
Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus
for virtio-blk-pci vs virtio-blk-device (and serial likewise).
- manifest.json: new `raspberry-pi-armhf` image_set wiring three
assets (kernel + initramfs + zstd rootfs).
- Frontend BoardKind gains the three new kinds + an isPiBoardKind()
helper. Replaces the eight scattered `=== 'raspberry-pi-3' ||
=== 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in
useSimulatorStore, Interconnect, loadExample, boardProtocols.
ComponentRegistry gets three new picker entries.
- board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds
(backend boards have no canvas examples).
The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes
live in velxio-prod's scripts/ (private overlay) — the upstream
kernel build script only knows about arm64; armhf is built in the
private repo because the assets ship through the license endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend: extract per-board config into a PI_CONFIGS dict keyed by
board_type. Pi 3/4/5 share the same arm64 image set (kernel +
initramfs + rootfs) and differ only in QEMU -cpu and -m:
raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit)
raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit)
raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit)
PiInstance now carries board_type so the per-board lookup happens
once at start_instance time. Unknown board_type falls back to
DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for
back-compat with older clients).
Pre-warm hook walks every unique image_set in PI_CONFIGS so the
provider only downloads each set once even when several Pi models
are registered.
Frontend:
- BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'.
- BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards
(FQBN null since they use the Pi VFS + Python toolchain like Pi 3).
- ComponentRegistry inserts two new component metadata entries
cloning the Pi 3 board art with different thumbnail colours.
Tag name reused so the same velxio-raspberry-pi-3 web element
draws the board on the canvas — the 40-pin GPIO layout is
identical across Pi 3/4/5.
- boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table
(PI3_BCM) since the 40-pin header layout is identical.
- loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS
ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike.
- Interconnect.isPi3Bridge() recognises all three Pi family members
so Arduino↔Pi serial routing keeps working.
- RaspberryPi3Bridge constructor gained a boardKind parameter
defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message
now ships the actual board kind so the backend knows which
PI_CONFIGS entry to use.
- useSimulatorStore.addBoard wires bridge construction for all
three Pi family members.
Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel
package + armhf rootfs build, no change here.
Smoke-tested inside the prod container:
Pi 4 (cortex-a72) → reached agetty login on hvc0
Pi 5 (cortex-a76) → reached agetty login on hvc0
Both show 'aarch64' in uname -m.
The user reported the default editor canvas — Arduino Uno + LED +
220Ω resistor — was correctly powered (1.84 V at the LED anode,
14 mA through the diode) but the LED visual stayed dark. Only the
built-in pin-13 LED on the wokwi-arduino-uno element lit up.
Root cause: ngspice's WASM build truncates branch-current vector
keys at the first hyphen. A sense source named V_led-builtin_sense
ends up exposed under a key like v_led#branch rather than the
expected v_led-builtin_sense#branch. CircuitSimulationService and
BasicParts.ts both look up the FULL key, miss, and the LED's
brightness update treats raw as undefined → digital-fallback path
runs but the SPICE memo timestamp is fresh so HOLD keeps zero
brightness. Visible symptom: a perfectly conducting LED that never
lights.
Fix in two places:
- Default canvas (useSimulatorStore.ts): rename 'led-builtin' /
'r-builtin' to 'led_builtin' / 'r_builtin' (and the matching
wire ids).
- DynamicComponent.tsx makeNewComponent: the id template was
'metadata.id-timestamp-rand' producing hyphens for every
user-added component too. Switched to underscores, AND replace
any hyphens already in metadata.id (e.g. 'led-bar-graph') so
the prefix doesn't reintroduce the bug.
Existing saved projects whose ids contain hyphens are not migrated
here — those will keep the visual bug until either the operator
edits the components or we add a sanitisation step inside
componentToSpice + BasicParts. The next follow-up commit can add
that if you confirm this default-canvas fix works.
The default canvas (Arduino Uno + LED on pin 13) wired the LED
directly between pin 13 and GND. Two consequences:
1. Real-world: that's a short across a forward-biased diode,
blowing the LED in seconds.
2. Simulator: ngspice can't find a steady-state branch current
for an unprotected diode (returns NaN / indeterminate), so
the LED visual never lights up. Only the wokwi-arduino-uno
element's BUILT-IN LED (rendered internally by the element,
not via wire+pinManager) was visible.
Fix: insert a 220Ω resistor between pin 13 and the LED anode,
cathode straight to GND. Same circuit every introductory Arduino
book teaches. SPICE converges, LED blinks visually on the canvas.
Reported by a user trying Blink on a fresh /editor visit.
Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a
proper signal-routing abstraction that mirrors the ESP32 SoC's
IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal
sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table
→ GPIO pins.
Motivation (from user bug report in
velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two
ESP32 servos on a solar-tracker visibly oscillated between two
positions instead of moving smoothly when the user changed LDR
sliders. Commit 77bf897 patched it (per-channel gpio memo +
broadcast guard) but the user requested a proper hardware-fidel
architecture, not patches.
Backend:
* `app/services/signal_router.py` — SignalRouter class. Forward
index (gpio → signal_id) + reverse index (signal_id → set of
gpios). `replace_snapshot()` returns the diff for the polling-
fallback path; future C plugin hook becomes a push without
touching this code.
* `app/services/esp32_signals.py` — Signal id constants from
ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()`
helper.
* `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone;
`_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing`
which emits `gpio_routing {gpio, signal_id}` events on diff.
The 0x5000 LEDC callback and the LEDC poll thread now emit
`ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside
the legacy `ledc_update {channel, duty, gpio}` for back-compat
during rollout.
Frontend:
* `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python
class. Same forward + reverse index; same `pinsForSignal` /
`updateRouting` / `clearRouting` API.
* `simulation/esp32-signals.ts` — Signal id constants, mirror
of the Python module.
* `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`,
`onGpioRoutingClear` callbacks; handlers for the new event types.
* `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up
pins via `router.pinsForSignal(ledcSignalForChannel(channel))`
and dispatches per pin. `makeGpioRoutingHandler` /
`makeGpioRoutingClearHandler` keep the mirror in sync. Per-board
`signalRouterMap` parallels `pinManagerMap` in lifecycle.
`makeLedcUpdateHandler` (and its memo workaround from 77bf897)
stays wired for back-compat during rollout; removed in a
follow-up commit once prod is verified stable on the new path.
Tests:
* `test/backend/unit/test_signal_router.py` (20 tests) covers
update/clear semantics, idempotency, multi-pin routing,
snapshot diff, channel↔signal-id helpers, and the multi-servo
regression scenario.
* `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the
mirror — same scenarios on the TS side.
* `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts`
(6 tests) drives the end-to-end SignalRouter handler pipeline,
asserts that two servos on GPIO 13/12 via LEDC channels 0/1
move independently (no mirroring), that re-routing carries
cleanly, and — critically — that `PinManager.broadcastPwm` is
never called.
Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend
unit tests pass (was 259).
Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture
diagram + a runbook for adding future peripherals through the
SignalRouter.
The C plugin hook in qemu-lcgamboa that would push gpio_out_sel
writes synchronously (eliminating the polling race window entirely)
is the next step — kept as a follow-up because the polling-fallback
path here already resolves the routing before each duty event
fires, so the bug is fixed end-to-end. The plugin work removes the
race condition fundamentally.
User-reported bug (project 5218f9e3, solar-tracker with 2× ESP32
servos): when LDR values change the servos visibly oscillate between
two positions instead of moving smoothly.
Root cause in useSimulatorStore.makeLedcUpdateHandler. When the
backend emits a ledc_update with gpio=-1 (the per-channel gpio_out_sel
map isn't populated yet on the very first duty change after attach),
the handler called PinManager.broadcastPwm(duty). broadcastPwm fans
the same duty out to ALL registered PWM consumers — for a project
with two servos both subscribed in the 0.01-0.20 duty range, each
broadcast made BOTH servos mirror whichever channel was last
written. Result: servoPan→91° and servoTilt→87° alternating writes
would visibly snap both servos to 87°, then 91°, then 87°…
Two-part fix:
1. PinManager grows `pwmListenerPinCount()` — number of distinct
pins with at least one PWM consumer registered.
2. makeLedcUpdateHandler now keeps a per-board memo of
{ledc_channel → last-known-good-gpio}. On a gpio=-1 update:
- if the channel has a remembered gpio, route there;
- else, only broadcast when there's at most ONE consumer
(single-LED / single-servo setups still work);
- otherwise drop the update — the backend's GPIO out_sel poll
repopulates the map within a few ms and the next ledc_update
arrives with a real gpio.
The drop is correct because the same LEDC channel keeps emitting
duty changes every Servo.write() call (~33 Hz at 30 ms loop delay),
so missing one transient gpio=-1 frame is invisible.
Tests: 1853 pass. The existing esp32-servo-pot tests already cover
the gpio>=0 happy path; the new memo path is exercised indirectly
through that handler.