Commit Graph

178 Commits

Author SHA1 Message Date
David Montero d64eebc200 fix(stop): reset CPU to PC=0 on Stop (real-life power-cycle semantics)
The previous fix preserved display state on Stop so Resume could pick
up the multiplexed frame seamlessly — but that's Pause semantics, not
Stop. On a real Arduino, hitting the physical Stop is cutting power:
the next Run must boot from setup(), not continue at the saved PC.

User report on https://velxio.dev/example/uno-7segment :
  > empieza a contar, le doy stop en el 6, le doy run y sigue desde 6

stopBoard now:
  - calls sim.reset() (was sim.stop()) — CPU back to PC=0
  - calls hardResetPinStates() (was the soft resetPinStates) — clears
    cached states AND notifies listeners so 7-seg / NeoPixel / LCD
    blank out instead of freezing on whatever was lit.

Reset and Stop are now the same cold-boot semantics; Reset still
additionally clears serial output + baud rate. The soft
resetPinStates() helper stays for internal SPICE-classification-only
paths that don't want listener fan-out.
2026-05-26 21:28:41 +02:00
David Montero b2474bf5d1 fix(stop): preserve display state on Stop, only blank on Reset
Reporter feedback after 7aca3db: pressing Stop on the uno-7segment
example turned the 7-segment off, and pressing Start again left
random segments lit / no number at all. The previous fix made
resetPinStates() notify every listener with (pin, false) on both
Stop and Reset, which was right for Reset (full reboot) but wrong
for Stop:

  - On Stop the AVR CPU is just paused. Internally it still has
    PORTD=0xFF (or whatever the last drive was).
  - resetPinStates blanked the pinStates cache + fan-out LOW
    notifications. Display turns off, fine.
  - On Start the CPU resumes from where it paused. avr8js's port
    listener fires only for bits that CHANGED relative to its OWN
    oldValue (which still holds the pre-stop value). If oldValue
    matches the live register, no pinChange event fires for that
    bit, and the display has no signal telling it to come back on.

Split the API into two methods:

  resetPinStates()    — soft cleanup, drops outputPins only. Used by
                        stopBoard. Cached pinStates and visual state
                        stay so the resume picks up where it left off.

  hardResetPinStates() — full cleanup, drops outputPins + pinStates
                        and fan-outs (pin, false) to listeners.
                        Used by resetBoard (CPU starts at PC=0,
                        firmware re-drives every pin from setup()).

Updated the test helper clearAllPinManagerState to call
hardResetPinStates between tests so the same-state short-circuit in
triggerPinChange doesn't suppress fresh events.

All 32 vitest tests pass (AVRSimulator, interconnect-routing,
dual-arduino-software-serial, pin-position-rotation).
2026-05-26 18:54:17 +02:00
David Montero 7aca3db51c fix(reset): clear display state + don't clobber Interconnect on Reset
Two paired bugs that surfaced on the Reset button.

(1) 7-segment / NeoPixel / LCD freeze on last pattern after Reset.
    resetPinStates() was wiping the pinStates cache + outputPins set
    silently — no listener notifications fired, so visual components
    that update on pinChange kept rendering whatever segments were
    lit at the instant the user pressed Reset. Now we snapshot every
    pin that was HIGH before clearing and fan out a synthetic
    (pin, false) to each registered listener. Stateful displays
    redraw cleanly to all-off; passive listeners (analog sensors,
    debounce-only buttons) ignore the synthetic LOW and recover on
    their next real write.

(2) Cross-board serial silently dies after pressing Reset. resetBoard
    was unconditionally reassigning:
        sim.onSerialData = (ch) => appendSerial(boardId, ch);
    immediately after sim.reset(). The comment said "re-wire after
    reset" but reset() does NOT clear that property — the new USART's
    onByteTransmit chains through `this.onSerialData` which IS the
    Interconnect wrapper. The reassignment destroyed that wrapper and
    sibling-board UART forwarding (Uno TX → Nano RX) stopped working
    until a full page reload. Same root pattern as the initSimulator
    bug fixed in 5480052 — Interconnect's __icSerialHookInstalled
    flag is on the live sim, so once the wrapper is blown away
    nothing reinstalls it. Removed the reassignment and left a NOTE
    so the next person doesn't reintroduce it.

Verified the AVRSimulator + dual-arduino-software-serial +
interconnect-routing test suites still pass (26 tests).
2026-05-26 17:30:44 +02:00
David Montero 5480052379 fix(multi-board): initSimulator wiped Interconnect's UART wrapper
Cross-board UART forwarding silently broke for any project loaded
with > 1 board. User report: Arduino Uno → Arduino Nano serial echo
test where the Uno transmits fine but the Nano's Serial.available()
is never true.

