Commit Graph

783 Commits

Author SHA1 Message Date
davidmonterocrespo24 ca2b76f1f8 test(pi3): fix Phase 2 E2E test + bump rootfs to shims-final
The Phase 2 E2E test was sending the Python GPIO command via
'python3 -c "..."' but bash quote-nesting silently corrupted the
script — the python process started, printed nothing, exited 0, and
the test asserted 'GPIO_SETUP 17 out' was missing in proto bytes
(it never got sent because the python script never ran).

Switch the test to base64-encode the script + pipe through base64 -d
into a file, then execute. Verified end-to-end now:

  [test] proto received 36 bytes:
      GPIO_SETUP 17 out pud_off
      GPIO 17 1
  [test] ✓ shim → proto pipeline works

Also bump the rootfs manifest entry to the final Phase 2 build
(d6d4a274 raw / debd1c33 zst, version 2026.05+phase2-shims-final).

Earlier auto-discovery in _transport.py was hanging at import time
on some glob/sysfs interaction. Now hardcoded /dev/vport1p1 which
is the empirical path under -M virt + virtio-blk-pci on slot 0.
2026-05-18 15:00:21 +02:00
David Montero Crespo 5c5336acc0
Merge pull request #187 from davidmonterocrespo24/feat/retro-intel-cpus
feat(chips): port retro Intel/Zilog CPUs as Velxio custom chips + 2 d…
2026-05-18 02:30:23 -03:00
David Montero Crespo b714c79e3d feat(chips): port retro Intel/Zilog CPUs as Velxio custom chips + 2 demos
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>
2026-05-18 02:27:53 -03:00
davidmonterocrespo24 eac845005c feat(pi3 phase 2): bump rootfs to autodiscovery shims + E2E test
The Phase 2 shim originally hardcoded CHARDEV_PATH = /dev/vport0p2.
With QEMU 10 -M virt + virtio-blk-pci consuming slot 0, the proto
port actually lands at /dev/vport1p1 (or further). _transport.py
now walks /sys/class/virtio-ports/*/name looking for the literal
qemu name 'velxio-protocol' set by qemu_manager.

test/pi3_protocols/test_pi3_protocols.py end-to-end runner:
  1. Boot QEMU virt + pipe chardev (same args as production)
  2. Connect cons TCP socket + open both ends of the FIFO pair
  3. Wait for agetty autologin
  4. Run 'import RPi.GPIO; setup(17, OUT); output(17, 1)' in guest
  5. Read FIFO.out, assert 'GPIO_SETUP 17 out' + 'GPIO 17 1' appeared.

Catches: shim path resolution wrong, pipe chardev regression,
mux protocol drift, site-packages overlay broken.
2026-05-18 07:05:12 +02:00
davidmonterocrespo24 de43ef47e4 fix(pi3): pipe chardev for proto channel + Phase 2 protocol mux
QEMU 10's virtserialport on a socket chardev (server=on,wait=off)
silently drops guest→host bytes. Reproduced cleanly: writes from
inside the guest to /dev/vport<N>p<M> succeed (no errno) but the
connected client socket receives 0 bytes. Same bug whether the
client is a single recv loop, multiple threads, TCP or UNIX socket,
or whether QEMU runs as server vs client. virtconsole on the same
socket works fine — only virtserialport is broken.

Workaround: use `pipe` chardev (a pair of named FIFOs created
beforehand by qemu_manager). guest→host through .out flows reliably
in QEMU 10 — verified with manual test: 'echo PIPE_TEST > /dev/vport1p1'
in the guest produces 'PIPE_TEST\n' immediately on the host side.

Changes:
- qemu_manager._boot: allocate a temp basename, mkfifo .in + .out,
  pass to QEMU as 'pipe,path=<base>'.
- qemu_manager._connect_gpio: open both FIFOs O_RDWR | O_NONBLOCK on
  host side (O_RDWR keeps the FIFOs open even when guest hasn't
  opened its side yet), wire .out into asyncio via loop.add_reader.
- qemu_manager._reply_gpio / _send_gpio: write to .in fd via os.write.
- qemu_manager._handle_gpio_line: extended Phase 1 GPIO-only parser
  into a full Phase 2 mux: GPIO/GPIO_SETUP/GPIO_IN/PWM_*/I2C/SPI/UART
  with appropriate replies.
