Commit Graph

298 Commits

Author SHA1 Message Date
David Montero a1137f1929 fix(sim): re-solve SPICE on ESP32/STM32/Pi output pin edges
WebSocket-backed boards (ESP32, STM32, Raspberry Pi) reach the electrical
simulation only through PinManager.triggerPinChange, which updated the pin
state + notified listeners but never requested an electrical re-solve. AVR
and RP2040 already resolve at their own toggle sites. As a result an analog
part on an MCU-driven net (e.g. a resistor-less LED whose brightness comes
from the SPICE solve) stayed at its first solved value until unrelated
activity (such as serial output) forced a solve — so an ESP32 blink with no
Serial in loop() left the LED stuck on.

Request an electrical re-solve after an 'mcu'-sourced pin edge, in one place
(triggerPinChange), covering all WS boards. Gated to source==='mcu' so the
solver's own input feedback (triggerPinChange with the default 'external'
source) can't loop; requestElectricalResolve coalesces overlapping ticks so
a per-edge call is cheap.
2026-07-02 22:33:46 +02:00
David Montero ed132afb91 sim: drive RP2040 + STM32 digital inputs from the real circuit
Extend the spice-driven input path (already live for AVR/ESP32) to RP2040 and
STM32 so digitalRead() of an INPUT pin reflects the actual wiring: a pin tied
to a rail reads that rail, and an INPUT_PULLUP button-to-GND reads idle-HIGH /
pressed-LOW instead of floating or inverted.

RP2040 (rp2040js, frontend-only): the GPIO listener now splits input vs output
mode. Input pins report their pad pull (InputPullUp/Down) via setPinPull and
seed the pull's idle level (rp2040js does not auto-apply the pad pull to the
readable input register); the SPICE solve then overrides via connectDigital-
InputsToMcu when the net is actually sourced. Output pins drive as before.
spiceDrivenInputs = true.

STM32 (backend QEMU): the worker now forwards a new gpio_pull event (from the
libqemu-arm picsimlab_pull_pin callback) so the netlist stamps the matching
weak resistor; Stm32Bridge surfaces it, Stm32BridgeShim opts into
spiceDrivenInputs, and collectPinStates maps PA0/PC13 names to the linear pin
so the pull is read. STM32 outputs stay on the part layer (unchanged).

Event-driven parts with no SPICE model (rotary encoder, keypad) remain
protected by the existing sourcedNets gate in the connector.
2026-06-26 22:05:24 +02:00
David Montero 48099c0bda feat(avr): drive inputs from real circuit, modeling the internal pull-up
Re-do the AVR spice-driven digital inputs (reverted in c11c195) the right way so
INPUT_PULLUP buttons keep working. PinManager.updatePort now detects the AVR
internal pull-up (input DDR bit + PORT bit high) and sets the pin pull, so the
netlist stamps the 45k pull-up and an INPUT_PULLUP input reads HIGH at idle.
connectDigitalInputsToMcu drives a pin from the solve only when its net is
source-backed by a RAIL or a COMPONENT card (button switch, divider, cross-board
output) — NOT by the internal pull alone — so INPUT_PULLUP pins wired to
event-driven parts with no SPICE model (rotary encoder, keypad) are left to the
part layer and never clobbered. AVR only; RP2040/STM32 stay on the part-seed
until their pulls are modeled.
2026-06-26 20:18:41 +02:00
David Montero c11c1954e1 revert(boards): undo spice-driven digital inputs for AVR/RP2040/STM32
The spiceDrivenInputs change (e81450e + f4401cc) fixed plain-INPUT-wired-to-rail
reads but BROKE the far more common INPUT_PULLUP + button-to-GND pattern: the
internal pull-up is not modeled in the netlist, so the input floated LOW and read
as permanently pressed (verified live on the stm32-bluepill-button example).
Revert all the spice-driven-input changes to the pre-fix part-seed behaviour,
which handles INPUT_PULLUP correctly. Proper fix (model the internal pull-up per
board so BOTH patterns work) is a follow-up. Keeps the Pi LED fix.
2026-06-26 18:51:06 +02:00
David Montero f4401cc2dd fix(rp2040,stm32): drive digital inputs from the real circuit, like AVR
Extend the source-backed SPICE-driven input fix to the Pico (RP2040) and STM32:
a GP/PA pin wired to a rail or button now reads the right level from the solve,
while floating event-part nets (encoder/keypad/dialer/dip/stepper) stay on the
part layer. RP2040 just opts in (spiceDrivenInputs); STM32 opts in via the
Stm32BridgeShim and connectDigitalInputsToMcu maps PA0/PC13 names to the linear
pin setPinState expects (stm32PinNameToLinear).
2026-06-26 18:34:54 +02:00
David Montero e81450e348 fix(avr): drive digital inputs from the real circuit (SPICE), not a fake seed
An Arduino input wired to a power rail read the wrong level: a pin tied to 5V
read LOW, and a button-to-5V read idle-HIGH / pressed-LOW. AVR inputs were never
fed the solved circuit voltage (only the ESP32 had spiceDrivenInputs), so a
bare-rail input had no driver and buttons fell back to a hardcoded active-low
pull-up seed that ignored the wiring.

Enable spiceDrivenInputs on AVRSimulator, and gate connectDigitalInputsToMcu on
a new NetlistBuilder sourcedNets set (rails, GPIO V-sources, pulls, and any net
a component card touches). Only source-backed input pins are driven from the
solve; floating nets are left to the part layer, so event-driven parts with no
SPICE model (rotary encoder, keypad, dialer, dip-switch, stepper) keep driving
their own pins instead of being forced LOW.
2026-06-26 18:00:03 +02:00
David Montero 7fb2ee3de9 feat(esp32): drive digital inputs from the solved circuit (real-wiring fidelity)
ESP32 digitalRead now reflects the actual circuit instead of a part-level
seed, so a button behaves like hardware — including breaking when it's
mis-wired.

- connectDigitalInputsToMcu: after each SPICE solve, threshold every ESP32
  input pin's net voltage (3.3 V LVCMOS, hysteresis) and push the level into
  QEMU. Only pins the MCU isn't driving as outputs are injected.
- Esp32BridgeShim advertises spiceDrivenInputs; the pushbutton / 6mm-button /
  slide-switch parts skip their direct setPinState seed for such boards and
  only flip the component property (pressed/value), which re-solves the
  circuit. The connector then decides the level from the real wiring.
- makePinPullHandler no longer seeds the pin; it only records the pull
  (netlist resistor) + requests a re-solve, so the read stays circuit-driven.
- GROUND_PIN_RE now matches bare numbered grounds (GND2, GND3) — the ESP32
  DevKit element labels its second pad 'GND2', which previously floated.

Net effect: a correctly-wired INPUT_PULLUP button idles HIGH and reads LOW
pressed; a button mis-wired with GND on the wrong terminal reads stuck-LOW,
matching real silicon. AVR / RP2040 keep the legacy part-seed path.
2026-06-24 22:52:52 +02:00
David Montero df9e06c99a feat(esp32): emulate internal pull-up/pull-down for GPIO inputs
INPUT_PULLUP / INPUT_PULLDOWN had no effect in simulation: the ESP32's
internal pull resistors live inside QEMU and were invisible to the SPICE
solver, so an input wired to a button-to-GND floated to 0 V and read LOW
even at idle. The canonical active-low button never worked.

Read the pull config straight out of the running guest: the IO_MUX
register (FUN_PU bit 8 / FUN_PD bit 7) is already exposed read-only via
qemu_picsimlab_get_internals(3), so no QEMU rebuild is needed. The worker
scans it on the 100 ms poll thread and emits gpio_pull; the bridge feeds
it to PinManager; the netlist stamps a weak 45k resistor to the rail so
idle inputs read the correct level. 45k matches the real internal pull
and is weak enough that any external driver/pull dominates.

Verified with ngspice: idle ~3.3 V (HIGH), pressed ~0 V (LOW).
2026-06-24 03:32:30 +02:00
David Montero d8b6c77335 fix(spice): model pushbutton as a real 4-pin tactile switch
The pushbutton was modelled as a switch between only 1.l and 2.l; the
other two legs (1.r, 2.r) connected to nothing. Wiring GND/GPIO to those
legs silently produced a dead button, and the failure was invisible.

Model it like hardware: 1.l is internally shorted to 1.r and 2.l to 2.r,
and pressing bridges terminal 1 to terminal 2. Wiring to any leg now
works, and putting GPIO and GND on the same terminal is a dead short,
exactly as on a real tactile switch. Back-compat A/B variant preserved.
2026-06-24 03:32:17 +02:00
David Montero 03339a132a feat(pi): gpiozero core + boot feedback, terminal and upload UX
- boot_images manifest: bump arm64 rootfs (gpiozero/colorzero baked in,
  hostname applied at boot, reworded MOTD)
- RaspberryPi3Bridge: onBooted shell-ready detector + sendAndWaitForPrompt
  flow control (resets on disconnect)
- RaspberryPiWorkspace: distinct Booting overlay + piBooted-driven status,
  inline SVG icons replacing emoji glyphs