Root cause traced live with chrome-devtools-mcp + temporary debug
logs in AVRSimulator.onSerialData setter and Interconnect:

  1. loadProjectState → addBoard(uno) → createSimulator → sim.onSerialData = appendSerial
  2. addBoard(nano) → same
  3. setWires → Interconnect.updateWires → ensureSerialHook(uno)
     wraps sim.onSerialData with a fan-out callback that ALSO pushes
     to the Nano's RX queue. __icSerialHookInstalled flag set.
  4. SimulatorCanvas mounts → useEffect calls store.initSimulator()
  5. initSimulator unconditionally did:
        simulatorMap.delete(boardId);
        const sim = createSimulator(...);   // ← brand-new sim
        simulatorMap.set(boardId, sim);     // ← Interconnect's wrapper is gone
     The new sim's onSerialData is just appendSerial. The old sim
     (where the wrapper lived) has been orphaned; Interconnect never
     re-installs because its flag was on the discarded sim.
  6. Run all boards → Uno.usart.onByteTransmit → this.onSerialData →
     appendSerial (Uno's monitor shows TX) but no fan-out call →
     Nano never receives anything.

initSimulator is a legacy single-board helper from the days when the
store only knew about one MCU. Multi-board flows already create
their sims in addBoard. Bail out early if a sim for the active
boardId already exists, so the legacy helper becomes a no-op when
the multi-board path has already done the work.

Verified the 3 related test suites still pass (AVRSimulator,
dual-arduino-software-serial, interconnect-routing).
2026-05-26 17:08:04 +02:00
David Montero 1663236184 debug: trace onSerialData setter 2026-05-26 16:57:37 +02:00
David Montero c8d06c9b90 debug: temp console.log in cross-board UART path 2026-05-26 16:47:13 +02:00
David Montero 56d3bc4f12 fix(avr/serial): drain RX queue every frame and clear it on stop
User report: Arduino Nano connected to an Uno-TX wire received bytes
but displayed them poorly, and pressing Stop then Run "killed" the
serial link until the page reloaded.

Two paired bugs in the cross-board serial path:

(1) drainSerialRxQueue was only ever re-fired from usart.onRxComplete,
    which itself only fires AFTER a successful delivery. If the very
    first delivery attempt fails (rxEnable=false because the sketch
    hasn't reached Serial.begin yet — extremely common when one board
    starts emitting bytes before the receiving board's setup() runs)
    nothing re-kicks the queue and every subsequent byte from the
    sibling board sits in serialRxQueue indefinitely. Adding a
    per-frame drain attempt (no-op when queue is empty or rxBusyValue
    is set, so cost is negligible) makes the link self-heal across
    cold-start races and Serial.end()/begin() toggles.

(2) stop() never cleared serialRxQueue. On Run after Stop the new
    USART would re-drain the previous run's leftovers into the fresh
    sketch before its setup() ran, corrupting the first bytes the
    user saw on the receiving side. Clearing the queue in stop() —
    same place we already clear scheduledPinChanges — keeps each Run
    a clean slate.

Verified 52 existing tests still pass (dual-pico-serial-passthrough,
dual-arduino-software-serial, interconnect-routing, avr-uart-tx
-waveform, serial-batching, AVRSimulator, pin-position-rotation).
2026-05-26 15:47:55 +02:00
David Montero 76f6cd9a37 fix(avr/serial): queue RX bytes so Serial.readStringUntil sees the whole input
avr8js's usart.writeByte(value) rejects the call (returns false, drops
the byte) whenever rxBusyValue is set — and rxBusyValue stays true for
one full cyclesPerChar after each accepted call. The old serialWrite()
fed every character in a synchronous for-loop, so only the first byte
made it through and the sketch saw 'h' when the user typed 'hello\n'.

Buffer pending bytes in serialRxQueue and pump them one at a time:
- serialWrite() now just queues + kicks drainSerialRxQueue once
- drainSerialRxQueue calls writeByte on the head of the queue and only
  shifts it off if writeByte returned true (avr8js accepted it)
- usart.onRxComplete is wired to drainSerialRxQueue so the next byte
  ships as soon as the sketch's RX side actually consumed the previous
  one — matches the cyclesPerChar pacing the real chip enforces

Same handler wired in both USART setup paths (the Uno/Nano branch and
the post-loadHex Mega/ATtiny branch). TX path (onByteTransmit +
emitUartTxFrame for the oscilloscope waveform) is unchanged.
2026-05-23 17:50:19 +02:00
David Montero f619a2cc7e feat(boards): expose Raspberry Pi 4 and Pi 5 in the picker (UI + pin wiring)
The BoardKind type and the QEMU backend already supported
raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing
the Pi 3 arm64 image set, but the frontend had no way to actually
select either: the board picker, the canvas renderer, the serial
monitor, the oscilloscope channel list, and the editor toolbar all
hard-coded "raspberry-pi-3" as the only Pi entry.  ComponentRegistry
even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3
custom-element tag — a placeholder that meant both boards rendered as
a Pi 3 in the picker thumbnail and on the canvas.

Add dedicated boards top-to-bottom:

  * `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style
    schematic SVG (authored from scratch, not traced).  Pi 4 is the
    green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI;
    Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge,
    2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power
    button.  Both carry a small "velxio" mark in the corner.

  * `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that
    returns the 40-pin BCM layout.  Every Pi from the 1B+ onwards
    uses the same physical pin positions and same BCM GPIO
    assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this
    helper and example wires drawn against one model transfer to
    the others without re-routing.

  * React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the
    custom elements at absolute positions (mirrors how
    RaspberryPi3.tsx handles the Pi 3 illustration).

  * Wire-up across the editor surface:
      - BoardOnCanvas: BOARD_SIZE entry + switch case.
      - BoardPickerModal: description, icon, kinds list.
      - ComponentPickerModal: thumbnails now instantiate the dedicated
        custom element (was velxio-raspberry-pi-3 fallback).
      - SerialMonitor / EditorToolbar: pill labels, icons, colours.
      - Oscilloscope: GPIO channel list (28 BCM pins).
      - SimulatorCanvas: remote-boards filter for run/stop sync.
      - SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3.
      - boardPinToNumber: accepts physical pin numbers ("1"-"40"),
        BCM names ("GPIO14") and power labels for any Pi 3/4/5 id.
      - ComponentRegistry: dedicated tagNames + per-board thumbnails
        (green for Pi 4, darker green for Pi 5).

  * EditorToolbar's Pi 3 special cases (Linux/Python compile path,
    Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5
    inherit the same behaviour automatically, and any future Pi
    family member (Zero / 1 / 2) lands in the right code paths the
    moment its backend boots.

QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry
'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes
both boards selectable end-to-end without any backend follow-up.
2026-05-23 04:46:59 +02:00
David Montero 8c58d2a1a7 fix(epaper/esp32): preserve PartSimulationRegistry sensors in setSensors + correct BUSY polarity per controller family
Two intertwined bugs were leaving every ESP32 ePaper example broken
end-to-end.  Only the 5.65" UC8159c panel surfaced the failure
audibly ("Busy Timeout!" repeating in serial), because its inverted
busy polarity caused the firmware to hang inside `_waitBusy()`.  The
SSD168x ePaper examples APPEARED to run cleanly but never actually
rendered anything to the panel — the canvas stayed at the idle paper
colour because the same registration path was broken.

Root cause #1 — `setSensors` was a full REPLACE, not a merge.
  `Esp32Bridge.setSensors(sensors)` did `this._pendingSensors =
  sensors`.  At `startBoard()` time the store iterates components,
  resolves wires for any entry in `SENSOR_COMPONENT_MAP` (DHT22 /
  HC-SR04 / I²C sensors) and calls `setSensors(...)` with that list.
  ePaper components live in `PartSimulationRegistry` (not in the
  sensor map) and are registered via `sendSensorAttach()` AT
  COMPONENT-MOUNT TIME — well before `startBoard()` runs.  Full-replace
  semantics blew that registration away on every Run click, so the
  worker never instantiated an `Ssd168xEpaperSlave` / `Uc8159cEpaperSlave`,
  no SPI bytes were decoded, no frames were latched, and BUSY was
  never driven.

  Fix: upsert by `pin` so pre-existing registrations from
  PartSimulationRegistry handlers are preserved alongside the
  startBoard-resolved sensors.  Confirmed via a WebSocket spy that the
  `start_esp32` payload now carries the ePaper sensor entry.

Root cause #2 — BUSY polarity was hard-coded for SSD168x only.
  Verified against upstream GxEPD2 source:
    * SSD168x family — constructor passes `_busy_level = HIGH`
                       → BUSY=HIGH means busy, LOW means ready.
    * UC8159c family — constructor passes `_busy_level = LOW`
                       → BUSY=LOW  means busy, HIGH means ready.
  The worker only drove BUSY after a frame flush (and at the wrong
  polarity for UC8159c), so the firmware's first `_waitBusy()` inside
  `_PowerOn()` / `_InitDisplay()` — which fires BEFORE any frame —
  blocked for the full 25 s `_busy_timeout`.

  Fix: read `controller_family` from the registration payload, pick the
  per-family idle level, and (a) seed the pin to IDLE at registration so
  the first `_waitBusy()` sees "ready" immediately, (b) use that
  polarity (idle vs. busy) when pulsing on frame flush.

Verified on https://velxio.dev/example/epaper-5in65-7c-esp32-rainbow:
the serial timeline now reads `_InitDisplay reset : 1566` /
`_PowerOn : 148` / `_PowerOff : 183` / `frame done` (all sub-2 ms
busy-waits, no timeouts).  Sensor registration confirmed via the
`start_esp32` payload carrying the `epaper-ssd168x` entry.
2026-05-22 23:32:33 +02:00
David Montero 1a0877f2af feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO
Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART
transmits the byte over the WebSocket as a 'serial_output' event with no
GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while
real silicon would render the 8N1 frame at the configured baud rate.

Two changes inside Esp32Bridge:

  * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback
    that hooks the oscilloscope at parity with AVRSimulator /
    RP2040Simulator.  The 'gpio_change' event now also flows through it
    (timestamped with `performance.now()` — QEMU virtual time isn't
    surfaced across the wire, but at 1× sim speed the wall-clock skew
    is invisible on any practical sweep).  This also fixes the broader
    issue that ESP32 boards previously couldn't show ANY digital GPIO
    activity on the scope.

  * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first
    + stop transitions at `this.uartBaudRate` (default 115200) on the
    UART0 TX pin, mapped per board variant:
        esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1
        esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32:               GPIO43
        esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini:   GPIO21

    Backend doesn't expose the live baud rate so we default to 115200
    (the Arduino default).  Override path:  bridge.uartBaudRate = N
    once we surface Serial.begin's argument via a backend event.

Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)`
inside the three Esp32Bridge construction sites in useSimulatorStore
(setBoardType, addBoard, changeBoard).
2026-05-22 19:58:24 +02:00
David Montero 6584a49a8f feat(rp2040/uart): synthesize bit-level TX waveform on GP0 / GP4
Same gap as the AVR USART: rp2040js's UART fires `onByte(value)` per
transmitted byte but never toggles the corresponding GPIO, so an
oscilloscope on GP0 (UART0 TX, default for Arduino-Pico's Serial1) sees
nothing during `Serial.print`.  Real silicon drives the pin with the
full UART frame at the configured baud rate, and Velxio should match.

`emitUartTxFrame(uartIdx, byte)` derives:
  * `txPin` via FUNCSEL inspection: walk GP0 / GP12 / GP16 / GP28 (the
    four candidates for UART0 TX per RP2040 datasheet) and pick the
    first whose `functionSelect == 2` (FUNCTION_UART).  Same for UART1.
    Fall back to GP0 / GP4 when nothing is mapped (firmware hasn't
    called `Serial1.begin()` properly).
  * `baudRate` and `bitsPerChar` directly from the UART peripheral
    (rp2040js already exposes these as live getters).
  * Time from the RP2040 IClock's `nanos` counter, matching the
    existing `setupGpioListeners` path — UART waveforms therefore stack
    consistently with PIO / SIO traces on the same scope.

Both `uart[0].onByte` and `uart[1].onByte` get hooked.  The seed-idle-
HIGH baseline is pushed once per UART per simulation run; `stop()`
clears the flag so a re-run gets a fresh seed (matching how the scope
buffer is cleared on restart).
2026-05-22 19:54:55 +02:00
David Montero b587faf1b0 feat(avr/uart): synthesize bit-level TX waveform on PD1/PE1
avr8js intercepts the transmitted byte at the UDR0 register and never
toggles the corresponding GPIO.  Real ATmega328P / ATmega2560 hardware
drives PD1 / PE1 with a start bit, 8 data bits LSB-first, and a stop bit
at the configured baud rate the moment TXEN is set.  An oscilloscope
probe on D1 therefore showed nothing in Velxio while the same probe in
the real world would resolve the UART frame.

Synthesize the frame from the inside of `onByteTransmit`:

  * Read `usart.baudRate`, `usart.bitsPerChar`, `usart.parityEnabled`,
    `usart.parityOdd`, `usart.stopBits` so unusual configurations stay
    accurate (avr8js already exposes these as public getters).
  * Build the bit list start + data(LSB first) + parity? + stopBit(s).
  * For each transition vs. previous state (initial = idle HIGH), call
    `onPinChangeWithTime(1, state, timeMs)` where
    `timeMs = (cpu.cycles + i * cyclesPerBit) / 16_000`. Same
    simulator-time clock the existing port-listener path uses, so the
    scope draws the UART waveform cycle-accurately alongside other GPIO
    activity.

Also hook `onConfigurationChange` to detect TXEN flipping 0→1 and seed
the scope baseline at idle HIGH; without that, the very first byte's
start bit transition would be invisible because the scope's pre-first-
sample default is LOW.

Both USART construction sites (initial setupSimulation around line 423,
re-init after stop around line 749) get the same hook.

Covered by `__tests__/avr-uart-tx-waveform.test.ts` (5 cases): idle seed,
byte with internal transitions, 0xFF edge case, TXEN-disabled no-op,
bit-period timing.
2026-05-22 19:49:43 +02:00
David Montero e6fdd54ba3 fix(joystick): map wokwi-analog-joystick direction (-1/0/+1) to ADC voltage
The PartSimulationRegistry handler for 'analog-joystick' was reading
`el.xValue` / `el.yValue` and computing `(value / 1023) * vcc` as if the
component were a potentiometer producing a raw 0..1023 reading.  It is
not — `@wokwi/elements/analog-joystick-element` emits xValue / yValue as
a tri-state DIRECTION signal:

  *   xValue = -1  → "left"   (mousedown on left zone)
  *   xValue =  0  → centered (mouseup snap-back)
  *   xValue = +1  → "right"  (mousedown on right zone)
  (same for yValue with up/down)

`(±1) / 1023 ≈ ±0.001`, so the ADC channel sat at ~0 V no matter which
directional zone was clicked.  Center-button clicks worked because that
path is digital (`setPinState(SEL, …)`) and bypasses the analog map.

Fix:
  * Tri-state → voltage with explicit map: -1 → 0V, 0 → Vcc/2, +1 → Vcc.
  * Vcc was hardcoded to 5V for "not RP2040" — wrong for ESP32 / S3 /
    Nano-ESP32 / etc., which all run at 3.3V like the Pi Pico.  Detect
    ESP32 via the BridgeShim's `setAdcVoltage` method and select 3.3V
    for everything that isn't pure AVR.

Reported on /example/esp32-joystick where center-button-only worked but
directional zones did nothing.  Verification via Chrome MCP after deploy.
2026-05-22 18:57:26 +02:00
David Montero Crespo 4ff71765e7
Merge pull request #202 from davidmonterocrespo24/fix/circuit-sim-service-stop
fix(circuit-sim): stop() guard + drop edges with no V-source post-reb…
2026-05-19 22:52:08 -03:00
David Montero Crespo f5ae4853eb fix(circuit-sim): stop() guard + drop edges with no V-source post-rebuild
Two related bugs that surfaced as "Vitest worker exited unexpectedly /
Timeout terminating forks worker" on the circuit-simulation-service
test file.

Bug 1 — tick() recursively re-schedules itself in its finally block.
After afterEach disposes the scheduler via __resetMixedModeScheduler(),
those re-scheduled ticks throw "call loadCircuit first", get caught by
the console.warn, and the finally schedules ANOTHER tick. Infinite
Promise loop survives until the worker OOMs.

  Fix: add CircuitSimulationService.stop() that flips a `stopped` flag
  short-circuiting tick() + handleMcuEdge(). The test harness now
  tracks each started service in _activeServices and calls stop() in
  afterEach alongside the existing unsubscribe sweep.

Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net
(buildNetlist skips it because netLookup returns null), handleMcuEdge
sees hasSource=false, self-heals by queueing the edge + tick().  The
rebuild still doesn't emit the V-source (no wire), so tick.finally
replays the edge → self-heal again → tick again → infinite loop AT
RUNTIME, not just in tests. A user toggling a digital pin without a
wire freezes the whole circuit simulation.

  Fix: in tick.finally's pendingMcuEdges replay loop, check whether
  the rebuilt netlist now contains a V-source for each pending edge's
  pin. If not, drop the edge silently — a future canvas tick triggered
  by adding the wire will pick it up via the normal subscription path.

Also fix the "coalesces an edge with an in-flight full solve" test
fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was
never emitted and the test was racing the (now-bounded) self-heal
rebuild. Replaced with an inline fixture wiring pin 9 → resistor →
GND, mirroring the wired fixture used by the alter+republish test
right above it.

Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per
shard) with no worker-exit warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 03:49:29 +02:00
David Montero Crespo 603b791daa feat(attiny85+customchip): full ATtiny85 ADC/Timer0 + custom-chip pipeline fixes
ATtiny85 (AVRSimulator + collectPinStates + connectAnalogInputsToMcu + SimulatorCanvas + Attiny85Element + examples):
- Add attiny85AdcConfig with correct register addresses (ADMUX=0x27,
  ADCSRA=0x26, ADCSRB=0x23, ADCL=0x24, ADCH=0x25, DIDR0=0x34, adcInterrupt=0x08).
  Without this, analogRead() polled the wrong address forever and the
  firmware hung on first ADC read.
- Add attiny85Timer0Config + instantiate AVRTimer so OVF fires at the
  ATTinyCore-expected ~1.024 ms cadence. delay() advance is still blocked
  on avr8js TIFR auto-clear semantics (separate upstream issue, see
  ATTINY85_TIMER0_UPSTREAM_ISSUE.md in velxio-prod test plan).
- Map ATtiny85 ADC channels to PB-style pin names (PB5/PB2/PB4/PB3 -> 0..3)
  in connectAnalogInputsToMcu so SPICE node voltages reach the right ADC
  channel.
- Recognise /^PB\d+$/ in collectPinStates.pinNameToArduinoPin so wires
  named "PB1" emit v_attiny85_pb1 V-source and the LED responds to MCU
  writes. Previously every PB-wire returned -1 and SPICE saw no source.
- SimulatorCanvas: subscribe pin 1 (PB1) for the built-in LED on the
  attiny85 board kind (Digispark convention), instead of falling through
  to the pin-13 default.
- Attiny85Element: remove the hand-drawn "yellow LED" circle that was
  floating above the chip. The bare DIP-8 has no on-board LED; examples
  wire a real wokwi-led + resistor instead.
- examples.ts: add a real wokwi-led + 220 Ohm wokwi-resistor + wires to
  attiny85-blink, and add missing series resistors to attiny85-button-led
  and attiny85-ntc-sensor. attiny85-pwm-fade was already correct.

Custom-chip pipeline (CustomChipPart + simulatorBridges):
- Add a requestAnimationFrame loop that calls instance.tickTimers() every
  frame in CustomChipPart. Chips that register vx_timer_create (e.g. an
  i8080 stepping its core, or a sensor publishing samples) had timers
  added to the queue but nothing fired them; tickTimers was dead code.
- Gate the ESP32 backend path with detectSimulatorKind(sim)==='esp32'.
  The previous `typeof sim.registerSensor === 'function'` check matched
  AVR and RP2040 simulators too (they expose registerSensor for I2C
  sensor proxies), routing client-side chips to a non-existent ESP32
  worker on those boards.
- Replace direct simulator.usart.writeByte calls in avrUartTx with a
  JS-level FIFO + setTimeout(1ms) drainer. avr8js writeByte drops bytes
  under burst load (a chip emitting print_string lost ~99% of bytes via
  non-immediate, or kept only the last byte via immediate). The drainer
  attempts one non-immediate write per tick and retries on RXC busy /
  RXEN off. Added a guard for ATtiny85 (no USART -> would queue forever).

End-to-end verified: i8080-banner-streamer now prints the boot banner
followed by "uptime ticks: 0xNN" lines stepping every ~50 ms, executing
real Intel 8080 instructions inside the WASM chip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 18:10:08 -03:00
David Montero Crespo dd22bcfe50 fix(ui+spice+example): interactive wokwi components, NTC formula, photoresistor alias
Three independent fixes uncovered during a systematic example-by-example
audit (plan/full_test_plan/):

1. DynamicComponent.handleMouseDown was calling e.stopPropagation()
   unconditionally in the capture phase. That swallowed pointerdown
   BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick
   could see it, so the rotary knob would not rotate and buttons
   wouldn't press even with a real OS mouse. Now we skip the swallow
   when the click target is an inner wokwi-* element during a live
   simulation, letting the wokwi component own its own pointerdown
   while still allowing the canvas drag-to-rearrange flow on the
   wrapper / non-interactive surface.

2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider
   formula inverted relative to both the SPICE mapper topology
   (VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring)
   and real wokwi-ntc-temperature-sensor modules. Moving the slider
   to 60 C made the firmware print -3.42 C. Flipped the formula to
   r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports
   60.12 C and A1 voltmeter shows 4.00 V.

3. componentToSpice.ts photoresistor mapper was only registered under
   the bare key `photoresistor`, but example components use the
   metadataId `photoresistor-sensor`. Added an alias so the LDR +
   pull-down divider gets emitted for the real component instance.

All three reproduce visually in seconds; documented per-example in
plan/full_test_plan/examples/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:10:42 -03:00
David Montero Crespo bbf8cd0303 feat(chips): programmable retro CPU chips with external ROM
Adds a new way to use the retro CPU chips: write your program in a
project file (.s / .asm / .hex / .bin), click Compile, click Run, and
the same chip emulates whatever you wrote. Same chip + different ROMs =
mini PC, calculator, LED demo, Kill-the-Bit game, etc.

SDK:
- velxio-chip.h gets two new host imports:
    uint32_t vx_rom_size(void);
    void     vx_rom_read(uint32_t off, uint8_t* dst, uint32_t len);
  CPU-emulator chips call these in chip_setup to pull their program out
  of the host's romBytes property.

Frontend runtime:
- ChipRuntime accepts opts.romBytes (Uint8Array) and exposes the new
  imports, copying bytes into chip memory on vx_rom_read.
- CustomChipPart pulls component.properties.romBytes (base64) and passes
  it through.
- Component registry declares three new custom-chip properties:
  romBytes (base64), programFile (matching project filename), and
  programTarget (cpu name).

New programmable bundled chip:
- frontend/src/components/customChips/examples/intel/i8080-cpu.{c,chip.json}
  Same clean-room 8080 emulator as i8080-repl/i8080-counter, but ROM is
  loaded externally via vx_rom_*. Has 8 LEDs, 8 buttons, UART, 16 KB RAM,
  32 KB of external ROM.

Backend:
- New /api/compile-rom endpoint and rom_compile service that turns
  chip-program source into ROM bytes. 8080 ASM is assembled by the
  in-tree two-pass assembler (moved to backend/app/services/asm8080.py).
  Intel HEX records are parsed; raw .bin is passed through. Future targets
  (z80, 8086, 4004) are scaffolded but not wired yet.

EditorToolbar:
- Compile button detects when the active file is .s/.asm/.hex/.bin and
  routes to compile-rom instead of arduino-cli. The compiled bytes are
  injected into every custom-chip on the canvas whose programFile property
  matches the active filename (or is empty).

Example:
- /examples/i8080-killbits loads Dean McDaniel's 1975 Kill-the-Bit on
  the programmable i8080-cpu chip. killbits.s is shipped as a project
  file alongside sketch.ino; the user clicks Compile then Run and the
  LED walks across 8 outputs, buttons kill it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:38:18 -03:00
David Montero Crespo ca1bf00597
Merge pull request #193 from davidmonterocrespo24/esp32-cleanup-broadcast-pwm
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo
2026-05-18 23:23:05 -03:00
davidmonterocrespo24 ba59fd4b4a refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo
The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3
deploys without regressions, so the temporary fallback shipped in
commit 77bf897 can come out. Closes #101.

Backend (esp32_worker.py + esp32_lib_manager.py):
- Stop emitting `ledc_update` from the 0x5000 LEDC callback and from
  the polling thread. Only `ledc_duty` (channel + duty_pct) and the
  GPIO matrix routing events ship now.
- Drop the channel→gpio reverse-lookup that fed the legacy event.

Frontend:
- Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`.
- Delete `makeLedcUpdateHandler` + its `channelGpioMemo`.
- Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':`
  message handler + the `LedcUpdate` type.