- qemu_manager._shutdown: close FDs + unlink the FIFOs.
- manifest.json: bump raspberry-pi-3-virt rootfs to 2026.05+phase2-shims
  (the new rootfs ships the velxio shim Python modules under
  /usr/lib/velxio-shims/).
2026-05-18 06:51:23 +02:00
davidmonterocrespo24 a7472e3411 feat(pi3): switch from raspi3b to virt + virtio (Phase 1)
raspi3b pl011 RX is broken in QEMU 10 + kernel 6.12 — see
project/pi-emulation/decisions.md for the full debugging trail.
This commit lands Phase 1 of the rebuild: switch the QEMU machine
to virt + cortex-a53, boot the velxio kernel/initramfs/rootfs over
virtio-blk-pci, and expose the user shell on /dev/hvc0 via
virtio-serial-pci + virtconsole.

End-to-end smoke verified: boot → agetty autologin → bash prompt →
echo round-trip returns the typed token. Tested inside the prod
container with QEMU 10.0.8 and our cloud-derived kernel 6.12.88.

What changed:

backend/app/services/qemu_manager.py
  PI3_IMAGE_SET -> raspberry-pi-3-virt
  PI3_KERNEL_NAME / PI3_INITRAMFS_NAME / PI3_ROOTFS_NAME new
  QEMU cmd rewritten end-to-end:
    -M virt -cpu cortex-a53 -smp 4 -m 1G
    -kernel <velxio-kernel-arm64> -initrd <velxio-initramfs-arm64.cpio.gz>
    -drive ... -device virtio-blk-pci  (NOT virtio-blk-device — mmio
                                         variant left /dev/vda unregistered)
    -nic none -display none -monitor none -serial none
    -chardev socket... -device virtio-serial-pci -device virtconsole
                                         (user console -> /dev/hvc0)
    -chardev socket... -device virtserialport,name=velxio-protocol
                                         (Phase 2 channel -> /dev/vport0p2)
  No -dtb (virt generates its own), no -append init=... (kernel runs
  our initramfs which then switch_root to rootfs and exec's its
  /sbin/init — Alpine OpenRC).

backend/app/services/boot_images/manifest.json
  New image set raspberry-pi-3-virt with three assets uploaded via
  the existing license-endpoint pipeline.  Old raspberry-pi-3 entry
  flagged deprecated:true and kept for one release for rollback.

test/pi3_console_boot/test_pi3_console_boot.py
  Updated QEMU argv to match qemu_manager exactly. Markers now look
  for the Velxio Pi Simulator MOTD + 'login on hvc0' (autologin
  proof). Round-trip echo still required to pass.
2026-05-18 05:36:13 +02:00
David Montero Crespo 174b4b94e8 test: cover power-supply mapping + refresh mega-multi-led snapshot
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.
2026-05-18 00:24:08 -03:00
David Montero Crespo 305170aeb9 feat(components): Regulated Power Supply with per-instance current limit
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.
2026-05-18 00:00:45 -03:00
David Montero Crespo 3b527f3dce fix(examples): add missing 220Ω series resistors to LED examples
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.
2026-05-18 00:00:45 -03:00
David Montero Crespo 06d6e0afbb fix(simulator): circuitVerifier worst-case GPIO + LED NaN guard
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).
2026-05-18 00:00:45 -03:00
David Montero Crespo 8a6d72aa50 fix(editor): default Blink circuit needs a series 220Ω resistor
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.
2026-05-18 00:00:45 -03:00
davidmonterocrespo24 9f4bf39f65 test(dht22): skip absolute-timing busy_wait checks under CI
test_busy_wait_100us and test_busy_wait_1us measure busy_wait_us()
elapsed time against absolute thresholds (500µs / 100µs). Under
contended CI/deploy-gate machines these can blow through the budget
even when the busy-wait implementation is correct, blocking deploys
that have nothing to do with DHT22 timing.