- SerialMonitor: strip CSI/DSR escapes so the dumb console no longer shows
  a literal [6n next to the prompt
- VirtualFileSystem: upload auto-starts the Pi and waits for the shell, then
  flow-controls each command (no more dropped lines on large files)
- i18n: bootingTitle/bootingNote + reworded offlineNote2 across 9 locales
2026-06-22 20:21:06 +02:00
David Montero 8c32b16589 fix(sim): SSD1306 page-addressing mode (Tiny4kOLED/U8g2 OLED garbled)
SSD1306Core only handled horizontal/vertical addressing (0x20/0x21/0x22) and
defaulted memMode to horizontal. Page-mode drivers (Tiny4kOLED on ATtiny85,
U8g2 page buffer, classic SSD1306 libs) position the cursor with the single-byte
commands 0xB0-0xB7 (page) and 0x00-0x0F / 0x10-0x1F (column nibbles) and rely on
the SSD1306 power-on default of PAGE addressing — they never send 0x20. velxio
ignored those cursor commands and advanced in horizontal mode, so every setCursor
was a no-op and the hatching/border/text piled onto wrong rows -> garbled display.
Fix: default memMode=2 (datasheet power-on) and handle the page/column-set
commands. Adafruit_SSD1306 still works (it sends 0x20,0x00 + 0x21/0x22 explicitly).
Verified: decoded the real ATTinyCore Tiny4kOLED I2C stream renders a clean
border + '128x64'. Adds a page-addressing render test.
2026-06-19 18:08:01 +02:00
David Montero 37b3fac978 feat(sim): ATtiny85 USI I2C — drive I2C devices (SSD1306 OLED) via TinyWireM
The ATtiny85 has no hardware TWI; TinyWireM/Tiny4kOLED drive I2C through the USI
peripheral (SDA=PB0, SCL=PB2). avr8js ships AVRUSI but velxio never instantiated
it, so the I2C bus had no master on the ATtiny85 and devices (e.g. SSD1306 OLED)
got no data — the display stayed blank (and wokwi-ssd1306 threw putImageData with
an empty framebuffer). New UsiI2cBridge instantiates AVRUSI and sniffs the SDA/SCL
lines, replaying START/STOP + 8-bit bytes onto the shared I2C bus as
start/connectToSlave/writeByte/stop (the same calls AVRTWI makes for the Uno).
Validated against real ATTinyCore Tiny4kOLED firmware: decodes addr 0x3C + SSD1306
init/data stream. Firmware tolerates NACK so the sniffer is passive.
2026-06-19 07:44:18 +02:00
David Montero 06f672f47f fix(sim): wire ATtiny85 PBx pin edges into the SPICE mixed-mode loop
arduinoPinToName() had no ATtiny85 case, so pin 1 reverse-mapped to "1"
instead of "PB1" (the wire/netlist name). The MCU-edge listener was never
attached (name not in pinsInCircuit) and the SPICE V-source was never altered
on digitalWrite, so a blink LED's branch current stayed at its HIGH value —
the LED latched ON and never turned off (and analogWrite duty changes never
re-solved). Map attiny85 pin N -> "PBN", mirroring pinNameToArduinoPin.
2026-06-19 05:15:26 +02:00
David Montero c0b1e577b4 fix(sim): correct ATtiny85 Timer0 PWM register addresses
AVRSimulator used wrong ATtiny85 Timer0 data-space addresses: OCR0A 0x56
(=PINB), OCR0B 0x5c (=EECR), TCCR0A 0x4f (=TCNT1). analogWrite() writes
OCR0B at data 0x48, so pollPwmRegisters() read the wrong register and PWM
duty was never seen — attiny85-pwm-fade showed no fade. Corrected both
PWM_PINS_TINY85 and attiny85Timer0Config to TCCR0A=0x4A/OCR0A=0x49/OCR0B=0x48
(verified against the ATTinyCore analogWrite disassembly). delay()/millis
(overflow-based) was unaffected. Tests updated off the old 0x5c/0x56.
2026-06-19 04:23:41 +02:00
David Montero 1a7d4fabf4 fix(sim): re-solve when a part burns out so the open actually applies
The live solver only re-solved on component/wire/board changes, so a runtime
burnout (which changes burntComponents, not components) wouldn't rebuild the
netlist — the burnt part stayed in the circuit until something else changed.
Trigger a re-solve on burntComponents change too. The monitor skips already-
burnt parts, so this converges (one extra solve).
2026-06-18 05:10:00 +02:00
David Montero e4aae82ca5 feat(sim): P4 slice 2 — burnt parts go open + LED joins the charred visual
- The live solver now excludes runtime-destroyed components from the netlist,
  so a burnt part actually goes OPEN: its current stops and anything it fed
  loses power (cascading failure), the way real hardware behaves once a part
  burns out. Filter is in CircuitSimulationService.runSolve (no-op when nothing
  is burnt).
- The LED's burnout now also marks it in the shared burntComponents set, so a
  burnt LED gets the same charred + smoke-badge visual (and is opened in the
  solve) as a resistor / capacitor, on top of going dark.
2026-06-18 04:57:00 +02:00
David Montero cbcf6ba81e feat(sim): P4 runtime burnout for resistors + electrolytic capacitors
Generalizes the LED's burnout to passive parts via a centralized monitor that
watches the live electrical solve. When a part is stressed past its rating for
a sustained moment it's marked "destroyed": the canvas renders it charred with a
smoke badge and a fault is logged to the output console. Clears on Reset.

Follows the Fritzing-simulator precedent (smoke-on-component) wrapped in a
first-order thermal delay so a brief inrush spike doesn't destroy a part — only
sustained overload (or a catastrophic >=3x overload, instant) does.

- runtimeBurnout.ts: pure stress (resistor power, cap voltage / reverse) + a
  thermal-delay burn decision, plus a monitor subscribed to the electrical +
  simulator stores. Resistor burns past 2x rated (the verifier already warns at
  1x for intentional teaching over-power); a cap bursts over its voltage rating
  or on reverse polarity.
- useSimulatorStore: burntComponents set + mark/clear actions; cleared on
  Reset / restartParts.
- DynamicComponent + SimulatorCanvas.css: charred filter + smoke badge.

Tests: thermal-delay decision (instant / sustained / spike / cooldown) + stress
computation (resistor power, cap over-voltage, reverse, unwired -> null).
2026-06-18 04:35:56 +02:00
David Montero 6f3603d88b feat(sim): P2 wiring ERC slice 2 — VCC-to-GND short + shorted-out parts
- Power short (blocking error): a wire joining a VCC-type pin directly to a
  GND-type pin shorts the supply to ground. The current-based short-circuit
  rule only inspects battery/signal-generator/power-supply sources, so it
  misses a board-rail-to-GND short with no such source -> name it structurally.
- Shorted-out part (warning): a 2-terminal part with both terminals on the same
  node has no effect on the circuit.

Both graph-based, run before the solve. Zero false positives across the 69
gallery examples; gallery pre-flight tests still pass (no spurious blocking).
2026-06-18 03:32:17 +02:00
David Montero 8683c1ecf0 feat(sim): P2 wiring ERC — missing power + dangling 2-terminal parts
First slice of the connection ("malas conexiones") checks, graph-based and run
before the solve so they report even on circuits too incomplete to solve:

- Missing power: a rated peripheral (sensor/display) wired into the circuit but
  missing its VCC or GND connection -> warning. Boards are excluded (they live
  in input.boards and self-power).
- Dangling 2-terminal part: a resistor / LED / capacitor / diode / inductor
  connected on only one side (the other terminal floating) -> warning.

Both non-blocking. Verified zero false positives across all 69 gallery
examples. Tests: dangling resistor warns, fully-wired doesn't, module missing
GND warns.
2026-06-18 02:05:42 +02:00
David Montero 4e20d03f4c feat(sim): P1 over-voltage for boards + electrolytic capacitors
Extends the over-voltage rule to the two cases the previous slice deferred:

- Boards (ESP32 / Pico / Arduino / ...): a board's supply pins all collapse to
  the self-driven vcc_rail net, so an external source on them makes the .op
  singular rather than readable. Added a graph-based check (runs before the
  solve): if a power source is wired to a board supply pin and its nominal
  voltage exceeds that pin's rating, warn. Threaded boardKind into
  BoardForSpice so the verifier can look up the board rating.
- Electrolytic capacitors: new `voltage` rating property (select, default 25V,
  on capacitor-electrolytic + cap-elec-* presets, via component-overrides +
  regenerated metadata). The verifier reads the DC voltage across the +/- pins
  and warns on over-voltage (vent/burst) and on reverse polarity (a polarized
  cap wired backwards). Defaults to 25V when the property is unset.

Tests: 9V battery -> ESP32 VIN warns, 1.5V doesn't; 24V across a 16V cap warns,
5V across a 25V cap doesn't; reverse-biased cap warns. All real-ngspice.
2026-06-18 01:19:13 +02:00
David Montero 5a9bb3a70e feat(sim): P1 over-voltage warnings for parts with a rated input voltage
Adds a non-blocking circuit-verifier rule: a component whose supply pin sees
more than its datasheet absolute-maximum voltage warns ("X V on the VIN pin --
above its Y V maximum; not emulated accurately"). This is the "fed too much
voltage" mistake the operator asked for (a 3.3-5V module wired to a 9V battery).

- New componentRatings.ts: per-PIN abs-max table (SSD1306/ILI9341 displays,
  DHT/BMP280/HC-SR04/MPU6050 sensors, NeoPixel, servo). Per-pin thresholds so a
  3V3 pin (3.6V) and a VIN pin (6V) are judged separately. Unknown parts are
  simply not checked; an unwired or floating supply pin is skipped.
- circuitVerifier reads each rated part's supply-vs-ground voltage from the
  solved nets (via pinNetMap) and warns when it exceeds the rating.
- VCC/VDD/3V3/5V pins ride the shared vcc_rail net (NetlistBuilder convention);
  VIN is a normal net. Both handled.
- Tests: 9V on a module VIN warns; 5V on VIN does not; a 3.3V pin on a 5V rail
  warns.

Boards (esp32/pico/arduino) carry ratings in the table but aren't checked yet
-- BoardForSpice doesn't thread its boardKind; follow-up.
2026-06-17 22:40:03 +02:00
David Montero 3372151405 fix(sim): circuit verifier was silently blind to current faults in prod
The pre-flight circuit verifier reads branch currents via runNetlist ->
readAllCurrentVectors() (ngSpice_AllVecs enumeration). The production
Web-Worker ngspice WASM build does not surface voltage-source #branch
vectors through that enumeration for an .op plot, so branchCurrents came
back empty and every current rule (short-circuit, LED over-current) read
?? 0 -> no fault. The live solver avoided this by requesting each current
explicitly by name; the Node test build enumerates them, so the gap was
invisible to the suite. Net effect: a 9V battery wired straight to an LED
ran with no warning (reported on project 2840fd12).

- runNetlist: request every V_* source branch current explicitly by name
  and merge with the enumeration, so source/LED currents are always present
  regardless of the worker WASM's AllVecs behaviour.
- circuitVerifier: non-finite source/LED current -> blocking unstable-solve
  fault ("could not solve a stable current - likely a short or a part with
  no current limit, e.g. an LED with no series resistor").
- LED runtime (BasicParts): burn out on a non-finite current instead of
  falling through to the digital fallback and glowing; raise burnout
  threshold 20mA -> 100mA so high-power/RGB channels are not falsely
  destroyed; clear the burnt latch on Reset (resetBoard bumps hexEpoch).

Tests: real-data repro, mocked non-finite verifier test, runtime
non-finite / high-power / latch-recovery tests.
2026-06-17 20:37:32 +02:00
David Montero 3f23a7950c fix(sim): emulate AVR EEPROM (fixes #203)
AVRSimulator never instantiated avr8js's AVREEPROM peripheral, so any
EEPROM.read/write/update hung the sketch: the Arduino EEPROM library spins
on `while (EECR & (1<<EEPE))` waiting for the write-complete bit to clear,
and with no peripheral driving EECR that bit never cleared (issue #203 —
EEPROM.update(0,123) + EEPROM.read(0) hangs instead of printing 123).

Wire AVREEPROM to the CPU in both loadHex() and reset() via a new
attachEeprom() helper. The EEPROMMemoryBackend is created once per
simulator instance and reused across firmware reloads and resets, so a
value written in one run is still readable on the next boot — matching
real hardware, where re-flashing leaves EEPROM intact. Sizes per variant
(Uno 1024 B, Mega2560 4096 B, ATtiny85 512 B); ATtiny85 gets its own
register map (EECR 0x3C / EEDR 0x3D / EEARL 0x3E / EEARH 0x3F) since
avr8js's default eepromConfig targets the ATmega328P.

Adds eeprom.test.ts: drives the EEPROM register protocol against the
production AVRSimulator (loadHex + step), asserting a byte round-trips,
the EEPE poll terminates (no hang), and contents survive a reset.
2026-06-16 04:18:51 +02:00
David Montero 608c2538c9 fix(sim): correct NTC temperature sensor divider + reset to default on restart
The NTC breakout's SPICE topology was inverted relative to the example
sketch's decode formula (rNtc = R_PULL * v / (5 - v)), which assumes a 10k
pull-up from VCC to OUT and the NTC from OUT to GND. The mapper had the NTC
on top (VCC->OUT) and the pull-down on the bottom, so the recovered
temperature ran backwards: dragging the slider to 100C made the sketch
print -25C. Swap the two resistors so V_OUT = 5 * Rntc / (Rntc + Rpull),
matching the sketch and the hand-built reference netlist in
spice-avr-mixed.test.ts (T=0 -> ADC 789, T=25 -> 511, T=50 -> 270).

Also replace the SensorParts linear approximation (2.5 - (t-25)*0.02) with
the same beta-model divider so the non-SPICE ADC injection decodes back to
the slider value, and drop the dead onInput path that treated the element's
value as a raw ADC count.

Reset now restores interactive sensors (temperature/lux/gas sliders) to
their configured defaults: resetBoard re-dispatches each sensor's default
into the running sim and bumps sensorResetNonce so the open
SensorControlPanel remounts and the slider snaps back. Previously a restart
left the NTC frozen at the last dragged temperature.

Updated the examples netlist snapshot for the swapped NTC cards.
2026-06-15 23:21:36 +02:00
David Montero d1088aefc2 fix(rp2040): Pico W always boots the W firmware (no `import network` crash)
Two robustness fixes for the paid-WiFi open-core split:

1. A pi-pico-w board now boots the RPI_PICO_W firmware variant (which has the
   `network` module) based on its BOARD KIND, not on whether the WiFi
   peripheral happens to be attached. Previously the variant was
   `pioPeripheral ? 'pico-w' : 'pico'`, so any moment the peripheral was
   absent (see #2) booted the plain Pico firmware and a Pico W sketch crashed
   with "ImportError: no module named 'network'". Store boardKind in
   attachPioPeripheral and pick the variant from it. (OSS: 'pico-w' isn't
   registered, so firmwareConfig falls back to 'pico' — a self-hosted Pico W
   has no WiFi engine anyway.)

2. Re-attach the PIO peripheral in loadMicroPythonProgram before loading
   firmware. An example deep-link adds the board during render, which races the
   pro overlay's async mountPro that installs the CYW43 factory — so the
   board-add attach returned null and a PAID user's Pico W booted plain
   firmware too. attachPioPeripheral is idempotent; by run time the factory is
   installed, so a paid user gets the W peripheral and real WiFi.
2026-06-15 18:55:13 +02:00
David Montero fb813ffde0 feat(opencore): extract Pico W WiFi to a pluggable PIO peripheral seam
Move the CYW43439 (Pico W) WiFi emulation out of the open-source tree so it
can ship as a paid feature in a private overlay. OSS keeps a plain Pico W
(no WiFi); the overlay registers the cyw43 protocol + backend network stack
at runtime via generic seams.

Frontend:
- Add simulation/PioPeripheral.ts: a generic "PIO bus peripheral" seam
  (feedWord / inDiscardableWriteData / resetFraming / hostWakeLevel /
  onHostWake / onSimulationStart). No factory is installed in OSS, so
  createPioPeripheral() returns null and a Pico W simulates as a plain Pico.
- RP2040Simulator: keep the fragile PIO-FIFO plumbing (it must re-run after
  loadMicroPython swaps the chip) but drive it through PioPeripheral instead
  of an inlined cyw43 import (attachCyw43 -> attachPioPeripheral, etc.).
- useSimulatorStore: generic attach/detach + setBoardWifiStatus; drop the
  cyw43 bridge map.
- MicroPythonLoader: add registerFirmwareVariant() so an overlay can add the
  RPI_PICO_W build; remove the OSS pico-w config + bundled .uf2.
- Delete simulation/cyw43/ (moved to the overlay).

Backend:
- core/hooks.py: add generic register_ws_sim_handler / dispatch_ws_sim_message
  and register_gateway_proxy / dispatch_gateway_proxy seams.
- simulation.py: route start_picow / stop_picow / picow_packet_out through the
  ws_sim_handler hook (the overlay handles + gates them).
- iot_gateway.py: resolve the Pico W gateway through the gateway_proxy hook.
- Delete services/picow_net/ + picow_net_bridge.py (moved to the overlay).

Tests: move the cyw43/picow suites to the overlay; update RP2040Simulator
mock stubs to attachPioPeripheral.
2026-06-15 08:33:28 +02:00
David Montero fd0edd8c6d feat(cyw43): bridge Pico W DNS/TCP/UDP to the backend for real internet
Wi-Fi sketches on the emulated Pico W associate via the chip's built-in
virtual net (DHCP/ARP answered locally), but outbound traffic had no
route, so DNS/MQTT/HTTP failed with OSError -2.

Wire the emulator's outbound DATA path to the backend picow_net bridge:

- Cyw43Emulator forwards every outbound Ethernet frame EXCEPT DHCP/ARP
  (still answered locally) to firePacketOut -> the WS bridge, which NATs
  DNS/TCP/UDP to the real internet and injects replies back.
- The virtual net stays ON unconditionally and shares the backend's
  subnet, gateway and gateway MAC (10.13.37.0/24, gw 10.13.37.1). Nothing
  is mutually exclusive, so an absent or flaky bridge can never break the
  Wi-Fi association -- it just falls back to no-internet, as before.
- useSimulatorStore opens the bridge (cyw43.connect()) for Wi-Fi sketches.

Validated end to end against a running backend: WiFi connect + DHCP, DNS
resolves example.com, TCP connect + HTTP GET returns 200 OK. Gated e2e in
picow-bridge-e2e.investigate.test.ts (CYW43_BRIDGE_E2E=1).
2026-06-13 05:27:49 +02:00
David Montero 2639f80a22 fix(cyw43): word-align SDPCM frames so the F2 byte-swap preserves the tail
The CYW43439 F2 (radio frame) channel is word-oriented: the real chip
always drives frames padded up to a 4-byte boundary and the host reads
that word-aligned length, byte-swapping every 32-bit word on the way in.

encodeSdpcm built buffers of exactly 12 + payload bytes, so any frame
whose total length was not a multiple of 4 ended with a partial word.
The emulator's F2 read path (encodeFrameWords) byte-swaps whole words and
copies the leftover tail raw; the host's symmetric per-word swap then
mangles that final word, corrupting the last 1-3 bytes of the frame.

This was invisible for DHCP/ARP (UDP checksum 0 -> lwIP skips the check,
and the damage lands in trailing option padding) but silently dropped
every DNS answer and TCP segment (real checksum -> lwIP discards the
frame), so getaddrinfo()/connect() retried forever.

Pad the backing buffer to a 4-byte boundary while keeping the size header
at the true length, so the driver still parses exactly the real frame and
ignores the pad. Matches real hardware framing.
2026-06-13 05:27:38 +02:00
David Montero 13e0841681 fix(micropython): write LittleFS files with UTF-8 byte length
loadUserFiles passed content.length (UTF-16 code units) as the byte count
to lfs_write_file, but cwrap marshals the content to the heap as UTF-8. A
file with multi-byte chars (e.g. an em-dash in a comment) is then written
short by the multi-byte overhead, truncating the tail. The async-LED Wi-Fi
example (2 em-dashes) lost its last 4 bytes, turning the final
'asyncio.run(main())' into 'asyncio.run(main' -> SyntaxError at EOF. Use
the UTF-8 byte length so the whole file lands; ASCII files are unaffected.
2026-06-13 04:08:02 +02:00
David Montero 1061685e84 feat(cyw43): WiFi-now — virtual net handles association, bridge deferred
For the first deploy, keep the chip emulator's built-in virtual DHCP/ARP
net ON and leave the backend internet bridge dormant (not validated end
to end yet). A Pico W board now associates and gets a link-local IP
locally (isconnected True); outbound internet (MQTT/HTTP) has no route
until the picow_net bridge is wired. Revert is a one-liner in the store
(cyw43.wifiEnabled = hasWifi; cyw43.connect()) + setVirtualNet(null).
2026-06-13 03:33:15 +02:00
David Montero e68746ed57 test(cyw43): validate WiFi on the production RP2040Simulator path
Headless test that drives the REAL RP2040Simulator (attachCyw43 +
installCyw43PioHooks + lockstep PIO stepping in runFrameForTime), boots
the Pico W firmware, injects a WiFi-connect snippet over the raw REPL, and
asserts isconnected(). Result:

  PYBOOT
  ACTIVE False            (this fw's active() getter reports link status)
  CONN_OK 192.168.4.2     (DHCP-leased IP, isconnected() == True)
  MAINPY_DONE

Reaches link-up in ~31s wall — the production lockstep PIO stepping is
faster than the harness's setTimeout-cranked PIO.

Also fixes a real production bug: the RP2040 logger was
ConsoleLogger(LogLevel.Error) which THROWS on rp2040js unaligned-read
warnings — lwIP reads the IPv4 header at ethernet offset 14 on every
received packet, so WiFi would have crashed on the first DHCP reply.
Now constructed with throwOnError=false.

Gated behind CYW43_PROD_HARNESS=1 (boots real firmware, ~30s).
2026-06-13 00:43:48 +02:00
David Montero c10e63764c feat(cyw43): wire WiFi bring-up into production RP2040Simulator
Port the gSPI wiring proven in the boot harness into the real simulator
so WiFi works in the browser, not just the test:

- Non-dropping TX FIFO (head-pointer queue) in installCyw43PioHooks, so
  the 260-word F2 IOCTL writes aren't truncated, with the firmware/
  backplane bulk-write fast-path (inDiscardableWriteData) keeping the
  ~224 KB download cheap. Fully restorable on detach.
- Drive WL_HOST_WAKE (GPIO24) from emu.onHostWake, and re-sync the pin
  level after installCyw43PioHooks (loadMicroPython resets GPIO while the
  chip's queue persists).
- With a backend bridge attached, disable the built-in DHCP/ARP net so
  the bridge owns the network.

Production steps the PIO in lockstep with the CPU (pioStepAccum), so no
PIO-rate crank is needed (that was harness-only). The emulator-side fixes
(host-wake, F2 byte order, join events, BDC header, virtual DHCP/ARP) are
already shared. Not yet exercised in a browser e2e — the headless harness
is the verification today.
2026-06-12 23:48:04 +02:00
David Montero 4d631b3a98 feat(cyw43): virtual DHCP/ARP net — WiFi reaches LINK_UP (isconnected)
The Pico W now connects end to end with NO backend: status reaches
CYW43_LINK_UP (3) and network.WLAN().isconnected() returns True.

After association the STA's lwIP broadcasts DHCP DISCOVER and ARPs the
gateway over the cyw43 DATA channel. A self-contained virtual network
(new virtualNet.ts) answers them:
  - DHCP DISCOVER -> OFFER, REQUEST -> ACK (Ethernet+IPv4+UDP+BOOTP, valid
    IPv4 header checksum, UDP checksum 0), leasing 192.168.4.2 with gateway
    192.168.4.1.
  - ARP who-has the gateway -> is-at the AP MAC.
On by default (Cyw43EmulatorOptions.virtualNet); pass null when an
external packet bridge owns the network.

Also fixes injectPacket to prepend the 4-byte BDC header that chip->host
DATA frames need (same as the event-frame fix), so injected packets parse.

Boot harness now reports: STEP_CONNECT_CALLED status=3 / POLL 0 status 3
conn True / HARNESS_DONE.

Known: receiving packets triggers ~30 rp2040js unaligned-read warnings
(lwIP reads the IPv4 header at ethernet offset 14); non-fatal here, but
the production RP2040Simulator must use a non-throwing logger.
2026-06-12 23:43:14 +02:00
David Montero 74365d4ae9 feat(cyw43): WiFi associates — join events drive link up (status NOIP)
The Pico W now joins the virtual AP end to end: active(True) returns,
connect() runs the full WPA/SET_SSID sequence, and the link comes up.

Root causes fixed (each blocked the join):
- mcast_list GET returned empty, so the driver read its own request bytes
  as the address count (ASCII 'mcas' ~1.9e9) and looped ~2e9 times,
  hanging wifi_on. GETs now return a zero-filled buffer of the asked-for
  length (count 0 / status 0), never empty.
- Async event frames lacked the 4-byte BDC header the driver expects at
  SDPCM header_length, so it read the broadcast-MAC byte as data_offset
  and the payload pointed out of bounds (WRONG_PAYLOAD_TYPE). Prepend BDC.
- WLC_E_LINK signalled link-up via the reason field, but the driver
  checks ev->flags & 1. encodeEventFrame now takes a flags arg; LINK uses
  flags=1.
- Join needs WIFI_JOIN_STATE_KEYED, which only a WLC_E_PSK_SUP(status=6)
  event sets (connect(ssid, "") still configures the WPA supplicant).
  Emit it on a successful join.
- Join events were raised synchronously during the SET_SSID ioctl, so the
  driver processed them before cyw43_wifi_join set wifi_join_state=ACTIVE,
  wiping the bits. Defer events until just after the ioctl reply.
- Event-mask stored 4 bytes misaligned vs queueEvent's read offset.
- SET/GET kind bit is 0x2 (SDPCM_SET), not 0x1.

Remaining for status UP / isconnected: DHCP (needs the packet-transport
bridge or an emulator-side DHCP responder).
2026-06-12 23:33:24 +02:00
David Montero 6bdb590b0a perf(cyw43): fast-path firmware download in boot harness
Add PioBusSniffer.inDiscardableWriteData(): true while framing a large
non-F2 write (firmware/backplane bulk write the chip discards). The boot
harness drops those data words (keeping ~4 so the PIO raises TXSTALL,
which is all the driver's write path waits for) instead of bit-banging
the full ~224 KB through the PIO. F2/SDPCM IOCTL writes and every
count/command word are retained in full, so the bring-up still completes
the 23-IOCTL wifi_on sequence (F1 framing 3613 -> 97, F2 unchanged).

Also adds IPSR + PC-histogram sampling: confirmed the post-mcast_list
stall is thread-mode (no GPIO IRQ storm) inside MicroPython's host-side
cyw43_cb_tcpip_init (lwIP), above the chip emulation.
2026-06-12 23:01:00 +02:00
David Montero c4cbb17591 feat(cyw43): host-wake IRQ + F2 frame byte-order + SET/GET fix
Unblocks the full wifi_on IOCTL sequence in the boot harness (clm_load
through the 23-IOCTL bring-up, no crash):

- Drive WL_HOST_WAKE (GPIO24, active-high): the driver gates poll_device
  on this pin until its first packet (had_successful_packet), so without
  it the first IOCTL response is never read. Emulator now exposes
  onHostWake(level) and toggles it with the inbound-frame queue.
- Encode F2/SDPCM frame reads per 32-bit word (encodeFrameWords), same
  as register reads: the DMA-in sets channel bswap=true, so an un-encoded
  frame landed byte-reversed -> header_length read back as garbage and
  the driver dereferenced ioctl_header at an unaligned address (crash).
  Guarded to boot mode pass-through (no F2 traffic there; keeps unit tests).
- Fix SET/GET detection: SDPCM_SET is bit 1 (0x2), not 0x1; echo the
  kind bit in IOCTL responses.
- Add IOCTL/SDPCM debug counters + sequence log for the harness.

Harness (investigation, CYW43_HARNESS=1 only): non-dropping TX FIFO so
large F2 writes are not truncated, crank PIO steps/tick so the firmware
drains in wall-clock, GPIO24 host-wake wiring, CPU-fault + PC-histogram
+ PIO-state instrumentation.

Remaining: stall after mcast_list (#22) inside cyw43_cb_tcpip_init.
2026-06-12 22:42:49 +02:00
David Montero b172cadbc5 wip(picow): deterministic gSPI framing via per-transfer restart hook
cyw43_spi_transfer calls pio_sm_restart before each transfer's count words, so
hooking restart() to reset the sniffer makes framing deterministic across the
firmware-stream fast-path (no phantom-transfer carryover). Verified: restarts
fire 3625x (once per transfer), F1 phantom count drops, and the CLM IOCTL write
now frames correctly (cmd decodes to F2, 'clmload' payload). Wired into
RP2040Simulator + the harness.

Remaining (next session): the CLM/IOCTL write doesn't complete its payload and
wifi_on still fails (active()=False) — bus_init stalls at/around clm_load with
only 2 STATUS reads and goes idle. Next: trace the CLM write's DMA/PIO drain and
the SDPCM IOCTL response path. See findings.md F-13.
2026-06-12 21:21:28 +02:00
David Montero f183f8add2 wip(picow): Phase 3 instrumentation — confirm credit frame is queued+visible
debugInboundCount + STATUS-read tracking show initInbound=1, statusReads=2,
statusReadsWithPkt=2, finalInbound=1: the credit frame IS visible at both STATUS
reads (not a credit tight-loop). The driver reaches clm_load's F2-ready check
(passes) but the F2 IOCTL write never appears on the bus and bus_init returns.
Next: instrument the F2-write path. See findings.md F-13.
2026-06-12 20:18:50 +02:00
David Montero 9197fcaaf6 wip(picow): CYW43 emulation — chip bring-up works, active(True) returns
Brings the Pico W CYW43439 gSPI emulation from "fails at the first register
read" to "the chip boots fully and MicroPython's network.WLAN().active(True)
returns" — validated end-to-end against the real RPI_PICO_W firmware via a
headless boot harness.

What now works (Phases 1-2):
- PioBusSniffer rewritten to the real cyw43_bus_pio_spi framing
  [out_bits][in_bits][cmd][write_data], skipping the two PIO loop-counter
  words. Self-healing: validates count1 (= tx_length*8-1, 4-aligned, <=2052)
  and skips non-conforming words — re-syncs after the extra word rp2040js
  pushes on large writes AND fast-paths the ~224 KB firmware stream.
- Dual word-order regime: boot 16-bit-LE (swap16x2 / swap16) flips to 32-bit
  big-endian (bswap32) at the SPI_BUS_CONTROL write. Calibrated empirically
  against the firmware. Sniffer reads the mode via setModeProvider().
- Cyw43Emulator: encodeReadWord (per-regime), readBytes-sized backplane reads
  with the value in the last word (response-delay pad), ALP+HT clocks and F2
  always ready, AI core registers (IOCTRL/RESETCTRL), interrupt register
  reports no errors, f1Mem echo store, SDPCM bus-credit granting + initial
  frame.
- RP2040Simulator: serves chip responses on rxFIFO.pull (on-demand) instead of
  racing the async DMA/PIO; passes readBytes through.

Not done yet (Phase 3+): connect() runs but stalls in the power-management /
save-restore phase before any F2/IOCTL traffic; packet transport (Tier 2) and
firmware-clocking perf are open. See project/picow-wifi-emulation/ for the full
research, phases, and findings.

The boot harness (picow-cyw43-boot-harness.investigate.test.ts) is gated behind
CYW43_HARNESS=1 so it stays out of the normal test run.
2026-06-12 20:04:06 +02:00
David Montero 4d80a9d1c3 fix(sim): Pico W MicroPython loads the RPI_PICO_W firmware (network + CYW43)
The RP2040 MicroPython loader always fetched the plain RPI_PICO build, which
ships no `network` module and no CYW43 WiFi driver. Every Pico W WiFi/MQTT
example therefore failed at `import network` ("no module named 'network'"),
which surfaced as a compile/run error in the editor.

- getFirmware()/loadUserFiles() are now variant-aware. pi-pico-w boards load
  RPI_PICO_W-20230426-v1.20.0 (network/socket/ssl + the CYW43439 driver) and
  write the LittleFS at the W board's flash offset (0x12c000, 212 blocks)
  instead of the plain Pico's 0xa0000/352. The W firmware spans flash to
  ~0xab000 and would otherwise be clobbered by the filesystem. Each variant
  gets its own IndexedDB cache key.
- The variant is selected by the presence of the already-wired CYW43 emulator
  (attachCyw43 runs for pi-pico-w boards only).
- loadMicroPython swaps in a fresh RP2040 each run, so the CYW43 PIO-FIFO hooks
  are re-installed on the new instance; otherwise the driver's gSPI traffic
  never reaches the emulator and WiFi never comes up.
- Bundle micropython-rp2040w.uf2 as the offline fallback.
- Point the ThingsBoard example at the simulator's Velxio-GUEST network.
2026-06-12 16:46:41 +02:00
David Montero Crespo c01f9d8d75
Merge pull request #220 from ciegovolador/fix/buzzer-sample-accurate-audio
fix(sim): sample-accurate, glitch-free buzzer audio (+ metronome quality tests)
2026-06-12 00:40:46 -03:00
ciegovolador 9b86c816c1 fix(sim): keep PwmCallback 2-arg compatible via arity dispatch
Revert the earlier approach of widening the existing PWM-callback assertions to
accept the new timeMs arg — that masked a contract change rather than fixing it.
Instead, updatePwm now hands the optional timeMs only to listeners that declare
a 3rd parameter (cb.length >= 3) — i.e. the buzzer, which needs the precise
onset time. Plain (pin, dutyCycle) listeners, and the existing
toHaveBeenCalledWith(pin, dutyCycle) tests, see an unchanged 2-arg call, so the
original PwmCallback contract is preserved.

Add a PinManager test locking the dispatch: a 2-param listener stays 2-arg; a
3-param listener receives timeMs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:29:44 -03:00
ciegovolador 6a1f79e331 fix(sim): monophonic buzzer guard — replace note on pitch change (no stacking)
A melody / continuous tone (consecutive tone() with no noTone() between) is
back-to-back nonzero-OCR PWM writes with no note-off, so startTone() overwrote
activeOsc without stopping the previous node — oscillators stacked and were
never stopped (reported: created 6, started 6, never stopped 6).

Add a monophonic guard at the top of startTone(): release the live note
(gain ramp + stop) before starting the new one, so a pitch change REPLACES
rather than STACKS. Extract a shared releaseActive(off) helper (also used by
stopTone). Add two melody tests: one asserts starts === stops (no orphans),
monotonic onsets and per-note pitch; one asserts a melody ending without a
trailing noTone() leaves only the final note ringing (stops === starts - 1).

The metronome path is unaffected (each click is an onset→note-off pair, so the
guard never fires there); the three existing metronome tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:35:33 -03:00
David Montero 22de488de2 feat(microsd): SD-over-SPI card storage for AVR, RP2040 and ESP32
Add a working microSD card part backed by a FAT16 image, following the
Wokwi storage model: the project's own workspace files are auto-copied
onto the card (free), and an optional "SD Card" panel uploads extra
files (gated as a paid feature by the velxio.dev overlay; OSS default
allows it).

Frontend (in-browser AVR / RP2040):
- ProtocolParts.ts: rewrite the microsd-card part from a handshake stub
  into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte
  addressing, single/multi-block read+write, CSD/CID, full CMD set).
- utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN).
- utils/sdCardFiles.ts: assemble the card image from workspace files plus
  uploaded files; base64 helpers.
- components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI.
- DynamicComponent + useSimulatorStore: build and inject the image on run.
- lib/proSdCardGate.ts: overlay-installable gate for the upload action.
- data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples.

Backend (ESP32 via QEMU):
- services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of
  the browser part) with a sparse backing store, idle-state R1 tracking and
  real CRC16 on data blocks when the host enables CRC (CMD59) -- both
  required by ESP-IDF's sdspi driver.
- esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously)
  and feed write-only bulk transfers.
- esp32_lib_manager.py + routes/simulation.py: forward the FAT image
  (sd_card.image_b64) from the start config into the worker.

Tested:
- frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware
  (real Arduino SD.h on avr8js) -- 86 passing.
- backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and
  CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa
  (mount, directory listing, read and write-readback).
2026-06-11 03:59:53 +02:00
David Montero 50823e9d49 fix(rp2040): keep delay()-based sketches real-time on slower hosts
The RP2040 core (125 MHz Cortex-M0) is ~8x heavier to emulate than the
AVR. The run loop used a FIXED per-frame cycle budget, and arduino-pico
delay() busy-waits the timer (no WFI), so a host that cannot sustain
125M instr/s rendered a 1s blink every 4-5s (sim ran in slow motion).

- Derive the frame budget from the MEASURED wall-clock delta (mirrors
  AVRSimulator) instead of assuming a perfect 60fps.
- Add IdleSpinDetector: recognise a side-effect-free busy-wait spin and
  advance the clock over it (capped at the next timer alarm / scheduled
  pin change) instead of executing every idle cycle - the same idea the
  WFI fast-path already uses for sleep(). Conservative: a bit-bang loop,
  an input-poll that just saw its pin move, or a loop that calls out are
  never elided; a false positive only ever advances time up to the
  wall-clock budget, never past the next event.
- Bound WFI sleeps to the wall-clock budget so they advance in real
  time across frames rather than leaping ahead.

Cuts emulation work for a delay-bound sketch ~1900x (125M -> ~65k
instructions per simulated second) so it tracks wall-time even on hosts
that cannot emulate 125 MHz in real time. Public API unchanged;
step()/stepCycles() untouched.

Adds rp2040-realtime.test.ts: IdleSpinDetector unit tests plus
end-to-end scheduler tests driving a real rp2040js core through a
hand-assembled busy-wait loop (no firmware fixture needed).
2026-06-10 18:21:29 +02:00
ciegovolador b06f500ad6 fix(sim): mature the buzzer — per-note oscillators, sim-time scheduling, ramps + metronome tests
Builds on the previous commit; reworks the buzzer audio for glitch-free,
cross-browser playback and adds a metronome quality suite.

- Per-note oscillators with short attack/release ramps, instead of one
  long-lived oscillator gated by gain: a fresh fixed frequency per note and no
  gain/frequency automation on a persistent node — Firefox in particular clicks
  and glitches the pitch otherwise.
- Schedule onsets by their SIMULATED inter-onset spacing (exact, even) with a
  light latency hold, instead of a wall-clock average. Turning a control (BPM,
  K…) re-locks immediately and the rhythm stays even — no bursts, no overlaps,
  no audio drifting away from the display.
- Place each note-off relative to its own onset, preserving the exact click
  length from the simulation (the onset scheduler now tracks onsets only).
- Poll PWM every 256 cycles (was 64): finer than any audible pulse, lighter on
  the frame loop.
- New src/__tests__/buzzer-metronome.test.ts: drives the buzzer as a metronome
  against a controllable audio clock and asserts even spacing, one oscillator
  per click with no overlap, correct pitch per metric level, burst absorption,
  and a clean re-lock on tempo change.

All simulation-parts + metronome tests pass (57).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:22:57 -03:00
ciegovolador 06526c7922 fix(sim): sample-accurate buzzer audio — precise PWM detection + display-aligned scheduling
A PWM-driven buzzer (analogWrite / Timer tones) was chaotic and unusable as a
metronome. Causes, all on the PWM path:

1. PWM was polled once per animation frame AFTER the cycle loop, so short clicks
   that started and ended within one frame were merged or lost, and onsets were
   quantised to the frame.
2. The buzzer started the oscillator with `oscillator.start()` (no scheduled
   time) — frame-delivery jitter and per-onset oscillator churn.
3. The digital HIGH/LOW path also fired on the ~490Hz PWM carrier edges,
   injecting spurious onsets (OCR read as 0 → 20kHz squeaks).

Fix:
- AVRSimulator: poll PWM sub-frame (every 256 cycles) so no pulse is merged or
  lost; pass the precise simulated time through updatePwm.
- PinManager: PwmCallback / updatePwm carry an optional timeMs (backward compat).
- Buzzer: one continuous oscillator gated by the gain node, each on/off scheduled
  on the AudioContext clock. The schedule predicts the next onset at a smoothed
  interval (de-jittering the simulator's bursty per-frame delivery) and holds a
  small bounded latency so the click stays aligned with the on-screen playhead
  (driven from the same clock) instead of drifting behind it. A `pwmActive` flag
  mutes the digital path once hardware PWM drives the pin.

Result: onset jitter for a firmware metronome drops from chaotic (σ ≈ 250ms,
dropped/extra beats, unbounded audio latency) to σ ≈ 15ms at ~30ms latency —
steady and aligned with the display. All 54 simulation-parts tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:46:21 -03:00
David Montero Crespo 6b281c9d6d
Merge pull request #217 from davidmonterocrespo24/feat/chipbus-phase0
Feat/chipbus phase0
2026-06-06 03:18:45 -03:00
David Montero Crespo b93b1b42c5 feat(digital-gate-engine): 4-bit ripple counter gallery example + sequential controller fix
Adds the first board-less SEQUENTIAL gallery example (digital-ripple-counter-4bit):
four T flip-flops chained into a ripple counter, LEDs showing the binary count,
clocked by a slide switch. Impossible on the SPICE engine (no edge detection at
DC) - it runs on the digital gate engine.

Controller fix (found by testing the counter live): the controller rebuilt the
network on every change, which reset flip-flop state so a counter never counted.
Now the network is built once and KEPT ALIVE; a switch toggle applies
incrementally via setSwitch (preserving sequential state), and a rebuild happens
only on a structural change (components/wires). Correct for combinational AND
sequential circuits.

examples-digital.test.ts: flip-flop examples are digital-engine-only, so they are
exempt from the SPICE-mapping / has-a-gate / netlist checks (the "logic" check
now accepts a gate OR a flip-flop). digitalgate-engine-examples: a correctness
test clocks the real counter example and asserts it counts 1..15,0 in binary.

Verified live (?digitalgates default ON): the counter counts 0..6 on the canvas;
and the complex examples all work - comparator-4bit (A=B correct), decoder-3to8
(perfect one-hot x8), alu-slice-1bit (32 combos deterministic), multiplier-2x2
(3*3=9, 7 distinct products), adder-subtractor-4bit (5+3=8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:58:42 -03:00
David Montero Crespo 8aa6e93460 feat(digital-gate-engine): Phase 5 - sequential logic (D/T/JK flip-flops)
Flip-flops are edge-triggered and hold state, which the combinational settle
kernel cannot model alone. buildDigitalNetwork now gives each flip-flop explicit
state + rising-CLK-edge detection (reusing the LogicGateParts sample semantics):
sample the data nets on the edge, drive Q + Qbar. Because a flip-flop only
updates on the clock edge, a Q->D / Q->CLK feedback (counter / shift register)
does not oscillate the settle loop. isAllDigital now accepts a gate OR a
flip-flop, so pure sequential circuits qualify.

Test digitalgate-sequential (4): D (capture + hold), T (toggle), JK
(hold/set/reset/toggle), and a 2-bit ripple counter (FF0.Qbar clocks FF1)
counting 1,2,3,0,1 - impossible on the SPICE path (no edge detection at DC, no
SPICE mapper). The controller already routes all-digital circuits through
buildDigitalNetwork, so a board-less counter/shift-register example would run
live; authoring those gallery examples is the only follow-up. Full digitalgate
+ examples-digital + circuit-simulation-service suites green (127 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:16:43 -03:00
David Montero Crespo 77c85eefac feat(digital-gate-engine): Phase 3 core - digital/analog boundary handoff
buildMixedNetwork evaluates the gate (digital) side of a MIXED circuit on the
settle kernel and exposes the boundary with the analog (ngspice) domain. Unlike
buildDigitalNetwork it does not bail on non-primitive components - those are the
analog side; their pins mark the nets they touch as boundary. Exposes
boundaryNets, readBoundary(net) (digital->analog: the gate-driven level to seed
an ngspice voltage source) and setBoundaryInput(net, level) (analog->digital:
ngspice's solved+thresholded level, which re-evaluates downstream gates).

Test digitalgate-mixed-boundary (4): the boundary nets are exactly the
digital/analog bridges; both directions track; a digital->analog->digital
coupler loop converges. No ngspice needed - the analog side is supplied by the
test. Wiring the handoff to the live ngspice netlist (0/Vcc sources + threshold
+ settle<->solve iteration) is the remaining step; it needs the running solver
(the node loader is broken by a pre-existing path bug) and a mixed example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:01:05 -03:00
David Montero Crespo 4f4ee0bf60 feat(digital-gate-engine): sweep all 38 examples + default the flag on
Phase 4 (brought forward before the mixed-mode boundary). digitalgate-sweep
proves the engine handles 38/38 gallery digital examples: every one builds,
resolves every LED, and never oscillates. Tightened isAllDigital to also require
at least one logic gate, so a degenerate analog {source, resistor, LED} circuit
stays on ngspice rather than being claimed by the digital path. Flipped
digitalGatesEnabled() default to ON (override with ?digitalgates=off).

Full frontend suite 2120 pass / 5 fail — the 5 are the same pre-existing
unrelated failures (ngspice node-path, attiny85 arduino-cli, component-to-spice
catalog); the default flip adds no new breakage and examples-digital +
circuit-simulation-service stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 20:29:32 -03:00
David Montero Crespo b08df89c9b feat(digital-gate-engine): evaluate logic gates on the event-driven kernel
Board-less digital circuits (logic gates + switches + LEDs) run today as ngspice
analog B-sources, which is fragile for deep logic: a 4-bit ripple adder re-solves
but never lights its result LEDs live. This adds an event-driven digital motor
that reuses the multichip-bus settle kernel, so the same engine that boots a Z80
over a chip bus evaluates a gate network exactly and instantly.

Phases 0-2 (project/digital-gate-engine/), all behind ?digitalgates=on (default
OFF — flag off is byte-for-byte the old behaviour):

- digitalGateEngine.ts: buildDigitalNetwork(components, wires) does union-find
  over the wires (merging pass-through resistors), identifies the rail/gnd from
  the signal-generator, registers drivers (rail STRONG-1, gnd 0, pull resistors
  PULL, slide-switch as a pass-gate) and event-driven gates (reusing the
  LogicGateParts boolean semantics), settles on busKernel, and exposes
  setSwitch / readLed / netOf. Tolerant of both the raw example `type` and the
  store `metadataId`. Returns {ok:false} for any non-primitive, so mixed/analog
  circuits stay entirely on ngspice.

- digitalGateController.ts + a SimulatorCanvas useEffect: when the flag is on and
  the circuit is all-digital, rebuild from the store on switch-toggle / load
  (rAF-coalesced) and paint the wokwi-led DOM. CircuitSimulationService.tick()
  skips the SPICE solve for all-digital circuits when the flag is on, so the two
  motors never fight over the LEDs.

Tests: digitalgate-kernel (22 — single gates -> half/full adder -> 4-bit
adder/subtractor -> exhaustive ADD 256 -> mux/decoder/comparator/parity/
multiplier) and digitalgate-engine-examples (6 — the real gallery data for
and/or/xor/not + the full adder/subtractor). Verified live: ?digitalgates=on
lights the adder's result LEDs that the SPICE path leaves dark. Full suite
2117 pass / 5 pre-existing unrelated fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 20:21:33 -03:00
David Montero Crespo 94627b99d2 feat(chipbus): Galaksija keyboard - type BASIC over the bus
Adds a memory-mapped keyboard so you can type into the Galaksija. Based on
the libretro Galaksija core's scheme (not guessed): reading 0x2000+offset
returns 0xFE when the key at that matrix offset is held, 0xFF otherwise;
the keyMap gives the offset per key ('A'=1 ... Enter=48, Space=31, etc.).

- galaksija-keyboard.c: drives reads of 0x2000-0x203F from a keys[] table and
  exports set_key(offset, down) for the host to push key events. Never drives
  outside the keyboard range.
- galaksija-ram.c: ram-64k variant that yields reads of 0x2000-0x203F to the
  keyboard (writes still go to RAM), so the two never fight for the bus.
- ChipRuntime: ChipInstance.hasKeyboard + setKey() expose the chip's set_key.
- CustomChipPart: bridges browser keydown/keyup (by KeyboardEvent.code, via
  GALAKSIJA_KEY_OFFSET) into the chip, ignoring keystrokes while the code
  editor or an input is focused so typing code is never hijacked.
- The gallery example gains the keyboard chip (now 7 chips, 99 wires) and uses
  galaksija-ram.

Test chipbus-galaksija-keyboard: pressing 'A' (offset 1) makes the BASIC
monitor echo "A" after its ">" prompt and advances the cursor. 41 chipbus
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:37:42 -03:00
David Montero Crespo a393e3e91d feat(chipbus): Galaksija home computer gallery example + browser perf throttle
Ships the full Galaksija (1983 Z80 home computer) as a runnable Retro
gallery example, plus the pieces needed to run a multi-chip bus live in the
browser.

Gallery example (examples-retro-intel.ts, id 'galaksija-z80-computer'):
Z80 + galaksija-rom (public-domain ROM A+B) + ram-64k + inverter (A13
decode) + galaksija-display + a power-on reset chip, wired chip-to-chip
over the bus (76 wires), no board. Click Resume and it boots the real ROM
to the "READY" prompt on the green display. Chip wasm is embedded
(wasmBase64) so it runs without a backend compile.

- ChipRuntime.tickTimers gains a wall-clock budget (CustomChipPart passes
  6 ms): a faithful-but-slow event-driven bus can't run a real-time CPU in
  one animation frame, so without a cap a Z80 fetching over the settle
  kernel froze the tab. With the budget the sim advances slower than real
  time (boots over a few seconds) and the UI stays responsive; fast
  single-chip examples finish under budget and are unaffected.
- galaksija-display: blits its framebuffer on a ~30 fps timer instead of on
  every character write, so a clear-screen burst doesn't flood the canvas.
- reset-gen: power-on reset (pulses RESET high, ties WAIT/BUSREQ/INT/NMI
  high) so the machine boots on Resume without a manual reset.
- chipbus flag now defaults ON (override with ?chipbus=off): chip-to-chip
  buses are a core capability; single-chip and board nets never take this
  path, so the only thing enabled is multi-chip buses, previously broken.

Verified live in the browser: the example boots and renders "@'READY" with
the ">_" prompt, responsive. Full suite 2084 pass (5 pre-existing,
unrelated env failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:49:33 -03:00
David Montero Crespo 3137c40a90 feat(chipbus): Phase 2 - synchronous settle kernel (settle-before-read)
Fixes root cause B: a CPU bus cycle drives address+strobe then reads the
data bus in the same tickTimers call, so the memory chip must react before
the read. Phase 0/1 applied each net change by firing PinManager listeners
immediately, which recurses one JS frame per hop - deep glue chains are
deep recursion and a combinational loop overflows the stack.

- busKernel.ts: a delta-cycle settle loop. A net change is recorded in a
  pending set, not applied recursively; settle() drains it in batches
  (deltas), applying each and letting the driven chips re-dirty the next,
  until a fixed point or DELTA_CAP trips (oscillation -> warn, not hang).
  Two-phase: a drive lands in pending and is applied on the next delta, so
  a chip evaluating mid-settle reads last-stable nets. The first drive of a
  cycle settles synchronously before returning to the chip's C code, so the
  in-cycle vx_pin_read sees settled data.
- busNets: publishes resolved levels through the kernel instead of calling
  triggerPinChange directly.

Tests (chipbus-buskernel): multi-hop chain settles; settle-before-read; a
5000-hop chain settles without stack overflow; a ring oscillator trips the
cap and warns instead of hanging. The two-real-chip integration still
exchanges 0xA5 through the kernel. Full suite 2079 pass flag-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 02:36:54 -03:00
David Montero Crespo 23516247a6 feat(chipbus): Phase 1 - 4-valued logic + drive-strength tri-state buses
A chip-to-chip net is now resolved by (value, strength), not last-writer-
wins, so a real multi-driver bus works: many chips on one data line, only
the enabled one drives, the rest release to Hi-Z.

- busLogic.ts: 4-valued (0/1/Z/X) + drive-strength resolution. Strongest
  driver wins; equal strength + opposite = X (contention); no driver = Z;
  pull resistor = pull strength. modeToDrive maps VX_OUTPUT -> strong,
  VX_INPUT -> Hi-Z (the rom/ram/8255 "release by input" idiom becomes real
  tri-state), VX_INPUT_PULLUP/DOWN -> pull.
- busNets.ts: per-net driver registry; resolves and pushes the resolved
  level into PinManager; warns once on contention.
- syntheticPins.ts: isSyntheticNetPin distinguishes bus net keys.
- ChipRuntime.ts: pin register/write/set_mode route bus-net pins through
  busNets (gated by chipBusEnabled + isSyntheticNetPin); non-bus pins keep
  the legacy path; dispose releases the chip's bus drivers. SPICE source
  emission is skipped for bus pins (digital fast path beside SPICE).

Tests: busLogic (14), busNets (6, incl. tri-state hand-off + contention),
and the two-real-chip integration now exchanges 0xA5 through the registry.
Full suite 2074 pass with the flag off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 02:18:47 -03:00
David Montero Crespo c050ae6e49 feat(chipbus): Phase 0 - net-identity shared key for chip-to-chip buses
Fixes root cause A of the multi-chip digital bus track
(project/multichip-bus/): chip-to-chip nets were keyed per-endpoint by
syntheticChipPin(chipId, pinName), so two chips on one wire resolved to
two different PinManager keys and never shared a net.

- chipNets.ts: union-find over the wire graph mints one canonical
  syntheticNetPin per net; resolveChipNetKey returns it only for pure
  chip-to-chip nets (>=2 chip endpoints, no board pin). Reuses the
  existing spice/unionFind.ts.
- syntheticPins.ts: add syntheticNetPin(netId), same allocator/space.
- DynamicComponent.tsx: traceDetailed consults resolveChipNetKey at
  depth 0 before the chipNeighbour fallback. Board priority (rule 1) and
  chip-to-component (rules 2/3) are unchanged.
- Gated behind ?chipbus=on / localStorage.velxio.chipbus (off by default).

Proof (D-008 go/no-go): __tests__/chipbus-netkey.test.ts - a byte written
on one chip's keys is visible synchronously to another via PinManager.
9 new tests; 85 resolver/PinManager/parts regression tests green flag-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:18:08 -03:00
David Montero Crespo bfe94a19f5 feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity
The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 /
GD7965 controller, but the panel config claimed controllerFamily 'ssd168x',
so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its
0x10/0x13 DTM stream.

- Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder).
  UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM,
  0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13
  (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90
  (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at
  absolute pixel coords inside the window, so compose is just the RAM. The
  Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it.
- EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it.
  EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder.
- Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW.
  The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's
  _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on
  every refresh. Now _PowerOn returns in ~129 us.
- esp32_worker.py: the runtime sensor_attach epaper path still emitted the
  epaper_update payload nested under 'data' (the old double-wrap bug); emit
  it flat like the init path.

The 5.65" ACeP UC8159c example already rendered (it has its own decoder and
got the WS-plumbing fix); verified the 7 colour bars are correct.
2026-06-04 23:45:37 -03:00
David Montero Crespo 3bb6f95a67 fix(epaper): wrap RAM Y counter at window end (tri-colour red plane)
The 2.9" tri-colour ESP32 alert badge rendered the red ALERT pill as white:
the red plane (0x26) was received but landed out of bounds and was dropped.

GxEPD2_3C writes the 0x24 (black) plane then the 0x26 (red) plane WITHOUT
re-seeking the RAM address counter between them — it relies on the SSD168x
counter wrapping back to the window start after the last byte of the window.
Our decoder advanced Y past the window end instead of wrapping, so every
0x26 byte hit y >= rows and was discarded (red_ram stayed all-init).

Mirror the hardware: when the X cursor wraps at the end of a row, advance Y
with a wrap at the active window boundary (yrange), honouring the data-entry
Y direction. Applied identically to the worker slave, the browser decoder,
and the Python golden reference so the three stay in lockstep. No regression
on the mono panels (their counter is re-seeked per plane, so the wrap is a
no-op for them); verified the tri-colour pill now renders red and the 2.9"
weather / 2.13" clock / 1.54" hello panels are unchanged.
2026-06-04 23:15:37 -03:00
David Montero Crespo 9ba8687743 fix(epaper): correct orientation across all boards + Pico VCC wire
ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13"
Pico clock came out sideways and clipped). The ESP32 worker decoder was just
taught to compose in the controller's native RAM geometry and rotate to the
display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040)
still composed at display dims with no rotation, so the two diverged.

- SSD168xDecoder.ts: port the worker's native-window compose + rotation.
  * Size RAM to the longer side both ways so a rotated native layout
    (128x296 behind a 296x128 panel) isn't truncated.
  * Compose in the active RAM window, then rotate via the inverse of
    Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a
    non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled.
  * Track the UNION of windows per frame: paged drivers (GxEPD2 page height
    < panel) set one partial window per page, so compose must use the full
    native area, not just the last page's strip. Fixes the all-white render
    on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32).
  * Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white
    only if both planes white), tri-colour panels keep red-wins.
  * Default the active window to display geometry; the firmware overrides it.
- EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder.
- esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation +
  window-union in the worker, and derive is_bwr from panel_kind on the
  runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge).
- test_epaper/ssd168x_decoder.py: re-port the golden reference to match
  (keeps the 3-way TS/Python/worker identity invariant). Tests updated to
  construct tri-colour cases with is_bwr/palette='bwr'.
- examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)',
  which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the
  wire snapped to the board corner. Use '3V3'.
2026-06-04 23:15:37 -03:00
David Montero dd62b94c1d feat(sim): custom chips read inputs (buttons/switches/sensors) with no board
The chip-output board-less path existed (chipPinDrives -> SPICE voltage sources
-> LEDs). The INPUT direction was missing: a chip pin wired to a pushbutton had
its net solved by ngspice, but nothing fed that net's state back to the
PinManager key the chip reads via vx_pin_read. So a board-less chip could light
LEDs but never read a button (verified: i8080 counter stayed at 0 on press).

connectChipInputsToSolve subscribes to the electrical store and, after each
solve, thresholds every wired chip input pin's net voltage to HIGH/LOW and
triggerPinChange()s the chip's synthetic pin — updating getPinState (polling)
and firing onPinChange edges. Pins the chip is actively driving are skipped so
it never fights its own outputs. Hooked alongside connectAnalogInputsToMcu in
start.ts. Solver-agnostic; reads only the electrical store shape.

Also gives the board-less button examples a pull-down on each chip BTN pin so
they read a clean LOW when open (a button-to-VCC floats HIGH otherwise):
i8080-button-counter (2) and i8080-killbits (8).

- new connectChipInputsToSolve.ts; start.ts wiring.
- examples-retro-intel: pull-down resistors + wires for the button examples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:09:51 +02:00
David Montero 694f038988 feat(sim): Stop halts custom chips + LEDs go dark; retro chip examples go board-less
Phase 1 of the run-system/UX work.

Stop bug: a programmable chip kept running after Stop when a board was present.
The chip rAF tick gated only on board presence (!boardless), so with a board it
ticked forever. Now it gates on the actual run state: board-less -> electrical
paused flag; with board(s) -> board.running. handleStop also clears every chip's
output drives (clearAllChipDrives) and re-solves so chip-driven LEDs go dark on
Stop instead of freezing at their last frame.

Examples to board-less (regulated power supply, no Arduino — the Arduino only
ever supplied 5V):
- z80-larson-scanner -> 'Z80 Comet Scanner': board-less, a faster TWO-LED comet
  (scanner.s) so it's visually distinct from z80-larson-no-board's single-bit
  walk; green/blue LEDs.
- i8080-killbits -> board-less (psu + resistors), keeps killbits.s as the chip's
  editable program; buttons re-powered from the supply.
- i8080-button-counter -> board-less (psu + resistors); behaviour chip, program
  baked in, so it shows a note (no editable file) and runs standalone.
banner-streamer stays Arduino-based (its TX/RX go through the AVR USART bridge).

- CustomChipPart: run-state-aware tick gate.
- EditorToolbar: clearAllChipDrives() helper + handleStop clears chip drives.
- examples-retro-intel: 3 conversions; drop now-unused sketch consts; add the
  larsonScannerAsm comet program.
- Tests: board+chip routing now uses an inline synthetic example (gallery chip
  examples are all board-less).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 04:32:44 +02:00
David Montero d5b9a9ceb5 feat(custom-chip): run custom chips with no board (general-purpose sim)
Velxio can now simulate one or more custom-chip CPUs with NO Arduino/ESP32
board on the canvas — a general-purpose electronics simulator, not an
MCU-only one.

- DynamicComponent: board-less parts get the real shared flat PinManager
  (instead of a no-op stub) so a custom chip's digital pin writes/reads reach
  the LEDs/inputs wired to it.
- CustomChipPart: the rAF tick respects board-less Run/Stop (freezes while
  the electrical sim is paused); board behaviour is unchanged.
- EditorToolbar.handleRun: board-less Run compiles each chip's WASM/ROM and
  re-attaches the parts (restartParts) so they pick up the fresh WASM, then
  resumes the solver.
- useSimulatorStore.restartParts(): bump hexEpoch to force part re-attach.
- New example "Z80 Larson Scanner (no board)": a programmable Z80 + 8 LEDs +
  the adjustable power-supply component, no MCU. The chip drives the LEDs
  through the synthetic-pin + ngspice path added earlier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:04:17 +02:00
David Montero 1d6961d03c fix(z80-cpu): map RAM over the whole 0x8000-0xFFFF so vanilla SDCC C runs
SDCC's z80 crt0 sets SP=0x0000 and makes its first stack push at 0xFFFF.
The chip only mapped RAM at 0x8000-0xBFFF (0xC000+ was MMIO/ignored), so the
stack landed on unmapped memory and a plain C program crashed inside crt0 —
before main — which is why z80-led-chaser-c compiled but drove nothing.

Extend RAM to cover 0x8000-0xFFFF (32 KB) with the MMIO window 0xC000-0xC0FF
carved out and checked first, in scripts/make-z80-cpu.py + regenerated
z80-cpu.c. Now SDCC's default stack works and "write C from scratch, click
Run" just works — no manual `LD SP` needed (dropped from chaser.c). Bumped
the chip WASM initial memory to 4 pages to hold the larger RAM buffer. Larson
(asm, SP=0xBFFF, LED at 0xC000) is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:31:26 +02:00
David Montero 4cb5748dce feat(custom-chip): make chip pins first-class circuit nodes (digital + SPICE)
A custom-chip output pin wired directly to a component (LED, resistor, ...)
had no Arduino pin on its net, so the chip could drive nothing and the pin
resolved to null. Now:

- Layer A (digital): such chip pins get a stable synthetic pin number
  (syntheticPins.ts). traceDetailed resolves a chip<->component net to that
  shared number, so the chip's PinManager drive reaches the wired components
  through the existing digital event flow. A real board pin still wins.
- Layer B (analog/SPICE): a custom-chip mapper in componentToSpice emits a DC
  voltage source on each driven output pin's net (recorded in chipPinDrives by
  ChipRuntime), exactly like a board GPIO, and the chip requests an electrical
  re-solve when it toggles a pin (electricalResolveHook -> service.tick).
  So LEDs / resistors / analog parts wired to a chip output are driven by
  ngspice too.

This makes the bundled Z80 / i8080 chip examples actually animate their LEDs,
and lets any custom chip drive components, passives and analog circuits from
its own pins. Non-chip circuits are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 05:40:49 +02:00
David Montero 10d88a44b7 perf(esp32): drop per-edge gpio_change console.log that throttled the sim
Esp32Bridge logged every GPIO transition (one per SPI clock edge on a
display-heavy sketch), which floods the console and measurably throttles
the main thread and simulation throughput. A full-screen 320x240 ILI9341
raycaster went from ~0.3-0.6 FPS to ~6-8 FPS once this log was removed.
Keep the functional onPinChange / oscilloscope callbacks intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:10:24 +02:00
David Montero daa46c6724 fix(sim): desktop QEMU sims use injected sidecar URL; fix nano-button-led wiring
- ESP32 / Raspberry Pi / STM32 / Pico-W bridges built their WebSocket URL
  from a bespoke API_BASE() that read only VITE_API_BASE (fallback
  localhost:8001) and ignored the desktop shell's runtime-injected
  window.__VELXIO_API_BASE__. On the desktop the sidecar runs on a random
  127.0.0.1 port, so the sim WebSocket dialed localhost:8001 and never
  connected: compile succeeded but the simulation never started. Honor
  __VELXIO_API_BASE__ first; web (/api) and dev (localhost:8001) unchanged.
- nano-button-led example: button was wired D2->1a and 1b->GND (same
  terminal), tying D2 to GND permanently. Rewire D2->1.l and GND->2.l
  (opposite terminals), matching the other examples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 03:24:19 +02:00
David Montero Crespo 0d5a1d5838 feat(motors): fix stepper rotation + add A4988 driver (real Fritzing SVG)
The stepper-motor and biaxial-stepper parts only decoded a one-hot wave-drive coil sequence, so they never rotated under the common two-phase full-step / Stepper.h / AccelStepper drive that Wokwi's own examples use -- only the servo moved. Rewrote both decoders to track the net magnetic-field vector of the coils (atan2 of the H-bridge currents), so the rotor follows wave, two-phase full-step and half-step drive alike, whether driven directly from GPIO or through a driver's outputs.

Also adds an A4988 STEP/DIR stepper driver (parity with Wokwi's wokwi-a4988): velxio-a4988 element renders the real Pololu A4988 Fritzing breadboard SVG (public/components/a4988.svg); MotorDriverParts.ts finds the wired stepper via the netlist and advances it one (micro)step per STEP rising edge in the DIR direction (MS1-3 microstep + active-low ENABLE). Metadata in component-overrides.json. Three examples (Uno/ESP32/Pico) wire MCU STEP/DIR -> A4988 -> stepper, coil map aligned to Wokwi (1A->B+,1B->B-,2A->A+,2B->A-).

Verified in-browser: motor rotates on Arduino Uno (avr8js) and Raspberry Pi Pico (rp2040js). tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:28:34 -03:00
David Montero Crespo ca8dcedcc7 feat: STM32 (Blue Pill / Black Pill) QEMU emulation + Pro board gating
STM32 emulation (open-core, runs via libqemu-arm in the backend worker):
- backend: stm32_lib_manager + stm32_worker (GPIO, USART, I2C/SPI device models
  reusing the ESP32 slaves, live sensor updates), arduino_cli STM32 branch,
  start_stm32 simulation route.
- frontend: Stm32Bridge + Stm32BluePill(/BlackPill) web components (Wokwi SVGs),
  board kinds, Interconnect/boardPinMapping/boardProtocols wiring, example
  projects (blink, serial, I2C BMP280/MPU6050/DS1307/SSD1306/weather, 7-seg,
  RGB, button, switch, stepper, cross-board interconnect).
- Raspberry Pi 4/5 board elements + thumbnails.

Pro board gating (generic OSS->Pro seam; entitlement logic lives in the overlay):
- lib/proBoardGate.ts: isProBoardKind (STM32 + every QEMU Raspberry Pi),
  installBoardGateImpl/boardGateDecision, triggerProUpgradePrompt.
- PRO badge on those boards in the component picker; gate at the picker add +
  the run backstop (startBoard).
- backend/app/services/board_access.py: server-side enforcement seam for the
  simulation WebSocket; STM32/Pi unavailable -> Pro-framed message.
- desktop: generic QemuDownloadPrompt + Stm32QemuPrompt (download-behind-license,
  mirrors the ESP32 prompt).
- .gitignore: never ship libqemu-* binaries in the public image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:06:14 -03:00
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