- Strip `this.onLedcUpdate = null` from 14 test mocks.
- Rewrite the `does not call broadcastPwm` guard in
  esp32-multi-servo-gpio-matrix.test.ts to assert the method itself
  no longer exists on PinManager (stronger regression guard than the
  spy version, and doesn't need vi).
- Remove the `PinManager.broadcastPwm fallback` describe block from
  esp32-servo-pot.test.ts — every test in it exercised the deleted
  fallback path.

Docs (ESP32_EMULATION.md):
- Replace `ledc_update` rows in the events / implementation tables
  with the SignalRouter trio (`ledc_duty`, `gpio_routing`,
  `gpio_routing_clear`).
- Update the visual flow diagram + the "why this matters" paragraph
  to past-tense the broadcastPwm bug.

Tests: 1886 frontend tests pass (the previously-failing
board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is
also green). Backend unit suite: 279 pass, the 11 espidf_real_paths
prereq failures are environment-dependent (need arduino-cli libs in
the local shell) and unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:05:34 +02:00
David Montero Crespo 81837eedb9 fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples
End-to-end pipeline fixes uncovered while auditing the /examples gallery.
Each bug shipped past green unit + snapshot tests because none of those run
firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven
visual harness that loads each example, runs the simulator, samples
`wokwi-led.brightness`, and asserts toggle / gradient / initial-off
invariants — exits non-zero on any regression.

Frontend simulator
- PinManager.updatePort: new optional ddrMask param. A pin is added to
  `outputPins` only if the DDR bit is set, so the PORTx write that
  enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin
  as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on
  Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it.
- AVRSimulator: pass DDR mask alongside every port-listener fire.
- BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so
  `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate
  INPUT_PULLUP — without this the firmware reads LOW from boot and
  thinks the button is permanently pressed (the "LED is always on,
  pressing does nothing" UX bug).
- connectMcuEdgesToService: suppress synthetic digital edges on pins
  with active PWM, AND subscribe to onPwmChange to re-tick the netlist
  on duty changes. Fade-LED now produces a true gradient (6 distinct
  brightness levels across a fade cycle) instead of a binary 0/full
  toggle.
- CircuitSimulationService.handleMcuEdge: replace single-slot
  pendingMcuEdge with a per-pin Map. Multiple pins toggling during the
  same in-flight tick used to overwrite each other; now every pin's
  most-recent edge replays after the tick. Fixes Traffic-Light RED→
  YELLOW→GREEN sequencing.
- NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with
  underscores in V-source names. ngspice's interactive `alter` command
  treats `-` as an operator and silently no-ops on hyphenated source
  names, so mid-simulation MCU pin transitions stopped propagating
  after the first solve. MixedModeScheduler.onMcuPinChange and
  CircuitSimulationService self-heal use the same sanitizer so names
  stay consistent across emit/alter/lookup. Also added a regex-based
  fallback in step 2 so any board pin matching `GND.\d+` canonicalises
  to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the
  per-board `groundPinNames` list missed several, leaving wires
  floating instead of grounded.
- collectPinStates: emit V-sources only for pins in `outputPins`, not
  every wired board pin. Leaves INPUT pins (analog sensors on A0,
  pull-down dividers, etc.) free for the SPICE solver instead of being
  shorted to 0 V by an ideal MCU V-source.
- start.ts: extended __spiceDebug to also expose outputPinsByBoard +
  nodeVoltages + pinNetMapEntries for the visual harness.
- ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to
  triggerPinChange / setPinState so the new outputPins tracking fires
  on those boards too (was AVR-only before).
- useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so
  outputPins clears between runs; Esp32Bridge.onPinChange passes the
  `'mcu'` flag in all three places it's wired.
- types/board.ts: ATtiny85 FQBN `clock=internal16mhz` →
  `clock=16pll` (ATTinyCore 1.5.2 renamed the option).

Backend
- esp-idf-template/main/CMakeLists.txt: skip the
  `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both
  variants already define LED_BUILTIN in pins_arduino.h via a
  self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const
  uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the
  command line expanded the static-const declaration to
  `static const uint8_t 2 = ...;` — a syntax error that broke every
  ESP32-C3 / S3 build (`expected unqualified-id before numeric
  constant`).

Examples
- examples.ts: bulk-fix 72 wire endpoints that referenced
  `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't
  exist on the canvas). Replaced with `'arduino-uno'` (the canvas
  board-id convention) and converted `D<n>` pin names to `GP<n>` for
  Pico-style boards. Affects pico-blink, pico-i2c-scanner,
  pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others.

Tests
- scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers
  Blink (single-pin), Button (idle-OFF invariant — catches the
  INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing),
  Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins
  driven). Run via `npm --prefix frontend run test:visual` against a
  Chrome on `:9222` + vite on `:5174` + backend on `:8001`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:52:07 -03:00
davidmonterocrespo24 18a582455c feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators
Closes the deferred Phase 3.3. Root-causes the Pi 2 "Attempted to
kill init" panic as `mount /dev/vda` failing with EINVAL — Debian
armmp does not have ext4 builtin (only fuseblk in /proc/filesystems).

- qemu_manager: PI_CONFIGS gains raspberry-pi-zero / -1 / -2 entries.
  All three use the armmp armhf kernel + Cortex-A7 CPU + the mmio
  virtio transport (arm-32 virt PCI fails -75 due to missing reg DT
  property). Pi Zero / Pi 1 get the small 1-core / 512 MB profile;
  Pi 2 gets 4-core / 1 GB. QEMU command builder branches on cfg.bus
  for virtio-blk-pci vs virtio-blk-device (and serial likewise).
- manifest.json: new `raspberry-pi-armhf` image_set wiring three
  assets (kernel + initramfs + zstd rootfs).
- Frontend BoardKind gains the three new kinds + an isPiBoardKind()
  helper. Replaces the eight scattered `=== 'raspberry-pi-3' ||
  === 'raspberry-pi-4' || === 'raspberry-pi-5'` branches in
  useSimulatorStore, Interconnect, loadExample, boardProtocols.
  ComponentRegistry gets three new picker entries.
- board-kinds-coverage test: ACCEPTED_UNCOVERED gains the new kinds
  (backend boards have no canvas examples).

The matching armhf build-pi-kernel.sh / build-pi-rootfs.sh changes
live in velxio-prod's scripts/ (private overlay) — the upstream
kernel build script only knows about arm64; armhf is built in the
private repo because the assets ship through the license endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:23:48 +02:00
David Montero Crespo 33cca9f08a
Merge pull request #188 from davidmonterocrespo24/pi-phase-2.5
feat(pi): pluggable slave handler + canvas wire detection for I2C/SPI…
2026-05-18 15:25:35 -03:00
David Montero Crespo 7ce184c4c6 fix(spice): always emit V-source per wired GPIO pin (root cause of intermittent dark-LED)
Bug reproduced via CDP probe across 5 Run/Stop cycles: cycle 1
worked (LED toggled), cycles 2-5 LED stayed dark — but exactly the
same code, same canvas, same circuit.

Tracing the live electrical store via __spiceDebug() showed:
  cycle-1 after-run: branchCurrentCount=3 (pin13 V-source present)
  cycle-2 after-run: branchCurrentCount=2 (pin13 V-source MISSING)
  cycle-3..5 after-run: branchCurrentCount=2

The flow:
  1. User clicks Run -> board.boards reference changes -> service ticks.
  2. runSolve calls collectPinStates(board, ...) to snapshot output pins.
  3. collectPinStates was emitting an entry ONLY when pinManager.getPinState(pin)
     was currently TRUE. If the pin was LOW at that exact instant
     (which is most of the time for a Blink sketch — 50% duty), no
     pinStates entry, no V-source card in the netlist.
  4. AVR runs, digitalWrite(13, HIGH) fires, handleMcuEdge calls
     scheduler.onMcuPinChange -> solver.alterSource('V_arduino-uno_13', 5).
  5. ngspice gets 'alter V_arduino-uno_13 dc 5' but that V-source
     doesn't exist in the deck. Silent no-op. branchCurrents never
     updates. LED stays dark forever.

The 'sometimes it works' impression came from cycle 1: the cold-boot
AVR happened to land on a HIGH state precisely when the tick fired,
so the V-source got emitted and every subsequent edge alter worked.
The other cycles caught the AVR in LOW.

Fix: always emit a digital PinSourceState — with v=0 when LOW, v=vcc
when HIGH — so the NetlistBuilder always produces V_<board>_<pin>
cards for every wired GPIO. alterSource then has a target to bind
to no matter what state the pin was in at solve time.

Verified live via CDP probe (_probe_blink.mjs in working tree):
  pin13 toggles 0V<->5V at the Blink frequency
  LED anode follows at 0V<->1.838V (matches manual calculation:
    (5 - 1.84) / 220 = 14.4 mA forward current through the red LED)
  branchCurrentCount = 3 stable across all cycles
2026-05-18 13:32:31 -03:00
davidmonterocrespo24 2072011fa4 feat(pi): pluggable slave handler + canvas wire detection for I2C/SPI/UART
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>
2026-05-18 16:26:37 +02:00
David Montero Crespo 5f378ed411 fix(spice): allow hyphens in voltage-source name regex (root cause of dark-LED bug)
The user-reported 'LED with proper series resistor shows 1.84 V at
the anode but never visually lights up' had its root cause here,
not in ngspice / not in the LED brightness handler / not in any
component id naming choice. ngspice parses 'V_led-builtin_sense'
just fine; the diode conducts and the node voltage is exactly what
you'd compute by hand.

What breaks is the JS regex that scans the emitted netlist to
collect voltage-source names so CircuitSimulationService can ask
the scheduler to read their branch-current vectors:

  const m = card.match(/^([Vv][_\w]*)\s/);

[_\w]* doesn't accept '-'. For a card 'V_led-builtin_sense …' the
capture is 'V_led' (truncated at the hyphen). The voltageSources
array gets the wrong name; CircuitSimulationService pushes
'i(v_led)' into extraVectorsOfInterest; ngspice has no such vector
so the readVec promise rejects silently; branchCurrents['v_led-builtin_sense']
is never populated; the LED handler in BasicParts.ts sees raw =
undefined, the SPICE-memo path is skipped, and the digital fallback
runs but only sets the LED on when the PinResolver classifies the
anode as a direct GPIO connection (which it does NOT when an
intermediate resistor is in series). Dark LED.

Fix is adding '-' to the character class. One character. All five
existing examples I previously 'fixed' by just adding a series
resistor will now light up correctly without renaming any of their
component ids. Same for any saved user project with hyphenated ids
and for the auto-generated picker ids that used to contain hyphens.

The earlier underscore-id workarounds (default canvas + picker
template) stay in place as defense in depth — they don't break
anything and they keep the SPICE side clear of avoidable special
characters.
2026-05-18 10:45:06 -03:00
davidmonterocrespo24 db5e3a8623 feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS
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.
2026-05-18 15:41:29 +02: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 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
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 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
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
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 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 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 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 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
davidmonterocrespo24 194ff94045 feat(sim): Phase 1c F2 — migrate 22 SPICE test files to NgSpiceNodeAdapter
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>
2026-05-15 21:23:53 +02:00
davidmonterocrespo24 8db973675a feat(sim): Phase 1c F1 — NgSpiceNodeAdapter runs real WASM in Node tests
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>
2026-05-15 20:49:39 +02:00
davidmonterocrespo24 5d64654668 feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish
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>
2026-05-15 20:26:09 +02:00
davidmonterocrespo24 5ce99fab5d feat(sim): Phase 1c C1+C2 — extract ADC/waveform bridge to solver-agnostic module
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>
2026-05-15 20:20:49 +02:00
davidmonterocrespo24 a8cd5dd8ce feat(sim): Phase 1c B1+B2+B3 — CircuitSimulationService (orchestrator)
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>
2026-05-15 19:28:26 +02:00
davidmonterocrespo24 d048a7d031 feat(sim): Phase 1c A4+A5 — scheduler depends on SolverPort
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>
2026-05-15 19:24:12 +02:00
davidmonterocrespo24 834f8f7e0a feat(sim): Phase 1c A2+A3 — NgSpiceWorkerAdapter + FakeSolverAdapter
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>
2026-05-15 19:21:46 +02:00