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>
The service is the single owner of the simulation loop. Replaces
the trio of wireElectricalSolver + connectLegacySolverToMixedMode +
connectMixedModeSchedulerToStore once G* lands.
Architecture:
- Depends on PORTS only — SimulatorStorePort, ElectricalStorePort,
MixedModeSchedulerPort. Zero coupling to useSimulatorStore /
useElectricalStore / WASM. Easy to test with fakes (and that's
what circuit-simulation-service.test.ts does).
- Single tick(): build netlist → load → solve → extract → publish.
Coalesces concurrent triggers so rapid store changes collapse to
one trailing solve.
- Domain ElectricalSnapshot type covers nodeVoltages + branchCurrents
+ pinNetMap + timeWaveforms + analysisMode + warnings. Shape
matches what the 12 existing useElectricalStore consumers read.
NetlistBuilder extension: BuildNetlistResult now reports `nets`
(every non-ground SPICE net) and `voltageSources` (every V card the
builder emitted). The service uses these to construct the full
vectorsOfInterest list — every node voltage + every branch current
— so the solver returns the data the legacy consumers want.
Scheduler addition: `setExtraVectorsOfInterest(vectors)` lets the
orchestrator add to the per-pin set. Branch currents (i(v_*))
flow through this hook.
8 service tests cover initial solve, branch current extraction,
re-solve on store change, no-spurious-solve, coalescing, .tran
waveforms, warnings forwarding, error-tolerance.
Next: C1+C2 — extract ADC injection / waveform replay into a
solver-agnostic module that just subscribes to useElectricalStore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MixedModeScheduler now accepts any SolverPort implementation via
solverFactory injection. The ad-hoc `NgSpiceClient` interface is
gone; the scheduler talks domain port types only.
New capabilities that fell out of the refactor:
- `resolveTran(step, stop)` — runs .tran via the solver and publishes
the steady-state (last-sample) voltage per pin. Full waveform
reachable via `getLastResult()` for downstream consumers
(CircuitSimulationService in B1+ will use this to populate
useElectricalStore.timeWaveforms).
- `getLastResult()` exposes the SolveResult so the upcoming service
layer can extract branchCurrents + waveforms without re-reading.
- `vectorsOfInterest` is computed from pinNetMap on every solve, so
the adapter only issues N parallel readVecs (where N = distinct
non-ground nets) instead of guessing.
`__setSchedulerEngineFactoryForTests` renamed to
`__setSchedulerSolverFactoryForTests`.
Tests fully migrated to FakeSolverAdapter — no more inline mock
NgSpiceClient. Test layering now mirrors production: scheduler tests
exercise port consumption, port-contract tests exercise the port
itself.
60 tests pass across mixed-mode-scheduler, solver-port-contract,
mixed-mode-bjt-switch-integration (real ngspice), pin-resolver,
pin-resolver-phase1b, connect-mixed-mode-scheduler-to-store,
connect-legacy-solver-to-mixed-mode. tsc clean.
Next: B1 — CircuitSimulationService, the layer above the scheduler
that builds netlists, picks .op vs .tran, and publishes results to
both useElectricalStore and the scheduler cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the second half of the mixed-mode event loop on top of the
voltage cache that step 1 added.
Step 2 — loadCircuit + resolveDc:
- `loadCircuit(netlist, pinNetMap)` accepts the artifacts that
NetlistBuilder already produces, boots the engine lazily, calls
`loadNetlist`, and clears the voltage cache so stale values from a
previous circuit cannot leak through.
- `resolveDc()` runs `op` and walks the pinNetMap, calling readVec for
each non-ground net and publishVoltage for each pin. Ground pins
short-circuit to 0 V without an extra round-trip. Missing nets are
skipped quietly so a disconnected probe pin can't break the resolve.
Step 3 — onMcuPinChange:
- Issues `alter V_<board>_<pin> dc <volts>` and re-resolves. Caller
decides the volts: `state ? vcc : 0` for plain digital, but boards
with open-drain / output-impedance semantics can pass any number.
- Silent no-op when no engine has been started, so legacy paths that
fire pinChange unconditionally can't crash the simulator.
NgSpiceClient interface added and exported so unit tests can inject a
fake engine that records alter() calls and returns canned readVec
values — `__setSchedulerEngineFactoryForTests`. 7 new tests cover the
load → resolve → alter → republish loop end-to-end without booting
the real WASM worker.
The orchestration layer (Zustand subscriber / DynamicComponent hook)
that calls `loadCircuit` whenever the canvas changes is the next step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the runtime plumbing that Phase 1b's SPICE event loop will drive:
- `publishVoltage(componentId, pin, voltage)` updates a (componentId,
pin) → volts cache and notifies every matching subscriber.
- `getCurrentVoltage(...)` reads the cache (was previously stubbed
null).
- subscribe/publish routing exercised by 7 new unit tests.
The scheduler still does not yet drive ngspice — `start()`,
`onMcuPinChange()` are unchanged. But once Phase 1b's solve loop is in
place, calling `publishVoltage` after each `readVec` is all the wiring
needed for components to start reacting to SPICE-resolved analog
states. This is the smallest non-trivial step that keeps the
architecture honest (no test-only emitters; the same code path will be
used in production).
Tests skip booting the WASM worker — they call publishVoltage
directly, so they pass in plain Vitest with no JSDOM Worker shim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.