Same pattern already applied to test_response_timing_analysis in
this file — skipped via @unittest.skipIf(os.environ['CI']=='true').
2026-05-17 22:31:37 +02:00
davidmonterocrespo24 5faf9cdf52 feat(deps): add @tanstack/react-table for pro admin DataTable
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.
2026-05-17 16:43:54 +02:00
davidmonterocrespo24 09a227466a fix(pi3): auto-focus terminal + log serial input bytes
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).
2026-05-17 16:37:56 +02:00
davidmonterocrespo24 adad446518 fix(esp32): LEDC signal IDs are 71-86 per ESP32 TRM, not 72-87
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.
2026-05-17 05:42:52 +02:00
davidmonterocrespo24 f6131d432e fix(esp32 worker): importlib fallback for SignalRouter modules
The esp32_worker.py subprocess is launched via `python <abs_path>`
and runs with a sys.path that does NOT include the backend/ package
root, so `from app.services.signal_router import SignalRouter`
raised ModuleNotFoundError at worker startup. The worker exited
with code 1 before QEMU even loaded, and the frontend surfaced the
generic "ESP32 crash detected — cache error" banner.

Mirror the existing esp32_flash_image fallback pattern (already in
this same file): try the package import first, fall back to
importlib.spec_from_file_location with the sibling .py path, then
publish the resulting module under its bare name in sys.modules so
typing references continue to work.

Verified: a synthetic test that strips backend/ from sys.path can
still construct a SignalRouter via the fallback. 20 unit tests in
test_signal_router.py still pass.
2026-05-17 05:26:12 +02:00
davidmonterocrespo24 0f05544ca8 feat(esp32): SignalRouter — model the GPIO Matrix as first-class
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.
2026-05-17 05:00:53 +02:00
davidmonterocrespo24 77bf8971ff fix(esp32): multi-servo blink — don't broadcast LEDC duty across consumers
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.
2026-05-17 04:22:11 +02:00
davidmonterocrespo24 9d26fa6de3 test(dht22): skip the microsecond-timing analysis under CI=true
test_response_timing_analysis measures the actual µs duration of the
DHT22 preamble LOW pulse and asserts it stays under 1000µs (real
hardware target ~80µs, busy-wait tolerance ~500µs). On a deploy box
under load (concurrent docker build + zstd compression + container
runtime) GIL contention inflates the observed timing far past the
threshold — the deploy gate just hit 3242µs and aborted.

Mirror the same @skipIf(CI=='true') gate the sibling
test_response_data_matches_payload already has (line 390-393).
Locally / when debugging the DHT22 path the test still runs in full.
2026-05-17 03:56:43 +02:00
David Montero Crespo a177471ed0 fix(sensor-panel): per-sensor state when switching between same-type sensors
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.
2026-05-16 22:28:52 -03:00
David Montero Crespo f73697c59b fix(sensor-panel): stop mousedown so the slider drags instead of panning
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.
2026-05-16 20:56:24 -03:00
David Montero Crespo 286b378d8e fix(canvas): sensors open slider panel on desktop click during run
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.
2026-05-16 20:40:38 -03:00
davidmonterocrespo24 04ac1bf53b chore(tests): silence three noisy warnings in deploy-gate output
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).
2026-05-16 22:39:43 +02:00
David Montero Crespo b189986a57 chore(sitemap): bump lastmod dates to 2026-05-16 2026-05-16 17:34:25 -03:00
davidmonterocrespo24 7ee8c54f26 fix(pi3): velxio-init reads stdin from /dev/ttyAMA1 (was /dev/console)
User-reported bug: Pi 3 simulator showed boot output but keyboard
input was ignored — the shell was effectively read-only.

