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>
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>