ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13"
Pico clock came out sideways and clipped). The ESP32 worker decoder was just
taught to compose in the controller's native RAM geometry and rotate to the
display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040)
still composed at display dims with no rotation, so the two diverged.
- SSD168xDecoder.ts: port the worker's native-window compose + rotation.
* Size RAM to the longer side both ways so a rotated native layout
(128x296 behind a 296x128 panel) isn't truncated.
* Compose in the active RAM window, then rotate via the inverse of
Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a
non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled.
* Track the UNION of windows per frame: paged drivers (GxEPD2 page height
< panel) set one partial window per page, so compose must use the full
native area, not just the last page's strip. Fixes the all-white render
on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32).
* Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white
only if both planes white), tri-colour panels keep red-wins.
* Default the active window to display geometry; the firmware overrides it.
- EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder.
- esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation +
window-union in the worker, and derive is_bwr from panel_kind on the
runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge).
- test_epaper/ssd168x_decoder.py: re-port the golden reference to match
(keeps the 3-way TS/Python/worker identity invariant). Tests updated to
construct tri-colour cases with is_bwr/palette='bwr'.
- examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)',
which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the
wire snapped to the board corner. Use '3V3'.
A tag-based 'Retro' tab (next to All) collects the Z80 / Intel / vintage-CPU
examples via their 'retro' tag, regardless of board filter (they still also
appear under Digital). One-file change: BOARD_TABS + an isRetro predicate
special-cased in the filter and the tab count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported: on a running circuit, clicking a pushbutton SELECTED the wire under it
instead of pressing the button — and you could still move wires / pick pins to
make connections during a run.
Root cause: component dragging was already locked during a run, but the
canvas-level onClick (wire selection via findWireNearPoint) wasn't — so a click
on a button bubbled to the canvas and selected the wire. The button press itself
fired (shadow DOM), but the wire-select made it feel broken.
Gate every EDIT interaction on the existing interactionRunning predicate while
keeping part interaction (buttons/switches/pots) and pan/zoom:
- canvas onClick wire-selection + onDoubleClick waypoint-insert
- wire segment / waypoint drag handles (mouse + touch)
- pin-click wire creation
- touch tap wire-selection
- hide the PinOverlay (was gated on !running, so board-less runs still showed
clickable pins) and skip wire-hover highlighting while running
- clear any wire/component selection when a run starts so leftover handles don't
linger over the live circuit
Component drag + property dialog were already gated on interactionRunning; this
extends the same 'freeze to edit, run to interact' model to wires and pins.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 of the run-system work. The compile console now groups output into a
section per run target (board or chip) with a status glyph and label, the way
multiple Arduinos already stream — instead of one flat list.
- CompilationLog gains an optional target { id, label, kind: 'board'|'chip' }.
message/type are unchanged so the pro overlay (diagnose-with-AI prompt +
errorCount slot) and the console's length-based clear/auto-error heuristics
are untouched. parseCompileResult stamps the target on every produced line.
- Producers stamp their lines: compileAllBoards (per-board, dropping the old
'<label>: ' string prefix the header now carries), prepareCustomChips
(per-chip, WASM + ROM), handleCompile + handleRun MicroPython (single board) —
including the Pi / MicroPython / FQBN / error paths so a target's lines never
fragment across sections.
- CompilationConsole groups filteredLogs into consecutive-run sections at RENDER
time only (the flat array is unchanged); each target section shows ✓/✕/▸ +
name + kind tag, with no-target lines ('Compiling all targets', 'Done') as
plain narration around them.
Reviewed by an adversarial pass; the flagged un-stamped edge paths (Pi /
MicroPython / single-board errors) are now stamped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3 of the run-system work. Generalises the boards-only Compile-All/Run-All
to RUN TARGETS = boards + programmable custom-chips, so a board+chip or several
chips compile and run together, the same way multiple Arduinos do.
- targetCount = boards + programmable chips; the Compile-All/Run-All buttons now
appear when targetCount > 1 (was boards.length > 1). Cheap string predicate
(no JSON.parse) since the selector runs on every sim tick.
- compileAllBoards builds chips (WASM+ROM) AND boards; works with zero boards;
prepareCustomChips now returns a failure count folded into the Done summary so
a failed chip no longer shows green / calls markCompiled.
- handleRunAll: compiles all targets, starts every board, then restartParts() so
chips pick up fresh WASM/ROM, and resumes the electrical solver when NO board
actually started (board-less, or a board that compiled to nothing) so chips
aren't left frozen.
Review fixes (2-agent adversarial pass):
- Stop now stops EVERY running board (Run-All can start several); otherwise a
non-active board kept the chip ticking after Stop.
- Run-All / Stop disabled gates use anyBoardRunning (+ digitalRunning) instead of
the flat active-board flag, which misreports multi-target runs.
- shared isQemuBoardKind() helper so handleRun and handleRunAll can't drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The pushbutton SPICE mapper reads pins '1.l' and '2.l', but killbits/counter
wired the power side to '2.r' (an un-unioned sub-pin), so netLookup('2.l')
returned null and the button was omitted from the netlist entirely — pressing
did nothing electrically board-less. Wire the power side via '2.l' so the
button becomes a real (pressed -> 0.01 ohm) bridge to VCC, which the pull-down
+ connectChipInputsToSolve then turn into a HIGH the chip reads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chip-output board-less path existed (chipPinDrives -> SPICE voltage sources
-> LEDs). The INPUT direction was missing: a chip pin wired to a pushbutton had
its net solved by ngspice, but nothing fed that net's state back to the
PinManager key the chip reads via vx_pin_read. So a board-less chip could light
LEDs but never read a button (verified: i8080 counter stayed at 0 on press).
connectChipInputsToSolve subscribes to the electrical store and, after each
solve, thresholds every wired chip input pin's net voltage to HIGH/LOW and
triggerPinChange()s the chip's synthetic pin — updating getPinState (polling)
and firing onPinChange edges. Pins the chip is actively driving are skipped so
it never fights its own outputs. Hooked alongside connectAnalogInputsToMcu in
start.ts. Solver-agnostic; reads only the electrical store shape.
Also gives the board-less button examples a pull-down on each chip BTN pin so
they read a clean LOW when open (a button-to-VCC floats HIGH otherwise):
i8080-button-counter (2) and i8080-killbits (8).
- new connectChipInputsToSolve.ts; start.ts wiring.
- examples-retro-intel: pull-down resistors + wires for the button examples.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of the run-system/UX work.
Stop bug: a programmable chip kept running after Stop when a board was present.
The chip rAF tick gated only on board presence (!boardless), so with a board it
ticked forever. Now it gates on the actual run state: board-less -> electrical
paused flag; with board(s) -> board.running. handleStop also clears every chip's
output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on
Stop instead of freezing at their last frame.
Examples to board-less (regulated power supply, no Arduino — the Arduino only
ever supplied 5V):
- z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet
(scanner.s) so it's visually distinct from z80-larson-no-board's single-bit
walk; green/blue LEDs.
- i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's
editable program; buttons re-powered from the supply.
- i8080-button-counter -> board-less (psu + resistors); behaviour chip, program
baked in, so it shows a note (no editable file) and runs standalone.
banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge).
- CustomChipPart: run-state-aware tick gate.
- EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives.
- examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the
larsonScannerAsm comet program.
- Tests: board+chip routing now uses an inline synthetic example (gallery chip
examples are all board-less).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes from live testing feedback:
1. Adding a programmable chip (Z80/8080) from the gallery created NO program
group — only the chip(s) from the example had one. Root cause: 'programmable'
was detected by a non-empty programFile, but a fresh chip's programFile is
empty until the user writes one. Now detection uses the canonical signal —
chip.json's programTargets — via isProgrammableChip(). When such a chip
lands with no program yet, the file explorer seeds an editable program.c
(DEFAULT_CHIP_PROGRAM_C, a working walking-LED skeleton) into its own group
and stamps programFile/programTarget onto the component so Compile/Run can
build it. Behaviour/driver and predefined chips (no programTargets) still
get no group — edited in the chip designer.
2. z80-led-chaser-c now runs board-less on a regulated power supply (no Arduino,
mirroring z80-larson-no-board) — the Arduino only ever supplied 5V and added
confusion. chaser.c stays the chip's editable program in its own section.
- romCompileService: isProgrammableChip(), DEFAULT_CHIP_PROGRAM_FILE/_C.
- FileExplorer: detect by programTargets; auto-seed program.c + persist
programFile/programTarget for fresh chips.
- examples-retro-intel: chaser-c -> board-less (psu + 8 resistors + 8 LEDs),
drop the now-unused Arduino sketch const; fix a stale sdcc --code-loc comment.
- Tests: board+chip case moved to z80-larson-scanner (still board-based);
isProgrammableChip unit tests.
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>
Two UX bugs in the board-less "Z80 Larson Scanner (no board)" example:
- It loaded "running" (electrical sim defaults to paused=false), so Run was
disabled and Stop enabled even though the chip hadn't started — the user had
to Stop then Run. loadExample now starts a board-less example that contains a
custom chip in the STOPPED state (paused=true) so Run is enabled; pure
analog/digital circuits stay live.
- The chip's program wasn't editable: it shipped a pre-baked ROM and the
board-less loader only setCode'd into an orphan file group (no-op → blank
editor). The example now ships larson.s as a real file (programFile), and
the board-less loader points the editor at the default group and loadFiles()
the example's files, so the program shows on the left and is editable, like
the board-backed examples. Run compiles it.
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>
SDCC treats plain `char` as unsigned on Z80, so `dir = -1` read back as 255,
`if (dir > 0)` was always true, the "walk right" branch never ran, and the
bit just shifted left until it fell off the end and the LEDs went dark after
one pass. Use `signed char dir`. Verified in a chip-WASM harness: with plain
char the chaser does 8 LED writes then stops; with signed char it walks the
bit back and forth continuously (14894 writes). Completes the C example fix
together with dropping --code-loc 0x100 in c_compile.py.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SDCC's z80 crt0 sets SP=0x0000 and makes its first stack push at 0xFFFF.
The chip only mapped RAM at 0x8000-0xBFFF (0xC000+ was MMIO/ignored), so the
stack landed on unmapped memory and a plain C program crashed inside crt0 —
before main — which is why z80-led-chaser-c compiled but drove nothing.
Extend RAM to cover 0x8000-0xFFFF (32 KB) with the MMIO window 0xC000-0xC0FF
carved out and checked first, in scripts/make-z80-cpu.py + regenerated
z80-cpu.c. Now SDCC's default stack works and "write C from scratch, click
Run" just works — no manual `LD SP` needed (dropped from chaser.c). Bumped
the chip WASM initial memory to 4 pages to hold the larger RAM buffer. Larson
(asm, SP=0xBFFF, LED at 0xC000) is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SDCC's z80 crt0 defaults SP to 0x0000; on the z80-cpu chip's memory map
(RAM 0x8000-0xBFFF, MMIO at 0xC000+) the stack would grow into unmapped
high memory and the program crashed on the first CALL (delay), so the LEDs
never moved. Set SP to the top of RAM (0xBFFF) at the start of main, the
same thing the asm Larson example does with "LD SP, 0xBFFF". Verified the
ROM runs and walks the LEDs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`(*(volatile unsigned char __at(0xC000)))` uses __at as a cast operator,
which neither avr-gcc nor sdcc accept (sdcc: "syntax error: token -> ')'").
__at is a storage specifier, not an operator. Use the portable absolute-
address pointer form `(*(volatile unsigned char *)0xC000)`, which sdcc -mz80
compiles cleanly. Verified: produces a 462-byte ROM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A custom-chip output pin wired directly to a component (LED, resistor, ...)
had no Arduino pin on its net, so the chip could drive nothing and the pin
resolved to null. Now:
- Layer A (digital): such chip pins get a stable synthetic pin number
(syntheticPins.ts). traceDetailed resolves a chip<->component net to that
shared number, so the chip's PinManager drive reaches the wired components
through the existing digital event flow. A real board pin still wins.
- Layer B (analog/SPICE): a custom-chip mapper in componentToSpice emits a DC
voltage source on each driven output pin's net (recorded in chipPinDrives by
ChipRuntime), exactly like a board GPIO, and the chip requests an electrical
re-solve when it toggles a pin (electricalResolveHook -> service.tick).
So LEDs / resistors / analog parts wired to a chip output are driven by
ngspice too.
This makes the bundled Z80 / i8080 chip examples actually animate their LEDs,
and lets any custom chip drive components, passives and analog circuits from
its own pins. Non-chip circuits are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Compile/Run now makes every custom-chip on the canvas live in a single
click instead of requiring a manual trip through the chip designer plus a
separate ROM compile:
- Each custom-chip's C source is auto-compiled to WASM when it has none
yet (via /api/compile-chip), and programmable CPU chips get their
program file (larson.s, chaser.c, ...) assembled/compiled to ROM bytes
(via /api/compile-rom) and injected, all before the board starts.
- Chip-program files are excluded from the arduino-cli sketch build, so
SDCC-only syntax such as __at(0xC000) no longer breaks the Arduino
compile (this is what made the Z80 LED-chaser-C example error out).
Fixes the Z80 examples that either errored on Run (z80-led-chaser-c) or
compiled but did nothing (z80-larson-scanner, whose chip never had WASM
or ROM). Works for any circuit built from scratch with a programmable
CPU chip, not just the bundled examples.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Landing pricing card copy updated across all 9 locales: free was advertised
as '100 daily AI credits (up to 1,500/month)'; lowered to 20/day, 600/month
to match the backend quota (see velxio-prod quota.py).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A full-screen Wolfenstein/Doom-style raycaster for ESP32 + ILI9341 over
hardware SPI (Adafruit_ILI9341, block writes), with auto-demo and 4 control
buttons. Doubles as an emulation-speed benchmark. Category: games.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Esp32Bridge logged every GPIO transition (one per SPI clock edge on a
display-heavy sketch), which floods the console and measurably throttles
the main thread and simulation throughput. A full-screen 320x240 ILI9341
raycaster went from ~0.3-0.6 FPS to ~6-8 FPS once this log was removed.
Keep the functional onPinChange / oscilloscope callbacks intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six gpiozero (Python) examples to exercise the Pi 3/4/5 QEMU Linux boards
with different sensors/actuators. All strictly digital — the Pi has no ADC
and PWM is not simulated, so this covers the GPIO in/out paths that work:
- [Pi 3] Blink an LED
- [Pi 3] Running Lights (5 LEDs)
- [Pi 4] Button Toggles LED
- [Pi 4] RGB LED Color Cycle (digital, 7 colors, pwm=False)
- [Pi 5] PIR Motion Alarm
- [Pi 5] Traffic Light
Structure mirrors the existing Pi example (boards[] + vfsFiles['script.py'],
run via 'python3 /home/pi/script.py'); LEDs wired directly like
nano-button-led. gpiozero is used because it works across Pi 3/4/5 (RPi.GPIO
doesn't on Pi 5). Adds a smoke test loading all six (board kind, components,
wiring consistency, gpiozero script present in the VFS).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ESP32 / Raspberry Pi / STM32 / Pico-W bridges built their WebSocket URL
from a bespoke API_BASE() that read only VITE_API_BASE (fallback
localhost:8001) and ignored the desktop shell's runtime-injected
window.__VELXIO_API_BASE__. On the desktop the sidecar runs on a random
127.0.0.1 port, so the sim WebSocket dialed localhost:8001 and never
connected: compile succeeded but the simulation never started. Honor
__VELXIO_API_BASE__ first; web (/api) and dev (localhost:8001) unchanged.
- nano-button-led example: button was wired D2->1a and 1b->GND (same
terminal), tying D2 to GND permanently. Rewire D2->1.l and GND->2.l
(opposite terminals), matching the other examples.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The console auto-switched to the 'errors' filter when a compile produced
an error, but never reset it. After one failing compile, every later
SUCCESSFUL compile (info/success lines only) was hidden by the sticky
filter — the console looked empty while the simulation started, 'unless
there was an error'. Now reset the filter to 'all' whenever the log
shrinks (a fresh compile cleared it) so the next batch is always visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The board roster grew to 30+ (8 STM32 variants + Raspberry Pi 3/4/5), so
the home pricing cards and SEO FAQ were stale at '19 boards'.
- Home pricing (9 locales): free bullet '19 boards' -> '30+ boards';
the Maker bullet that just repeated the board count now states the real
paid differentiator — unlimited ESP32 / STM32 / Raspberry Pi simulation
time (free is time-capped on these server-side QEMU boards).
- SEO FAQ: roster updated to 30+ boards across 6 CPU architectures,
adding ARM Cortex-M (STM32) and Raspberry Pi 3/4/5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cleaner follow-up to the multi-board residue fix. Instead of removing the
extra boards and retyping the surviving one (which left a stale id such as
"stm32-bluepill" on what was now an Arduino Uno), the single-board path now
tears every board down and adds exactly one fresh board of the target kind.
This mirrors the multi-board and board-less paths and guarantees the
surviving board's id matches its kind.
Drops the now-unused setBoardType/activeBoardId destructures and tightens
the boardFilter cast off `any`. Strengthens the regression test to assert
the surviving board's id and kind.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stepper-motor and biaxial-stepper parts only decoded a one-hot wave-drive coil sequence, so they never rotated under the common two-phase full-step / Stepper.h / AccelStepper drive that Wokwi's own examples use -- only the servo moved. Rewrote both decoders to track the net magnetic-field vector of the coils (atan2 of the H-bridge currents), so the rotor follows wave, two-phase full-step and half-step drive alike, whether driven directly from GPIO or through a driver's outputs.
Also adds an A4988 STEP/DIR stepper driver (parity with Wokwi's wokwi-a4988): velxio-a4988 element renders the real Pololu A4988 Fritzing breadboard SVG (public/components/a4988.svg); MotorDriverParts.ts finds the wired stepper via the netlist and advances it one (micro)step per STEP rising edge in the DIR direction (MS1-3 microstep + active-low ENABLE). Metadata in component-overrides.json. Three examples (Uno/ESP32/Pico) wire MCU STEP/DIR -> A4988 -> stepper, coil map aligned to Wokwi (1A->B+,1B->B-,2A->A+,2B->A-).
Verified in-browser: motor rotates on Arduino Uno (avr8js) and Raspberry Pi Pico (rp2040js). tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <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>
The KY-040 rotary encoder was already fully simulated (wokwi-ky-040 element + PartSimulationRegistry 'ky-040' driving CLK/DT quadrature and the SW button) and present in the catalog, but unfindable: named 'KY040', in the 'other' category, with no rotary/encoder search tags and a placeholder thumbnail. A user searching 'rotary encoder' got nothing (issue #104).
- generate-component-metadata.ts: let component-overrides.json patch category, description and tags on scanned wokwi parts (previously only name/thumbnail) -- the fields the picker category tab and ComponentRegistry.search() actually use. - component-overrides.json: ky-040 override -> name 'KY-040 Rotary Encoder', category 'input', rotary/encoder/knob tags, description, real encoder thumbnail SVG. - examples.ts: KY-040 + Arduino Uno example (quadrature read + SW reset). Regenerated components-metadata.json; searching rotary/encoder/knob now returns the KY-040. tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The boards added in 6813891 (F103CB / F401 pill variants, F4 Discovery,
Olimex H405, Netduino 2/+2) run on the libqemu-arm backend with no
in-browser canvas example, like the existing Blue/Black Pill. Add them to
ACCEPTED_UNCOVERED so the coverage matrix passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
useSimulatorStore eagerly imports STM32_LED from this module, so the
top-level `class extends HTMLElement` + customElements.define ran at import
time and threw "HTMLElement is not defined" under vitest's node environment,
breaking 20 test files that load the store. Guard the base class with a
dummy fallback and skip registration when customElements is absent; browser
behavior is unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sort filteredExamples by the board's position in BOARD_TABS — which puts
Arduino Uno first — and alphabetically by title within each board. Applies
to the 'All' view and to each board tab.
Co-Authored-By: Claude Opus 4.7 <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>
Add STM32 Blue Pill / Black Pill tabs to BOARD_TABS, and make getBoardFilter
honor an explicit boardFilter before the boards[] check. The STM32 examples
are authored with the multi-board boards[] format even when single-board, so
they were all bucketed under "Multi-Board" and had no STM32 filter tab.
Now they appear under their dedicated STM32 tabs (attiny85 single-board
examples authored the same way get correctly bucketed too).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
stm32-bluepill and stm32-blackpill are Pro features emulated on the backend
via the licensed libqemu-arm QEMU lib (no in-browser canvas engine, same as
the Raspberry Pi boards), and their gallery examples are intentionally not
shipped to the free tier. Add them to ACCEPTED_UNCOVERED so the board-kind
coverage matrix passes — this was missed when the boards were introduced.
Co-Authored-By: Claude Opus 4.7 <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>
Sixth overflow-menu item, Pro-badged. Dispatches
velxio-pro-replay-record-toggle (projectId in detail) which the pro
overlay handles — plan check, board-type check, start/stop the
recorder. OSS build has no listener → silent no-op.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two unrelated polish fixes.
espidf_compiler: headers that resolve to an arduino-esp32 CORE lib
(WebServer, WiFi, …) were correctly skipped from the user-lib merge but
then fell through to a scary "Library for <X> not found — build may
fail" warning — even though the build succeeds because the symbols are
compiled into the core. Now logs an accurate "provided by arduino-esp32
core — already compiled in, not merging". Same treatment for core
headers that aren't standalone lib dirs (Udp.h, IPAddress.h,
WiFiUdp.h, …) via a new _CORE_ESP32_HEADERS allowlist.
SimulatorCanvas: the WiFi badge's "open IoT gateway" click now consults
an optional window.__velxio_iot_gateway_open_gate__ hook before opening
the gateway tab. A private overlay can install it to gate the gateway
behind a paid plan and show an in-place upgrade modal instead of dumping
a 402 page in a new tab. OSS builds have no hook → opens normally. The
check is synchronous so it doesn't trip popup blockers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Search-engine indexing of public projects
- Update robots.txt to also list /sitemap-projects.xml so Googlebot /
Bingbot discover every public project's canonical /:username/:slug URL.
- Add /docs/github-sync + /classroom entries to seoRoutes.ts so the
build-time sitemap.xml picks them up.
Navigation polish
- AppHeader gains a "For schools" link between Pricing and Download.
- LandingPage's pricing section gets a slim banner under the cards
pointing institutional visitors to /classroom (visible discovery path,
not just a footer link).
- Localised header.nav.classroom + landing.pricing.classroomBanner +
landing.pricing.classroomCta across all 9 maintained locales (en/es/
pt-br/fr/de/it/ja/ru/zh-cn).
Community examples
- New CommunityProjectsGrid component lives next to ExamplesGallery on
/examples. Fetches /api/projects/featured (Pro-overlay-only endpoint)
and renders the top public projects ranked by run_count. Quietly
hides itself when the endpoint returns nothing or fails, so the OSS
build still ships cleanly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
LandingPage footer gains a "For schools" link sitting between Pricing
and About — gives institutional visitors a discoverable path to the
Classroom landing without burying it inside the FAQ.
seoRoutes.ts adds the /classroom entry (priority 0.85, monthly
changefreq) so the auto-generated sitemap picks it up on every build.
Bonus: getSeoMeta('/classroom') now returns the institutional title +
description if any other code wants to read it programmatically.
The static public/sitemap.xml is not committed — `npm run generate:sitemap`
overwrites it during the Docker build, so any hand-edit would be wiped.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The home page's pricing section was still showing the dropped Phase 0
shape (Free / Pro $15 / Pro Max $35) instead of what /pricing and the
billing backend actually serve (Free / Maker $7 / Pro $19).
- Replace the middle card from "Pro $15" → "Maker $7" (CTA "Start
Maker") + the right card from "Pro Max $35" → "Pro $19" (CTA
"Subscribe to Pro"). "Most popular" badge moves to the now-Pro
card (still the upsell sweet spot).
- i18n keys renamed in lockstep: tiers.pro → tiers.maker, tiers.pro_max
→ tiers.pro. Updated in all 9 locales (en/es/pt-br/fr/de/it/ja/ru/
zh-cn) with translated copy that mentions the actually-shipped Pro
perks (private projects, GitHub Sync, BOM CSV, schematic PNG,
watermark-free embed). The Spanish line about "Maker" is left as
the loanword so it stays consistent with /pricing.
No backend changes — quota.py PLANS was already correct.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the proposed llmstxt.org file under frontend/public/ so the SPA
nginx serves it at https://velxio.dev/llms.txt. ChatGPT-driven traffic
has had the highest engagement of any channel (82% session quality per
GA), so feeding the AI crawlers a curated, machine-readable summary
is high-leverage: the model gets accurate tier prices, supported
boards, comparison framing vs Wokwi / Tinkercad / Proteus, plus FAQ
answers — instead of stitching together a fuzzy view from blog posts.
Notable departures from the original phase-5 draft:
- Tier shape is the shipped one (Free / Maker $7 / Pro $19), not the
proposed Pro/Hobbyist + LemonSqueezy variants that never landed.
- Geo-pricing section dropped (Phase 2 deferred — same reason).
- Supported boards list reflects the actual MCU emulator coverage in
the latest velxio image, not the aspirational roadmap.
- GitHub Sync and embed iframe (D3.5) are now first-class features.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fifth item in the overflow menu next to Sync to GitHub. Free for
all users (no PRO badge); dispatches velxio-pro-share-prompt with the
current project id so the overlay's ShareModal can render the direct
link + iframe snippet copy UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dispatches velxio-pro-upgrade-prompt's sibling event
velxio-pro-github-sync-prompt with the current project id. The pro
overlay's GithubSyncModal listens and runs the four-state link/sync
flow (no-pro / not-connected / not-linked / linked) inline without
leaving the editor.
Pure OSS builds have no listener so the click is a silent no-op —
those users can't have linked repos anyway.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the hard /pricing redirect on 402 with a window-dispatched
'velxio-pro-upgrade-prompt' event so private overlays can surface an
in-editor upgrade modal instead of bouncing the user out of context.
Move BOM, Schematic image and firmware upload buttons into a "..." More
menu next to the existing Export ZIP icon, freeing two button slots in
the inline toolbar. Mark the two premium items with a small "PRO" pill
so free-plan users know they're gated before they click — Notion- /
Linear-style discoverability cue.
Also wire Import + Export ZIP to fall back into that same menu once the
toolbar container drops below 320 / 280 px (container queries on the
editor pane width). Mobile / narrow-split layouts keep full feature
parity through the dropdown instead of overflowing into a horizontally
scrolling row.
Co-Authored-By: Claude Opus 4.7 <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>
Front-end half of the schematic image export. New camera-icon button in
the editor toolbar between BOM and Upload-Firmware. Handler:
1. POSTs to /api/pro/projects/{id}/screenshot.png (server renders the
canvas with headless chromium, returns a PNG).
2. 402 → /pricing?from=screenshot_export
3. 401 → /login with redirect-back
4. 422 → friendly "add at least one component" toast
5. 200 → blob download with Content-Disposition filename
6. The "rendering..." toast surfaces during the 5-10 s of headless
chromium time so users know to wait, not click again.
i18n key editor.toolbar.exportScreenshot added in en/es/pt-br/zh-cn.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 D3.1 — front-end half of the BOM export. The toolbar gains a
new spreadsheet-icon button next to the existing project-export button.
On click:
1. POST is NOT used — the backend endpoint is GET-based and streams a
CSV. We just open the URL.
2. 402 (Pro-required) routes the user to /pricing?from=bom_export
so the page can show the right upgrade narrative.
3. 401 routes to /login with redirect-back.
4. 200 triggers a Blob download with Content-Disposition filename.
i18n key editor.toolbar.exportBom added in en/es/pt-br/zh-cn — the
" — Pro" suffix on the tooltip hints at the gating without forcing the
user to discover it only on click.
The button is shown to everyone, not hidden by plan. Free/Maker users
clicking it gets the 402 route to /pricing, which is intentional — that
is the upgrade-discovery funnel we want, not a silent locked icon.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 1 D1.8 — the home-page hero leads with three defensible
differentiators Velxio has that the dominant alternative doesn't:
- AI agent integrated (Wokwi has none)
- Works offline as a desktop app (Wokwi is cloud-only)
- AGPLv3 open source (Wokwi is proprietary)
Subtitle and trustLine rewritten across all four shipped locales
(en, es, pt-br, zh-cn). No layout change — the LandingPage.tsx
component renders both strings already.
The competitor name isn't mentioned anywhere — the user comparing
side-by-side does the math themselves. Anchoring on the USPs makes
the eventual /pricing visit ("Maker $7 = AI included") land in
context instead of cold.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The quota-exhausted modal (rendered by the velxio.dev pro overlay when
a free user hits the daily AI cap) was hardcoded English. The modal is
seen by users from CN/BR/MX/CO/AR/PE/IN — the audiences most likely to
bounce on English-only UX. This adds the four key languages.
Keys:
titleFree — "You've hit today's free limit"
titlePaid — "You've reached your daily limit"
bodyFree — explainer + Pro upgrade pitch (interpolates cap/proCap/multiplier)
bodyPaid — explainer for paid users who hit their own tier's cap
today / thisMonth / resets — stats labels
ctaUpgrade — primary CTA ("Upgrade to Pro — $15/mo")
ctaSeePlans — fallback CTA for non-free users
ctaWait — secondary "Wait until reset"
Upstream-only change — the velxio-prod overlay's AgentChatPanel.tsx is
wired to consume these via useTranslation in a separate commit.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same root cause as the previous test fix in 3e38397 — upstream commit
d64eebc (fix(stop): reset CPU to PC=0) added a hardResetPinStates() call
to useSimulatorStore.stopBoard. The vi.mock factories in these 6 ESP32-
adjacent test files only exposed updatePort/onPinChange/getListenersCount,
so any test path that hits stopBoard crashed with "is not a function"
once the real prod code called the new method.
Each gets a single-line addition: this.hardResetPinStates = vi.fn();
Verified with full vitest run: 127 files pass, 2,005 tests pass, 0 failures.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. multi-board-integration.test.ts — PinManager mock was missing
hardResetPinStates(). Upstream commit d64eebc (fix(stop): reset CPU
to PC=0) added that method to PinManager and useSimulatorStore.stopBoard
calls it, but this test's vi.mock factory never exposed it. Result:
"TypeError: getBoardPinManager(...)?.hardResetPinStates is not a function"
even though the optional chain looks safe — the chain only short-circuits
on null/undefined, not on a non-function property.
2. vitest.config.ts — was missing the @velxio alias that vite.config.ts
defines. defineConfig from vitest/config does NOT auto-inherit from
vite.config.ts; the alias has to be re-declared. Without it, overlay
tests importing @velxio/store/useEditorStore failed with "Cannot find
package '@velxio/...'" even though the build (which DOES inherit the
alias) resolves them fine.
Verified: full set of 3 previously-failing tests now pass cleanly
(multi-board-integration: 43 passed, snapshot: 0, pinIntrospection: 10).
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inserts a Download entry between Pricing and Blog in the main nav.
Routes to the existing DesktopInstallPage in the velxio-prod pro
overlay (auth gate + platform-detect + signed-licence download flow).
i18n: header.nav.download added across all 9 shipped locales
(en/es/ja/it/de/ru/pt-br/zh-cn/fr) with native translations.
Self-hosted OSS image: the route doesn't exist there, so the link
lands on the upstream router's 404 — same fallback behaviour as
/pricing already has for self-hosters. Acceptable until the OSS
side gets its own placeholder.
Brings hardware flashing into Velxio Desktop. Per-board "Flash to
real board" entry in the canvas context menu opens a modal that
enumerates USB serial ports, lets the user pick one, then
streams arduino-cli upload output live until the board is flashed.
Backend (Phase D1) — backend/app/api/routes/flash.py (new):
POST /api/flash/upload (multipart: board_id, port, fqbn,
program_format, program)
→ SSE stream of {phase, line?, progress?} events
→ final {phase:'done', success, elapsed_ms, error?}
- Wraps `arduino-cli upload -p <port> -i <file> --fqbn <fqbn> -v`
so AVR (avrdude), ESP32 (esptool), RP2040 (picotool), SAMD
(bossac) all share one code path — arduino-cli internally
dispatches by FQBN.
- Per-port asyncio.Lock prevents two simultaneous flashes from
fighting over the same /dev/ttyACM0.
- Allow-list of FQBN prefixes (arduino:avr, ATTinyCore:avr,
rp2040:rp2040, esp32:esp32, arduino:samd) so a typo can't
cause a confusing arduino-cli error.
- Format allow-list (hex / bin / uf2 / elf) drives the temp
file extension - arduino-cli uses the extension to route to
the right uploader.
- 8MB hard cap on the uploaded program (real sketches are
well under that; protects against a runaway frontend).
- X-Accel-Buffering: no header so nginx doesn't hold the SSE
chunks until the flash completes.
Frontend (Phase D3):
- frontend/src/services/flashService.ts (new):
async generator streamFlash() yields parsed SSE events.
Handles the base64-vs-text gotcha (compile returns hex_content
as text but binary_content as base64; for binary formats we
atob() into a Uint8Array before posting so the form upload
sends actual bytes, not the base64 ASCII).
- frontend/src/components/simulator/FlashModal.tsx (new):
Three-state UI: picking (port dropdown), flashing (progress
bar + live log), success/error (verdict + retry).
Empty-ports state shows a Linux dialout-group hint.
- SimulatorCanvas.tsx: board context menu gains "Flash to real
board" entry, gated on isTauri() + presence of compiledProgram.
Hidden in web (WebSerial is a separate sprint).
- tauriBridge.ts: SerialPortInfo type + listSerialPorts() helper
that invokes the Rust shell command added in Phase D2.
The sidecar already has arduino-cli on PATH (per
`pro/desktop/sidecar/main.py::_expose_bundled_arduino_cli`), so
no installer changes are needed — flash works the moment the
0.4.x desktop bundle ships with these commits.
Plan + remaining phase tracked in project/hardware-flashing/.
D2 (Rust serial enum) committed separately as a Tauri-shell-only
concern; D4 (manual smoke matrix with real boards) requires
physical hardware so it stays a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#208 — stale binary executes after compile error
EditorToolbar.handleCompile: on failed compile, clear the active
board's compiledProgram so a subsequent Run can't silently execute
the previous successful build (which doesn't match the editor any
more). The Run gate already short-circuits on !compiledProgram and
forces a fresh compile.
#209 — compile terminal kept stale messages across runs
EditorToolbar.handleCompile: setCompileLogs([]) at the top of the
handler. Previously logs from the prior compile lingered, making it
hard to tell new errors / warnings apart from old ones.
#210 — desktop File > New Project did nothing
desktop/menu.ts: the menu action used to dispatch a CustomEvent
nobody listened to. Replaced with a real `newProject()` function
that stops the running simulation, removes every board (also drops
the bridges + wires touching them), clears components / wires,
loads the default Blink sketch into the editor, clears project
metadata, and wipes the compile output. Confirms first if there's
unsaved work on the canvas.
#211 — deleting the only board made every other component
unresponsive (wires still worked)
SimulatorCanvas.tsx::interactionRunning: the old expression
treated boards.length === 0 as "boardless electrical mode is
running" — which suppressed the property dialog on click and made
non-sensor components look frozen. Fixed by also requiring
useElectricalStore.submittedNetlist !== '' before flipping to the
boardless-running branch. SPICE has to have actually solved at
least once for the mode to engage.
#212 — ESP32 Support 404 with no actionable message
desktop/Esp32QemuPrompt.tsx: catch the raw "download HTTP 404" /
"not found" upstream error and reword it to "ESP32 support is not
yet available for your platform. The Velxio team is preparing
this build - try again in a few days, or use Arduino/RP2040
boards in the meantime." The real fix is server-side (the velxio
team needs to publish a qemu-xtensa.tar.gz for the user's
platform into the asset bucket and update esp32-qemu/latest.json).
Tracked in project/desktop-agent-v040/ follow-ups.
All five fixes verified with `tsc --noEmit` clean and the existing
25-test vitest suite green.
Co-Authored-By: Claude Opus 4.7 (1M context) <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.
Replaces the native OS-modal update dialog (which blocked the editor
and looked dated) with a non-intrusive bottom-right toast that
appears 30 s after app mount when the Tauri updater finds a newer
release.
State machine:
idle → no update detected, render nothing
available → "Update available - Velxio Desktop X.Y.Z" + Install/Later
downloading → progress bar with "X.X / Y.Y MB (NN%)"
installing → "Installing X.Y.Z... will restart automatically"
error → error message + Retry/Dismiss
Click "Install and restart":
1. downloadAndInstall() streams the full signed installer (~70 MB)
2. Tauri verifies the minisign sig against the embedded pubkey
3. Replaces the install in-place
4. Auto-relaunch (the app exits and reopens on the new version)
"Later" dismisses for the rest of the session (sessionStorage flag).
A close+reopen re-checks. Manual re-check via the menu still works.
Companion change in velxio-prod flips tauri.conf.json
updater.dialog from true to false so our custom toast is the only
update UI - no double-prompting.
Files:
- frontend/src/desktop/UpdateAvailableToast.tsx (new): the component
- frontend/src/desktop/desktop.css: toast styles + slide-in animation
- frontend/src/desktop/index.ts: mount alongside GraceBanner +
Esp32QemuPrompt in the existing sidePanelRoot
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix rotated overlay hotspots but the pivot was off by
+6px in each axis, which manifested as a 12px X-offset for a 90°
rotation (because (I - R) maps (6,6) to (12, 0) for R = 90° CW).
The wrapper top-left in container-local coords is -wrapperOffsetX,
not -(6 - wrapperOffsetX). The container origin already sits INSIDE
the wrapper's padding+border by exactly wrapperOffsetX/Y; we have
to back out by that same amount, not by 6 - that amount.
Visual verification on https://velxio.dev/example/esp32-pwm-led-rgb:
overlay centers of rotated resistor now match the rotated pin tips
exactly (was 12px off in X).
Reporter on GitHub: after rotating a component the WIRES followed
the pin tips (already fixed in the (6,6) offset commit) but the
clickable connection boxes stayed in the unrotated layout —
visible misalignment between the rotated component and its
hotspots, no way to start a fresh wire from a rotated pin.
Root cause: PinOverlay renders as a SIBLING of the DynamicComponent
wrapper, not as a child. CSS rotation on the wrapper doesn't reach
the overlay div, so its child pin boxes stay at the unrotated
(pin.x, pin.y) coordinates.
Fix:
- Plumb component.properties.rotation from SimulatorCanvas into
PinOverlay as a new `rotation` prop.
- In PinOverlay, capture wrapper.offsetWidth/Height when reading
pinInfo and apply the same rotation matrix the wire calculator
uses (pivot at wrapper center, transform-origin: center center).
- Use the rotated (pinX, pinY) for both the visual `left/top` AND
the canvas-coord passed to onPinClick, so wires that get started
from the hotspot anchor at the rotated tip too.
Also align the default wrapperOffsetX from 4 to 6 (padding:4 +
border:2 on each side of the DynamicComponent wrapper). The
previous asymmetric (4, 6) was the same 2px X bias we fixed in
pinPositionCalculator a few commits back; the overlay was reading
its own copy of the bad number and putting hotspots 2 px left of
the pin tip on unrotated components too. Board paths that pass
wrapperOffsetX/Y = 0 explicitly are unaffected.
All 29 vitest tests in the rotation + simulator suites pass.
Two related changes for the v0.4.0 desktop-agent rollout:
- include glob now also matches `../../pro/frontend/src/pro/**/__tests__/`
so the agent-overlay tests in velxio-prod are discovered when this
config is used from a velxio-prod checkout. On pure-OSS clones the
glob has nothing to match - harmless.
- server.fs.allow extended to `..` and `../..` so Vite's filesystem
sandbox doesn't reject the cross-project test paths with
"Cannot find module '/@fs/...'".
No behavior change for OSS-only contributors. velxio-prod gets the
agent's `desktopAuth` unit tests picked up automatically by
`npx vitest run` in this directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.3.x main.tsx explicitly REFUSED to load @pro when VITE_DESKTOP was
true ("desktop owns its own license + auth UI"). v0.4.0 brings the
AI agent into the desktop bundle, so the refusal needs to relax for
that one use case.
New routing inside the if(VITE_PRO_BUILD) branch:
- VITE_DESKTOP also set → load @pro/desktop_index (slim entry that
only mounts AgentChatPanel + DiagnoseCompileButton, no analytics
/ sessions / billing / admin / save overrides - those expect
velxio.dev cookies the desktop has no way to send)
- VITE_DESKTOP not set → load @pro/index (existing web behavior)
VITE_DESKTOP alone (no pro) still loads zero overlay - that's the
pure-OSS desktop build path for self-hosters who don't have the
pro source tree at $PRO_OVERLAY_PATH.
Companion commit in velxio-prod creates @pro/desktop_index, adapts
the agent client for license-key Bearer auth, and updates
build-frontend.sh to pass both flags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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: Arduino Nano connected to an Uno-TX wire received bytes
but displayed them poorly, and pressing Stop then Run "killed" the
serial link until the page reloaded.
Two paired bugs in the cross-board serial path:
(1) drainSerialRxQueue was only ever re-fired from usart.onRxComplete,
which itself only fires AFTER a successful delivery. If the very
first delivery attempt fails (rxEnable=false because the sketch
hasn't reached Serial.begin yet — extremely common when one board
starts emitting bytes before the receiving board's setup() runs)
nothing re-kicks the queue and every subsequent byte from the
sibling board sits in serialRxQueue indefinitely. Adding a
per-frame drain attempt (no-op when queue is empty or rxBusyValue
is set, so cost is negligible) makes the link self-heal across
cold-start races and Serial.end()/begin() toggles.
(2) stop() never cleared serialRxQueue. On Run after Stop the new
USART would re-drain the previous run's leftovers into the fresh
sketch before its setup() ran, corrupting the first bytes the
user saw on the receiving side. Clearing the queue in stop() —
same place we already clear scheduledPinChanges — keeps each Run
a clean slate.
Verified 52 existing tests still pass (dual-pico-serial-passthrough,
dual-arduino-software-serial, interconnect-routing, avr-uart-tx
-waveform, serial-batching, AVRSimulator, pin-position-rotation).
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 4 polish: GraceBanner was rendering for state=locked/tampered
even though LockoutOverlay covers the screen for those states. The
banner leaked through the overlay's 96%-opaque background as a
faint red strip - confusing.
- GraceBanner.tsx: bannerFor() returns null for locked/tampered
(LockoutOverlay handles the messaging). Also exported bannerFor
so the new unit tests can exercise the pure decision logic.
- __tests__/GraceBanner.test.ts (new): 13 vitest cases covering
pre-expiry amber/red thresholds (trial_ends_at vs subscription_period_end),
fallback to claims.exp for legacy JWTs, soft/hard grace messaging,
dismissibility rules.
- vitest.config.ts: include also matches src/**/__tests__/ so the
desktop tests are discovered without moving them.
Runtime ~600ms vs 5-25 min for a full installer rebuild - lets
future iterations on the banner state machine skip the build cycle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the frontend half of the v0.3.0 desktop paid model. The Tauri
shell (in velxio-prod) emits velxio://license-required when the
license gate refuses to spawn the sidecar; this commit teaches the
OSS desktop overlay to react.
- LockoutOverlay.tsx (new): full-screen modal with three variants
(no_credential / tampered / expired). Sign-in or paste-key
resolves it via restartApp().
- DesktopWelcomePage.tsx: new grandfather variant - "you have N
days to keep using Velxio Desktop" + "Continue without signing in".
- GraceBanner.tsx: rewrite with pre-expiry tones (5d amber, 24h
red, dismissible), polling every 10 min while document visible,
separates pre/post-expiry messaging.
- Esp32QemuPrompt.tsx: signup gate for grandfather users (ESP32
binaries are not part of the grandfather grace) + inline progress
bar driven by velxio://esp32-qemu-progress events.
- index.ts: rewires on getGateInfo() at first paint to decide
welcome vs lockout vs nothing; installs license-required listener
+ 10-min foreground polling for the locked transition.
- tauriBridge.ts: adds GateInfo type, getGateInfo(), restartApp().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Three coordinated changes that fix the "Waiting for browser…" hang
and unblock first-launch UX on the Tauri desktop build:
1. desktop/index.ts — DON'T mountWelcome unconditionally on first
launch. Before, an empty keychain (no key yet) forced the
welcome / sign-in screen on top of the editor, gating 100% of
the app behind an account. Now the editor opens directly:
compile + run + sim + save .vlx all work for free (they're
upstream OSS features), and the license check still runs in
the background just to populate state for the GraceBanner
(which shows for invalid keys — locked, tampered, in
soft/hard grace). Pro-only features (ESP32 QEMU download,
agent IA) prompt for license at use time, where it actually
matters. Matches the "try before you buy" expectation a
desktop install creates.
2. desktop/tauriBridge.ts — rewrite `openExternal` to try every
known IPC path in cascade order and log via the desktop debug
file which one worked. The previous implementation invoked
`plugin:shell|open` with `{ path: url }`, which silently
failed (no ACL match + wrong arg shape) and fell back to
`window.open`, which inside a Tauri webview is a no-op for
external URLs — the browser never opened. New cascade:
plugin:opener|open_url (paired with tauri-plugin-opener which
ships in this revision), then plugin:shell|open with both
`{ path, with: null }` and `{ url }` shapes, then the
window.__TAURI__.shell / opener high-level wrappers that
specific Tauri 2.x flag combos expose. Each attempt logged
via the dlog helper so the next operator can see exactly
which path was used (or that all failed) without devtools.
3. desktop/menu.ts — new `navigate-route` action type. Routes
bundled in the SPA (DocsPage, ExamplesPage, AboutPage) that
used to open velxio.dev in the system browser now navigate
in-window via history.pushState + popstate (mirrors the
locale-switch handler). Respects the current locale prefix
so `/examples` from `/es/editor` lands at `/es/examples`
instead of jumping back to English.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
OSError: [Errno 19] ENODEV at ssd1306.SSD1306_I2C(...) on
100d-esp32-oled-smart-ui-eyes-animation-time-and-weather-micropython.
MicroPython SoftI2C bit-bangs GPIO directly. Velxio's ESP32 QEMU
bridge listens on the emulated I2C peripheral (registers slaves like
0x3C wokwi-ssd1306 against it) and doesn't decode bit-banged GPIO
toggles as I2C frames, so the OLED never sees any writes and
i2c.writeto() returns ENODEV on first use.
machine.I2C(0, ...) routes through the hardware I2C peripheral that
QEMU emulates, the registered slave receives the bytes, the OLED
panel updates. Same code path the other working SSD1306 MicroPython
examples on this repo already use.
API surface is identical to SoftI2C — only the constructor differs —
so the rest of the user sketch needs zero changes.
avr8js's usart.writeByte(value) rejects the call (returns false, drops
the byte) whenever rxBusyValue is set — and rxBusyValue stays true for
one full cyclesPerChar after each accepted call. The old serialWrite()
fed every character in a synchronous for-loop, so only the first byte
made it through and the sketch saw 'h' when the user typed 'hello\n'.
Buffer pending bytes in serialRxQueue and pump them one at a time:
- serialWrite() now just queues + kicks drainSerialRxQueue once
- drainSerialRxQueue calls writeByte on the head of the queue and only
shifts it off if writeByte returned true (avr8js accepted it)
- usart.onRxComplete is wired to drainSerialRxQueue so the next byte
ships as soon as the sketch's RX side actually consumed the previous
one — matches the cyclesPerChar pacing the real chip enforces
Same handler wired in both USART setup paths (the Uno/Nano branch and
the post-loadHex Mega/ATtiny branch). TX path (onByteTransmit +
emitUartTxFrame for the oscilloscope waveform) is unchanged.
Two related fixes for the ESP32 Arduino-compat compile path:
(a) backend/app/services/esp-idf-template/main/CMakeLists.txt:
Demote -Werror=comment / =parentheses / =sign-compare / =narrowing
/ =write-strings / =missing-field-initializers / =reorder back to
plain warnings. ESP-IDF's project defaults are stricter than what
Arduino/arduino-cli users expect, so common Arduino idioms (nested
/* */, missing field initializers in struct literals, etc.) were
failing builds that compile fine in the Arduino IDE. -Wall stays
on; we just stop the abort.
(b) examples-robot-desktop.ts (robot-desktop-eyes example):
Replace the nested /* xTaskCreatePinnedToCore( ... /* Task function. */
... */ block with `#if 0 / #endif` so the inner block comments
don't terminate the outer one. Even with -Wno-error=comment the
real-syntax-level issue (the first inner `*/` closes the outer
comment, leaving the rest of the lines as bare code) would still
bite, so this needs an actual code fix.
Drops `/.well-known/assetlinks.json` so the Trusted Web Activity APK
(dev.velxio.twa, generated by bubblewrap from this same manifest.webmanifest)
can prove to Chrome that it's allowed to claim velxio.dev as its own
origin. Without this file the TWA falls back to a Custom Tab with the
URL bar visible — losing the whole "feels native" UX that TWAs
exist for.
The sha256_cert_fingerprints entry pins the production signing key
held locally as android.keystore in the velxio-twa/ build dir (NOT
in any repo). If we ever lose that key + need to re-issue, this
file has to be updated with the new fingerprint and re-deployed
BEFORE the new APK reaches users; otherwise their previously-
installed TWA verifies against an asset link that no longer matches
the APK signature and breaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sketch fails to compile out of the box with
fatal error: ESP32Servo.h: No such file or directory
because the ESP32Servo / U8g2 / DHT / Adafruit Unified Sensor libs
aren't part of arduino-esp32 and weren't declared on the example.
loadExample.ts already iterates `example.libraries` and runs
arduino-cli lib install for any missing entry before the user
touches Compile. Adding the four real deps the sketch needs gets
the example compiling cleanly on a fresh container without any
manual Library Manager dance.
When the auto-compile path in handleRun() finishes without producing a
compiledProgram, the previous code dropped the failure on the floor with
only a `console.warn` — the user clicked Run, nothing happened, and they
had no idea why. The accompanying comment also promised "always start
even if compiledProgram is empty" but the code did the opposite.
This commit replaces the dead comment + silent warn with a top-level
error toast + addLog entry, with a different copy for MicroPython mode
(suggests "click Load MicroPython to retry") vs Arduino C++ mode
(directs the user to the output console for the underlying error).
handleCompile already writes the actual cause to the compile-output
console via addLog — this fix just makes sure the user knows their
click failed and where to look.
Three small fixes to frontend/public/manifest.webmanifest so the
Bubblewrap-generated TWA (and Add-to-Home-Screen PWA installs) feel
right on a phone:
- orientation: landscape → any. Landscape-forced on a phone
locks the device in side-grip whenever Velxio is foregrounded;
the editor + simulator work fine in portrait too (the file
explorer collapses gracefully). Tablets and desktops still
default to landscape because they're naturally wider, so this
only changes behaviour where the lock would actively hurt.
- name: "Arduino Emulator" → "Circuit & Arduino Simulator".
Matches the title tag + Open Graph copy that velxio.dev uses
everywhere else and reflects the SPICE / ESP32 / RP2040 work
the project has grown into since the original name was written.
- description: was 'Free local Arduino emulator … No cloud, no
latency.' That was true for the OSS self-host but misleading
for an installed PWA that talks to velxio.dev. Rewritten to
describe the actual product surface (the boards, the SPICE
sim, "free and open source") without making a claim the live
site can't keep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snapshot was written when the example had components: [] and wires:
[]. Now that 5bd541e populated the circuit (OLED + 2 buttons) and
2dea3f0 renamed the OLED pins to match wokwi-ssd1306's real
pinInfo, the netlist contains the button pull-down resistors and
floating-net autopulls. Regenerated with `vitest run -u`.
robot-desktop-eyes and the day-30/100-days OLED example wired the
SSD1306 OLED with SDA/SCL/VCC, but the wokwi-ssd1306 element
exposes pinInfo as DATA/CLK/VIN/GND. The mismatched names couldn't
resolve, so all three wire endpoints fell back to (0,0) of the
component and visually attached to the corner instead of the pins.
Same class of bug on the wokwi-big-sound-sensor in
robot-desktop-eyes: the element has AOUT/DOUT (no plain OUT). The
sketch uses digitalRead(SOUND_PIN), so route to DOUT.
The COMPONENT_PIN_ALIASES map in wokwiZip.ts only normalises on
.zip import — static examples have to use the real pinInfo names.
The 100d-esp32-oled-smart-ui-eyes-animation-time-and-weather-micropython
example had components: [] and wires: [] — the MicroPython code wired
an SSD1306 OLED on I2C (GPIO 21/22) plus two buttons (GPIO 14, 27) but
the circuit had nothing on the canvas, so users saw a bare ESP32 board
and the simulation was missing every peripheral the code drives.
Adds:
- wokwi-ssd1306 on I2C (3V3 / GND / SDA=21 / SCL=22)
- two wokwi-pushbuttons wired HIGH-when-pressed (3V3 → 1.l, 2.l → GPIO
14 / 27) to match the `if pin.value(): pressed` check in main.py
Pulls https://github.com/davidmonterocrespo24/robot_desktop into the
examples gallery as a real-world ESP32 + sensors project. Cozmo-style
desktop robot: SSD1306 OLED face that blinks, looks around, and shows
emotions; DHT11 weather mode triggered after 10 min idle; PIR wakeup
from sleep; LDR-driven sleep when the room goes dark; sound-triggered
reactions; and two eyebrow servos.
Ships as 34 separate files (one .ino + 33 headers / source) rather
than the usual single-sketch flatten. The face engine
(Eye / EyeTransition / EyeVariation / FaceBehavior / FaceExpression
/ FaceEmotions / BlinkAssistant / LookAssistant / …) splits
responsibility across enough classes that flattening would obscure
the design. Velxio's multi-file `files: [{ name, content }]`
mechanism handles this cleanly — the editor mounts the .ino as the
active sketch and the rest sit in the same workspace.
Pre-placed components match the original board's pin map verbatim
from Common.h:
- SSD1306 OLED on I²C (SDA=21, SCL=22 — ESP32 default)
- DHT11 on GPIO 15
- PIR motion on GPIO 4
- Big sound sensor on GPIO 2
- Photoresistor on GPIO 34 (ADC1)
- Right eyebrow servo on GPIO 12
- Left eyebrow servo on GPIO 13
Arduino libraries (U8g2lib, DHT, ESP32Servo, Adafruit_Sensor) are
auto-installed by velxio's Library Manager on the first compile.
Category 'displays', difficulty 'advanced', tags cover both the
sensor list and the project's identity (cozmo / robot / animation /
eyes) so the gallery search surfaces it from multiple angles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BoardKind type and the QEMU backend already supported
raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing
the Pi 3 arm64 image set, but the frontend had no way to actually
select either: the board picker, the canvas renderer, the serial
monitor, the oscilloscope channel list, and the editor toolbar all
hard-coded "raspberry-pi-3" as the only Pi entry. ComponentRegistry
even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3
custom-element tag — a placeholder that meant both boards rendered as
a Pi 3 in the picker thumbnail and on the canvas.
Add dedicated boards top-to-bottom:
* `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style
schematic SVG (authored from scratch, not traced). Pi 4 is the
green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI;
Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge,
2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power
button. Both carry a small "velxio" mark in the corner.
* `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that
returns the 40-pin BCM layout. Every Pi from the 1B+ onwards
uses the same physical pin positions and same BCM GPIO
assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this
helper and example wires drawn against one model transfer to
the others without re-routing.
* React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the
custom elements at absolute positions (mirrors how
RaspberryPi3.tsx handles the Pi 3 illustration).
* Wire-up across the editor surface:
- BoardOnCanvas: BOARD_SIZE entry + switch case.
- BoardPickerModal: description, icon, kinds list.
- ComponentPickerModal: thumbnails now instantiate the dedicated
custom element (was velxio-raspberry-pi-3 fallback).
- SerialMonitor / EditorToolbar: pill labels, icons, colours.
- Oscilloscope: GPIO channel list (28 BCM pins).
- SimulatorCanvas: remote-boards filter for run/stop sync.
- SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3.
- boardPinToNumber: accepts physical pin numbers ("1"-"40"),
BCM names ("GPIO14") and power labels for any Pi 3/4/5 id.
- ComponentRegistry: dedicated tagNames + per-board thumbnails
(green for Pi 4, darker green for Pi 5).
* EditorToolbar's Pi 3 special cases (Linux/Python compile path,
Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5
inherit the same behaviour automatically, and any future Pi
family member (Zero / 1 / 2) lands in the right code paths the
moment its backend boots.
QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry
'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes
both boards selectable end-to-end without any backend follow-up.
Three QoL fixes for the Tauri shell:
1. Hide the entire AppHeader strip in VITE_DESKTOP, not just the
marketing nav. The previous gate left the black bar painting
over the editor with the brand + auto-save + share + auth
slot, all of which are irrelevant in desktop (cloud Pro
features, license is handled by DesktopWelcomePage, the title
bar already says "Velxio Desktop"). Return null at the top so
the editor takes the full window height.
2. Splash screen during sidecar boot + Monaco hydration. Cold
launch was a 3-8 s black window — now there's an inline SVG
logo, "Velxio" wordmark, slogan, animated spinner, and a
"Starting local backend…" caption. Lives in index.html as a
fixed-position overlay with display:none by default; the inline
script reveals it only when `window.__TAURI__` is present, so
web users never see it. main.tsx fades it out (250 ms ease-out)
after two animation frames — guarantees React's first paint has
committed before the handoff, no black flash. Self-contained:
inline styles, inline SVG, inline CSS keyframes, zero external
requests.
3. Native locale switcher under View → Language. Emits
`velxio://menu` with action='set-locale' + the locale code; the
desktop/menu.ts handler navigates via history.pushState +
popstate so React Router picks it up without a hard reload
(Monaco + simulator state preserved). Locale list mirrors
i18n/config.ts::LOCALES.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two intertwined bugs were leaving every ESP32 ePaper example broken
end-to-end. Only the 5.65" UC8159c panel surfaced the failure
audibly ("Busy Timeout!" repeating in serial), because its inverted
busy polarity caused the firmware to hang inside `_waitBusy()`. The
SSD168x ePaper examples APPEARED to run cleanly but never actually
rendered anything to the panel — the canvas stayed at the idle paper
colour because the same registration path was broken.
Root cause #1 — `setSensors` was a full REPLACE, not a merge.
`Esp32Bridge.setSensors(sensors)` did `this._pendingSensors =
sensors`. At `startBoard()` time the store iterates components,
resolves wires for any entry in `SENSOR_COMPONENT_MAP` (DHT22 /
HC-SR04 / I²C sensors) and calls `setSensors(...)` with that list.
ePaper components live in `PartSimulationRegistry` (not in the
sensor map) and are registered via `sendSensorAttach()` AT
COMPONENT-MOUNT TIME — well before `startBoard()` runs. Full-replace
semantics blew that registration away on every Run click, so the
worker never instantiated an `Ssd168xEpaperSlave` / `Uc8159cEpaperSlave`,
no SPI bytes were decoded, no frames were latched, and BUSY was
never driven.
Fix: upsert by `pin` so pre-existing registrations from
PartSimulationRegistry handlers are preserved alongside the
startBoard-resolved sensors. Confirmed via a WebSocket spy that the
`start_esp32` payload now carries the ePaper sensor entry.
Root cause #2 — BUSY polarity was hard-coded for SSD168x only.
Verified against upstream GxEPD2 source:
* SSD168x family — constructor passes `_busy_level = HIGH`
→ BUSY=HIGH means busy, LOW means ready.
* UC8159c family — constructor passes `_busy_level = LOW`
→ BUSY=LOW means busy, HIGH means ready.
The worker only drove BUSY after a frame flush (and at the wrong
polarity for UC8159c), so the firmware's first `_waitBusy()` inside
`_PowerOn()` / `_InitDisplay()` — which fires BEFORE any frame —
blocked for the full 25 s `_busy_timeout`.
Fix: read `controller_family` from the registration payload, pick the
per-family idle level, and (a) seed the pin to IDLE at registration so
the first `_waitBusy()` sees "ready" immediately, (b) use that
polarity (idle vs. busy) when pulsing on frame flush.
Verified on https://velxio.dev/example/epaper-5in65-7c-esp32-rainbow:
the serial timeline now reads `_InitDisplay reset : 1566` /
`_PowerOn : 148` / `_PowerOff : 183` / `frame done` (all sub-2 ms
busy-waits, no timeouts). Sensor registration confirmed via the
`start_esp32` payload carrying the `epaper-ssd168x` entry.
Two new modules under the existing desktop/ subtree, both no-op
outside a Tauri runtime (tauriBridge.listen / .invoke fail gracefully).
desktop/menu.ts — listens for the `velxio://menu` event the Rust
shell emits from the native menubar (Velxio Desktop / File / Edit
/ View / Help, full menu defined in
pro/desktop/src-tauri/src/menu.rs). Internal actions handled
directly here: Save .vlx and Open .vlx via utils/vlxFile, Toggle
Serial Monitor via useSimulatorStore, Check for Updates via the
tauri-plugin-updater global. The rest (new-project, Find,
Toggle File Explorer) re-emit as window CustomEvent so the owners
of that UI state can subscribe without pulling this module in.
desktop/log.ts — `dlog(message, extra?)` round-trips a line to a
Rust `write_debug_log` command that appends to
`<app_data_dir>/desktop-debug.log`. Packaged Tauri apps have no
devtools or stdout capture, so this is the only way to see what
the webview did when a user reports a bug. Falls back to plain
console.log when the command isn't registered (older shell).
mountDesktop() now installs the menu listener and dlog's its own
start — useful as a "did the desktop module even load" smoke marker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The marketing nav (Home/Docs/Examples/Pricing/Blog/GitHub/Discord) and
the LandingPage hero are great for velxio.dev visitors but become
clutter once the SPA ships inside a Tauri shell — the user installed
the desktop app to land in the editor, not to read about the project.
Two small VITE_DESKTOP gates handle this:
- AppHeader.tsx hides the <nav> + the mobile hamburger that toggles
it. The brand, language switcher, auto-save indicator, share
button, and the pro overlay's auth slot all stay visible — they
carry real per-session info, not navigation.
- App.tsx swaps the `/` route's element for a <Navigate to=/editor>
so first-launch (and any future `velxio://` deep-link that lands
on `/`) goes straight to the editor.
Equivalent actions for the items being hidden live on the native
menubar that the velxio-prod overlay builds via
pro/desktop/src-tauri/src/menu.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Velxio had two parallel import paths that confused users (reported on
Discord by AgUn / dmontero):
* Toolbar "Import a project from a .zip file" → Wokwi .zip only
* File-explorer "Open .vlx file" → Velxio .vlx only
If you exported a Velxio project as .vlx and tried to bring it back via
the toolbar Import button, you bounced off "wrong format" with no hint
that the .vlx loader was hiding behind the file-explorer save-bar.
Fix: introduce `utils/importProject.ts` as the single dispatcher. It
sniffs the extension and routes:
*.vlx → importVlxFile (writes directly to stores)
*.zip → importFromWokwiZip (returns a payload the caller applies,
so the toolbar can still trigger the
install-libraries modal afterwards)
Both UI entry points now go through the dispatcher with the same
`accept=".vlx,.zip,application/json,application/zip"` filter:
* Toolbar "Import project (.vlx Velxio or .zip Wokwi)"
* File-explorer "Open project (.vlx Velxio or .zip Wokwi)"
The toolbar tooltip is i18n-driven — updated EN + 8 other locales
(es, fr, de, it, pt-br, ja, ru, zh-cn) so every user sees the same
clarification.
Wokwi compatibility kept intact — the .zip path still resolves to
`importFromWokwiZip` and the same library-install modal pops if the
imported project lists libraries we don't have locally.
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).
Same gap as the AVR USART: rp2040js's UART fires `onByte(value)` per
transmitted byte but never toggles the corresponding GPIO, so an
oscilloscope on GP0 (UART0 TX, default for Arduino-Pico's Serial1) sees
nothing during `Serial.print`. Real silicon drives the pin with the
full UART frame at the configured baud rate, and Velxio should match.
`emitUartTxFrame(uartIdx, byte)` derives:
* `txPin` via FUNCSEL inspection: walk GP0 / GP12 / GP16 / GP28 (the
four candidates for UART0 TX per RP2040 datasheet) and pick the
first whose `functionSelect == 2` (FUNCTION_UART). Same for UART1.
Fall back to GP0 / GP4 when nothing is mapped (firmware hasn't
called `Serial1.begin()` properly).
* `baudRate` and `bitsPerChar` directly from the UART peripheral
(rp2040js already exposes these as live getters).
* Time from the RP2040 IClock's `nanos` counter, matching the
existing `setupGpioListeners` path — UART waveforms therefore stack
consistently with PIO / SIO traces on the same scope.
Both `uart[0].onByte` and `uart[1].onByte` get hooked. The seed-idle-
HIGH baseline is pushed once per UART per simulation run; `stop()`
clears the flag so a re-run gets a fresh seed (matching how the scope
buffer is cleared on restart).
avr8js intercepts the transmitted byte at the UDR0 register and never
toggles the corresponding GPIO. Real ATmega328P / ATmega2560 hardware
drives PD1 / PE1 with a start bit, 8 data bits LSB-first, and a stop bit
at the configured baud rate the moment TXEN is set. An oscilloscope
probe on D1 therefore showed nothing in Velxio while the same probe in
the real world would resolve the UART frame.
Synthesize the frame from the inside of `onByteTransmit`:
* Read `usart.baudRate`, `usart.bitsPerChar`, `usart.parityEnabled`,
`usart.parityOdd`, `usart.stopBits` so unusual configurations stay
accurate (avr8js already exposes these as public getters).
* Build the bit list start + data(LSB first) + parity? + stopBit(s).
* For each transition vs. previous state (initial = idle HIGH), call
`onPinChangeWithTime(1, state, timeMs)` where
`timeMs = (cpu.cycles + i * cyclesPerBit) / 16_000`. Same
simulator-time clock the existing port-listener path uses, so the
scope draws the UART waveform cycle-accurately alongside other GPIO
activity.
Also hook `onConfigurationChange` to detect TXEN flipping 0→1 and seed
the scope baseline at idle HIGH; without that, the very first byte's
start bit transition would be invisible because the scope's pre-first-
sample default is LOW.
Both USART construction sites (initial setupSimulation around line 423,
re-init after stop around line 749) get the same hook.
Covered by `__tests__/avr-uart-tx-waveform.test.ts` (5 cases): idle seed,
byte with internal transitions, 0xFF edge case, TXEN-disabled no-op,
bit-period timing.
The previous test fed `xValue: 0, yValue: 1023` to the analog-joystick
handler and asserted X=0V, Y=5V. That was wrong for two reasons:
1. wokwi-analog-joystick emits direction (-1/0/+1), never 0..1023, so
the fixture didn't match how the real component behaves.
2. The OLD `(value/1023) * vcc` mapping happened to produce 0V for 0
and 5V for 1023, so the broken test still passed against the
broken handler — and now breaks against the correct one.
Switch the fixture to `xValue: -1, yValue: 1` so the expectations line up
with the corrected handler (-1 → 0V, +1 → Vcc) and exercise the same
voltage rails the real component will produce.
The PartSimulationRegistry handler for 'analog-joystick' was reading
`el.xValue` / `el.yValue` and computing `(value / 1023) * vcc` as if the
component were a potentiometer producing a raw 0..1023 reading. It is
not — `@wokwi/elements/analog-joystick-element` emits xValue / yValue as
a tri-state DIRECTION signal:
* xValue = -1 → "left" (mousedown on left zone)
* xValue = 0 → centered (mouseup snap-back)
* xValue = +1 → "right" (mousedown on right zone)
(same for yValue with up/down)
`(±1) / 1023 ≈ ±0.001`, so the ADC channel sat at ~0 V no matter which
directional zone was clicked. Center-button clicks worked because that
path is digital (`setPinState(SEL, …)`) and bypasses the analog map.
Fix:
* Tri-state → voltage with explicit map: -1 → 0V, 0 → Vcc/2, +1 → Vcc.
* Vcc was hardcoded to 5V for "not RP2040" — wrong for ESP32 / S3 /
Nano-ESP32 / etc., which all run at 3.3V like the Pi Pico. Detect
ESP32 via the BridgeShim's `setAdcVoltage` method and select 3.3V
for everything that isn't pure AVR.
Reported on /example/esp32-joystick where center-button-only worked but
directional zones did nothing. Verification via Chrome MCP after deploy.
Two coordinated copy/layout changes on the landing hero:
- Primary CTA label gets "Online" added across the 9 supported
locales — "Try Simulator Free Online →" / "Probar el simulador
online gratis →" / etc. Reason: with the Velxio Desktop
download path now live, users should immediately understand
that the green button is the BROWSER version, and there's a
local install option for people who want it faster offline.
- The pro-overlay slot for the desktop download CTA was
`landing-hero-primary-cta` and sat ABOVE the hero CTAs. Renamed
to `landing-hero-download-cta` and moved BELOW so the funnel
reads "try online (primary) → or install locally (secondary)"
instead of "install locally (primary) → try online (secondary)".
OSS layout is unchanged — the slot is still empty in pure builds.
Also picks up the auto-regenerated sitemap.xml lastmod dates from a
recent deploy (every URL bumped 2026-05-19 → 2026-05-21).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `frontend/src/desktop/` module loaded only when VITE_DESKTOP=true.
Hosts the Tauri-bound UI that pure OSS doesn't need:
- tauriBridge.ts typed invoke / listen / openExternal / beginSignIn
wrappers with no-op fallbacks for `vite dev` outside
the Tauri webview.
- DesktopWelcomePage.tsx sign-in flow with browser handoff +
paste-key fallback; listens for
`velxio://auth-completed` from the shell.
- GraceBanner.tsx renders soft/hard grace banners driven by
`license_status` + `velxio://license-status`
emits from the background checkin loop.
Toggles `body.vlx-desktop-readonly` so the
editor's Save / Compile buttons disable
themselves via CSS in hard-grace.
- Esp32QemuPrompt.tsx one-time download modal when the user picks
an ESP32 board on a fresh install.
- index.ts mounts welcome conditionally and the side
panels unconditionally.
- desktop.css shared styles.
All entry points are no-ops outside Tauri so the existence of the
folder has zero effect on the OSS build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `lib/apiBase.ts` so the SPA can be repointed at a non-default backend
at runtime (via `window.__VELXIO_API_BASE__`) without losing the existing
`VITE_API_BASE` build-time override or the default `/api` reverse-proxy
behaviour. compilation / libraryService / projectService / metricsService
all flow through it now; axios clients use a request interceptor so the
base resolves per-request rather than at module-load time.
main.tsx grows a `VITE_DESKTOP` flag: when set, the @pro overlay is
skipped (the desktop shell handles license + auth natively) and a
small `./desktop/index` module is dynamic-imported in its place. OSS
builds tree-shake both branches.
LandingPage gets a `data-velxio-slot="landing-hero-primary-cta"` marker
above the existing hero CTAs so velxio.dev can inject an OS-detect
"Download Velxio Desktop" button as the visual primary. The slot is
empty in pure OSS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On the ESP32 DevKit V1 the silkscreen labels GPIO 16 / 17 as RX2 / TX2,
and Esp32Element.PINS_ESP32 only exposed the silkscreen names. Examples
that wire to numeric pin "16" or "17" (e.g. ledcAttach(16, 5000, 8) on
esp32-pwm-led-rgb) couldn't resolve those names — pinPositionCalculator
failed lookups, the wire endpoint fell back to (0,0)/(50,50) and the
LED component visually floated off the board, breaking the SPICE
netlist for the example.
Add "16" and "17" as aliases pointing to the same (134,143) / (134,131)
coordinates as RX2 / TX2 so both naming conventions resolve to the same
physical pin tip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExampleThumbnail.tsx only serves /examples-thumbs/<id>.webp; the .png
copies were sharp's intermediate format committed by mistake on
2026-05-19 (commit 8693f93) when scripts/refresh-example-thumbs.sh
copied both formats to public. Removing ~18 MB of unreferenced PNGs
keeps the public folder lean. WebP is supported by ~97% of browsers
in use; the old Safari fallback path goes through CircuitPreview
(the SVG mock), not the .png.
Generated by velxio-prod's scripts/capture-example-thumbs.mjs after
fixing three bugs (CTA-based navigation against renamed route,
heterogeneous data-file parsing, false-positive prefix match). The
gallery now has a real canvas screenshot for every example
exampleProjects exports — 263 / 263. Previously 44 examples (mostly
digital-* and i8080-* / z80-*) had no preview and rendered the
SVG mock fallback.
Two related bugs that surfaced as "Vitest worker exited unexpectedly /
Timeout terminating forks worker" on the circuit-simulation-service
test file.
Bug 1 — tick() recursively re-schedules itself in its finally block.
After afterEach disposes the scheduler via __resetMixedModeScheduler(),
those re-scheduled ticks throw "call loadCircuit first", get caught by
the console.warn, and the finally schedules ANOTHER tick. Infinite
Promise loop survives until the worker OOMs.
Fix: add CircuitSimulationService.stop() that flips a `stopped` flag
short-circuiting tick() + handleMcuEdge(). The test harness now
tracks each started service in _activeServices and calls stop() in
afterEach alongside the existing unsubscribe sweep.
Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net
(buildNetlist skips it because netLookup returns null), handleMcuEdge
sees hasSource=false, self-heals by queueing the edge + tick(). The
rebuild still doesn't emit the V-source (no wire), so tick.finally
replays the edge → self-heal again → tick again → infinite loop AT
RUNTIME, not just in tests. A user toggling a digital pin without a
wire freezes the whole circuit simulation.
Fix: in tick.finally's pendingMcuEdges replay loop, check whether
the rebuilt netlist now contains a V-source for each pending edge's
pin. If not, drop the edge silently — a future canvas tick triggered
by adding the wire will pick it up via the normal subscription path.
Also fix the "coalesces an edge with an in-flight full solve" test
fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was
never emitted and the test was racing the (now-bounded) self-heal
rebuild. Replaced with an inline fixture wiring pin 9 → resistor →
GND, mirroring the wired fixture used by the alter+republish test
right above it.
Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per
shard) with no worker-exit warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three failures introduced by 603b791 (which added
MAPPERS['photoresistor-sensor'] = MAPPERS['photoresistor'] so the
metadata-id 'photoresistor-sensor' resolves to a SPICE mapper):
- component-to-spice.test.ts "every mapped metadataId has a test fixture"
flagged photoresistor-sensor as missing. Added a fixture entry that
mirrors the photoresistor one — the part is electrically identical.
- examples-netlist-snapshot.test.ts > photoresistor-light and
> nano-sensor-station snapshots now contain R_ldr_ldr + R_ldr_pull
cards (correct LDR + 10k pull) instead of the previous
R_autopull_n0 100M stub. This is the intended behaviour change:
before the alias the LDR was unmapped and the netlist autopulled the
net to ground with a 100M dummy; after the alias the SPICE deck
carries the real divider topology. Regenerated only these two
snapshot entries (vitest -u on the single file).
No production code changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ATtiny85 (AVRSimulator + collectPinStates + connectAnalogInputsToMcu + SimulatorCanvas + Attiny85Element + examples):
- Add attiny85AdcConfig with correct register addresses (ADMUX=0x27,
ADCSRA=0x26, ADCSRB=0x23, ADCL=0x24, ADCH=0x25, DIDR0=0x34, adcInterrupt=0x08).
Without this, analogRead() polled the wrong address forever and the
firmware hung on first ADC read.
- Add attiny85Timer0Config + instantiate AVRTimer so OVF fires at the
ATTinyCore-expected ~1.024 ms cadence. delay() advance is still blocked
on avr8js TIFR auto-clear semantics (separate upstream issue, see
ATTINY85_TIMER0_UPSTREAM_ISSUE.md in velxio-prod test plan).
- Map ATtiny85 ADC channels to PB-style pin names (PB5/PB2/PB4/PB3 -> 0..3)
in connectAnalogInputsToMcu so SPICE node voltages reach the right ADC
channel.
- Recognise /^PB\d+$/ in collectPinStates.pinNameToArduinoPin so wires
named "PB1" emit v_attiny85_pb1 V-source and the LED responds to MCU
writes. Previously every PB-wire returned -1 and SPICE saw no source.
- SimulatorCanvas: subscribe pin 1 (PB1) for the built-in LED on the
attiny85 board kind (Digispark convention), instead of falling through
to the pin-13 default.
- Attiny85Element: remove the hand-drawn "yellow LED" circle that was
floating above the chip. The bare DIP-8 has no on-board LED; examples
wire a real wokwi-led + resistor instead.
- examples.ts: add a real wokwi-led + 220 Ohm wokwi-resistor + wires to
attiny85-blink, and add missing series resistors to attiny85-button-led
and attiny85-ntc-sensor. attiny85-pwm-fade was already correct.
Custom-chip pipeline (CustomChipPart + simulatorBridges):
- Add a requestAnimationFrame loop that calls instance.tickTimers() every
frame in CustomChipPart. Chips that register vx_timer_create (e.g. an
i8080 stepping its core, or a sensor publishing samples) had timers
added to the queue but nothing fired them; tickTimers was dead code.
- Gate the ESP32 backend path with detectSimulatorKind(sim)==='esp32'.
The previous `typeof sim.registerSensor === 'function'` check matched
AVR and RP2040 simulators too (they expose registerSensor for I2C
sensor proxies), routing client-side chips to a non-existent ESP32
worker on those boards.
- Replace direct simulator.usart.writeByte calls in avrUartTx with a
JS-level FIFO + setTimeout(1ms) drainer. avr8js writeByte drops bytes
under burst load (a chip emitting print_string lost ~99% of bytes via
non-immediate, or kept only the last byte via immediate). The drainer
attempts one non-immediate write per tick and retries on RXC busy /
RXEN off. Added a guard for ATtiny85 (no USART -> would queue forever).
End-to-end verified: i8080-banner-streamer now prints the boot banner
followed by "uptime ticks: 0xNN" lines stepping every ~50 ms, executing
real Intel 8080 instructions inside the WASM chip.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three independent fixes uncovered during a systematic example-by-example
audit (plan/full_test_plan/):
1. DynamicComponent.handleMouseDown was calling e.stopPropagation()
unconditionally in the capture phase. That swallowed pointerdown
BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick
could see it, so the rotary knob would not rotate and buttons
wouldn't press even with a real OS mouse. Now we skip the swallow
when the click target is an inner wokwi-* element during a live
simulation, letting the wokwi component own its own pointerdown
while still allowing the canvas drag-to-rearrange flow on the
wrapper / non-interactive surface.
2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider
formula inverted relative to both the SPICE mapper topology
(VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring)
and real wokwi-ntc-temperature-sensor modules. Moving the slider
to 60 C made the firmware print -3.42 C. Flipped the formula to
r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports
60.12 C and A1 voltmeter shows 4.00 V.
3. componentToSpice.ts photoresistor mapper was only registered under
the bare key `photoresistor`, but example components use the
metadataId `photoresistor-sensor`. Added an alias so the LDR +
pull-down divider gets emitted for the real component instance.
All three reproduce visually in seconds; documented per-example in
plan/full_test_plan/examples/.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues in PR #200 became obvious from the next CI run:
1. **`poolOptions.forks.execArgv` had no effect.** Vitest 4 removed
`test.poolOptions` entirely — every key under it moved to
top-level `test.*`. The runner emits this banner on every run:
DEPRECATED `test.poolOptions` was removed in Vitest 4.
All previous poolOptions are now top-level options.
So `test.poolOptions.forks.execArgv: ['--max-old-space-size=8192']`
was silently ignored. Move it to `test.forks.execArgv` (and
`test.forks.singleFork`).
2. **NODE_OPTIONS got dropped when the shard step was rewritten.**
PR #199 added `env: NODE_OPTIONS: --max-old-space-size=8192` to
the `npm test` step; PR #200 replaced the step with `npx vitest
run --shard X/2` but did not carry the env-var across. Combined
with #1, the workers reverted to Node's 4 GB default and shard 2
(58 files) still OOMs at exactly 4128 MB heap.
Restore NODE_OPTIONS on the workflow step as belt-and-braces — it
gets honored by the parent vitest process, and the migrated config
covers the forked children.
With 8 GB heap × 2 shards × 58 files each, the cumulative state
from ngspice WASM + singletons fits comfortably and the suite
should exit cleanly.
PR #198 added NODE_OPTIONS=--max-old-space-size=8192 to the
frontend-tests workflow assuming vitest's forks pool would inherit
it. It does NOT. Vitest 4's forks pool spawns workers via
child_process.fork() with an explicit execArgv list and ignores
the parent shell's NODE_OPTIONS env var — verified by reading the
post-merge GHA log: the Node OOM still fires at ~4.0 GB heap,
exactly the default v8 ceiling.
Set the heap cap at the pool level instead so the workers actually
see it. This is the canonical vitest 4 idiom for raising worker
limits — `poolOptions.forks.execArgv` is forwarded verbatim to
each forked child.
Independent of: the gpio_matrix_cb SIGSEGV fix in qemu-lcgamboa
(now landed) which addresses the Backend E2E failure mode. This
PR is exclusively the Frontend Tests heap fix.
This also serves as the trivial commit needed to re-trigger the
master CI run against the now-fixed libqemu binaries (v1.1.1
served from the license endpoint).
Two CI failures landed together on master after PR #194 merged:
1. **components-metadata.json stale.** The `power-supply` thumbnail
in scripts/component-overrides.json was updated (grey placeholder
→ branded PSU SVG with voltage/current labels) but the generated
JSON wasn't regenerated. The pre-merge check
`git diff --quiet frontend/public/components-metadata.json` now
fails on master. Fix: `cd frontend && npm run generate:metadata`,
commit the result.
2. **Frontend Tests > test (20/22): vitest worker hang.**
`circuit-simulation-service.test.ts` had been calling
`service.start()` in ~10 tests without storing the returned
unsubscribe handle. Each call subscribes the service to the
simStore; the listener captures the service + scheduler in
its closure. After all tests complete, vitest's forks pool
tries to terminate the worker but the still-active listeners
keep the event loop pinned, producing:
"Worker exited unexpectedly / Timeout terminating forks worker"
All assertions actually pass — only the worker shutdown hangs.
Fix: introduce a `startTracked(service)` helper that records
the unsubscribe in a module-level array, plus an `afterEach`
that drains the array. `__resetMixedModeScheduler()` still runs
after to dispose the scheduler singleton. Replaced all 9 raw
`service.start()` callsites.
Both are independent of any production code change. The fix is
test/scaffolding only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the editor opens a .s or .asm file (the chip-program files routed
to /api/compile-rom), Monaco now colorizes 8080/Z80 mnemonics, registers,
hex/binary literals, comments, and directives. Same highlighter covers
both ISAs since most mnemonics overlap.
- frontend/src/components/editor/retroAsmLanguage.ts: a Monarch tokenizer
+ LanguageConfiguration + idempotent registration helper. Recognises
the full 8080 ISA, all the Z80 additions (LD/JR/DJNZ/EXX/EX/IM/LDIR/
bit ops/index ops), the directives ORG/DB/DW/EQU/END, and registers
including condition codes (NZ/Z/NC/etc.) and IX/IY.
- CodeEditor.tsx: maps `.s` and `.asm` to the new `retro-asm` language
and calls `registerRetroAsm(monaco)` in beforeMount so the language
exists by the time the editor first paints. Other extensions
(.ino/.cpp/.c/.py/.json/.md) behave exactly as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a third format to /api/compile-rom: `c` (C source compiled by SDCC
to Z80 bytes). Same chip-program flow as 8080/Z80 asm — write C in a
project file, click Compile, click Run.
Backend:
- backend/app/services/c_compile.py — async SDCC wrapper. Locates the
sdcc binary on PATH (or via SDCC env var, or common Windows install
paths) and shells out with target=mz80 + --code-loc 0x100 --data-loc
0x8000. Parses the resulting Intel HEX into raw ROM bytes. Pure 8080
is rejected with a clear error (SDCC has no 8080 backend; Z80 ROMs
also run on the i8080-cpu chip if you avoid Z80-only ops).
- rom_compile.py: compile_rom is now async; the new c branch delegates
to c_compile. compile_rom_endpoint awaits it.
Frontend:
- romCompileService: RomFormat gains 'c'; formatForFile maps .c/.cpp to
'c'. isChipProgramFile intentionally still excludes .c — disambiguation
happens at the EditorToolbar level.
- EditorToolbar: the chip-program path also fires when a custom-chip
has programFile === activeFile.name (regardless of extension). That
lets .c files route to /api/compile-rom (SDCC) when bound to a CPU
chip, while .c files NOT bound to any chip continue to route to
arduino-cli as before.
Docker:
- Dockerfile.standalone adds `sdcc` to the apt-get install list, so the
prod image ships with SDCC out of the box.
Example:
- /examples/z80-led-chaser-c — z80-cpu chip + chaser.c (a Larson
scanner written in C with __at() MMIO definitions). Compiles cleanly
with SDCC's --code-loc 0x100 default crt0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Zilog Z80 to the programmable-retro-CPU lineup. Same compile-rom
flow that landed for the 8080 in PR #189: write Z80 asm in a project
file, click Compile (backend assembles via in-tree two-pass asm-z80),
click Run, the chip emulator boots from the resulting ROM bytes.
Backend:
- backend/app/services/asmz80.py — two-pass Z80 assembler covering the
practical demo subset: LD r,n / r,r' / rp,nn / (nn),A / A,(nn) +
ALU r/n + INC/DEC + JP/JR/DJNZ/CALL/RET + PUSH/POP + IN/OUT +
EX/EXX + LDIR/LDDR/IM/NEG + RLCA/RRCA/RLA/RRA + the simple
ED-prefix variants. Not yet: CB-prefix bit ops, DD/FD index ops.
- rom_compile.py routes target=z80 through the new assembler.
Chip:
- frontend/src/components/customChips/examples/intel/z80-cpu.{c,chip.json}
Generated by scripts/make-z80-cpu.py from the existing z80.c emulator
(same clean-room implementation that passes ZEXDOC end-to-end). The
external pin/bus protocol is replaced with internal RAM + ROM + MMIO
for LED/BTN/UART. 35 KB WASM.
Example:
- /examples/z80-larson-scanner — Knight-Rider-style walking LED.
Demonstrates JR/DJNZ/RLCA which the 8080 can't run.
Plus a small Z80 smoke-test asm under scripts/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #192 (spice-led-pipeline) added two PinManager changes that were
not reflected in the test mocks / assertions:
- `setPinState(pin, state)` gained an optional `source: 'mcu' |
'external'` third arg. Production ESP32-C3 / RISC-V simulators
now pass `'mcu'` to mark the call as an MCU output (so the SPICE
collector emits a V-source). The esp32c3-blink and esp32c3-simulation
tests asserted on the old 2-arg shape.
- `resetPinStates()` is a new public method on PinManager called by
`stopBoard` / `resetBoard` to clear cached pin states. The mocks in
esp32-integration.test.ts and multi-board-integration.test.ts did
not add it, so any test that ran stopBoard hit
`TypeError: getBoardPinManager(...)?.resetPinStates is not a function`.
This commit:
- Adds `'mcu'` to the two ESP32-C3 setPinState assertions.
- Adds `this.resetPinStates = vi.fn()` to both integration mocks.
These are pure test fixups — no production code touched. The
`circuit-simulation-service.test.ts > handleMcuEdge` failure
(`expected 1 to be 2`) is a separate regression in production code
introduced by PR #192 and is NOT fixed here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new way to use the retro CPU chips: write your program in a
project file (.s / .asm / .hex / .bin), click Compile, click Run, and
the same chip emulates whatever you wrote. Same chip + different ROMs =
mini PC, calculator, LED demo, Kill-the-Bit game, etc.
SDK:
- velxio-chip.h gets two new host imports:
uint32_t vx_rom_size(void);
void vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len);
CPU-emulator chips call these in chip_setup to pull their program out
of the host's romBytes property.
Frontend runtime:
- ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new
imports, copying bytes into chip memory on vx_rom_read.
- CustomChipPart pulls component.properties.romBytes (base64) and passes
it through.
- Component registry declares three new custom-chip properties:
romBytes (base64), programFile (matching project filename), and
programTarget (cpu name).
New programmable bundled chip:
- frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json}
Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is
loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM,
32 KB of external ROM.
Backend:
- New /api/compile-rom endpoint and rom_compile service that turns
chip-program source into ROM bytes. 8080 ASM is assembled by the
in-tree two-pass assembler (moved to backend/app/services/asm8080.py).
Intel HEX records are parsed; raw .bin is passed through. Future targets
(z80, 8086, 4004) are scaffolded but not wired yet.
EditorToolbar:
- Compile button detects when the active file is .s/.asm/.hex/.bin and
routes to compile-rom instead of arduino-cli. The compiled bytes are
injected into every custom-chip on the canvas whose programFile property
matches the active filename (or is empty).
Example:
- /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on
the programmable i8080-cpu chip. killbits.s is shipped as a project
file alongside sketch.ino; the user clicks Compile then Run and the
LED walks across 8 outputs, buttons kill it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
`PinTracer` signature is `(componentId, componentPinName) => number | null`
but the local `getArduinoPin` lambda only accepted one arg and used the
closure-captured `id`. When `createDefaultPinResolver` passed both args
(per the typed signature), JS bound the FIRST arg (the componentId) into
the lambda's single `componentPinName` parameter. `traceDetailed` then
looked up a pin literally named "rgb-led-1" on component "rgb-led-1",
returned null, and the resolver locked itself into 'FLOATING' state —
its onChange path never subscribed and the wokwi-rgb-led element's
ledRed/ledGreen/ledBlue stayed at 0 forever even as the SPICE side
correctly cycled through R, G, B, Y, C, M, W via analogWrite().
Same bug latent for any multi-pin component that goes through the
PinResolver path (multi-pin LEDs, RGB strips, 7-seg drivers, anything
that calls `getPinResolver(<pinName>)` for several pin names).
Fix: lambda now accepts both shapes — `getArduinoPin(pinName)` (legacy
single-arg used by every PartSimulationRegistry handler) AND
`getArduinoPin(componentId, pinName)` (PinTracer 2-arg form used by
createDefaultPinResolver / createSpiceResolvedPinResolver). Picks the
right componentId in either case.
Verified via the rgb-led example: ledRed/ledGreen/ledBlue now cycle
0→255→0 in sync with the SPICE node voltages on pins 9/10/11.
Co-Authored-By: Claude Opus 4.7 <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>
Backend:
- api/routes/compile.py accepts board-specific compile options
and dedups in-flight identical requests
- services/espidf_compiler.py expanded ESP-IDF wrapper with the new
options surface (sdkconfig.defaults.in
template added)
- services/arduino_cli.py honour the new options envelope
- services/esp32_lib_bridge.py thread board options through to QEMU
Tests:
- tests/test_compile_request_dedup.py end-to-end dedup behaviour
- tests/test_espidf_options.py covers the new options parsing
Frontend:
- services/compilation.ts client-side mirror — sends the new
options field on every compile request
Co-Authored-By: Claude Opus 4.7 (1M context) <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>
Two pieces of drift introduced in 305170a (Regulated Power Supply):
1. The committed components-metadata.json carries a custom rich
thumbnail SVG (showing 5.00V / 1.00A / PSU labels), but the
_customComponents override has no `thumbnail` field — so any
regen via `npm run generate:metadata` replaces it with the
generic placeholder. CI catches the drift and fails.
Fix: lift the rich SVG into the override entry.
2. The committed metadata description is a short one-liner while
the override description is the longer explanatory version.
The override is the source of truth, so the metadata now
matches: longer description wins.
Verified locally: regenerator now produces zero diff against the
committed metadata.
Bug reproduced via CDP probe across 5 Run/Stop cycles: cycle 1
worked (LED toggled), cycles 2-5 LED stayed dark — but exactly the
same code, same canvas, same circuit.
Tracing the live electrical store via __spiceDebug() showed:
cycle-1 after-run: branchCurrentCount=3 (pin13 V-source present)
cycle-2 after-run: branchCurrentCount=2 (pin13 V-source MISSING)
cycle-3..5 after-run: branchCurrentCount=2
The flow:
1. User clicks Run -> board.boards reference changes -> service ticks.
2. runSolve calls collectPinStates(board, ...) to snapshot output pins.
3. collectPinStates was emitting an entry ONLY when pinManager.getPinState(pin)
was currently TRUE. If the pin was LOW at that exact instant
(which is most of the time for a Blink sketch — 50% duty), no
pinStates entry, no V-source card in the netlist.
4. AVR runs, digitalWrite(13, HIGH) fires, handleMcuEdge calls
scheduler.onMcuPinChange -> solver.alterSource('V_arduino-uno_13', 5).
5. ngspice gets 'alter V_arduino-uno_13 dc 5' but that V-source
doesn't exist in the deck. Silent no-op. branchCurrents never
updates. LED stays dark forever.
The 'sometimes it works' impression came from cycle 1: the cold-boot
AVR happened to land on a HIGH state precisely when the tick fired,
so the V-source got emitted and every subsequent edge alter worked.
The other cycles caught the AVR in LOW.
Fix: always emit a digital PinSourceState — with v=0 when LOW, v=vcc
when HIGH — so the NetlistBuilder always produces V_<board>_<pin>
cards for every wired GPIO. alterSource then has a target to bind
to no matter what state the pin was in at solve time.
Verified live via CDP probe (_probe_blink.mjs in working tree):
pin13 toggles 0V<->5V at the Blink frequency
LED anode follows at 0V<->1.838V (matches manual calculation:
(5 - 1.84) / 220 = 14.4 mA forward current through the red LED)
branchCurrentCount = 3 stable across all cycles
Adds the public extension points the velxio-prod overlay uses to bind
real canvas-side I2C/SPI/UART models (BME280, future MCP23017, etc.)
to a running Pi guest's protocol shims:
- qemu_manager: set_pi_slave_handler(fn) / get_pi_slave_handler() for
pi_attach_slave + pi_detach_slave WebSocket messages. OSS image
leaves the hook unset so the messages are silently dropped.
- simulation route: parses the two new WS message types and forwards
them to the registered handler when present.
- RaspberryPi3Bridge: attachSlave(spec) / detachSlave(spec) frontend
side of the protocol.
- piSlaveScanner: at simulation start walks components + wires,
identifies I2C/SPI/UART peers wired to Pi protocol pins (40-pin
header physical-pin numbering), and emits one attach per
bus/address pair (deduped across SDA+SCL wires).
- RaspberryPiWorkspace: invokes the scanner once the bridge is open,
with retries to ride out the WS-still-connecting race.
- integration test: pi3_bme280_attach.py boots the Pi, pre-attaches a
BME280 via the slave handler, runs a host-side proto loop, runs
guest python smbus2.read_byte_data(0x76, 0xD0) and asserts the
console reads back CHIP=0x60.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pi 4 and Pi 5 were added to the BoardKind union in db5e3a8 ("feat(pi3
phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS") but no gallery example
ships for them — same situation as Pi 3, which is already accepted.
All three boot a full Linux image under QEMU on the backend; there is
no in-browser canvas demo to register.
The user-reported 'LED with proper series resistor shows 1.84 V at
the anode but never visually lights up' had its root cause here,
not in ngspice / not in the LED brightness handler / not in any
component id naming choice. ngspice parses 'V_led-builtin_sense'
just fine; the diode conducts and the node voltage is exactly what
you'd compute by hand.
What breaks is the JS regex that scans the emitted netlist to
collect voltage-source names so CircuitSimulationService can ask
the scheduler to read their branch-current vectors:
const m = card.match(/^([Vv][_\w]*)\s/);
[_\w]* doesn't accept '-'. For a card 'V_led-builtin_sense …' the
capture is 'V_led' (truncated at the hyphen). The voltageSources
array gets the wrong name; CircuitSimulationService pushes
'i(v_led)' into extraVectorsOfInterest; ngspice has no such vector
so the readVec promise rejects silently; branchCurrents['v_led-builtin_sense']
is never populated; the LED handler in BasicParts.ts sees raw =
undefined, the SPICE-memo path is skipped, and the digital fallback
runs but only sets the LED on when the PinResolver classifies the
anode as a direct GPIO connection (which it does NOT when an
intermediate resistor is in series). Dark LED.
Fix is adding '-' to the character class. One character. All five
existing examples I previously 'fixed' by just adding a series
resistor will now light up correctly without renaming any of their
component ids. Same for any saved user project with hyphenated ids
and for the auto-generated picker ids that used to contain hyphens.
The earlier underscore-id workarounds (default canvas + picker
template) stay in place as defense in depth — they don't break
anything and they keep the SPICE side clear of avoidable special
characters.
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.
Adds 17 chips from the test/test_intel clean-room research to the Custom
Chip gallery, all sourced from manufacturer datasheets and validated by
the existing 129-test vitest harness (CPUDIAG end-to-end for the 8080,
ZEXDOC for the Z80).
CPUs: 4004, 4040, 8080, 8086, Z80 (categoria retro-cpu)
Bus chips: rom-32k, ram-64k, rom-1m, latch-8282, 4001-rom, 4002-ram,
8255-ppi, 8251-usart, 8259-pic, 8253-pit (retro-bus)
Two bundled "mini-computer" demos under retro-bundle that drop on the
canvas as a single chip and run real 8080 code out of an embedded ROM:
* i8080-repl 8080 + RAM + ROM + UART, prints a banner and an
"uptime ticks: 0xNN" counter every ~50 ms via a real
DCR/JNZ busy-wait. Visible in Serial Monitor.
* i8080-counter 8080 + RAM + ROM + 8 LED pins + 2 button pins.
Counts up in binary on BTN_INC, clears on BTN_RST.
Two example projects under /examples reuse these chips end-to-end:
* /examples/i8080-banner-streamer
* /examples/i8080-button-counter
The bundled chips inline a 328 / 34-byte 8080 ROM produced by a new
two-pass 8080 assembler in Python (scripts/asm8080.py) from the .s
sources in scripts/. Both ROMs are pre-assembled and committed under
scripts/*.txt so contributors can rebuild deterministically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups to the realistic-simulator sprint:
1. component-to-spice.test.ts requires every mapped metadataId in
componentToSpice.ts to have a corresponding MINIMAL_FIXTURES entry
so the catalog-completeness assertion passes. Adds the power-supply
fixture (2 pins, default DC 5V/1A topology).
2. examples-netlist-snapshot.test.ts snapshot for mega-multi-led now
includes the new 8x 220Ω series resistors and the matching
autopull nodes. Net IDs shift from n0..n7 -> n9..n16 because the
resistor adds an intermediate node per LED. Verified the diff is
correct (every added R_r* card is a series resistor between the
board pin and the LED anode) before applying.
Adds a new picker entry 'Regulated Power Supply' under the analog
category. Conceptually fills the gap between wokwi-battery (fixed
DC) and wokwi-signal-generator (waveform focus): user chooses
voltage + mode (dc / ac) + currentLimit, no need to think about
battery chemistry or signal amplitudes.
Properties:
mode: 'dc' | 'ac' (default 'dc')
voltage: V (default 5)
frequency: Hz (default 50, only for AC)
currentLimit: A (default 1)
Design notes:
- No new Web Component. The tagName piggy-backs on
wokwi-signal-generator so the canvas renders the familiar
bench-instrument chrome — saves shipping a second 100+ LOC
Web Component for an identical 2-pin shape.
- SPICE: ideal V-source + ESR sized so a near-short reads
I ≈ 1.5·limit. ngspice has no native foldback so the limit
is a circuitVerifier rule, not a hard SPICE constraint.
- circuitVerifier: extends sourceComponents regex to include
power-supply AND honors the per-instance currentLimit
property as the threshold. Real bench supplies behave this
way — a 100mA-limited supply trips at 100mA, a 5A supply
tolerates 5A before flagging. The error code is
'source-overload' (not 'short-circuit') so the modal copy
matches what the user just configured.
The board GND / VCC pins of Arduino / ESP32 / etc. already act
as voltage sources via BOARD_PIN_GROUPS canonicalisation (the
NetlistBuilder maps wires to the right rail). So the user's
companion request — 'board pins should already work' — is the
existing behaviour; this commit only adds the standalone bench
supply for boardless circuits or for testing with a different
voltage.
Five examples wired LEDs directly between a GPIO pin and GND with
no current-limiting resistor:
- examples.ts: traffic-light (3 LEDs), button-led (1), fade-led (1),
simon-says (4)
- examples-circuits.ts: mega-multi-led (8 LEDs)
In real hardware these wire-ups blow the LED in seconds. In the
simulator, ngspice cannot converge on a forward-biased diode with
no series resistance so the branch current comes back as NaN; the
LED visual stays dark even though the user's code is driving the
pin HIGH every cycle.
Adds a 220Ω wokwi-resistor per LED (textbook value for 5 V supplies
and standard diodes) and rewires:
arduino pin → r.1 / r.2 → led anode / led cathode → GND
The same upstream commit hardens the verifier (worst-case GPIO drive
in pre-flight) and the LED renderer (NaN guard) so this class of
mistake is now caught immediately and degrades gracefully when a
user creates their own broken circuit.
Two related correctness fixes that make the simulator's realism
match what users actually see.
1. circuitVerifier was running pre-flight against the IDLE circuit
(every pin LOW). A Blink sketch is going to write pin 13 HIGH
eventually — at which point a missing series resistor produces a
~500 mA spike through the diode. But because pre-flight ran with
pin 13 LOW the led-overcurrent rule never fired, and the user
sailed through Run only to see the LED stay mysteriously dark on
the canvas.
The verifier now forces every digital pin connected to a load to
HIGH = vcc, the worst case any well-defined sketch will eventually
impose. The existing rules (led-overcurrent, resistor-overpower,
short-circuit) now fire correctly and the existing
CircuitVerificationModal blocks Run until the user adds a proper
current limiter or chooses Run Anyway.
Pins that are inputs-only (a pull-up + button) get over-driven
here too, but the rules tolerate that — a pull-up at 5 V draws
~0.5 mA, well below all thresholds. A circuit that would actually
fault under HIGH is flagged.
2. LED simulator was crashing visually on non-finite ngspice branch
currents. A degenerate diode (no series R) makes ngspice return
NaN, which fell through 'raw !== undefined && current > 1e-6' as
false and never triggered the digital fallback. Now we check
Number.isFinite(raw) before trusting it — non-finite returns
route to the digital fallback so the LED at least lights visually
when its driver pin is HIGH (the user still sees the verifier
warning that the real-world circuit is wrong, but Run Anyway is
not a black screen).
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.
pro/frontend/src/pro/components/admin/DataTable.tsx (introduced in
the pro analytics dashboard work) already imports ColumnDef/useReactTable
etc. from @tanstack/react-table. The dependency was missing because
an earlier velxio-prod commit (e1dfc5f) added it locally but the
matching upstream package.json change was never pushed. Adding it
here unblocks the prod docker build.
PiTerminal didn't call term.focus() on mount, so xterm.js stayed
passive — onData only fires when the DOM element has focus. Users
saw the boot prompt but their keystrokes went to whatever element
held focus when they clicked Run (canvas, code editor), never
reaching the bridge. Calling focus() right after fit() makes the
prompt receive input the moment it's visible.
The qemu_manager change adds INFO-level logging when serial_input
WebSocket messages reach send_serial_bytes — useful diagnostic for
future Pi3 input problems (proves whether bytes reached the backend
before we look at TTY / kernel / PL011 wiring).
User report: on the solar-tracker project (5218f9e3) only one servo
moved and the log showed `ch=0 duty=X% gpio=12` (wrong — servoPan was
attached to GPIO 13) and `ch=1 ... gpio=-1` (servoTilt's channel
never resolved).
Root cause traced through the GPIO Matrix dump: the firmware does
exactly what the Arduino-ESP32 Servo library says — `ledcAttachPin(
13, 0)` writes signal 71 (LEDC_HS_SIG_OUT0) into `gpio_out_sel[13]`,
and `ledcAttachPin(12, 1)` writes signal 72 (LEDC_HS_SIG_OUT1) into
`gpio_out_sel[12]`. Per the ESP32 Technical Reference Manual section
4.11, Table 4-3:
71 .. 78 → LEDC HS channels 0..7
79 .. 86 → LEDC LS channels 0..7
The legacy worker code at esp32_worker.py:426 used the off-by-one
range `72 <= signal <= 87` with `ledc_ch = signal - 72`. The mistake
masked itself for single-servo projects because the 0x5000 duty
callback's channel index was internally consistent with the bogus
math, so the duty STILL reached the correctly-routed pin (just
labelled wrong). The new SignalRouter unit tests caught the
discrepancy the moment two servos drove distinct channels: signal
71 (HS_CH0, gpio 13) was REJECTED by the off-by-one filter and
signal 72 (HS_CH1, gpio 12) was misclassified as channel 0.
When I ported the legacy range into `esp32_signals.SIG_LEDC_HS_CH0_OUT_IDX`
the bug came along for the ride. Fix both modules:
* `backend/app/services/esp32_signals.py`: HS 71-78, LS 79-86.
* `frontend/src/simulation/esp32-signals.ts`: mirror.
* tests updated; 20 backend + 23 frontend pass.
After deploy the user's two servos will resolve to their declared
pins:
ch=0 duty=X% gpio=13 (servoPan, was wrongly emitting gpio=12)
ch=1 duty=X% gpio=12 (servoTilt, was wrongly emitting gpio=-1)
This is also why the multi-servo blink "patch" in commit 77bf897
appeared to help: with both pins ALIASED to the same channel via
the off-by-one, the broadcast fallback was the only thing producing
ANY movement on the second servo at all.
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.
Clicking a second photoresistor (or any second sensor of the same
metadataId) showed the previously-clicked sensor's slider value because
the panel was reused across clicks and its useState only ran once. The
mount useEffect also unconditionally dispatched config defaults, which
would have wiped any prior customisation if we naively remounted.
Three changes:
- SensorUpdateRegistry caches the last-dispatched values per componentId
(and clears them on unregister) so the panel has a place to read from.
- SensorControlPanel hydrates from that cache on mount, falling back to
config defaults only when the sensor has never been touched. The
default-dispatch useEffect skips when cached values already exist.
- SimulatorCanvas keys the panel on sensorControlComponentId, forcing a
fresh mount when the user switches sensors — without that, hydration
wouldn't run on subsequent opens.
The previous fix opened the SensorControlPanel on a desktop sensor
click during simulation, but the slider thumb still couldn't be
dragged — the canvas pan handler claims any left mousedown that isn't
explicitly stopped, so grabbing the slider was panning the canvas.
The panel only stopped click events. We now stop mousedown and
pointerdown on the panel wrapper as well, so input[type=range] gets
its native drag and the pan handler stays out.
Commit 77a63ca made handleComponentMouseDown return early while the
simulator was running so clicks on pushbuttons / switches / pots would
reach the wokwi-element shadow DOM. That was correct for components
whose interaction lives inside the Web Component, but wrong for sensors
(photoresistor, DHT22, MPU6050, NTC, gas, flame, sound, joystick, tilt,
PIR, ultrasonic, BMP280) whose only interaction is the React-side
SensorControlPanel we open ourselves. Their mousedowns were bubbling to
the canvas pan handler — the user saw the grab cursor and no panel.
Touch already handled this correctly: tap-up checks SENSOR_CONTROLS and
opens the panel even while running. The mouse path now mirrors that —
if interactionRunning is true we only short-circuit for non-sensors.
deploy.sh's vitest + pytest output was polluted with three benign
but loud warnings that buried real signal:
1. AVRSimulator.start() unconditionally read `window.__spiceDebug`.
In node-side vitest runs `window` is undefined → ReferenceError
→ console.warn('[spice] debug dump failed', e). Logged once per
AVR test. Guarded with `typeof window !== 'undefined'`; in
production the browser path is unchanged.
2. pinPositionCalculator.calculatePinPosition() warned every time
document.getElementById returned null. In node-side tests there
is no real DOM and every wire-related test triggers the warning
for every component. Skip the console.warn when
import.meta.env.MODE === 'test' (vitest sets MODE=test); the
function still returns null and production retains the
actionable warning for unmounted components.
3. test_esp32_wifi_args.py::test_start_instance_accepts_wifi_params
mocked asyncio.create_task with no side_effect, so the coroutine
from self._boot(...) leaked and triggered a "coroutine never
awaited" RuntimeWarning. Mock now closes the coroutine.
After fixes:
frontend tests: 0 spice/pinPositionCalculator stderr lines
backend tests: 259 passed, 15 skipped, 1 warning (starlette
third-party python_multipart deprecation —
not ours, fixed when starlette updates).
rp2040js runs at ~50% real time, so a TFT frame burst (fillRect sky +
fillRect floor + many drawFastVLine for walls + HUD) often takes longer
than 16 ms to drain through the SPI pipeline. Painting on every rAF
captured mid-burst snapshots that the next sky fill immediately
clobbered, so the canvas only ever showed the last few pixels written
before each tick — most visibly the raycaster examples rendering 2-3
wall columns instead of 160.
Strategy: each SPI pixel write resets a 16 ms idle timer. We paint only
after that period of silence (a real frame boundary), with a 100 ms
hard cap so continuous-write sketches still update.
Also adds test/pico_doom_demo/raycaster-perf.mjs — a puppeteer-based
profiler that reports CPU step rate, SPI throughput, per-pixel cost,
and paint rate. Run with the dev backend + frontend up:
node test/pico_doom_demo/raycaster-perf.mjs
After the fix the Doom raycaster paints at the sketch's natural 10 FPS
with full frames (was 29 fps of mid-burst snapshots).
Mobile was working fine; desktop had a string of issues that surfaced
together on the Pico Doom example after the simulator/wiring fixes.
1. Selection action bar appeared during simulation, intercepting button
presses. handleComponentMouseDown unconditionally called
e.stopPropagation() + setSelectedComponentId, so clicking a wokwi-
pushbutton on a running canvas ate the mousedown — the
button-press event never fired and the floating Rotate/Delete bar
popped up on top of the button. Now: while running, the handler
returns early so the event propagates to the underlying component
and the canvas stays read-only.
2. The selection action bar was always visible on desktop. It was
introduced as the primary delete UI for touch devices (no Delete
key, no right-click), but it kept showing on mouse-and-keyboard
too — covering pins and intercepting clicks. Now gated on
isTouchDevice (already wired via useIsCoarsePointer) AND !running.
Desktop users keep Delete key + right-click context menu for the
same operations.
3. Left-click drag on the canvas background didn't pan. Pan was
limited to middle/right click. Now left-click on empty canvas
panning works too (component mousedowns stopPropagation so they
still drag the component, not the camera). Wiring mode keeps left
click for waypoint drops, so the pan only kicks in when not in
wire mode and not in a property dialog. Matches Figma / Miro /
draw.io convention.
Build verified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production crash on the simulator page after init:
Uncaught ReferenceError: traceDetailed is not defined
at Z (index.js)
at Object.attachEvents (index.js)
Root cause (introduced in 27c5966 Phase 1b skeleton): `traceDetailed`
was declared as a `const` inside `getArduinoPin` but called from the
sibling `getPinResolver`, which is a separate inner function. Vite dev
sometimes inlined the call differently so the bug only surfaced in the
minified Rollup bundle. Reproduces with any part that has an Arduino
pin reachable through wires (i.e. almost every canvas component).
Fix: hoist `traceDetailed` (and its `PASSIVE_PIN_PAIRS` /
`PRESET_TO_BASE` data) to module scope. Pure function takes the
simulator state as an argument. Both `getArduinoPin` (now a thin
wrapper) and `getPinResolver` call it correctly.
No behavioural change. 1853 tests still pass, build:docker green.
K (frontend-tests.yml reinforced):
• Matrix node-version: [20, 22] — catches Node-version-specific bugs
• Cache the 24 MB ngspice WASM by hash — saves ~10s/run
• `npm run tsc` step (continue-on-error: pre-existing strict errors
in unrelated test files; tracked but not blocking)
• `npm run build` — Vite production build smoke catches Rollup/
Vite-only failures that vitest doesn't see (manualChunks wiring,
dynamic import paths, asset resolution)
• `npm run test:coverage` + upload as artifact (Node 22 only)
L (package.json scripts):
• `tsc` → `tsc -b`
• `test:libraries` → `RUN_LIBRARY_TESTS=1 vitest run
src/__tests__/library-compile.integration.test.ts`
I (library-compile nightly):
• New `.github/workflows/library-compile.yml` — 5 AM UTC cron +
workflow_dispatch. Not on PRs (slow + external deps).
• Sets up arduino-cli + caches `~/.arduino15` cores (avr, esp32,
rp2040 — ~500 MB).
• New `library-compile.integration.test.ts` — iterates every
example with `code` + `libraries` + a known FQBN. For each:
arduino-cli lib install → write .ino → arduino-cli compile.
7 examples currently match (epaper-displays).
• Gated behind RUN_LIBRARY_TESTS=1; default vitest skips the file.
Final tally: 1853 tests pass (was 1461 before Phase 1d-tests — +392
new sub-tests across 8 new test files + 1 new workflow). Vite build
green (2.68 MB main chunk, unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
D (board-kinds-coverage): iterates every BoardKind in
src/types/board.ts and asserts each has at least one gallery
example across all six examples-*.ts modules. Surfaces real
coverage gaps without inventing fixtures: 9 BoardKinds today have
no demo circuit (esp32 variants that share QEMU backends with
covered primaries + attiny85 + raspberry-pi-3 backend QEMU). All
documented as ACCEPTED_UNCOVERED with rationale. Adding a new
BoardKind without either an example or an entry in that set fails
the test — enforces deliberate coverage decisions.
G (solver-perf-baseline): opt-in via `CI_PERF=1` env var. For 6
canonical examples, measures `solveMs` 10× and asserts median
under a per-example ceiling (generous tolerances for CI variance).
Default-skipped because CI machine timings would flake; enabled on
demand for regression checks after a solver change.
Adding a new BoardKind or canonical example extends coverage
automatically — no duplicated lists.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
E (part-simulators-coverage): iterates every metadataId returned by
PartSimulationRegistry.listRegisteredParts() and asserts the
attachEvents surface is valid (no throw, unsubscribe callable). 82
parts covered automatically + 1 sanity baseline. Surfaces real Node
compat gaps — discovered servo + neopixel reach for
requestAnimationFrame, now shimmed in a beforeAll.
F (solver-determinism): 8 canonical examples run through solveInput
three times each; node voltages must agree within 1e-12. Plus a
state-leak test (solve A, solve B, solve A again — A's results must
be bit-identical). Catches RNG / residual-state regressions in the
NgSpiceNodeAdapter singleton.
Adding either a new part registration or a new canonical example
extends coverage automatically — no fixture duplication per the
test-fidelity rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the existing analog+digital gallery smoke (which covered
68 examples) to the four buckets that had ZERO coverage:
• 100-days: 49 MicroPython tutorial circuits
• epaper-displays: 7 e-paper firmware examples
• picow-wifi: 4 Pico W wifi demos
• circuits: 40 mixed Arduino+SPICE circuits
100 new sub-tests, all green against the real ngspice via solveInput.
Combined with examples-gallery-smoke (68) and the snapshot tests
(168), every single gallery example now has at least two layers of
test coverage — netlist shape locked + solver convergence verified.
Per fidelity rule: importing example arrays from data/examples-*.ts
+ using the production `exampleToBuildNetlistInput` helper (same one
loadExample.ts uses). Adding a new example automatically extends
this test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snapshots the full SPICE netlist for every example across all six
data/examples-*.ts modules (168 examples total):
analog: 30, digital: 38, 100-days: 49, epaper: 7,
picow-wifi: 4, circuits: 40.
Pipeline: example → exampleToBuildNetlistInput → buildNetlist →
strip leading timestamp comment → toMatchSnapshot. Uses the
production helper (same one loadExample.ts uses) so any future
change to the brand-prefix rule / board filter / analysis picker
appears in the snapshot diff automatically.
To regenerate after a legitimate model change:
npx vitest run -u src/__tests__/examples-netlist-snapshot.test.ts
The PR diff of the snapshot file becomes the evidence of which
circuits change in response. Reviewer can scan the diff to confirm
the change is intended.
168 new sub-tests bring total to 1640 passing (was 1472).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
J: vitest.config.ts split out from inline `test:` block in
vite.config.ts. CI workflows can now reference vitest.config.ts
directly; test settings no longer pulled into vite build deps.
Settings: testTimeout 30s, hookTimeout 30s, forks pool with
singleFork:false (per-file worker isolation for the
NgSpiceNodeAdapter singleton), coverage excludes
`src/simulation/spice/wasm/**` (irrelevant lcov bytes).
C: components-metadata-integrity.test.ts — 11 sub-tests, all live
checks against the real `public/components-metadata.json` + every
examples-*.ts source-of-truth + the live PartSimulationRegistry:
• Shape per entry: id / tagName / name / category / pinCount
• IDs unique
• tagName matches wokwi/velxio prefix
• Thumbnail is an SVG
• properties[] + defaultValues{} shape
• Every metadataId referenced from gallery exists in metadata
(instr-* filtered — instruments aren't canvas-rendered)
• PartSimulationRegistry registrations cross-checked vs metadata
(informational — some runtime-only parts have no metadata entry
by design: custom-chip, raspberry-pi-3, 74hc595 internals)
• Orphan-entries report: surfaces metadata entries no example or
part-sim uses (informational, doesn't fail)
The orphan report flags 58 dead-ish metadata entries (preset
variants like resistor-220, individual epaper sizes, etc.) for
later cleanup conversation. Not an error.
`PartSimulationRegistry.listRegisteredParts()` exposed for the test
to enumerate without duplicating the list.
1472 tests pass (was 1461 — +11 new metadata sub-tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#10 — ESP32 ADC clipping warning: `pushEsp32Waveforms` now counts
how many samples land outside the 0-3.3 V ADC range. If > 10% of a
pin's waveform clips, console.warn once per pin with the observed
range. Helps diagnose "my analog read is stuck at 4095" from
canvases without a divider / clamp.
#11 — PinManager subscriptions scoped to circuit pins. Previously
`connectMcuEdgesToService.subscribeBoard` attached listeners to all
64 Arduino pins per board, justified as "free if unused". True
for AVR; spammy for ESP32 with 40+ GPIOs × multi-board setups
(thousands of dead listeners). Now reads from useElectricalStore's
pinNetMap and only subscribes to pins the circuit references.
Re-subscribes when pinNetMap changes (new wire added/removed).
#16 — `__spiceDebug()` window helper. Restored after the legacy
subscribeToStore deletion in Phase 1c. Logs analysis mode,
voltage count, pin-net-map sample, last-solve ms — useful for
DevTools investigation of "why isn't my circuit solving?" reports.
1461 tests pass.
#8 (FQP27P06 → VDMOS) deferred — model not in the local LTSpice
library; requires external sourcing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`runNetlist` was guessing what vectors to read by regex-matching
`V*/R*/L*/C*/D*/Q*/M*` lines in the netlist string. Fragile —
missed extra-card nets, custom prefixes, subckt-internal nets.
This commit gives the Worker adapter the same enumeration surface
the Node adapter already had:
• New `listVectors` message type in the worker, calling
`ngSpice_AllVecs(curPlot)` and decoding the NULL-terminated
char** result. Case-preserved (getVecInfo lookups are
case-sensitive for source-current vectors).
• `NgSpiceInteractive.listVectors()` exposes it to the adapter.
• `NgSpiceWorkerAdapter.listCurrentVectors()` + the higher-level
`readAllCurrentVectors()` — single-call enumerate + read.
• `runNetlist.ts` simplified: ONE solve, then read every vector
via the adapter. No more regex parsing. No more guess-set.
`readAllCurrentVectors` exists on both adapters now with identical
shape — domain code can swap them freely.
1461 tests pass. Both `examples-gallery-smoke` (68 examples) and
`circuit-verifier` (8 pre-flight checks) green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this commit the production bundle landed almost everything
in a single `index.js` chunk weighing ~23 MB. Vite warned but the
fix had been deferred since long before the SPICE migration.
manualChunks now splits the entry into:
• index: 2.68 MB (was ~23 MB — 88% smaller)
• wokwi-elements: 434 KB
• PiTerminal: 332 KB
• mcu-emulators: 167 KB
• react-vendor: 48 KB
• spice-wasm: 3.6 KB
• ngspice worker: 27 KB
The cold-load entry is now < 3 MB. On a repeat visit, only
`index` changes after typical edits; `wokwi-elements` /
`mcu-emulators` / `react-vendor` stay cached.
`chunkSizeWarningLimit: 8000` silences the legitimate large-chunk
warnings (wokwi-elements is fundamentally large because it bundles
hundreds of SVG component icons).
1461 tests still pass. No code paths changed — only chunk shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#3: `start.ts` now kicks `scheduler.start()` (lazy-boot the WASM
engine) right when the editor mounts. Without this, the first
solve — typically the user's first canvas edit — paid 2-5 s of
WASM init while the canvas appeared frozen. Now the Worker boots
while the user looks at the empty canvas; by the time they wire
anything, the engine is warm.
#5: deleted three unimported dead files that pre-existing tsc -b
strict errors referenced. Nothing in the live codebase imports
`wireOffsetCalculator`, `wirePathGenerator`, or `wireSegments` —
they were left behind by an earlier wire-routing refactor.
Removing them clears 10+ tsc errors plus the `WireControlPoint`
phantom type they relied on.
Also cleaned up an unused import in
`capacitor-charge-transient.test.ts` (leftover from F2).
1461 tests pass, vite build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#2: NgSpiceWorkerAdapter.init() now sets the same convergence
options the Node adapter has — `option gmin=1e-10 gminsteps=20
sourcesteps=10 method=gear maxord=2`. Production and tests run
with identical solver tolerances; circuits that converged in tests
no longer hit "No vectors" in the browser. Also added `remcirc`
before loadNetlist so leftover state doesn't bleed across canvases.
#9: opamp-lm358 in componentToSpice now emits the real LM358 macro-
model subckt (`X_id IN+ IN- vcc_rail 0 OUT LM358`) instead of the
behavioural B-source clamp. The subckt was vendored as an asset in
Phase 2.2 and has been waiting for #2 to land — now active.
Smoke-test side effect: 67/68 → 68/68 examples converge. The opamp
follower (`an-opamp-follower`) was the last one that didn't.
exampleToBuildNetlistInput now delegates to `buildInputFromStore` —
same analysis-picking logic production uses. A signal-generator
circuit gets `.tran`, an MCU-driven RC step gets `.tran` with the
right τ window, plain DC gets `.op`. No more inline analysis guess.
examples-analog.test.ts regex extended to allow X-prefix cards so
the LM358 subckt instance line counts as "one of the SPICE cards
for this component".
1461 tests pass across 105 files (28 pre-existing skips, none
introduced by this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the manual "open each example in browser" step from the
post-migration plan with an automated test that:
• Imports `analogExamples` and `digitalExamples` from the real
`data/examples-*.ts` modules — new gallery entries pick up the
test automatically.
• Uses the same `stripBrandPrefix` + board-filter logic that
production `loadExample.ts` uses, via the new shared helper
`utils/exampleToBuildNetlistInput.ts`. Single source of truth:
if the wokwi/velxio prefix rule ever changes, both production
and the smoke test track it.
• Runs each example through `solveInput` (Phase 1c F2 helper)
against the same ngspice WASM production uses.
`loadExample.ts` refactored to call `stripBrandPrefix` instead of
inlining the regex (two call sites converged on the helper).
Result against the gallery:
• 67/68 examples converge cleanly.
• 1 known regression: `an-opamp-follower` (LM358 follower) — the
same case `examples-analog-live.test.ts` already skips. Item
#2 (.op convergence helpers in NgSpiceWorkerAdapter) targets it.
The smoke test now serves as the safety net for the remaining
post-migration work — it'll flag if a future fix breaks examples
that converge today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mixed-mode migration's endgame. After this commit there is ONE
SPICE solver path in the codebase — the vendored ngspice WASM via
SolverPort, behind both NgSpiceWorkerAdapter (production browser) and
NgSpiceNodeAdapter (Vitest Node). Zero hybrids; zero legacy left to
maintain.
Deleted production files:
• simulation/spice/CircuitScheduler.ts (200ms-poll legacy)
• simulation/spice/SpiceEngine.ts (eecircuit-engine wrap)
• simulation/spice/SpiceEngine.lazy.ts (lazy code-split)
• simulation/spice/subscribeToStore.ts (legacy solve loop)
• simulation/spice/connectLegacySolverToMixedMode.ts (bridge)
• simulation/spice/connectMixedModeSchedulerToStore.ts (feature flag)
Deleted tests (no longer cover any live code):
• connect-legacy-solver-to-mixed-mode.test.ts
• connect-mixed-mode-scheduler-to-store.test.ts
• spice-rectifier-live-bootstrap.test.ts
Migrated 6 tests off the deleted `circuitScheduler.solveNow` API to
the new `__tests__/helpers/solveInput.ts` (same shape, backed by
NgSpiceNodeAdapter).
`useElectricalStore` rewritten as a pure state container:
• setSolveResult(snapshot) — atomic publish from the service
• paused / setPaused — UI control unchanged
• reset — project unload
• REMOVED: triggerSolve, solveNow, setDebounceMs, scheduler hook
• REMOVED: dependency on SpiceEngine.lazy preload
EditorPage now mounts a single `startSimulation()` from
`simulation/spice/start.ts`, which constructs
CircuitSimulationService + ADC bridge + MCU edge bridge. Four
useEffect calls collapsed to one.
`circuitVerifier.ts` (production) and `runNetlist.ts` use an
environment-aware factory: Web Worker in browser, in-proc WASM in
Node tests. `/* @vite-ignore */` keeps the Node adapter chain
(node:fs, node:url) out of the browser bundle while still letting
Node resolve it dynamically.
Removed `eecircuit-engine` from package.json dependencies.
`collectPinStates` extracted to its own module so the service doesn't
depend on the (now deleted) subscribeToStore.ts.
Verification:
• 1392/1392 tests pass across 103 files (28 pre-existing skips).
• `tsc --noEmit` clean.
• `vite build` succeeds (27 s, only the existing chunk-size
warning that pre-dates this work).
Phase 1c — COMPLETE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-call mount for the new mixed-mode loop:
• CircuitSimulationService (orchestrator)
• connectAnalogInputsToMcu (ADC bridge)
• connectMcuEdgesToService (pin event subscriptions)
References useElectricalStore.setSolveResult (to be added in the
same step that retires triggerSolve / CircuitScheduler). Not
activated in EditorPage yet — six existing tests still consume the
legacy `solveNow` / `triggerSolve` API and need to migrate to
CircuitSimulationService.tick() first.
Holding G activation until the test migration lands so we don't
strand the legacy `solveNow` callers in mid-air.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test suite now runs against the SAME ngspice WASM that
production uses — closing the "no hybrid" gap. Every test file
that used to import `runNetlist` from `SpiceEngine.ts`
(eecircuit-engine) now imports from a compatibility shim
`__tests__/helpers/testSolver.ts` that uses the new
NgSpiceNodeAdapter under the hood.
Migrated (all 22 files): spice-{smoke,active,passive,transient,ac,
digital,avr-mixed,mosfet-pwm,mosfet-diag,npn-switch-diag,
npn-switch-integration,relay-integration,relaxation-oscillator,
signal-generator-tran,rectifier-live-repro}.test.ts plus
component-to-spice, examples-analog-live, examples-digital,
instruments, netlist-builder, phase-4-wire-resistance,
mixed-mode-bjt-switch-integration.
Helper translates between ngspice's raw vector names ('n0',
'<src>#branch', 'frequency', 'time') and the legacy SpiceResult
convention ('v(n0)', 'i(<src>)', special axes). Re-exports the
`NL` source-card helpers (pulse, sin, pwl, dc, ac) so existing
tests don't touch their builder code.
Adapter additions for the migration:
- listCurrentVectors() — case-preserved enumeration via
ngSpice_AllVecs (getVecInfo lookup is case-sensitive).
- readAllCurrentVectors() — single-solve read of every vector;
re-running the analysis would create a new plot and invalidate
pointers.
- Complex-vector handling: interleaved [re,im,re,im,...] doubles
in compDataPtr, separate from real-only vectors.
- Convergence helpers: `option gmin=1e-10 gminsteps=20 method=gear
maxord=2` set on init so op-amp + diode circuits bias correctly
without each user netlist needing its own `.option`.
- loadCircuit strips inline `.op` / `.tran` / `.ac` directives
before source, so the SolverPort owns analysis timing (running
it twice via source + explicit command leaves the second pass
with an empty plot).
- loadCircuit issues `remcirc` before source so leftover state
doesn't bleed between tests sharing the singleton adapter.
`circuitVerifier.ts` (production) migrated to the new
`simulation/spice/runNetlist.ts` (Worker-adapter-backed) so the
last consumer of SpiceEngine.ts can be retired in F3.
One test skipped with documentation: `an-opamp-follower` (.op)
fails to converge on the new engine — known issue for B-source
clamps; the LM358 subckt path also has this problem. Slot in
Phase 1c E1 (convergence helpers / .options tuning) to fix.
233/233 migrated tests pass against real ngspice via the Node
adapter.
Next: F3 — delete SpiceEngine.ts + SpiceEngine.lazy.ts + the
eecircuit-engine dependency from package.json. Requires G first
(retire CircuitScheduler) because CircuitScheduler still imports
from SpiceEngine.lazy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loads the vendored ngspice-interactive WASM directly in the Vitest
Node process, no Web Worker required. Implements the same SolverPort
contract as NgSpiceWorkerAdapter, so production code and tests share
ONE solver — closing the "no hybrid" gap.
loadNgSpiceForNode (Node-only loader):
- Reads ngspice-lib.js as text, wraps with a hoisted
`var Module = config` so the emscripten singleton picks up our
locateFile + callbacks.
- Re-wires Module.onRuntimeInitialized to copy closure-local FS /
HEAP* into Module._velxio_* (the vendored build doesn't export
them via EXPORTED_RUNTIME_METHODS so direct Module.FS triggers an
abort accessor).
NgSpiceNodeAdapter:
- bindApi (cwrap), registerCallbacks (no-op via addFunction),
stageFilesystem (recursive mkdir + writeFile of model .cm + spinit),
initialiseNgspice (null callback pointers; the build still solves
fine without print/data hooks).
- loadCircuit writes the netlist to /circuit.spc on the FS and
issues `source /circuit.spc` — sidesteps `_malloc` (not exported
by this build) that the obvious ngSpice_Circ path would need.
- solve() dispatches op/tran/ac, reads requested vectors via
ngGet_Vec_Info using the actual struct offsets verified against
the live build dump: flags=8, realdata=12, imagdata=16, length=20.
- alterSource issues `alter` for incremental re-solves.
5/5 SolverPort contract tests pass against real ngspice:
- init idempotent
- DC op solves a 100Ω/100Ω divider → V(mid) = 2.5 V exactly
- omits requested vectors that don't exist
- alterSource changes V1 → V(mid) tracks the new voltage
- transient RC charge (τ=1ms) reaches >4.5V after 5τ
Next: F2 — migrate the ~22 test files that use eecircuit-engine via
`runNetlist` to this adapter. After F2, F3 deletes eecircuit-engine
and `SpiceEngine.ts` for good.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc)
runs the WASM alter + .op + extract path instead of rebuilding the
netlist. Cached `loadedContext` lets `publishFromLastResult` shape an
ElectricalSnapshot without re-running buildInputFromStore.
Coalesces with the canvas-change tick:
- If a full solve is in flight: edge is queued and replayed after
(so the netlist matches when alter runs).
- Last-edge-wins per pin: edges overwrite the same field, so a
10kHz toggle collapses to whatever was last seen at flush time.
connectMcuEdgesToService.ts wires PinManager.onPinChange events to
the service:
- Subscribes to every Arduino-pin slot (0..63) per board. Per-pin
listeners are no-cost when the pin never fires.
- Coalesces edges per pin in a 16 ms window before calling
handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms).
- Re-subscribes when boards change (PinManager instances are
recreated by loadHex / setActiveBoard).
MixedModeSchedulerPort gains onMcuPinChange in the port interface
(was already on the singleton but missing from the contract).
3 new service tests cover:
- initial full solve + alter + republish on edge
- coalescing edges with in-flight full solves
- handleMcuEdge kicks a full tick when no circuit is loaded
11 service tests + 90-test regression suite pass. tsc clean.
Next: E — convergence helpers (.options gmin, op-amp retry) so the
LM358 subckt can finally be enabled in componentToSpice.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
connectAnalogInputsToMcu.ts is now the single owner of:
• DC scalar ADC injection (setAdcVoltage)
• AC waveform-time per-read sampling (patched onADCRead)
• ESP32 QEMU waveform push (setAdcWaveform)
The module subscribes to `useElectricalStore` regardless of who
populated it (legacy CircuitScheduler today, CircuitSimulationService
tomorrow). Replacing the solver path no longer touches ADC logic.
subscribeToStore.ts cut from 591 to 161 lines. Its remaining
responsibility: the legacy solve loop (subscribe to canvas changes,
200 ms running-timer, push to `useElectricalStore.triggerSolve`).
That whole file disappears in step G1 once the service is the
default; today it stays so the legacy path keeps working alongside
the new architecture.
EditorPage mounts the four subscribers explicitly:
1. wireElectricalSolver — legacy solve loop
2. connectLegacySolverToMixedMode — bridge to scheduler cache
3. connectAnalogInputsToMcu — ADC + waveform replay (NEW)
4. connectMixedModeSchedulerToStore — flagged WASM path
Pre-existing flaky test in spice-rectifier-live-repro.test.ts
(asserted "wireElectricalSolver queues NO RAF") removed. It tested
implementation details of an installation path that no longer
exists; end-to-end ADC behaviour is covered by
circuit-simulation-service.test.ts and the BJT-switch integration
test. Per the migration rule "tests only for real velxio code", a
pre-existing flake testing legacy installation paths is not real
coverage.
Next: D1+D2 — MCU pin event subscriptions so MCU edges drive
scheduler.alterSource + re-resolve, with throttling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The service is the single owner of the simulation loop. Replaces
the trio of wireElectricalSolver + connectLegacySolverToMixedMode +
connectMixedModeSchedulerToStore once G* lands.
Architecture:
- Depends on PORTS only — SimulatorStorePort, ElectricalStorePort,
MixedModeSchedulerPort. Zero coupling to useSimulatorStore /
useElectricalStore / WASM. Easy to test with fakes (and that's
what circuit-simulation-service.test.ts does).
- Single tick(): build netlist → load → solve → extract → publish.
Coalesces concurrent triggers so rapid store changes collapse to
one trailing solve.
- Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents
+ pinNetMap + timeWaveforms + analysisMode + warnings. Shape
matches what the 12 existing useElectricalStore consumers read.
NetlistBuilder extension: BuildNetlistResult now reports `nets`
(every non-ground SPICE net) and `voltageSources` (every V card the
builder emitted). The service uses these to construct the full
vectorsOfInterest list — every node voltage + every branch current
— so the solver returns the data the legacy consumers want.
Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the
orchestrator add to the per-pin set. Branch currents (i(v_*))
flow through this hook.
8 service tests cover initial solve, branch current extraction,
re-solve on store change, no-spurious-solve, coalescing, .tran
waveforms, warnings forwarding, error-tolerance.
Next: C1+C2 — extract ADC injection / waveform replay into a
solver-agnostic module that just subscribes to useElectricalStore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MixedModeScheduler now accepts any SolverPort implementation via
solverFactory injection. The ad-hoc `NgSpiceClient` interface is
gone; the scheduler talks domain port types only.
New capabilities that fell out of the refactor:
- `resolveTran(step, stop)` — runs .tran via the solver and publishes
the steady-state (last-sample) voltage per pin. Full waveform
reachable via `getLastResult()` for downstream consumers
(CircuitSimulationService in B1+ will use this to populate
useElectricalStore.timeWaveforms).
- `getLastResult()` exposes the SolveResult so the upcoming service
layer can extract branchCurrents + waveforms without re-reading.
- `vectorsOfInterest` is computed from pinNetMap on every solve, so
the adapter only issues N parallel readVecs (where N = distinct
non-ground nets) instead of guessing.
`__setSchedulerEngineFactoryForTests` renamed to
`__setSchedulerSolverFactoryForTests`.
Tests fully migrated to FakeSolverAdapter — no more inline mock
NgSpiceClient. Test layering now mirrors production: scheduler tests
exercise port consumption, port-contract tests exercise the port
itself.
60 tests pass across mixed-mode-scheduler, solver-port-contract,
mixed-mode-bjt-switch-integration (real ngspice), pin-resolver,
pin-resolver-phase1b, connect-mixed-mode-scheduler-to-store,
connect-legacy-solver-to-mixed-mode. tsc clean.
Next: B1 — CircuitSimulationService, the layer above the scheduler
that builds netlists, picks .op vs .tran, and publishes results to
both useElectricalStore and the scheduler cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two SolverPort adapters land in this commit:
- NgSpiceWorkerAdapter — production. Wraps the vendored
NgSpiceInteractive client. Translates SolverPort calls into worker
messages. Parallel readVec for every vectorOfInterest after each
solve. .tran also reads the `time` vector for the axis.
- FakeSolverAdapter — in-memory test double. Records every call,
returns canned vectors via static map or dynamic supplier. Optional
solveDelayMs for race-condition tests.
Port surface refined: solve(analysis, options) now takes
SolveOptions.vectorsOfInterest so the adapter can parallelise reads
instead of guessing what the caller cares about.
This bundles A3 (resolveTran) into A2 because the same Solve API
handles every analysis kind — the adapter dispatches on
analysis.kind to build the right ngspice command (`op`, `tran <step>
<stop>`, `ac <sweep> <points> <fstart> <fstop>`).
11 SolverPort contract tests pass. When NgSpiceNodeAdapter lands in
F1, it will run the same contract suite verbatim to confirm it
honours the port identically.
Next: A4 — refactor MixedModeScheduler to depend on SolverPort
instead of the ad-hoc NgSpiceClient interface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First commit of the full migration to a single WASM-driven solver.
Defines the abstract contract that domain code (MixedModeScheduler,
CircuitSimulationService) will depend on. Adapters in ./adapters/
implement the port against concrete engines.
Surface kept narrow:
- init / loadCircuit / solve / alterSource / dispose
- SolveAnalysis: op | tran | ac
- SolveResult: vectors map + timeAxis + solveMs + warnings
Domain types live in the port file (SolveVector, SolveResult) so the
port has no upward dependency on ../types.ts. Adapters bridge between
domain types and engine-specific shapes.
Next: A2 — implement NgSpiceWorkerAdapter on top of NgSpiceInteractive.
Then A3 (resolveTran), A4 (scheduler refactor), A5 (fake + tests).
See velxio-prod/project/sim-mixedmode/phase-1c-migration-plan.md for
the full sub-step roadmap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `connectMixedModeSchedulerToStore` — when enabled, it subscribes
to the simulator store and drives the MixedModeScheduler's WASM path
(`loadCircuit` + `resolveDc`) directly, parallel to the legacy
`wireElectricalSolver` + `connectLegacySolverToMixedMode` bridge.
Opt-in mechanisms (two ways, either works):
- URL query: `?mixedmode=on`
- Persistent: `localStorage.velxio.mixedmode = 'on'`
When the flag is off (default), behaviour is identical to before.
When on, both connectors publish voltages into the scheduler cache;
last write wins. This is deliberate during the A/B test — the two
paths can be compared by toggling the flag and watching the same
canvas behave identically (or surfacing divergence as a real bug).
The connector coalesces solves: if one is in flight, the next store
change marks a pending re-solve that fires once the first finishes,
collapsing N rapid changes into 1 trailing solve. Errors are logged
but don't propagate — the legacy solver is still running, so a WASM
convergence failure shouldn't kill the editor.
`collectPinStates` is now exported from `subscribeToStore.ts` so the
new connector reuses the same per-board pin-number mapping.
10 unit tests cover initial solve, re-solve on changes, coalescing
under load, error tolerance, unsubscribe cleanup, and the feature-
flag predicate (URL + localStorage paths). jsdom env scoped to this
file via `// @vitest-environment jsdom`.
Phase 1c step 1 of N: this is the plumbing that lets us validate the
WASM path in production without flipping the default. Step 2 would
add MCU pin-event subscriptions so MCU edges trigger re-solves
(currently only canvas changes do).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires can now carry a `length_cm` property. When set, the NetlistBuilder
treats them as a real resistor (0.01 ohm/cm ≈ AWG 22 copper) instead of
the legacy perfect-conductor union. Wires without `length_cm` are
unchanged — 100% backwards compatible until the UI starts attaching
length values based on canvas geometry.
Implementation:
- `WireForSpice.length_cm?: number` added to types
- Union-Find pass skips `union(a, b)` when length_cm > 0, so endpoints
end up in separate nets
- After component-card emission, scan `resistiveWires` and emit
`R_wire_<id> <netA> <netB> <ohms>` for each
- Pull-down detection runs after so the wire R counts as a DC path
Verified end-to-end with real ngspice:
- 100/100 divider at 5V → vmid = 2.5V (legacy, no wire R)
- Same with 1 cm supply wire → vmid = 2.4999 V (0.25 mV drop)
- Same with 500 cm supply wire → vmid ≈ 2.439 V (~6% drop)
5 new Phase 4 tests + 208 regression tests pass.
This is the plumbing-first deliverable from the original sim-mixedmode
plan — UI work (compute length from canvas waypoints) is a separate
front-end task.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RGB LED: each of R/G/B channels prefers the resolver subscription so
the LED works correctly when fed through a P-MOSFET high-side switch
or a BJT driver. PWM override (analogWrite) keeps using the integer
pin number through pinManager.onPwmChange — duty cycle handling isn't
yet exposed on PinResolver.
Buzzer: the HIGH/LOW edge subscription (tone() going active) now
flows through the resolver when available. Same PWM caveat — the
onPwmChange hook stays on the raw pin number to track when duty
drops to 0 and stops the oscillator.
Both fall back to pinManager.onPinChange when the resolver isn't
provided (tests / Phase-0-less builds).
Phase 5 progress: 19 of ~22 handlers migrated. Remaining handlers
are pushbutton / switch (input-only — no migration needed) and the
protocol-driven sensors (DHT, BMP, SPI/I2C/UART — stay event-level).
This is effectively the migration plateau.
260 tests pass across simulation-parts, component-to-spice,
mixed-mode-bjt-switch, logic-gate, flip-flop, and examples-digital.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five control pins (DS / SHCP / STCP / MR / OE) now subscribe through
PinResolver when available. Rising-edge detection on SHCP / STCP
keeps working — resolver.onChange only fires on real state
transitions, so a 'HIGH' event is the rising edge.
Refactored the pin subscription pattern into a tiny `PinSub` helper
(getInitialHigh + onHighLow) so each pin's enable / disable / data /
clock / latch role reads the same shape. Falls back to the legacy
pinManager.onPinChange path when the resolver isn't provided.
Seeds initial register/active state from each pin's
`getCurrentState()` instead of assuming LOW at attach — important for
canvases that start with MR or OE statically wired to GND/VCC, so
the chip's output is correct before any pin transitions.
Phase 5 progress: 17 of ~22 handlers migrated. Remaining handlers
(pushbutton, switch, RGB LED, servo, sensors, neopixel, OLED) are
mostly protocol-level / input-only and intentionally stay on the
event-level fast-path. The output-style migration plateau is
essentially reached.
131 tests pass across simulation-parts + examples-digital.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
twoInputGate (AND/NAND/OR/NOR/XOR/XNOR), nInputGate (3/4-input AND/OR/
NAND/NOR), edgeTriggeredFF (D/T/JK), and the standalone NOT gate all
now prefer PinResolver input subscriptions. Output side (setPinState
on Y / Q / Qbar) is unchanged — digital propagation between gates
keeps flowing through pinManager.
Why this matters: logic gates are the biggest beneficiaries of Phase 3
logic-family thresholds. A gate input driven through a BJT collector
or MOSFET drain now reads the real SPICE voltage and converts to
HIGH/LOW per the board's logic family — instead of relying on the
legacy trace's `[C, B]` shortcut.
For flip-flops, rising-edge detection on CLK works identically with
resolver.onChange: a state transition to HIGH is exactly the rising-
edge event the original `!prevClk && s` was watching for.
All migrated handlers fall back to the legacy pinManager.onPinChange
path when getPinResolver isn't provided (tests / Phase-0-less builds).
Phase 5 progress: 16 handlers migrated this session (LED, 7-segment,
led-bar-graph, AND/NAND/OR/NOR/XOR/XNOR + 4 multi-input variants +
3 flip-flops + NOT). Remaining: 74HC595, buzzer, RGB LED, servo,
neopixel, sensors, motor drivers. Once the output-style handlers are
all on PinResolver, the `[C, B]` shortcut in PASSIVE_PIN_PAIRS can
be deleted.
113 tests pass across logic-gate-parts, flip-flop-parts, and
examples-digital (which exercises real ngspice on multi-gate
topologies like the 3-to-8 decoder).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same backwards-compatible pattern as LED and 7-segment migrations.
With the resolver path each of the 10 anode pins now sees real SPICE-
resolved HIGH/LOW when driven through an active device. Legacy
pinManager.onPinChange path is kept as the fallback.
Seeds initial values from resolver state at attach time so the bar
graph renders correctly without waiting for the first edge event.
Phase 5 progress: 3 of ~12 handlers migrated (LED, 7-segment,
led-bar-graph). Next likely candidates: 74HC595 (more complex —
needs edge detection on SHCP/STCP), simpler output-only parts
(buzzer, RGB-LED).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 7-segment display was the canary case for the original problem:
multiplexed displays with BJTs driving digit-select pins (COM/DIG)
required the `[C, B]` shortcut in PASSIVE_PIN_PAIRS to even discover
that the COM was wired to an Arduino pin. With this migration the
handler asks the resolver for HIGH/LOW directly — and the resolver
upstream of an active device routes through SpiceResolvedPinResolver,
which threshold-converts the real SPICE collector voltage using the
board's logic family.
Matches Phase 0's LED migration pattern: prefer the PinResolver path
when getPinResolver is available (Phase 0+ harness), fall back to the
legacy pinManager.onPinChange + getArduinoPinHelper for tests / builds
without it. Backwards-compatible — both digit-select (COM.1/COM.2 on
1-digit, DIG1..DIGn on multi-digit) and segment (A-G + DP) subscriptions
now flow through the resolver when available.
Seeds initial state from resolver.getCurrentState() so static-wire
topologies (e.g. COM directly to GND) work at sim-start without an
explicit edge event.
Phase 5 progress: 2 of ~12 *Parts handlers migrated (LED, 7-segment).
Remaining handlers (pushbutton, switch, 74HC595, etc.) follow the
same pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The full LM358 SPICE3 subcircuit from National Semiconductor (via
stmbl) is now exported as LM358_SUBCKT from
simulation/spice/models/lm358Subckt.ts. Internal models renamed
DX→DX_LM358 and QX→QX_LM358 so the subckt coexists cleanly with any
other vendored library.
Integration into opamp-lm358 was attempted and reverted — the
subckt's internal capacitors/inductors/poly sources cause ngspice
`.op` to time out (>60 s) on a simple unity-gain follower. The
behavioural B-source clamp remains the active model. When Phase 1c
moves the default analysis to `.tran` (or we add `.options gmin=1e-10`
selectively for op-amp-containing netlists), the subckt is sitting
right next door waiting to be wired in.
Phase 2.2 lockdown test guards the asset:
- declares `.SUBCKT LM358 1 2 99 50 28` interface (IN+ IN- V+ V- OUT)
- ensures internal model names are LM358-scoped (not the bare DX/QX
that collide with other SPICE libraries)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the gap that Phase 1b step 4 surfaced: the legacy pinNetMap was
built from board endpoints only, so the bridge from legacy solver to
MixedModeScheduler had nothing to publish for component pins like
"q1:C" — every SpiceResolvedPinResolver was stuck on FLOATING.
Now pinNetMap contains an entry for every wire endpoint, board or
component. Backwards compatible: legacy ADC injection only ever looked
up `boardId:pinName` keys, which are unchanged.
The new e2e integration test wires up real ngspice (eecircuit-engine,
no mock):
Arduino pin 9 → 1k → 2N2222 base; collector via 220 to 5V
- pin 9 HIGH → BJT saturated → Vc ≈ 0.05V → resolver emits LOW
- pin 9 LOW → BJT cut off → Vc ≈ 5V → resolver emits HIGH
Validated against the AVR_HC logic family (Phase 3). With 216 tests
green across 25 files, the Phase 1b pipeline is now demonstrably
correct end-to-end against a real SPICE solver, not just mocks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Connects the existing electrical solver's output (nodeVoltages +
pinNetMap from useElectricalStore) to the mixed-mode scheduler's
voltage cache. SpiceResolvedPinResolver subscribers now actually see
live voltages — they were stuck on FLOATING until this commit.
Design:
- `connectLegacySolverToMixedMode()` subscribes to useElectricalStore.
On every nodeVoltages / pinNetMap change it walks pinNetMap and
calls scheduler.publishVoltage(componentId, pinName, v) for each
pin. Ground pins (canonical net '0') resolve to 0 V directly.
NaN / Infinity voltages are skipped.
- `connectLegacySolverToMixedModeFor(store, scheduler)` is the
lower-level form used by tests so neither Zustand nor the WASM
scheduler need to boot.
- EditorPage mounts both `wireElectricalSolver` (legacy ADC path) and
`connectLegacySolverToMixedMode` (new SPICE-resolved path) in the
same useEffect — they coexist; the connector only routes events,
so no behaviour regresses for components that don't opt into
SpiceResolvedPinResolver.
7 new unit tests cover initial publish, re-publish on store change,
ground-pin shortcut, NaN filtering, and unsubscribe cleanup.
This is the wiring that completes Phase 1b's end-to-end pipe. The
WASM-driven onMcuPinChange path (loadCircuit + alter + tran in the
scheduler itself) stays available for future migration off the legacy
solver entirely — see Phase 1b doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the second half of the mixed-mode event loop on top of the
voltage cache that step 1 added.
Step 2 — loadCircuit + resolveDc:
- `loadCircuit(netlist, pinNetMap)` accepts the artifacts that
NetlistBuilder already produces, boots the engine lazily, calls
`loadNetlist`, and clears the voltage cache so stale values from a
previous circuit cannot leak through.
- `resolveDc()` runs `op` and walks the pinNetMap, calling readVec for
each non-ground net and publishVoltage for each pin. Ground pins
short-circuit to 0 V without an extra round-trip. Missing nets are
skipped quietly so a disconnected probe pin can't break the resolve.
Step 3 — onMcuPinChange:
- Issues `alter V_<board>_<pin> dc <volts>` and re-resolves. Caller
decides the volts: `state ? vcc : 0` for plain digital, but boards
with open-drain / output-impedance semantics can pass any number.
- Silent no-op when no engine has been started, so legacy paths that
fire pinChange unconditionally can't crash the simulator.
NgSpiceClient interface added and exported so unit tests can inject a
fake engine that records alter() calls and returns canned readVec
values — `__setSchedulerEngineFactoryForTests`. 7 new tests cover the
load → resolve → alter → republish loop end-to-end without booting
the real WASM worker.
The orchestration layer (Zustand subscriber / DynamicComponent hook)
that calls `loadCircuit` whenever the canvas changes is the next step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the runtime plumbing that Phase 1b's SPICE event loop will drive:
- `publishVoltage(componentId, pin, voltage)` updates a (componentId,
pin) → volts cache and notifies every matching subscriber.
- `getCurrentVoltage(...)` reads the cache (was previously stubbed
null).
- subscribe/publish routing exercised by 7 new unit tests.
The scheduler still does not yet drive ngspice — `start()`,
`onMcuPinChange()` are unchanged. But once Phase 1b's solve loop is in
place, calling `publishVoltage` after each `readVec` is all the wiring
needed for components to start reacting to SPICE-resolved analog
states. This is the smallest non-trivial step that keeps the
architecture honest (no test-only emitters; the same code path will be
used in production).
Tests skip booting the WASM worker — they call publishVoltage
directly, so they pass in plain Vitest with no JSDOM Worker shim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches 3 of the 4 simulated MOSFETs from Level=1 Shichman-Hodges
to LTSpice VDMOS macro-models. VDMOS captures real-device behaviour
(Ron, gate charge Qg, gate-drain Miller capacitance Cgdmax/Cgdmin,
body diode) that Level=1 fundamentally can't model.
Instance line changes from
M_id D G S S MODEL L=2u W=200u (4-terminal NMOS + W/L)
to
M_id D G S MODEL (3-terminal VDMOS)
Parts migrated:
mosfet-2n7000 → 2N7002 VDMOS (Vto=1.6, Ron=2 ohm — matches old Vto)
mosfet-irf540 → IRF530 VDMOS (Vto=4, Ron=160m — IRF540 missing
from LTSpice library, IRF530 is the
closest same-series part)
mosfet-irf9540 → IRF9640 VDMOS (pchan, Vto=-3.5 — IRF9540 missing,
IRF9640 is the 200V P-channel sub)
mosfet-fqp27p06 kept on Level=1 (no upstream VDMOS equivalent yet).
spice-mosfet-pwm regression test still passes: Id=8.6 mA at Vgs=5V,
0 at Vgs=0V, monotonic across the ramp. All 155 SPICE + analog
examples + lockdown tests pass.
Phase 2.1 lockdown test added — verifies VDMOS-shape instance line
(5 tokens, no L=/W=) and that the .model card carries `VDMOS(`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Asserts the Phase 2 BJTs include Gummel-Poon junction caps (CJC/CJE)
and forward transit time (TF), and the diode upgrades include
reverse-recovery time (tt) and Schottky band-gap (Eg). If anyone
simplifies the models in the future, these regress fail and surface
the loss of AC/transient fidelity.
Also guards the dedupe identity between the canonical diode-1n4148
emission and the relay flyback diode — they must serialise as the same
string or ngspice will reject the netlist for duplicate .model lines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the truncated 4-5 param NPN/PNP/D models in componentToSpice.ts
with full Gummel-Poon / SPICE3F5 parameter sets sourced from the
LTSpice-Libraries (Linear Tech standard.bjt and standard.dio). Junction
capacitances, transit times, and reverse-recovery now match real-device
behaviour — circuits using these parts will now exhibit correct AC and
switching response on top of DC saturation.
Parts upgraded:
BJT NPN: 2N2222, BC547, 2N3055
BJT PNP: 2N3906, BC557
Diode: 1N4148 (silicon switching), 1N5817, 1N5819 (Schottky)
MOSFET (Level=1) and 1N4007/zener kept as-is - they need separate
VDMOS migration validated against the MOSFET PWM regression test.
Phase 2.0 of the mixed-mode simulator project. See
velxio-prod/project/sim-mixedmode/phase-02-device-models.md.
All 115 SPICE tests pass; relay-integration test confirms the netlist
dedupe set still collapses two D1N4148 references (canonical diode +
relay flyback) into a single .model line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>