Root cause: velxio-init's bash redirect was `</dev/console
>/dev/console`. From userspace, /dev/console is write-only — it is
the kernel's printk target and accepts writes (so we saw boot output
fine) but reads return EOF / block forever. Bash never saw a
keystroke and the user couldn't type.

Fix: read AND write through /dev/ttyAMA1. The 12 s devtmpfs-wait
already in velxio-init guarantees the device node exists by the
time the shell-respawn loop runs. setsid -c still gives bash a
controlling terminal so PS1, job control, and Ctrl-C all work.

Manifest version bumped to 2026-04-21+ttyAMA1; sidecar SHA check
invalidates the cached SD on every velxio backend so the fix lands
without an operator dance.
2026-05-16 17:01:46 +02:00
davidmonterocrespo24 4b13662657 fix(pi3): velxio-init v2 — wait for /dev/ttyAMA1, exec on /dev/console
The previous velxio-init was racing devtmpfs population: its bash
redirect '</dev/ttyAMA1 >/dev/ttyAMA1' fired before the kernel had
enumerated the PL011 driver and populated the device node, so PID 1's
fd 0/1/2 redirect failed and the `while true` loop spun on
"No such file or directory" forever.

Two fixes baked into the SD image:

1. Wait up to 12 s for /dev/ttyAMA1 to appear (200 ms poll × 60).
   On real bare-metal Pi the node is there at init time, but
   under QEMU emulation the PL011 probe races.

2. Exec the shell with </dev/console >/dev/console — /dev/console is
   set up by the kernel (no race) and points at the last `console=`
   arg from the cmdline, which is ttyAMA1. Also wrap in `setsid -c`
   so bash gets a controlling terminal and behaves interactively.

Verified end-to-end with a live QEMU boot against the patched .img:
shell prompt `root@raspberrypi:/#` appears within ~50 s wall (most
of that is the kernel waiting on the second SD slot mmc1 timeout
twice = 20 s).
2026-05-16 08:39:37 +02:00
davidmonterocrespo24 c4b6b8ea69 fix(pi3): bypass systemd with velxio-init — ~10s to root shell
Pi 3 simulator boot through Pi OS systemd graph was unworkable inside
QEMU's raspi3b emulation:

* The PL011 UART at 0x3f201000 enumerates as ttyAMA1 (not ttyAMA0 —
  the mini-UART at 0x3f215040 takes ttyAMA0 and fails to probe under
  QEMU). After ~9 s of kernel time the boot effectively went silent
  on the serial: earlycon was disabled by the normal console init
  and the IRQ-driven serial driver loses TX under QEMU's emulation.
* Even with `keep_bootcon`, systemd dependency graph took 2-3 min to
  walk inside emulation (network waits, tmpfiles, journald,
  hostname/machine-id randomness). Masking 9 boot-blocking units
  helped but didn't fix the silent-after-9s problem.

Solution: skip systemd. The SD image is now baked with
`/usr/local/sbin/velxio-init` (a 30-line bash script) and the kernel
cmdline points init= at it. velxio-init mounts /proc /sys /dev /pts
/run /tmp, sets hostname, then loops a passwordless `/bin/bash
--login </dev/ttyAMA1 >/dev/ttyAMA1`. User sees the prompt within
~10 s of clicking Run; Ctrl-D respawns a fresh session.

Cmdline additions:
  - `keep_bootcon` — keep earlycon alive after the regular console
    registers, so kernel printk continues to reach ttyAMA1.
  - `console=ttyAMA1,115200` — the correct PL011, not ttyAMA0.
  - `init=/usr/local/sbin/velxio-init` — bypass systemd entirely.

Python, GPIO shim, apt, mount, etc. all work — they don't need
systemd as PID 1, just a populated rootfs + mounted pseudo-fs.

Manifest version bumped to 2026-04-21+velxio-init. Same byte size,
different SHA, so the sidecar-based cache invalidator forces a
re-fetch on every velxio backend the next time it starts.
2026-05-16 08:04:57 +02:00
davidmonterocrespo24 4d4d4622ff fix(pi3): mask boot-blocking services on the SD image
Boot from cold to root prompt was 2-3 min because Pi OS Trixie waits
on a handful of services that timeout instead of completing:
  - systemd-networkd-wait-online (60s default)
  - NetworkManager-wait-online    (30s default)
  - wpa_supplicant + dhcpcd5      (no usable interfaces)
  - raspi-config / firstboot / userconfig (no point in QEMU)

