From 81837eedb9e0ea3925ccad92d8a436986720d5ce Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 18 May 2026 21:47:45 -0300 Subject: [PATCH] fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` pin names to `GP` 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 --- .../esp-idf-template/main/CMakeLists.txt | 14 +- frontend/package.json | 5 +- frontend/src/data/examples.ts | 304 +++++++++--------- frontend/src/simulation/AVRSimulator.ts | 24 +- frontend/src/simulation/Esp32C3Simulator.ts | 2 +- frontend/src/simulation/PinManager.ts | 56 +++- frontend/src/simulation/RP2040Simulator.ts | 2 +- frontend/src/simulation/RiscVSimulator.ts | 2 +- frontend/src/simulation/parts/BasicParts.ts | 11 + .../spice/CircuitSimulationService.ts | 53 ++- .../simulation/spice/MixedModeScheduler.ts | 6 +- .../src/simulation/spice/NetlistBuilder.ts | 37 ++- .../src/simulation/spice/collectPinStates.ts | 30 +- .../spice/connectMcuEdgesToService.ts | 26 ++ frontend/src/simulation/spice/start.ts | 33 +- frontend/src/store/useSimulatorStore.ts | 27 +- frontend/src/types/board.ts | 2 +- scripts/visual-led-test.mjs | 297 +++++++++++++++++ 18 files changed, 707 insertions(+), 224 deletions(-) create mode 100644 scripts/visual-led-test.mjs diff --git a/backend/app/services/esp-idf-template/main/CMakeLists.txt b/backend/app/services/esp-idf-template/main/CMakeLists.txt index 8638bd12..7319be84 100644 --- a/backend/app/services/esp-idf-template/main/CMakeLists.txt +++ b/backend/app/services/esp-idf-template/main/CMakeLists.txt @@ -55,8 +55,18 @@ if(DEFINED ENV{ARDUINO_ESP32_PATH}) # for ESP32-CAM where GPIO 2 isn't broken out, the user should use an # explicit pin number — but this default keeps the new-project flow # smooth. - target_compile_definitions(${COMPONENT_LIB} PRIVATE - "LED_BUILTIN=2") + # + # SKIP on ESP32-C3 and S3 variants: their pins_arduino.h DOES define + # LED_BUILTIN already (as `static const uint8_t LED_BUILTIN = ...;` + # plus `#define LED_BUILTIN LED_BUILTIN`). Pre-defining it from the + # command line clashes with the self-define: the preprocessor expands + # `static const uint8_t LED_BUILTIN = ...` to `static const uint8_t 2 = ...` + # which is a syntax error. Only set the fallback for the generic ESP32 + # Xtensa variant that genuinely lacks the symbol. + if(NOT IDF_TARGET STREQUAL "esp32c3" AND NOT IDF_TARGET STREQUAL "esp32s3") + target_compile_definitions(${COMPONENT_LIB} PRIVATE + "LED_BUILTIN=2") + endif() else() # Pure ESP-IDF mode: main.c #includes sketch_translated.c idf_component_register( diff --git a/frontend/package.json b/frontend/package.json index 25632b46..23602e2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,8 @@ "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", "tsc": "tsc -b", - "test:libraries": "RUN_LIBRARY_TESTS=1 vitest run src/__tests__/library-compile.integration.test.ts" + "test:libraries": "RUN_LIBRARY_TESTS=1 vitest run src/__tests__/library-compile.integration.test.ts", + "test:visual": "node ../scripts/visual-led-test.mjs" }, "dependencies": { "@google/genai": "^0.6.0", @@ -78,4 +79,4 @@ "vite": "^7.3.1", "vitest": "^4.0.18" } -} \ No newline at end of file +} diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index 5a3ec016..e807dc81 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -2000,7 +2000,7 @@ void loop() { wires: [ { id: 'w1', - start: { componentId: 'nano-rp2040', pinName: 'D2' }, + start: { componentId: 'arduino-uno', pinName: 'GP25' }, end: { componentId: 'led-blink', pinName: 'A' }, color: '#00cc00', }, @@ -2013,7 +2013,7 @@ void loop() { { id: 'w3', start: { componentId: 'r1', pinName: '2' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -2062,14 +2062,14 @@ void loop() { wires: [ { id: 'w1', - start: { componentId: 'nano-rp2040', pinName: 'TX' }, + start: { componentId: 'arduino-uno', pinName: 'TX' }, end: { componentId: 'led-rx', pinName: 'A' }, color: '#ff8800', }, { id: 'w2', start: { componentId: 'led-rx', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -2126,7 +2126,7 @@ void loop() { wires: [ { id: 'w1', - start: { componentId: 'nano-rp2040', pinName: 'D2' }, + start: { componentId: 'arduino-uno', pinName: 'GP2' }, end: { componentId: 'led-status', pinName: 'A' }, color: '#00cc00', }, @@ -2139,7 +2139,7 @@ void loop() { { id: 'w3', start: { componentId: 'r1', pinName: '2' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -2207,26 +2207,26 @@ void loop() { wires: [ { id: 'w1', - start: { componentId: 'nano-rp2040', pinName: 'D12' }, + start: { componentId: 'arduino-uno', pinName: 'GP12' }, end: { componentId: 'led-scan', pinName: 'A' }, color: '#4488ff', }, { id: 'w2', - start: { componentId: 'nano-rp2040', pinName: 'D10' }, + start: { componentId: 'arduino-uno', pinName: 'GP10' }, end: { componentId: 'led-found', pinName: 'A' }, color: '#00cc00', }, { id: 'w3', start: { componentId: 'led-scan', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'w4', start: { componentId: 'led-found', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -2299,26 +2299,26 @@ void loop() { wires: [ { id: 'w-sda', - start: { componentId: 'nano-rp2040', pinName: 'D12' }, + start: { componentId: 'arduino-uno', pinName: 'GP12' }, end: { componentId: 'led-i2c', pinName: 'A' }, color: '#4488ff', }, { id: 'w-scl', - start: { componentId: 'nano-rp2040', pinName: 'D10' }, + start: { componentId: 'arduino-uno', pinName: 'GP10' }, end: { componentId: 'led-rtc', pinName: 'A' }, color: '#ffaa00', }, { id: 'w-sda-gnd', start: { componentId: 'led-i2c', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'w-scl-gnd', start: { componentId: 'led-rtc', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -2409,26 +2409,26 @@ void loop() { wires: [ { id: 'w-sda', - start: { componentId: 'nano-rp2040', pinName: 'D12' }, + start: { componentId: 'arduino-uno', pinName: 'GP12' }, end: { componentId: 'led-write', pinName: 'A' }, color: '#ff4444', }, { id: 'w-scl', - start: { componentId: 'nano-rp2040', pinName: 'D10' }, + start: { componentId: 'arduino-uno', pinName: 'GP10' }, end: { componentId: 'led-read', pinName: 'A' }, color: '#00cc00', }, { id: 'w-write-gnd', start: { componentId: 'led-write', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'w-read-gnd', start: { componentId: 'led-read', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -2491,38 +2491,38 @@ void loop() { wires: [ { id: 'w-mosi', - start: { componentId: 'nano-rp2040', pinName: 'D7' }, + start: { componentId: 'arduino-uno', pinName: 'GP7' }, end: { componentId: 'led-mosi', pinName: 'A' }, color: '#ff4444', }, { id: 'w-miso', - start: { componentId: 'nano-rp2040', pinName: 'D4' }, + start: { componentId: 'arduino-uno', pinName: 'GP4' }, end: { componentId: 'led-miso', pinName: 'A' }, color: '#00cc00', }, { id: 'w-sck', - start: { componentId: 'nano-rp2040', pinName: 'D6' }, + start: { componentId: 'arduino-uno', pinName: 'GP6' }, end: { componentId: 'led-sck', pinName: 'A' }, color: '#ffaa00', }, { id: 'w-mosi-gnd', start: { componentId: 'led-mosi', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'w-miso-gnd', start: { componentId: 'led-miso', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'w-sck-gnd', start: { componentId: 'led-sck', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -2576,50 +2576,50 @@ void loop() { wires: [ { id: 'w-a0', - start: { componentId: 'nano-rp2040', pinName: 'A0' }, + start: { componentId: 'arduino-uno', pinName: 'A0' }, end: { componentId: 'pot-a0', pinName: 'SIG' }, color: '#4488ff', }, { id: 'w-a1', - start: { componentId: 'nano-rp2040', pinName: 'A1' }, + start: { componentId: 'arduino-uno', pinName: 'A1' }, end: { componentId: 'pot-a1', pinName: 'SIG' }, color: '#44cc44', }, { id: 'w-temp', - start: { componentId: 'nano-rp2040', pinName: 'D2' }, + start: { componentId: 'arduino-uno', pinName: 'GP2' }, end: { componentId: 'led-temp', pinName: 'A' }, color: '#ff4444', }, { id: 'w-pot-a0-vcc', - start: { componentId: 'nano-rp2040', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'pot-a0', pinName: 'VCC' }, color: '#ff0000', }, { id: 'w-pot-a0-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pot-a0', pinName: 'GND' }, color: '#000000', }, { id: 'w-pot-a1-vcc', - start: { componentId: 'nano-rp2040', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'pot-a1', pinName: 'VCC' }, color: '#ff0000', }, { id: 'w-pot-a1-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pot-a1', pinName: 'GND' }, color: '#000000', }, { id: 'w-temp-gnd', start: { componentId: 'led-temp', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -2750,55 +2750,55 @@ void loop() { wires: [ { id: 'w-i2c', - start: { componentId: 'nano-rp2040', pinName: 'D12' }, + start: { componentId: 'arduino-uno', pinName: 'GP12' }, end: { componentId: 'led-i2c', pinName: 'A' }, color: '#4488ff', }, { id: 'w-spi', - start: { componentId: 'nano-rp2040', pinName: 'D7' }, + start: { componentId: 'arduino-uno', pinName: 'GP7' }, end: { componentId: 'led-spi', pinName: 'A' }, color: '#ffaa00', }, { id: 'w-adc', - start: { componentId: 'nano-rp2040', pinName: 'A0' }, + start: { componentId: 'arduino-uno', pinName: 'A0' }, end: { componentId: 'pot-adc', pinName: 'SIG' }, color: '#cc44cc', }, { id: 'w-gpio', - start: { componentId: 'nano-rp2040', pinName: 'D2' }, + start: { componentId: 'arduino-uno', pinName: 'GP2' }, end: { componentId: 'led-gpio', pinName: 'A' }, color: '#00cc00', }, { id: 'w-i2c-gnd', start: { componentId: 'led-i2c', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'w-spi-gnd', start: { componentId: 'led-spi', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'w-gpio-gnd', start: { componentId: 'led-gpio', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'w-pot-adc-vcc', - start: { componentId: 'nano-rp2040', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'pot-adc', pinName: 'VCC' }, color: '#ff0000', }, { id: 'w-pot-adc-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pot-adc', pinName: 'GND' }, color: '#000000', }, @@ -2847,7 +2847,7 @@ void loop() { // GPIO4 → LED anode { id: 'w-gpio4-led', - start: { componentId: 'esp32', pinName: '4' }, + start: { componentId: 'arduino-uno', pinName: '4' }, end: { componentId: 'led-ext', pinName: 'A' }, color: '#e74c3c', }, @@ -2855,7 +2855,7 @@ void loop() { { id: 'w-gnd', start: { componentId: 'led-ext', pinName: 'C' }, - end: { componentId: 'esp32', pinName: 'GND' }, + end: { componentId: 'arduino-uno', pinName: 'GND' }, color: '#2c3e50', }, ], @@ -4035,7 +4035,7 @@ void loop() { wires: [ { id: 'c3w1', - start: { componentId: 'esp32-c3', pinName: '8' }, + start: { componentId: 'arduino-uno', pinName: '8' }, end: { componentId: 'c3-led1', pinName: 'A' }, color: '#22cc22', }, @@ -4048,7 +4048,7 @@ void loop() { { id: 'c3w3', start: { componentId: 'c3-r1', pinName: '2' }, - end: { componentId: 'esp32-c3', pinName: 'GND.9' }, + end: { componentId: 'arduino-uno', pinName: 'GND.9' }, color: '#000000', }, ], @@ -4131,26 +4131,26 @@ void loop() { wires: [ { id: 'c3-rw1', - start: { componentId: 'esp32-c3', pinName: '6' }, + start: { componentId: 'arduino-uno', pinName: '6' }, end: { componentId: 'c3-rgb1', pinName: 'R' }, color: '#ff2222', }, { id: 'c3-rw2', - start: { componentId: 'esp32-c3', pinName: '7' }, + start: { componentId: 'arduino-uno', pinName: '7' }, end: { componentId: 'c3-rgb1', pinName: 'G' }, color: '#22cc22', }, { id: 'c3-rw3', - start: { componentId: 'esp32-c3', pinName: '8' }, + start: { componentId: 'arduino-uno', pinName: '8' }, end: { componentId: 'c3-rgb1', pinName: 'B' }, color: '#2244ff', }, { id: 'c3-rw4', start: { componentId: 'c3-rgb1', pinName: 'COM' }, - end: { componentId: 'esp32-c3', pinName: 'GND.8' }, + end: { componentId: 'arduino-uno', pinName: 'GND.8' }, color: '#000000', }, ], @@ -4191,26 +4191,26 @@ void loop() { wires: [ { id: 'c3-bw1', - start: { componentId: 'esp32-c3', pinName: '9' }, + start: { componentId: 'arduino-uno', pinName: '9' }, end: { componentId: 'c3-btn1', pinName: '1a' }, color: '#00aaff', }, { id: 'c3-bw2', - start: { componentId: 'esp32-c3', pinName: '8' }, + start: { componentId: 'arduino-uno', pinName: '8' }, end: { componentId: 'c3-led-btn', pinName: 'A' }, color: '#2244ff', }, { id: 'c3-bw3', start: { componentId: 'c3-led-btn', pinName: 'C' }, - end: { componentId: 'esp32-c3', pinName: 'GND.8' }, + end: { componentId: 'arduino-uno', pinName: 'GND.8' }, color: '#000000', }, { id: 'c3-bw4', start: { componentId: 'c3-btn1', pinName: '1b' }, - end: { componentId: 'esp32-c3', pinName: 'GND.9' }, + end: { componentId: 'arduino-uno', pinName: 'GND.9' }, color: '#000000', }, ], @@ -4412,50 +4412,50 @@ void loop() { wires: [ { id: 'ps-a', - start: { componentId: 'nano-rp2040', pinName: 'GP2' }, + start: { componentId: 'arduino-uno', pinName: 'GP2' }, end: { componentId: 'pico-seg1', pinName: 'A' }, color: '#ff4444', }, { id: 'ps-b', - start: { componentId: 'nano-rp2040', pinName: 'GP3' }, + start: { componentId: 'arduino-uno', pinName: 'GP3' }, end: { componentId: 'pico-seg1', pinName: 'B' }, color: '#ff8800', }, { id: 'ps-c', - start: { componentId: 'nano-rp2040', pinName: 'GP4' }, + start: { componentId: 'arduino-uno', pinName: 'GP4' }, end: { componentId: 'pico-seg1', pinName: 'C' }, color: '#ffcc00', }, { id: 'ps-d', - start: { componentId: 'nano-rp2040', pinName: 'GP5' }, + start: { componentId: 'arduino-uno', pinName: 'GP5' }, end: { componentId: 'pico-seg1', pinName: 'D' }, color: '#44cc44', }, { id: 'ps-e', - start: { componentId: 'nano-rp2040', pinName: 'GP6' }, + start: { componentId: 'arduino-uno', pinName: 'GP6' }, end: { componentId: 'pico-seg1', pinName: 'E' }, color: '#4488ff', }, { id: 'ps-f', - start: { componentId: 'nano-rp2040', pinName: 'GP7' }, + start: { componentId: 'arduino-uno', pinName: 'GP7' }, end: { componentId: 'pico-seg1', pinName: 'F' }, color: '#aa44ff', }, { id: 'ps-g', - start: { componentId: 'nano-rp2040', pinName: 'GP8' }, + start: { componentId: 'arduino-uno', pinName: 'GP8' }, end: { componentId: 'pico-seg1', pinName: 'G' }, color: '#ffffff', }, { id: 'ps-gnd', start: { componentId: 'pico-seg1', pinName: 'COM' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -4519,50 +4519,50 @@ void loop() { wires: [ { id: 'es-a', - start: { componentId: 'esp32', pinName: '12' }, + start: { componentId: 'arduino-uno', pinName: '12' }, end: { componentId: 'esp-seg1', pinName: 'A' }, color: '#ff4444', }, { id: 'es-b', - start: { componentId: 'esp32', pinName: '13' }, + start: { componentId: 'arduino-uno', pinName: '13' }, end: { componentId: 'esp-seg1', pinName: 'B' }, color: '#ff8800', }, { id: 'es-c', - start: { componentId: 'esp32', pinName: '14' }, + start: { componentId: 'arduino-uno', pinName: '14' }, end: { componentId: 'esp-seg1', pinName: 'C' }, color: '#ffcc00', }, { id: 'es-d', - start: { componentId: 'esp32', pinName: '25' }, + start: { componentId: 'arduino-uno', pinName: '25' }, end: { componentId: 'esp-seg1', pinName: 'D' }, color: '#44cc44', }, { id: 'es-e', - start: { componentId: 'esp32', pinName: '26' }, + start: { componentId: 'arduino-uno', pinName: '26' }, end: { componentId: 'esp-seg1', pinName: 'E' }, color: '#4488ff', }, { id: 'es-f', - start: { componentId: 'esp32', pinName: '27' }, + start: { componentId: 'arduino-uno', pinName: '27' }, end: { componentId: 'esp-seg1', pinName: 'F' }, color: '#aa44ff', }, { id: 'es-g', - start: { componentId: 'esp32', pinName: '32' }, + start: { componentId: 'arduino-uno', pinName: '32' }, end: { componentId: 'esp-seg1', pinName: 'G' }, color: '#ffffff', }, { id: 'es-gnd', start: { componentId: 'esp-seg1', pinName: 'COM' }, - end: { componentId: 'esp32', pinName: 'GND' }, + end: { componentId: 'arduino-uno', pinName: 'GND' }, color: '#000000', }, ], @@ -4719,26 +4719,26 @@ void loop() { wires: [ { id: 'pb-btn', - start: { componentId: 'nano-rp2040', pinName: 'GP2' }, + start: { componentId: 'arduino-uno', pinName: 'GP2' }, end: { componentId: 'pico-btn1', pinName: '1a' }, color: '#00aaff', }, { id: 'pb-led', - start: { componentId: 'nano-rp2040', pinName: 'GP3' }, + start: { componentId: 'arduino-uno', pinName: 'GP3' }, end: { componentId: 'pico-led-btn', pinName: 'A' }, color: '#ffcc00', }, { id: 'pb-led-gnd', start: { componentId: 'pico-led-btn', pinName: 'C' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, { id: 'pb-btn-gnd', start: { componentId: 'pico-btn1', pinName: '1b' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -4785,26 +4785,26 @@ void loop() { wires: [ { id: 'pr-r', - start: { componentId: 'nano-rp2040', pinName: 'GP6' }, + start: { componentId: 'arduino-uno', pinName: 'GP6' }, end: { componentId: 'pico-rgb1', pinName: 'R' }, color: '#ff2222', }, { id: 'pr-g', - start: { componentId: 'nano-rp2040', pinName: 'GP7' }, + start: { componentId: 'arduino-uno', pinName: 'GP7' }, end: { componentId: 'pico-rgb1', pinName: 'G' }, color: '#22cc22', }, { id: 'pr-b', - start: { componentId: 'nano-rp2040', pinName: 'GP8' }, + start: { componentId: 'arduino-uno', pinName: 'GP8' }, end: { componentId: 'pico-rgb1', pinName: 'B' }, color: '#2244ff', }, { id: 'pr-gnd', start: { componentId: 'pico-rgb1', pinName: 'COM' }, - end: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + end: { componentId: 'arduino-uno', pinName: 'GND.1' }, color: '#000000', }, ], @@ -5287,19 +5287,19 @@ void loop() { wires: [ { id: 'pcd-vcc', - start: { componentId: 'nano-rp2040', pinName: '3.3V' }, + start: { componentId: 'arduino-uno', pinName: '3.3V' }, end: { componentId: 'pico-dht1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'pcd-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pico-dht1', pinName: 'GND' }, color: '#000000', }, { id: 'pcd-sda', - start: { componentId: 'nano-rp2040', pinName: 'GP7' }, + start: { componentId: 'arduino-uno', pinName: 'GP7' }, end: { componentId: 'pico-dht1', pinName: 'SDA' }, color: '#22aaff', }, @@ -5352,25 +5352,25 @@ void loop() { wires: [ { id: 'pcs-vcc', - start: { componentId: 'nano-rp2040', pinName: '3.3V' }, + start: { componentId: 'arduino-uno', pinName: '3.3V' }, end: { componentId: 'pico-sr1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'pcs-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pico-sr1', pinName: 'GND' }, color: '#000000', }, { id: 'pcs-trig', - start: { componentId: 'nano-rp2040', pinName: 'D5' }, + start: { componentId: 'arduino-uno', pinName: 'GP5' }, end: { componentId: 'pico-sr1', pinName: 'TRIG' }, color: '#ff8800', }, { id: 'pcs-echo', - start: { componentId: 'nano-rp2040', pinName: 'D6' }, + start: { componentId: 'arduino-uno', pinName: 'GP6' }, end: { componentId: 'pico-sr1', pinName: 'ECHO' }, color: '#22cc22', }, @@ -5420,19 +5420,19 @@ void loop() { wires: [ { id: 'pp-vcc', - start: { componentId: 'nano-rp2040', pinName: '3.3V' }, + start: { componentId: 'arduino-uno', pinName: '3.3V' }, end: { componentId: 'pico-pir1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'pp-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pico-pir1', pinName: 'GND' }, color: '#000000', }, { id: 'pp-out', - start: { componentId: 'nano-rp2040', pinName: 'D4' }, + start: { componentId: 'arduino-uno', pinName: 'GP4' }, end: { componentId: 'pico-pir1', pinName: 'OUT' }, color: '#ffcc00', }, @@ -5473,19 +5473,19 @@ void loop() { wires: [ { id: 'psv-vcc', - start: { componentId: 'nano-rp2040', pinName: '3.3V' }, + start: { componentId: 'arduino-uno', pinName: '3.3V' }, end: { componentId: 'pico-sv1', pinName: 'V+' }, color: '#ff4444', }, { id: 'psv-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pico-sv1', pinName: 'GND' }, color: '#000000', }, { id: 'psv-pwm', - start: { componentId: 'nano-rp2040', pinName: 'D3' }, + start: { componentId: 'arduino-uno', pinName: 'GP3' }, end: { componentId: 'pico-sv1', pinName: 'PWM' }, color: '#ff8800', }, @@ -5542,19 +5542,19 @@ void loop() { wires: [ { id: 'pnt-vcc', - start: { componentId: 'nano-rp2040', pinName: '3.3V' }, + start: { componentId: 'arduino-uno', pinName: '3.3V' }, end: { componentId: 'pico-ntc1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'pnt-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pico-ntc1', pinName: 'GND' }, color: '#000000', }, { id: 'pnt-out', - start: { componentId: 'nano-rp2040', pinName: 'A0' }, + start: { componentId: 'arduino-uno', pinName: 'A0' }, end: { componentId: 'pico-ntc1', pinName: 'OUT' }, color: '#aa44ff', }, @@ -5604,31 +5604,31 @@ void loop() { wires: [ { id: 'pj-vcc', - start: { componentId: 'nano-rp2040', pinName: '3.3V' }, + start: { componentId: 'arduino-uno', pinName: '3.3V' }, end: { componentId: 'pico-joy1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'pj-gnd', - start: { componentId: 'nano-rp2040', pinName: 'GND.1' }, + start: { componentId: 'arduino-uno', pinName: 'GND.1' }, end: { componentId: 'pico-joy1', pinName: 'GND' }, color: '#000000', }, { id: 'pj-vert', - start: { componentId: 'nano-rp2040', pinName: 'A0' }, + start: { componentId: 'arduino-uno', pinName: 'A0' }, end: { componentId: 'pico-joy1', pinName: 'VERT' }, color: '#22aaff', }, { id: 'pj-horz', - start: { componentId: 'nano-rp2040', pinName: 'A1' }, + start: { componentId: 'arduino-uno', pinName: 'A1' }, end: { componentId: 'pico-joy1', pinName: 'HORZ' }, color: '#22cc44', }, { id: 'pj-sel', - start: { componentId: 'nano-rp2040', pinName: 'D4' }, + start: { componentId: 'arduino-uno', pinName: 'GP4' }, end: { componentId: 'pico-joy1', pinName: 'SEL' }, color: '#aa44ff', }, @@ -5688,19 +5688,19 @@ void loop() { wires: [ { id: 'e32d-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-dht1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'e32d-gnd', - start: { componentId: 'esp32', pinName: 'GND' }, + start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'e32-dht1', pinName: 'GND' }, color: '#000000', }, { id: 'e32d-sda', - start: { componentId: 'esp32', pinName: '4' }, + start: { componentId: 'arduino-uno', pinName: '4' }, end: { componentId: 'e32-dht1', pinName: 'SDA' }, color: '#22aaff', }, @@ -5752,25 +5752,25 @@ void loop() { wires: [ { id: 'e32s-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-sr1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'e32s-gnd', - start: { componentId: 'esp32', pinName: 'GND' }, + start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'e32-sr1', pinName: 'GND' }, color: '#000000', }, { id: 'e32s-trig', - start: { componentId: 'esp32', pinName: '18' }, + start: { componentId: 'arduino-uno', pinName: '18' }, end: { componentId: 'e32-sr1', pinName: 'TRIG' }, color: '#ff8800', }, { id: 'e32s-echo', - start: { componentId: 'esp32', pinName: '19' }, + start: { componentId: 'arduino-uno', pinName: '19' }, end: { componentId: 'e32-sr1', pinName: 'ECHO' }, color: '#22cc22', }, @@ -5825,25 +5825,25 @@ void loop() { wires: [ { id: 'e32m-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-mpu1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'e32m-gnd', - start: { componentId: 'esp32', pinName: 'GND' }, + start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'e32-mpu1', pinName: 'GND' }, color: '#000000', }, { id: 'e32m-sda', - start: { componentId: 'esp32', pinName: '21' }, + start: { componentId: 'arduino-uno', pinName: '21' }, end: { componentId: 'e32-mpu1', pinName: 'SDA' }, color: '#22aaff', }, { id: 'e32m-scl', - start: { componentId: 'esp32', pinName: '22' }, + start: { componentId: 'arduino-uno', pinName: '22' }, end: { componentId: 'e32-mpu1', pinName: 'SCL' }, color: '#ff8800', }, @@ -5898,19 +5898,19 @@ void loop() { wires: [ { id: 'e32p-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-pir1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'e32p-gnd', - start: { componentId: 'esp32', pinName: 'GND' }, + start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'e32-pir1', pinName: 'GND' }, color: '#000000', }, { id: 'e32p-out', - start: { componentId: 'esp32', pinName: '5' }, + start: { componentId: 'arduino-uno', pinName: '5' }, end: { componentId: 'e32-pir1', pinName: 'OUT' }, color: '#ffcc00', }, @@ -5958,37 +5958,37 @@ void loop() { wires: [ { id: 'e32sv-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-sv1', pinName: 'V+' }, color: '#ff4444', }, { id: 'e32sv-gnd', - start: { componentId: 'esp32', pinName: 'GND' }, + start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'e32-sv1', pinName: 'GND' }, color: '#000000', }, { id: 'e32sv-pwm', - start: { componentId: 'esp32', pinName: '13' }, + start: { componentId: 'arduino-uno', pinName: '13' }, end: { componentId: 'e32-sv1', pinName: 'PWM' }, color: '#ff8800', }, { id: 'e32pt-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-pot1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'e32pt-gnd', - start: { componentId: 'esp32', pinName: 'GND2' }, + start: { componentId: 'arduino-uno', pinName: 'GND2' }, end: { componentId: 'e32-pot1', pinName: 'GND' }, color: '#000000', }, { id: 'e32pt-sig', - start: { componentId: 'esp32', pinName: '34' }, + start: { componentId: 'arduino-uno', pinName: '34' }, end: { componentId: 'e32-pot1', pinName: 'SIG' }, color: '#aa44ff', }, @@ -6035,31 +6035,31 @@ void loop() { wires: [ { id: 'e32j-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-joy1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'e32j-gnd', - start: { componentId: 'esp32', pinName: 'GND' }, + start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'e32-joy1', pinName: 'GND' }, color: '#000000', }, { id: 'e32j-vert', - start: { componentId: 'esp32', pinName: '34' }, + start: { componentId: 'arduino-uno', pinName: '34' }, end: { componentId: 'e32-joy1', pinName: 'VERT' }, color: '#22aaff', }, { id: 'e32j-horz', - start: { componentId: 'esp32', pinName: '35' }, + start: { componentId: 'arduino-uno', pinName: '35' }, end: { componentId: 'e32-joy1', pinName: 'HORZ' }, color: '#22cc44', }, { id: 'e32j-sel', - start: { componentId: 'esp32', pinName: '15' }, + start: { componentId: 'arduino-uno', pinName: '15' }, end: { componentId: 'e32-joy1', pinName: 'SEL' }, color: '#aa44ff', }, @@ -6120,19 +6120,19 @@ void loop() { wires: [ { id: 'c3d-vcc', - start: { componentId: 'esp32-c3', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'c3-dht1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'c3d-gnd', - start: { componentId: 'esp32-c3', pinName: 'GND.9' }, + start: { componentId: 'arduino-uno', pinName: 'GND.9' }, end: { componentId: 'c3-dht1', pinName: 'GND' }, color: '#000000', }, { id: 'c3d-sda', - start: { componentId: 'esp32-c3', pinName: '3' }, + start: { componentId: 'arduino-uno', pinName: '3' }, end: { componentId: 'c3-dht1', pinName: 'SDA' }, color: '#22aaff', }, @@ -6182,25 +6182,25 @@ void loop() { wires: [ { id: 'c3s-vcc', - start: { componentId: 'esp32-c3', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'c3-sr1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'c3s-gnd', - start: { componentId: 'esp32-c3', pinName: 'GND.9' }, + start: { componentId: 'arduino-uno', pinName: 'GND.9' }, end: { componentId: 'c3-sr1', pinName: 'GND' }, color: '#000000', }, { id: 'c3s-trig', - start: { componentId: 'esp32-c3', pinName: '5' }, + start: { componentId: 'arduino-uno', pinName: '5' }, end: { componentId: 'c3-sr1', pinName: 'TRIG' }, color: '#ff8800', }, { id: 'c3s-echo', - start: { componentId: 'esp32-c3', pinName: '6' }, + start: { componentId: 'arduino-uno', pinName: '6' }, end: { componentId: 'c3-sr1', pinName: 'ECHO' }, color: '#22cc22', }, @@ -6252,19 +6252,19 @@ void loop() { wires: [ { id: 'c3p-vcc', - start: { componentId: 'esp32-c3', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'c3-pir1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'c3p-gnd', - start: { componentId: 'esp32-c3', pinName: 'GND.9' }, + start: { componentId: 'arduino-uno', pinName: 'GND.9' }, end: { componentId: 'c3-pir1', pinName: 'GND' }, color: '#000000', }, { id: 'c3p-out', - start: { componentId: 'esp32-c3', pinName: '7' }, + start: { componentId: 'arduino-uno', pinName: '7' }, end: { componentId: 'c3-pir1', pinName: 'OUT' }, color: '#ffcc00', }, @@ -6305,19 +6305,19 @@ void loop() { wires: [ { id: 'c3sv-vcc', - start: { componentId: 'esp32-c3', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'c3-sv1', pinName: 'V+' }, color: '#ff4444', }, { id: 'c3sv-gnd', - start: { componentId: 'esp32-c3', pinName: 'GND.9' }, + start: { componentId: 'arduino-uno', pinName: 'GND.9' }, end: { componentId: 'c3-sv1', pinName: 'GND' }, color: '#000000', }, { id: 'c3sv-pwm', - start: { componentId: 'esp32-c3', pinName: '10' }, + start: { componentId: 'arduino-uno', pinName: '10' }, end: { componentId: 'c3-sv1', pinName: 'PWM' }, color: '#ff8800', }, @@ -6873,25 +6873,25 @@ void loop() { wires: [ { id: 'e32b-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-bmp1', pinName: 'VCC' }, color: '#ff4444', }, { id: 'e32b-gnd', - start: { componentId: 'esp32', pinName: 'GND' }, + start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'e32-bmp1', pinName: 'GND' }, color: '#000000', }, { id: 'e32b-sda', - start: { componentId: 'esp32', pinName: '21' }, + start: { componentId: 'arduino-uno', pinName: '21' }, end: { componentId: 'e32-bmp1', pinName: 'SDA' }, color: '#22aaff', }, { id: 'e32b-scl', - start: { componentId: 'esp32', pinName: '22' }, + start: { componentId: 'arduino-uno', pinName: '22' }, end: { componentId: 'e32-bmp1', pinName: 'SCL' }, color: '#ff8800', }, @@ -6959,25 +6959,25 @@ void loop() { wires: [ { id: 'e32o-vcc', - start: { componentId: 'esp32', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'e32-oled1', pinName: '3V3' }, color: '#ff4444', }, { id: 'e32o-gnd', - start: { componentId: 'esp32', pinName: 'GND' }, + start: { componentId: 'arduino-uno', pinName: 'GND' }, end: { componentId: 'e32-oled1', pinName: 'GND' }, color: '#000000', }, { id: 'e32o-sda', - start: { componentId: 'esp32', pinName: '21' }, + start: { componentId: 'arduino-uno', pinName: '21' }, end: { componentId: 'e32-oled1', pinName: 'DATA' }, color: '#22aaff', }, { id: 'e32o-scl', - start: { componentId: 'esp32', pinName: '22' }, + start: { componentId: 'arduino-uno', pinName: '22' }, end: { componentId: 'e32-oled1', pinName: 'CLK' }, color: '#ff8800', }, @@ -7537,50 +7537,50 @@ void loop() { // SPI bus { id: 'cam-w-mosi', - start: { componentId: 'esp32-cam', pinName: '13' }, + start: { componentId: 'arduino-uno', pinName: '13' }, end: { componentId: 'tft1', pinName: 'MOSI' }, color: '#3498db', }, { id: 'cam-w-sck', - start: { componentId: 'esp32-cam', pinName: '12' }, + start: { componentId: 'arduino-uno', pinName: '12' }, end: { componentId: 'tft1', pinName: 'SCK' }, color: '#27ae60', }, { id: 'cam-w-cs', - start: { componentId: 'esp32-cam', pinName: '15' }, + start: { componentId: 'arduino-uno', pinName: '15' }, end: { componentId: 'tft1', pinName: 'CS' }, color: '#e67e22', }, { id: 'cam-w-dc', - start: { componentId: 'esp32-cam', pinName: '14' }, + start: { componentId: 'arduino-uno', pinName: '14' }, end: { componentId: 'tft1', pinName: 'D/C' }, color: '#f1c40f', }, { id: 'cam-w-rst', - start: { componentId: 'esp32-cam', pinName: '2' }, + start: { componentId: 'arduino-uno', pinName: '2' }, end: { componentId: 'tft1', pinName: 'RST' }, color: '#ecf0f1', }, // Power { id: 'cam-w-vcc', - start: { componentId: 'esp32-cam', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'tft1', pinName: 'VCC' }, color: '#e74c3c', }, { id: 'cam-w-led', - start: { componentId: 'esp32-cam', pinName: '3V3' }, + start: { componentId: 'arduino-uno', pinName: '3V3' }, end: { componentId: 'tft1', pinName: 'LED' }, color: '#e74c3c', }, { id: 'cam-w-gnd', - start: { componentId: 'esp32-cam', pinName: 'GND.2' }, + start: { componentId: 'arduino-uno', pinName: 'GND.2' }, end: { componentId: 'tft1', pinName: 'GND' }, color: '#2c3e50', }, diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index 2dca3252..d846a481 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -427,6 +427,14 @@ export class AVRSimulator { if (!this.cpu) return; console.log('Setting up pin hooks...'); + // DDR register addresses (used to distinguish OUTPUT pins from + // INPUT_PULLUP — see PinManager.updatePort ddrMask param). + // ATmega328P/Uno/Nano: DDRB=0x24, DDRC=0x27, DDRD=0x2A + // ATtiny85: DDRB=0x37 + // ATmega2560: per-port table below + const cpu = this.cpu; + const readDdr = (addr: number) => cpu.data[addr] ?? 0; + if (this.boardVariant === 'tiny85') { // ATtiny85: PORTB only, PB0-PB5 → pins 0-5 // Must pass an explicit pinMap so updatePort uses offset 0 instead of the @@ -434,20 +442,26 @@ export class AVRSimulator { const TINY85_PIN_MAP = [0, 1, 2, 3, 4, 5, -1, -1]; this.portB!.addListener((value) => { if (value !== this.lastPortBValue) { - this.pinManager.updatePort('PORTB', value, this.lastPortBValue, TINY85_PIN_MAP); + this.pinManager.updatePort('PORTB', value, this.lastPortBValue, TINY85_PIN_MAP, readDdr(0x37)); this.firePinChangeWithTime(value, this.lastPortBValue, null, 0); this.lastPortBValue = value; } }); } else if (this.boardVariant === 'mega') { // Mega: use explicit per-bit pin maps for all 11 ports + const MEGA_DDR_ADDRS: Record = { + PORTA: 0x21, PORTB: 0x24, PORTC: 0x27, PORTD: 0x2A, + PORTE: 0x2D, PORTF: 0x30, PORTG: 0x33, PORTH: 0x101, + PORTJ: 0x104, PORTK: 0x107, PORTL: 0x10A, + }; for (const [portName, port] of this.megaPorts) { const pinMap = MEGA_PORT_BIT_MAP[portName]; + const ddrAddr = MEGA_DDR_ADDRS[portName]; this.megaPortValues.set(portName, 0); port.addListener((value) => { const old = this.megaPortValues.get(portName) ?? 0; if (value !== old) { - this.pinManager.updatePort(portName, value, old, pinMap); + this.pinManager.updatePort(portName, value, old, pinMap, ddrAddr ? readDdr(ddrAddr) : undefined); this.firePinChangeWithTime(value, old, pinMap); this.megaPortValues.set(portName, value); } @@ -457,21 +471,21 @@ export class AVRSimulator { // Uno / Nano: simple 3-port setup this.portB!.addListener((value) => { if (value !== this.lastPortBValue) { - this.pinManager.updatePort('PORTB', value, this.lastPortBValue); + this.pinManager.updatePort('PORTB', value, this.lastPortBValue, undefined, readDdr(0x24)); this.firePinChangeWithTime(value, this.lastPortBValue, null, 8); this.lastPortBValue = value; } }); this.portC!.addListener((value) => { if (value !== this.lastPortCValue) { - this.pinManager.updatePort('PORTC', value, this.lastPortCValue); + this.pinManager.updatePort('PORTC', value, this.lastPortCValue, undefined, readDdr(0x27)); this.firePinChangeWithTime(value, this.lastPortCValue, null, 14); this.lastPortCValue = value; } }); this.portD!.addListener((value) => { if (value !== this.lastPortDValue) { - this.pinManager.updatePort('PORTD', value, this.lastPortDValue); + this.pinManager.updatePort('PORTD', value, this.lastPortDValue, undefined, readDdr(0x2A)); this.firePinChangeWithTime(value, this.lastPortDValue, null, 0); this.lastPortDValue = value; } diff --git a/frontend/src/simulation/Esp32C3Simulator.ts b/frontend/src/simulation/Esp32C3Simulator.ts index baf9fc76..be0347e1 100644 --- a/frontend/src/simulation/Esp32C3Simulator.ts +++ b/frontend/src/simulation/Esp32C3Simulator.ts @@ -298,7 +298,7 @@ export class Esp32C3Simulator { `[ESP32-C3] GPIO${bit} → ${state ? 'HIGH' : 'LOW'} @ ${timeMs.toFixed(1)}ms`, ); this.onPinChangeWithTime?.(bit, state, timeMs); - this.pinManager.setPinState(bit, state); + this.pinManager.setPinState(bit, state, 'mcu'); } } } diff --git a/frontend/src/simulation/PinManager.ts b/frontend/src/simulation/PinManager.ts index 19fa2596..4551c26a 100644 --- a/frontend/src/simulation/PinManager.ts +++ b/frontend/src/simulation/PinManager.ts @@ -27,6 +27,12 @@ export class PinManager { private analogListeners: Map> = new Map(); private pinStates: Map = new Map(); private pwmValues: Map = new Map(); + // Pins the MCU has driven (digitalWrite / PWM / port-listener fire). + // Consumed by collectPinStates.ts to emit a SPICE V-source only for + // real outputs — leaving INPUT pins floating so external sensors + // (NTC + divider on A0, photoresistor, etc.) don't get clamped to + // the MCU's idle V-source. + private outputPins: Set = new Set(); // ── Digital pin API ────────────────────────────────────────────────────── @@ -54,8 +60,21 @@ export class PinManager { * Use -1 for bits that are not exposed as Arduino pins. * When omitted the legacy Uno/Nano fixed offsets are used: * PORTB→8, PORTC→14, PORTD→0. + * @param ddrMask Optional DDR register value (8 bits). When provided, + * a pin is added to `outputPins` only if its DDR bit is + * 1 (the AVR is actively driving it as OUTPUT). Without + * this guard, the PORTx write that activates INPUT_PULLUP + * (DDR=0, PORT=1) falsely marks the pin as MCU output + * and emits an ideal V-source on the SPICE side, fighting + * the real external circuit (button, sensor, pull-down). */ - updatePort(portName: string, newValue: number, oldValue: number = 0, pinMap?: number[]) { + updatePort( + portName: string, + newValue: number, + oldValue: number = 0, + pinMap?: number[], + ddrMask?: number, + ) { const legacyOffsets: Record = { PORTB: 8, PORTC: 14, PORTD: 0 }; for (let bit = 0; bit < 8; bit++) { @@ -68,6 +87,10 @@ export class PinManager { if (arduinoPin < 0) continue; // unmapped bit this.pinStates.set(arduinoPin, newState); + // Only mark as MCU-output if DDR bit is set (or DDR unknown → legacy). + if (ddrMask === undefined || (ddrMask & mask) !== 0) { + this.outputPins.add(arduinoPin); + } const callbacks = this.listeners.get(arduinoPin); if (callbacks) { @@ -84,32 +107,47 @@ export class PinManager { /** * Set a single pin state and notify listeners. * Alias for triggerPinChange — used by ESP32-C3, RISC-V, and RP2040 simulators. + * + * `source` distinguishes MCU GPIO writes (mark pin as output for SPICE) + * from external actors like buttons or sensor parts (don't mark). */ - setPinState(pin: number, state: boolean): void { - this.triggerPinChange(pin, state); + setPinState(pin: number, state: boolean, source: 'mcu' | 'external' = 'external'): void { + this.triggerPinChange(pin, state, source); } /** * Directly fire pin change callbacks for a specific pin. * Used by RP2040Simulator which has individual GPIO listeners instead of PORT registers. */ - triggerPinChange(pin: number, state: boolean): void { + triggerPinChange(pin: number, state: boolean, source: 'mcu' | 'external' = 'external'): void { const current = this.pinStates.get(pin); - if (current === state) return; // no change + if (current === state) { + if (source === 'mcu') this.outputPins.add(pin); + return; + } this.pinStates.set(pin, state); + if (source === 'mcu') this.outputPins.add(pin); const callbacks = this.listeners.get(pin); if (callbacks) { callbacks.forEach((cb) => cb(pin, state)); } } + /** Pins the MCU has actively driven this session. */ + getOutputPins(): ReadonlySet { + return this.outputPins; + } + /** - * Clear cached pin states (without removing listeners). - * Useful in tests so that a fresh `triggerPinChange` after a reset - * isn't suppressed by the same-state early-return. + * Clear cached pin states + output-pin classifications. Called by + * stopBoard / resetBoard so the next Run starts without stale output + * classifications from a previous session forcing premature V-source + * emission. Also keeps the test fixtures' fresh-triggerPinChange path + * (the original reason this helper exists) working. */ resetPinStates(): void { this.pinStates.clear(); + this.outputPins.clear(); } // ── PWM duty cycle API ─────────────────────────────────────────────────── @@ -133,6 +171,7 @@ export class PinManager { */ updatePwm(pin: number, dutyCycle: number): void { this.pwmValues.set(pin, dutyCycle); + if (dutyCycle > 0) this.outputPins.add(pin); const callbacks = this.pwmListeners.get(pin); if (callbacks) { callbacks.forEach((cb) => cb(pin, dutyCycle)); @@ -209,5 +248,6 @@ export class PinManager { this.listeners.clear(); this.pwmListeners.clear(); this.analogListeners.clear(); + this.outputPins.clear(); } } diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index 04adf228..cf8d5bff 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -513,7 +513,7 @@ export class RP2040Simulator { const unsub = gpio.addListener((state: GPIOPinState) => { const isHigh = state === GPIOPinState.High || state === GPIOPinState.InputPullUp; - this.pinManager.triggerPinChange(pin, isHigh); + this.pinManager.triggerPinChange(pin, isHigh, 'mcu'); if (this.onPinChangeWithTime && this.rp2040) { // IClock interface exposes `nanos` (not `timeUs`) // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/frontend/src/simulation/RiscVSimulator.ts b/frontend/src/simulation/RiscVSimulator.ts index 07060ea4..eac70909 100644 --- a/frontend/src/simulation/RiscVSimulator.ts +++ b/frontend/src/simulation/RiscVSimulator.ts @@ -139,7 +139,7 @@ export class RiscVSimulator { const pin = pinOffset + bit; const state = !!(val & (1 << bit)); this.onPinChangeWithTime?.(pin, state, timeMs); - this.pinManager.setPinState(pin, state); + this.pinManager.setPinState(pin, state, 'mcu'); } } } diff --git a/frontend/src/simulation/parts/BasicParts.ts b/frontend/src/simulation/parts/BasicParts.ts index ebfc8137..570603c5 100644 --- a/frontend/src/simulation/parts/BasicParts.ts +++ b/frontend/src/simulation/parts/BasicParts.ts @@ -13,6 +13,13 @@ PartSimulationRegistry.register('pushbutton', { getArduinoPinHelper('1.r') ?? getArduinoPinHelper('2.r'); + // Seed the input pin HIGH so `digitalRead()` returns HIGH while the + // button is idle. avr8js does not auto-simulate INPUT_PULLUP — without + // this, the firmware reads LOW from the moment loop() starts and + // believes the button is permanently pressed (the classic "LED is + // always on, pressing the button does nothing" UX bug). + if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true); + const onButtonPress = () => { if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false); // Active LOW (element as any).pressed = true; @@ -44,6 +51,10 @@ PartSimulationRegistry.register('pushbutton-6mm', { getArduinoPinHelper('1.r') ?? getArduinoPinHelper('2.r'); + // Same INPUT_PULLUP seeding as the full-size pushbutton — see comment + // in `register('pushbutton', ...)` above for why this is required. + if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, true); + const onPress = () => { if (arduinoPin !== null) avrSimulator.setPinState(arduinoPin, false); (element as any).pressed = true; diff --git a/frontend/src/simulation/spice/CircuitSimulationService.ts b/frontend/src/simulation/spice/CircuitSimulationService.ts index 5538bd57..10dea897 100644 --- a/frontend/src/simulation/spice/CircuitSimulationService.ts +++ b/frontend/src/simulation/spice/CircuitSimulationService.ts @@ -26,7 +26,7 @@ * - No SPICE-engine knowledge — that's in the adapters. */ import { buildInputFromStore } from './storeAdapter'; -import { buildNetlist } from './NetlistBuilder'; +import { buildNetlist, sanitizeSpiceId } from './NetlistBuilder'; import type { TimeWaveforms } from './types'; /** What the service needs from the simulator store. */ @@ -106,8 +106,15 @@ export interface ServiceOptions { export class CircuitSimulationService { private inFlight = false; private pending = false; - private pendingMcuEdge: { boardId: string; pinName: string; state: boolean; vcc: number } | null = - null; + // Per-pin pending edge buffer. Keyed by `${boardId}|${pinName}`. A single + // slot would be overwritten when several pins toggle during the same + // in-flight tick (Traffic Light: red→yellow→green hand off within ~µs of + // each other) — the LATEST edge would win, dropping the rest. With a Map + // every pin's most-recent edge is preserved and replayed after the tick. + private pendingMcuEdges = new Map< + string, + { boardId: string; pinName: string; state: boolean; vcc: number } + >(); /** * Last loaded circuit context — used by `handleMcuEdge` to extract @@ -145,10 +152,12 @@ export class CircuitSimulationService { if (this.pending) { this.pending = false; void this.tick(); - } else if (this.pendingMcuEdge) { - const edge = this.pendingMcuEdge; - this.pendingMcuEdge = null; - void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc); + } else if (this.pendingMcuEdges.size > 0) { + const edges = Array.from(this.pendingMcuEdges.values()); + this.pendingMcuEdges.clear(); + for (const edge of edges) { + void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc); + } } } } @@ -160,13 +169,15 @@ export class CircuitSimulationService { * voltages to useElectricalStore. * * Coalesces with the canvas-change tick: if a full solve is in - * flight, the edge is queued and replayed after that solve - * completes (so the netlist is fresh). Last-edge-wins per pin - * since edges overwrite the same field. + * flight, the edge is queued per-pin (Map keyed by `boardId|pinName`) + * and replayed after the tick completes — last-state-wins PER pin, + * so different pins toggling during the same tick don't drop each + * other's edges. */ async handleMcuEdge(boardId: string, pinName: string, state: boolean, vcc: number): Promise { + const pinKey = `${boardId}|${pinName}`; if (this.inFlight) { - this.pendingMcuEdge = { boardId, pinName, state, vcc }; + this.pendingMcuEdges.set(pinKey, { boardId, pinName, state, vcc }); return; } if (!this.loadedContext) { @@ -175,6 +186,20 @@ export class CircuitSimulationService { void this.tick(); return; } + // Self-heal: if this is the FIRST edge on a pin that wasn't classified + // as MCU-output when the netlist was built, the matching V-source + // doesn't exist — alterSource would be a silent no-op. Trigger a + // full rebuild instead; collectPinStates now sees the pin in + // outputPins and emits the V-source, so the next edge alters normally. + const expected = `v_${sanitizeSpiceId(boardId)}_${sanitizeSpiceId(pinName)}`.toLowerCase(); + const hasSource = this.loadedContext.voltageSources.some( + (vs) => vs.toLowerCase() === expected, + ); + if (!hasSource) { + this.pendingMcuEdges.set(pinKey, { boardId, pinName, state, vcc }); + void this.tick(); + return; + } this.inFlight = true; try { // alter + resolveDc internally; the scheduler's @@ -189,6 +214,12 @@ export class CircuitSimulationService { if (this.pending) { this.pending = false; void this.tick(); + } else if (this.pendingMcuEdges.size > 0) { + const edges = Array.from(this.pendingMcuEdges.values()); + this.pendingMcuEdges.clear(); + for (const edge of edges) { + void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc); + } } } } diff --git a/frontend/src/simulation/spice/MixedModeScheduler.ts b/frontend/src/simulation/spice/MixedModeScheduler.ts index 86a64ad0..0c0628e4 100644 --- a/frontend/src/simulation/spice/MixedModeScheduler.ts +++ b/frontend/src/simulation/spice/MixedModeScheduler.ts @@ -37,6 +37,7 @@ import type { SolverPort, SolveAnalysis } from './ports/SolverPort'; import { NgSpiceWorkerAdapter } from './adapters/NgSpiceWorkerAdapter'; +import { sanitizeSpiceId } from './NetlistBuilder'; import type { PinState, SpiceVoltageSource } from '../PinResolver'; /** @@ -249,7 +250,10 @@ class MixedModeSchedulerImpl implements SpiceVoltageSource { vcc: number, ): Promise { if (!this.solver) return; - const sourceName = `V_${boardId}_${pinName}`; + // Must match the sanitized name NetlistBuilder emits — see comment + // at the V-source emission site for why ngspice's `alter` command + // can't accept hyphens. + const sourceName = `V_${sanitizeSpiceId(boardId)}_${sanitizeSpiceId(pinName)}`; const voltage = state ? vcc : 0; await this.solver.alterSource(sourceName, voltage); await this.resolveDc(); diff --git a/frontend/src/simulation/spice/NetlistBuilder.ts b/frontend/src/simulation/spice/NetlistBuilder.ts index ccbb0919..a040de18 100644 --- a/frontend/src/simulation/spice/NetlistBuilder.ts +++ b/frontend/src/simulation/spice/NetlistBuilder.ts @@ -30,6 +30,21 @@ function skipCanonicalization(metadataId: string): boolean { return metadataId.startsWith('instr-'); } +/** + * Sanitize a component / board / pin id for use inside a SPICE identifier + * (V-source name, etc.). ngspice's interactive `alter` command treats `-` + * as an arithmetic / range operator and silently no-ops on hyphenated + * source names — so even though the netlist *parser* tolerates hyphens + * via the regex on line 206, mid-simulation MCU pin transitions stop + * propagating after the first solve. Substituting `-` → `_` here keeps + * the identifier ngspice-safe AND consistent with whatever the scheduler + * passes to `alter`. Must be reused by MixedModeScheduler when building + * the alter target name. + */ +export function sanitizeSpiceId(id: string): string { + return id.replace(/-/g, '_'); +} + export interface BuildNetlistResult { netlist: string; /** "boardId:pinName" → SPICE net name, from the same UF used to build the netlist. */ @@ -81,6 +96,18 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult { for (const pinName of board.vccPinNames ?? []) { uf.setCanonical(pinKey(board.id, pinName), 'vcc_rail'); } + // Fallback: any board pin a wire references whose name looks like a + // ground pin (GND, GND.1, GND.9, etc.) is canonicalized to "0" even if + // it's not in `groundPinNames`. Boards with many GND pins (ESP32-C3 + // dev kits ship up to 10) often miss some in the per-board list, which + // would leave wires connected to those pins floating instead of grounded. + for (const pinName of pinsReferencedByWires(board.id, wires)) { + if (GROUND_PIN_RE.test(pinName)) { + uf.setCanonical(pinKey(board.id, pinName), '0'); + } else if (VCC_PIN_RE.test(pinName)) { + uf.setCanonical(pinKey(board.id, pinName), 'vcc_rail'); + } + } } for (const comp of components) { if (skipCanonicalization(comp.metadataId)) continue; @@ -117,6 +144,14 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult { } // ── 5. Board GPIO sources ───────────────────────────────────────────────── + // Hyphens are kept in the V-source name (NetlistBuilder regex on line 206 + // captures `[_\w-]*`), so ngspice itself can parse + load the source and + // expose the branch current as `v_-#branch` — visible in the + // canvas voltmeters / branch-current map. BUT ngspice's *interactive* + // `alter` command treats `-` as an operator and silently no-ops on + // hyphenated source names — making MCU pin transitions stop propagating + // after the very first solve. So MixedModeScheduler.onMcuPinChange must + // build the same sanitized name we emit here. See sanitizeSpiceId(). for (const board of boards) { for (const [pinName, state] of Object.entries(board.pins)) { if (state.type === 'input') continue; // don't drive the pin @@ -124,7 +159,7 @@ export function buildNetlist(input: BuildNetlistInput): BuildNetlistResult { if (!net) continue; if (net === '0' || net === 'vcc_rail') continue; // already served const v = state.type === 'digital' ? state.v : state.duty * board.vcc; - cards.push(`V_${board.id}_${pinName} ${net} 0 DC ${v}`); + cards.push(`V_${sanitizeSpiceId(board.id)}_${sanitizeSpiceId(pinName)} ${net} 0 DC ${v}`); } } diff --git a/frontend/src/simulation/spice/collectPinStates.ts b/frontend/src/simulation/spice/collectPinStates.ts index 4a06ceb0..fc8ccf2c 100644 --- a/frontend/src/simulation/spice/collectPinStates.ts +++ b/frontend/src/simulation/spice/collectPinStates.ts @@ -66,32 +66,32 @@ export function collectPinStates( if (w.end.componentId === boardId) pinNames.add(w.end.pinName); } + const outputPins = pm.getOutputPins(); + for (const pinName of pinNames) { const arduinoPin = pinNameToArduinoPin(pinName, boardKind); if (arduinoPin < 0) continue; const pwmDuty = pm.getPwmValue(arduinoPin); if (pwmDuty > 0) { result[pinName] = { type: 'pwm', duty: pwmDuty }; - } else { - // ALWAYS emit a digital source — even when the pin is currently - // LOW. NetlistBuilder turns this into a `V__` card, - // and MixedModeScheduler.onMcuPinChange later calls - // `alterSource('V__', vcc | 0)` on every MCU edge. - // If the V-source isn't in the netlist (because we skipped it - // here while LOW), the alter is a silent no-op against a name - // that doesn't exist; the LED then never lights up no matter - // how many times the sketch toggles the pin afterwards. - // - // This was the root cause of the reported "sometimes the LED - // works after Run, sometimes it doesn't" symptom. The behaviour - // was deterministic but appeared random because it depended on - // whether the AVR happened to be in the HIGH half of its blink - // cycle at the moment runSolve() captured pin states. + continue; + } + if (outputPins.has(arduinoPin)) { + // The MCU has actually driven this pin at least once + // (digitalWrite / PWM / port-listener fire), so emit a V-source + // for NetlistBuilder. MixedModeScheduler.onMcuPinChange will + // alterSource() on each subsequent edge; the V-source needs to + // exist in the netlist for the alter to bind (otherwise it's a + // silent no-op and the LED never updates). result[pinName] = { type: 'digital', v: pm.getPinState(arduinoPin) ? vcc : 0, }; } + // else: leave the net free — external components (sensor divider, + // pull-up, button, etc.) drive the SPICE node. Without this guard, + // an unconditional V-source at 0 V would short-circuit any analog + // sensor on the pin and analogRead() would always return 0. } return result; } diff --git a/frontend/src/simulation/spice/connectMcuEdgesToService.ts b/frontend/src/simulation/spice/connectMcuEdgesToService.ts index ac7bbbfb..035b861a 100644 --- a/frontend/src/simulation/spice/connectMcuEdgesToService.ts +++ b/frontend/src/simulation/spice/connectMcuEdgesToService.ts @@ -128,9 +128,35 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): () if (!pinName) continue; if (wanted.size > 0 && !wanted.has(pinName)) continue; const unsub = pm.onPinChange(pin, (_p, state) => { + // Suppress digital edges when the pin has active PWM. The OCR-based + // PWM duty is converted to a DC-averaged voltage in NetlistBuilder + // (`state.duty * board.vcc`), giving smooth analog dimming. If we + // also let the Timer1/Timer2-driven port toggles fire alterSource, + // each PWM cycle's HIGH/LOW transition would race with the duty + // average and force the V-source to bounce between 0 and vcc — + // making `analogWrite(pin, 128)` look like a binary blink instead + // of a steady 2.5 V (Fade-LED example regression). + if (pm.getPwmValue(pin) > 0) return; schedulePin(boardId, pinName, state, vcc); }); pinSubs.set(pin, unsub); + + // Re-tick when PWM duty changes so the duty-averaged V-source picks + // up new analogWrite values. Without this, duty stays whatever it was + // at first solve and `analogWrite()` in a loop never updates the + // visible LED. Throttled to ~60 Hz to amortise the netlist-rebuild + // cost (the firmware ramps brightness every 30 ms in the canonical + // Fade-LED example, well within this budget). + let pwmTickPending = false; + const unsubPwm = pm.onPwmChange(pin, () => { + if (pwmTickPending) return; + pwmTickPending = true; + setTimeout(() => { + pwmTickPending = false; + void service.tick(); + }, 16); + }); + pinSubs.set(pin + 1000, unsubPwm); // key offset to avoid collision } } diff --git a/frontend/src/simulation/spice/start.ts b/frontend/src/simulation/spice/start.ts index b9104136..3768386e 100644 --- a/frontend/src/simulation/spice/start.ts +++ b/frontend/src/simulation/spice/start.ts @@ -17,7 +17,7 @@ * * Phase 1c step G1 of the mixed-mode migration. */ -import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { useSimulatorStore, getBoardPinManager } from '../../store/useSimulatorStore'; import { useElectricalStore } from '../../store/useElectricalStore'; import { getMixedModeScheduler, @@ -86,22 +86,41 @@ export function startSimulation(): () => void { // to get a snapshot of the simulation state (analysis mode, voltage // count, pin map, last solve time, etc.). Useful for diagnosing // "why is my circuit not solving?" reports from users. - (window as unknown as { __spiceDebug?: () => void }).__spiceDebug = () => { + (window as unknown as { __spiceDebug?: () => unknown }).__spiceDebug = () => { const electrical = useElectricalStore.getState(); - // eslint-disable-next-line no-console - console.log('[__spiceDebug]', { + // Probe: collect every board's outputPins set so we can verify the + // MCU-direction-tracking fix from the harness. + const outputPinsByBoard: Record = {}; + try { + const boards = useSimulatorStore.getState().boards; + for (const b of boards) { + const pm = getBoardPinManager(b.id); + if (pm && typeof pm.getOutputPins === 'function') { + outputPinsByBoard[b.id] = [...pm.getOutputPins()]; + } + } + } catch { + // ignore — fallback already covered by snapshot fields below + } + const snapshot = { analysisMode: electrical.analysisMode, converged: electrical.converged, error: electrical.error, lastSolveMs: electrical.lastSolveMs, nodeVoltageCount: Object.keys(electrical.nodeVoltages).length, branchCurrentCount: Object.keys(electrical.branchCurrents).length, + branchCurrentNames: Object.keys(electrical.branchCurrents), pinNetMapSize: electrical.pinNetMap.size, + pinNetMapEntries: [...electrical.pinNetMap.entries()], + nodeVoltages: { ...electrical.nodeVoltages }, hasTimeWaveforms: !!electrical.timeWaveforms, paused: electrical.paused, - sampleVoltages: Object.entries(electrical.nodeVoltages).slice(0, 8), - pinNetSample: [...electrical.pinNetMap.entries()].slice(0, 8), - }); + outputPinsByBoard, + }; + (window as unknown as { __lastSpice?: unknown }).__lastSpice = snapshot; + // eslint-disable-next-line no-console + console.log('[__spiceDebug]', snapshot); + return snapshot; }; return () => { diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 81a07431..05221a15 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -969,7 +969,7 @@ export const useSimulatorStore = create((set, get) => { bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(id); - if (boardPm) boardPm.triggerPinChange(gpioPin, state); + if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; bridge.onCrash = () => { set({ esp32CrashBoardId: id }); @@ -1157,19 +1157,6 @@ export const useSimulatorStore = create((set, get) => { ), })); } - if (b.boardOptions || b.spiffsFiles) { - set((s) => ({ - boards: s.boards.map((bb) => - bb.id === b.id - ? { - ...bb, - boardOptions: b.boardOptions, - spiffsFiles: b.spiffsFiles, - } - : bb, - ), - })); - } }); // Replace editor file groups atomically. Skip groups that already exist @@ -1549,6 +1536,11 @@ export const useSimulatorStore = create((set, get) => { getBoardSimulator(boardId)?.stop(); } + // Drop MCU-output classification so the next Run starts clean and + // collectPinStates doesn't emit stale V-sources before the new + // firmware has driven any pins. + getBoardPinManager(boardId)?.resetPinStates(); + set((s) => { const boards = s.boards.map((b) => (b.id === boardId ? { ...b, running: false } : b)); const isActive = s.activeBoardId === boardId; @@ -1571,6 +1563,9 @@ export const useSimulatorStore = create((set, get) => { const sim = getBoardSimulator(boardId); if (sim) { sim.reset(); + // Drop MCU-output classification so the new program starts + // clean — no stale V-sources from the previous run. + getBoardPinManager(boardId)?.resetPinStates(); // Re-wire serial callback after reset sim.onSerialData = (ch) => appendSerial(boardId, ch); if (sim instanceof AVRSimulator) { @@ -1637,7 +1632,7 @@ export const useSimulatorStore = create((set, get) => { bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(boardId); - if (boardPm) boardPm.triggerPinChange(gpioPin, state); + if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; bridge.onCrash = () => { set({ esp32CrashBoardId: boardId }); @@ -1739,7 +1734,7 @@ export const useSimulatorStore = create((set, get) => { bridge.onSerialData = serialCallback; bridge.onPinChange = (gpioPin, state) => { const boardPm = pinManagerMap.get(boardId); - if (boardPm) boardPm.triggerPinChange(gpioPin, state); + if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; bridge.onCrash = () => { set({ esp32CrashBoardId: boardId }); diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 7705f423..b73163c3 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -129,5 +129,5 @@ export const BOARD_KIND_FQBN: Record = { 'esp32-c3': 'esp32:esp32:esp32c3', 'xiao-esp32-c3': 'esp32:esp32:XIAO_ESP32C3', 'aitewinrobot-esp32c3-supermini': 'esp32:esp32:esp32c3', - attiny85: 'ATTinyCore:avr:attinyx5:chip=85,clock=internal16mhz', + attiny85: 'ATTinyCore:avr:attinyx5:chip=85,clock=16pll', }; diff --git a/scripts/visual-led-test.mjs b/scripts/visual-led-test.mjs new file mode 100644 index 00000000..7987d8ad --- /dev/null +++ b/scripts/visual-led-test.mjs @@ -0,0 +1,297 @@ +// scripts/visual-led-test.mjs +// +// Automated visual LED test — drives the running vite + backend stack via +// the Chrome DevTools Protocol (CDP). For each named example it: +// +// 1. Navigates to /example/ +// 2. Clicks the Run button (auto-compiles + starts the sim) +// 3. Waits up to `simulateMs` for the LED brightness to swing +// 4. Asserts: `wokwi-led.brightness > 0` AT LEAST ONCE (LED lights up) +// 5. Asserts: `wokwi-led.brightness == 0` AT LEAST ONCE (LED toggles off) +// — this catches "stuck on" bugs the snapshot tests miss. +// +// Why this matters: unit tests + snapshot tests check static structure +// (netlist string, SPICE cards). They don't run firmware, don't render +// LEDs, and don't observe whether `wokwi-led.brightness` ever moves. Every +// LED-related bug in the recent sprint (INPUT_PULLUP false positive, +// hyphen-truncated V-source, missing pushbutton init, ESP32-C3 compile +// failure) shipped past green unit tests and was only caught by manual +// browser checks. This script makes that check repeatable. +// +// Prerequisites: +// - Chrome running with `--remote-debugging-port=9222` +// - `vite` on http://localhost:5174 +// - `uvicorn app.main:app --port 8001` with arduino-cli + cores +// +// Usage: +// node scripts/visual-led-test.mjs # default suite +// node scripts/visual-led-test.mjs blink-led # single example +// FRONTEND=http://localhost:5175 node scripts/visual-led-test.mjs +// +// Exits 0 on PASS, 1 on FAIL. + +const CDP_HTTP = process.env.CDP_HTTP || 'http://localhost:9222'; +const FRONTEND = process.env.FRONTEND || 'http://localhost:5174'; + +// Each entry: { slug, label?, simulateMs?, expectToggle?, allowAlwaysOn? } +// expectToggle (default true) — require BOTH a lit sample AND a dark +// sample. Disable for "permanently on" sketches like sensor read-outs. +// allowAlwaysOn — accept "lit but never off" as a pass (still requires lit). +// simulateMs — total observation window after Run (default 12000). +const DEFAULT_SUITE = [ + { slug: 'blink-led', label: 'Blink (AVR digitalWrite)', simulateMs: 12000 }, + { slug: 'button-led', label: 'Button + INPUT_PULLUP', simulateMs: 6000, + expectInitialOff: true, expectLitAfterRun: false, + note: 'LED MUST be off idle AND stay off without button press — catches INPUT_PULLUP false positive' }, + { slug: 'traffic-light', label: 'Multi-pin sequencing', simulateMs: 14000 }, + { slug: 'fade-led', label: 'PWM fade gradient', simulateMs: 14000, + expectGradient: true, + note: 'Brightness must hit 3+ distinct non-zero values (smooth fade)' }, + { slug: 'rgb-led', label: 'RGB 3-PWM driven', simulateMs: 12000, + leafCheck: 'pwmActive' }, +]; + +// ── CDP plumbing ────────────────────────────────────────────────────────── + +async function getPages() { + const res = await fetch(`${CDP_HTTP}/json`); + return await res.json(); +} + +class CDP { + constructor(wsUrl) { + this.ws = new WebSocket(wsUrl); + this.id = 0; + this.pending = new Map(); + this.events = new Map(); + this.ready = new Promise((resolve, reject) => { + this.ws.addEventListener('open', () => resolve(), { once: true }); + this.ws.addEventListener('error', (e) => reject(e), { once: true }); + }); + this.ws.addEventListener('message', (ev) => { + const msg = JSON.parse(ev.data.toString()); + if (msg.id != null) { + const p = this.pending.get(msg.id); + if (p) { + this.pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error.message)); + else p.resolve(msg.result); + } + } else if (msg.method) { + const handlers = this.events.get(msg.method); + if (handlers) handlers.forEach((h) => h(msg.params)); + } + }); + } + send(method, params = {}) { + const id = ++this.id; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify({ id, method, params })); + }); + } + async eval(expression, { awaitPromise = false } = {}) { + const r = await this.send('Runtime.evaluate', { + expression, returnByValue: true, awaitPromise, + }); + if (r.exceptionDetails) { + throw new Error( + r.exceptionDetails.exception?.description || + r.exceptionDetails.text || 'eval failed', + ); + } + return r.result?.value; + } + close() { this.ws.close(); } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ── Per-example runner ──────────────────────────────────────────────────── + +async function runOne(cdp, ex) { + const simulateMs = ex.simulateMs ?? 12000; + await cdp.send('Page.navigate', { url: `${FRONTEND}/example/${ex.slug}` }); + // Wait for editor + Run button. + const waitForEditor = async () => { + for (let i = 0; i < 60; i++) { + const has = await cdp.eval(` + Array.from(document.querySelectorAll('button')).some(b => + /^Run/.test(b.getAttribute('title') || '')) + `); + if (has) return true; + await sleep(250); + } + return false; + }; + if (!(await waitForEditor())) return { slug: ex.slug, fail: 'editor never loaded' }; + + // Optional pre-Run check — for examples like button-led where the LED + // MUST be off until interacted with. + let preRunBrightness = null; + if (ex.expectInitialOff) { + // Give SPICE one pass. + await sleep(2000); + preRunBrightness = await cdp.eval(` + (() => { + const led = document.querySelector('wokwi-led'); + return led?.brightness ?? null; + })() + `); + } + + await cdp.eval(` + Array.from(document.querySelectorAll('button')).find(b => + /^Run/.test(b.getAttribute('title') || ''))?.click() + `); + + // Poll LED brightness every 250 ms for simulateMs. + const samples = []; + const steps = Math.ceil(simulateMs / 250); + for (let i = 0; i < steps; i++) { + const reading = await cdp.eval(` + (() => { + const leds = Array.from(document.querySelectorAll('wokwi-led')); + return leds.map(el => ({ + id: el.id, + brightness: el.brightness, + value: el.value, + })); + })() + `); + samples.push({ t: i * 250, leds: reading }); + await sleep(250); + } + + // ── Assertions ────────────────────────────────────────────────────────── + const result = { slug: ex.slug, label: ex.label, preRunBrightness, samples: samples.length }; + + if (ex.leafCheck === 'pwmActive') { + // No wokwi-led for RGB — check SPICE outputPins via __spiceDebug. + const snap = await cdp.eval(` + (() => { + const s = window.__spiceDebug?.(); + return s ? { outputPins: s.outputPinsByBoard, branches: s.branchCurrentNames } : null; + })() + `); + const pinCount = snap?.outputPins + ? Object.values(snap.outputPins).reduce((n, arr) => n + arr.length, 0) : 0; + result.outputPinCount = pinCount; + if (pinCount < 3) result.fail = `expected ≥3 pins driven (RGB), got ${pinCount}`; + return result; + } + + // Standard LED assertions. + if (samples[0].leds.length === 0) { + // No wokwi-led on canvas — only validate SPICE pin activity. + const snap = await cdp.eval(` + (() => { const s = window.__spiceDebug?.(); return s ? s.outputPinsByBoard : null; })() + `); + const pinCount = snap ? Object.values(snap).reduce((n, arr) => n + arr.length, 0) : 0; + result.noLed = true; + result.outputPinCount = pinCount; + if (pinCount === 0) result.fail = 'no LED and no MCU pins driven'; + return result; + } + + const ledId = samples[0].leds[0].id; + const bvals = samples.map(s => s.leds.find(l => l.id === ledId)?.brightness ?? 0); + const maxB = Math.max(...bvals); + const minB = Math.min(...bvals); + const distinct = new Set(bvals.map(b => Math.round(b * 100))).size; + result.led = ledId; + result.maxBrightness = Math.round(maxB * 100) / 100; + result.minBrightness = Math.round(minB * 100) / 100; + result.distinctLevels = distinct; + + if (ex.expectInitialOff && preRunBrightness != null && preRunBrightness > 0.05) { + result.fail = `pre-Run LED brightness ${preRunBrightness.toFixed(2)} — INPUT_PULLUP false positive?`; + return result; + } + + // expectLitAfterRun:false → button-led style: LED must stay dark without + // user interaction. PASS if LED stays under 0.05. FAIL only if it lights + // up (which would mean the firmware is misreading the pin). + if (ex.expectLitAfterRun === false) { + if (maxB > 0.05) { + result.fail = `LED lit without user interaction (max=${maxB.toFixed(2)}) — firmware likely reading wrong pin state`; + } + return result; + } + + if (maxB < 0.05) { + result.fail = `LED never lit (max brightness ${maxB.toFixed(3)})`; + return result; + } + + if (ex.expectGradient) { + // Smooth fade: require ≥3 distinct brightness buckets across the run. + if (distinct < 3) { + result.fail = `PWM fade only ${distinct} distinct level(s) — expected smooth gradient (≥3)`; + return result; + } + } else if (ex.expectToggle !== false && !ex.allowAlwaysOn) { + // Toggle: require both a lit AND a dark sample. + if (minB > 0.05) { + result.fail = `LED never went dark (min brightness ${minB.toFixed(3)}) — stuck on?`; + return result; + } + } + + return result; +} + +// ── Driver ──────────────────────────────────────────────────────────────── + +async function main() { + const args = process.argv.slice(2); + const suite = args.length + ? args.map(slug => DEFAULT_SUITE.find(e => e.slug === slug) ?? { slug }) + : DEFAULT_SUITE; + + const pages = await getPages(); + const target = pages.find(p => p.type === 'page' && p.url.startsWith('http')); + if (!target) { + console.error('[visual-led] No usable Chrome tab. Open one and re-run.'); + process.exit(2); + } + const cdp = new CDP(target.webSocketDebuggerUrl); + await cdp.ready; + await cdp.send('Page.enable'); + await cdp.send('Runtime.enable'); + + let failed = 0; + for (const ex of suite) { + process.stdout.write(`\n[${ex.slug}] ${ex.label || ''}\n`); + try { + const r = await runOne(cdp, ex); + if (r.fail) { + console.log(` FAIL — ${r.fail}`); + console.log(` ${JSON.stringify({ maxB: r.maxBrightness, minB: r.minBrightness, distinct: r.distinctLevels, pre: r.preRunBrightness })}`); + failed++; + } else { + let detail; + if (r.outputPinCount != null && r.maxBrightness == null) { + detail = `no canvas LED, ${r.outputPinCount} pin(s) driven`; + } else if (r.outputPinCount != null) { + detail = `${r.outputPinCount} pin(s) driven, max=${r.maxBrightness} min=${r.minBrightness}`; + } else { + detail = `max=${r.maxBrightness} min=${r.minBrightness} levels=${r.distinctLevels}`; + } + console.log(` PASS — ${detail}`); + } + } catch (e) { + console.log(` ERROR — ${e.message}`); + failed++; + } + } + + cdp.close(); + console.log(`\n${suite.length - failed}/${suite.length} examples passed.`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(err => { + console.error('[visual-led] fatal:', err); + process.exit(3); +});