The SD image was re-baked through scripts/configure-pi3-autologin.sh
with all of them masked (the script grew a `mask_unit` helper that
symlinks each unit to /dev/null inside the rootfs). Login prompt now
appears in ~30s wall.

New manifest version 2026-04-21+autologin+fastboot — same byte count
as the previous build (still 5.4 GiB raw) but a different SHA so the
sidecar-based cache invalidation forces every container to refetch.
2026-05-16 07:13:58 +02:00
davidmonterocrespo24 93ad8fac6a docs(pi3): refresh RASPBERRYPI3_EMULATION.md for the boot_images flow
The doc still described the May 2025 design (hard-coded /img/ paths,
`quiet init=/bin/sh` cmdline, "2-5 second boot"). Update every section
that was inaccurate after the boot_images / autologin / earlycon
fixes:

* §1 Overview — boot time 30-60 s (full systemd graph), autologin
  to root, link to BOOT_IMAGES.md.
* §5 Boot sequence — added the provider.get() step and the
  systemd serial-getty autologin step.
* §12 Boot Images — full rewrite. Documents the three asset slots,
  what configure-pi3-autologin.sh patches in (drop-in + shadow +
  service masks), the three storage locations (binaries/, named
  volume cache, manifest.json), and the "refresh to a newer Pi OS"
  runbook end-to-end.
* §13 QEMU launch command — new cmdline with
  `earlycon=pl011,mmio32,0x3f201000` (without it the kernel can't
  set up the PL011 UART early enough and boot is silent) and the
  kernel-must-be-decompressed warning.
* §14 Known limitations — realistic boot-time entry, plus a new
  "boot file size" row noting the ~7 GiB volume requirement.
* §16 Key files — added boot_images/ module, manifest.json,
  configure-pi3-autologin.sh, upload-binary.sh, binaries/ host
  dir, and the docker-compose boot-images volume.
2026-05-16 07:07:41 +02:00
davidmonterocrespo24 1a2c26aab4 fix(pi3): decompressed kernel + explicit earlycon PL011 address
Two more defects making Pi 3 boot silently:

1. The kernel8.img that ships in the Pi OS armhf boot partition is a
   gzip-compressed PE-COFF Image (first 4 bytes 0x1f8b0800). QEMU's
   `-kernel` does NOT auto-decompress; it tries to execute the gzip
   header as ARM code and the CPU faults immediately. Result: zero
   bytes on ttyAMA0, simulator looks dead. Switch the asset_id to a
   pre-decompressed kernel (24 MiB raw vs 9.7 MiB gzipped) so QEMU
   gets a valid Image to boot.

2. Even with a real kernel, the original cmdline `console=ttyAMA0`
   alone wasn't enough — the kernel can't initialise the BCM2837
   PL011 UART early enough for `printk` to reach the serial console
   under QEMU's bare-metal boot (no Pi firmware to set it up
   beforehand). Adding `earlycon=pl011,mmio32,0x3f201000` makes the
   kernel program the UART itself in the early boot path.
   Verified: boot output starts streaming within 100 ms of QEMU
   launch instead of never.

The cmdline also locks the baud rate at 115200 to match the agetty
drop-in created by scripts/configure-pi3-autologin.sh.
2026-05-16 06:46:04 +02:00
davidmonterocrespo24 b9d39c0bd7 fix(pi3): show kernel boot + autologin SD + sidecar cache invalidation
User report: clicked Pi 3 board → nothing visible happens. Three
defects, all on the same path:

1. The kernel cmdline carried over from the original pre-OSS-split
   code: `quiet init=/bin/sh`. Result: kernel boot messages
   suppressed, then dropped straight to bare /bin/sh with no PS1 so
   the user sees an empty serial. Removed both. The kernel cmdline
   is now just `console=ttyAMA0 root=/dev/mmcblk0p2 rootwait rw
   dwc_otg.lpm_enable=0`, which lets systemd start a real
   serial-getty@ttyAMA0.service.

2. Pi OS Trixie armhf since Bookworm ships without a default user
   (no more pi/raspberry). With cmdline #1 fixed, the user would
   land at a login prompt and be stuck. Fix: pre-bake a systemd
   drop-in at /etc/systemd/system/serial-getty@ttyAMA0.service.d/
   autologin.conf that uses `agetty --autologin root` so the serial
   console drops to a root shell on first prompt. The browser
   canvas IS the authentication boundary; the SD image is mounted
   RO via a qcow2 overlay so per-session edits don't persist.
   Edit happens in velxio-prod/scripts/configure-pi3-autologin.sh
   (to follow in a separate commit).

3. Architectural: the original cache-hit probe was size-only.
   Today's SD image rebake produced a file with identical byte count
   but different SHA256 — the cache served stale content for every
   request even after a manifest bump. Fix: write a sidecar
   `<file>.sha256` after every successful materialise and trust it
   on subsequent probes. Manifest SHA bumps invalidate the cache
   regardless of size. Two regression tests guard this:
     - test_provider_sidecar_invalidates_on_sha_mismatch
     - test_provider_missing_sidecar_treats_file_as_invalid

Manifest bumped to version "2026-04-21+autologin" for the SD image
(kernel + DTB unchanged, still 2026-04-21).
2026-05-16 06:23:22 +02:00
David Montero Crespo c39a00c07c fix(ili9341): debounce flush instead of rAF — paint on frame boundary
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).
2026-05-16 00:48:38 -03:00
davidmonterocrespo24 93fd4617af feat(sim): boot_images module + Pi 3 emulation restored
Pi 3 simulation had been broken since at least April 2026 (51
fail-events / 24h per docs/PI3_EMULATION_BROKEN.md). Two distinct
defects compounded:

1. qemu_manager.py hard-coded paths for kernel8.img, a device-tree
   blob (under a DOS 8.3 short name!), and a 5.4 GiB Raspberry Pi OS
   SD image — none of which shipped in the repo or were pulled at
   image build.

2. qemu-system-arm + qemu-utils were missing from the Docker image
   entirely, so even with the boot files in place QEMU couldn't
   launch. Add both to Dockerfile.standalone (~200 MB).

The architecture fix is a new `app.services.boot_images` module:

  * Manifest-driven (boot_images/manifest.json, versioned in repo,
    declares SHA256 + size for each file, supports an optional
    `compressed.{encoding,sha256,size_bytes}` block for assets shipped
    as .zst).

  * `BootImageProvider` materialises files lazily, atomically (temp +
    rename), verifies SHA256 pre- AND post-decompression, caches under
    /var/cache/velxio/boot-images, serialises concurrent get() calls
    per image set via asyncio.Lock.

  * `AssetDownloader` Protocol with two impls:
    - `LicenseGatedDownloader` — same flow ESP32 / RISC-V QEMU libs
      use (VELXIO_BINARY_BASE_URL + VELXIO_LICENSE_KEY).
    - `LocalDirectoryDownloader` — for tests + in-prod use where the
      licence-module storage is already on the same filesystem (saves
      the loopback HTTP roundtrip on a 1.4 GiB blob).

  * `build_downloader_from_env()` picks one — local-dir wins if both
    sets of env vars are present, so the prod box short-circuits to
    direct disk reads automatically.

  * Lifespan hook in qemu_manager.py pre-warms the cache on container
    boot so first-time user requests don't pay the 30-60 s download
    + decompress latency.

Adding a future board kind (Pi 4 / Pi 5) is now: upload assets via
upload-binary.sh, append an entry to manifest.json, register a
lifespan pre-warm in the new board's service module. Zero edits to
provider.py / downloader.py.

Manifest entries for raspberry-pi-3:
  kernel8.img             9 695 883 bytes  (uncompressed)
  bcm2710-rpi-3-b.dtb        34 687 bytes  (uncompressed)
  raspios-trixie-armhf.img  5 729 419 264 bytes raw
                          / 1 488 002 803 bytes .zst on wire (zstd -19)
  source: 2026-04-21 build from raspberrypi.com

Tests: 21 new unit tests covering manifest parsing, integrity
helpers, both downloaders, and the provider's idempotent /
concurrent / integrity / decompression / warmup paths. In-process
FakeDownloader keeps the suite under 1 s and httpx-free.

Docs: new docs/BOOT_IMAGES.md describes the architecture, on-disk
layout, named-volume operation, and the procedure for adding a new
image set.
2026-05-16 05:41:46 +02:00
David Montero Crespo 77a63ca10b fix(canvas): three desktop interaction bugs
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>
2026-05-15 23:59:32 -03:00
davidmonterocrespo24 fa224acb8d fix(canvas): traceDetailed not defined when attaching events on parts with active-device path
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.
2026-05-15 23:55:07 +02:00
davidmonterocrespo24 e8557bd25f ci: fix Frontend Tests — use build:docker instead of build
The Vite production build step was running `npm run build` which
includes `tsc -b` in its chain. The repo has ~100 pre-existing strict
TS errors (TS6133 unused vars, TS1294 erasableSyntaxOnly, JSX
intrinsic-element types for custom elements) gated by the separate
`tsc` step above with continue-on-error.

`build:docker` is the script the Dockerfile actually uses to ship
prod — it runs generate:component-svgs + generate:sitemap +
vite build + prerender-seo. It skips `tsc -b` for the same reason
the workflow's `tsc` step is continue-on-error.

Verified locally: 285 SEO pages prerendered, vite build green.
2026-05-15 23:42:52 +02:00
davidmonterocrespo24 07552b5d9e ci: Phase 1d-tests I + K + L — workflow hardening + nightly library-compile
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>
2026-05-15 23:19:11 +02:00
davidmonterocrespo24 68c19a6663 test(sim): Phase 1d-tests D + G — board-kind coverage matrix + perf baseline
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>
2026-05-15 23:13:48 +02:00
davidmonterocrespo24 bed2bd90ef test(sim): Phase 1d-tests E + F — part simulator coverage + solver determinism
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>
2026-05-15 23:10:05 +02:00
davidmonterocrespo24 1770d51ccd test(sim): Phase 1d-tests B — smoke test 100-days + epaper + picow-wifi + circuits
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>
2026-05-15 23:05:33 +02:00
davidmonterocrespo24 a594cbf76d test(sim): Phase 1d-tests A — netlist snapshots for every gallery example
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>
2026-05-15 23:00:14 +02:00
davidmonterocrespo24 8bde313a91 test(sim): Phase 1d-tests J + C — vitest.config.ts + components-metadata integrity
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>
2026-05-15 22:58:12 +02:00
davidmonterocrespo24 37f35488c7 feat(sim): Phase 1d #10 + #11 + #16 — observable + perf + UX touch-ups
#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>
2026-05-15 22:34:29 +02:00
davidmonterocrespo24 6c6dea3326 feat(sim): Phase 1d #6 — listCurrentVectors in Worker adapter, no more heuristic parsing
`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>
2026-05-15 22:31:08 +02:00
davidmonterocrespo24 f7d3ee95e4 perf(build): Phase 1d #4 — split heavy chunks via manualChunks
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>
2026-05-15 22:26:57 +02:00
davidmonterocrespo24 29d76348aa feat(sim): Phase 1d #3 + #5 — WASM pre-boot on mount + delete dead wire* utils
#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>
2026-05-15 22:24:31 +02:00
davidmonterocrespo24 54936ef660 feat(sim): Phase 1d #2 + #9 — convergence helpers in Worker + enable LM358 subckt
#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>
2026-05-15 22:17:06 +02:00
davidmonterocrespo24 33570d7690 feat(sim): Phase 1d #1 — gallery smoke test imports real examples + shared helper
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>
2026-05-15 22:11:28 +02:00
davidmonterocrespo24 f9e5c19f95 feat(sim): Phase 1c G+F3 — retire legacy CircuitScheduler / eecircuit-engine
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>
2026-05-15 21:46:34 +02:00
davidmonterocrespo24 848786dd16 feat(sim): Phase 1c G prep — production wiring file (start.ts)
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>
2026-05-15 21:25:57 +02:00