velxio/frontend/src/simulation/spice/connectMcuEdgesToService.ts

211 lines
8.5 KiB
TypeScript
Raw Normal View History

feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
/**
* connectMcuEdgesToService bridges MCU pin transitions to the
* CircuitSimulationService, completing the mixed-mode loop.
*
* Without this wiring, the service only re-solves on canvas changes
* MCU edges propagate via PinManager component handlers directly,
* but SPICE never sees them. This module:
*
* 1. Subscribes to each board's PinManager for every pin referenced
* by a wire (i.e., pins that appear in the SPICE netlist).
* 2. Coalesces edges per pin (last-state-wins inside a 16 ms
* window) so kHz toggles don't drown the solver.
* 3. Calls `service.handleMcuEdge(boardId, pinName, state, vcc)`
* which alters the corresponding V source + re-resolves +
* publishes the new electrical snapshot.
*
* Why batching here and not in the service:
* - The service is solver-rate (limited by ngspice solve time).
* - PinManager events fire at MCU clock rate (16 MHz simulated).
* - Throttling at the source matches event rates; throttling at the
* service would still queue O(N) edges per ms.
*
* Lifecycle: mount alongside the service in EditorPage. Re-subscribes
* when boards change (board lifecycle = new PinManager instance).
*/
import {
useSimulatorStore,
getBoardPinManager,
} from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
import { BOARD_PIN_GROUPS } from './boardPinGroups';
import type { CircuitSimulationService } from './CircuitSimulationService';
/** How long edges per pin coalesce. 16 ms 60 fps, well below any
* human-perceptible MCU update rate and above the solver's per-edge
* cost (~5-15 ms for typical netlists). */
const COALESCE_WINDOW_MS = 16;
/**
* Wire MCU pin transitions to the service. Returns an unsubscribe
* handle. Idempotent calling twice double-subscribes; callers
* should hold a single instance per editor mount.
*/
export function connectMcuEdgesToService(service: CircuitSimulationService): () => void {
// Per-board, per-pin subscriptions (Arduino pin number → unsubscribe).
const boardSubs = new Map<string, Map<number, () => void>>();
// Pending coalesced state per pin.
const pending = new Map<string, { state: boolean; vcc: number; pinName: string; timer: ReturnType<typeof setTimeout> | null }>();
function pinKey(boardId: string, pinName: string): string {
return `${boardId}|${pinName}`;
}
function flushPin(boardId: string, pinName: string): void {
const key = pinKey(boardId, pinName);
const entry = pending.get(key);
if (!entry) return;
pending.delete(key);
void service.handleMcuEdge(boardId, pinName, entry.state, entry.vcc);
}
function schedulePin(boardId: string, pinName: string, state: boolean, vcc: number): void {
const key = pinKey(boardId, pinName);
const existing = pending.get(key);
if (existing) {
existing.state = state; // last-state-wins
return;
}
const timer = setTimeout(() => flushPin(boardId, pinName), COALESCE_WINDOW_MS);
pending.set(key, { state, vcc, pinName, timer });
}
function arduinoPinToName(arduinoPin: number, boardKind: string): string | null {
// Reverse of pinNameToArduinoPin in subscribeToStore.ts. Both
// need to live until subscribeToStore is deleted; trade-off
// accepted for now since the mapping is per-board-family.
if (boardKind === 'arduino-uno' || boardKind === 'arduino-nano' || boardKind === 'arduino-mega') {
if (arduinoPin >= 14 && arduinoPin <= 21) return `A${arduinoPin - 14}`;
return String(arduinoPin);
}
if (boardKind === 'raspberry-pi-pico' || boardKind === 'pi-pico-w') {
return `GP${arduinoPin}`;
}
if (boardKind.startsWith('esp32')) {
return `GPIO${arduinoPin}`;
}
return String(arduinoPin);
}
/**
* Look up which pin names this board actually wires into the SPICE
* netlist. Reads from `pinNetMap` (populated after each solve) so
* we subscribe to ~3-8 pins per board instead of all 64.
*
* Phase 1d #11: previously we subscribed to every Arduino pin 0..63
* "since unused listeners are free" true for AVR (8 pins) but
* spammy for ESP32 (40+ GPIOs × multiple boards = thousands of
* dead listeners). Now scoped to pins the circuit references.
*/
function pinsInCircuit(boardId: string): Set<string> {
const { pinNetMap } = useElectricalStore.getState();
const pins = new Set<string>();
for (const key of pinNetMap.keys()) {
const idx = key.indexOf(':');
if (idx < 0) continue;
if (key.slice(0, idx) === boardId) pins.add(key.slice(idx + 1));
}
return pins;
}
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
function subscribeBoard(boardId: string, boardKind: string): void {
const pm = getBoardPinManager(boardId);
if (!pm) return;
const group = BOARD_PIN_GROUPS[boardKind as keyof typeof BOARD_PIN_GROUPS] ?? BOARD_PIN_GROUPS.default;
const vcc = group.vcc;
const pinSubs = new Map<number, () => void>();
boardSubs.set(boardId, pinSubs);
const wanted = pinsInCircuit(boardId);
// Sweep 0..63 but only attach a listener when the pin name maps
// to one of the wires in the current netlist. Re-subscription
// when the canvas changes happens via `syncBoardSubscriptions` on
// store-level board diffs and on `pinNetMap` updates below.
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
for (let pin = 0; pin < 64; pin++) {
const pinName = arduinoPinToName(pin, boardKind);
if (!pinName) continue;
if (wanted.size > 0 && !wanted.has(pinName)) continue;
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
const unsub = pm.onPinChange(pin, (_p, state) => {
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples End-to-end pipeline fixes uncovered while auditing the /examples gallery. Each bug shipped past green unit + snapshot tests because none of those run firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven visual harness that loads each example, runs the simulator, samples `wokwi-led.brightness`, and asserts toggle / gradient / initial-off invariants — exits non-zero on any regression. Frontend simulator - PinManager.updatePort: new optional ddrMask param. A pin is added to `outputPins` only if the DDR bit is set, so the PORTx write that enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it. - AVRSimulator: pass DDR mask alongside every port-listener fire. - BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate INPUT_PULLUP — without this the firmware reads LOW from boot and thinks the button is permanently pressed (the "LED is always on, pressing does nothing" UX bug). - connectMcuEdgesToService: suppress synthetic digital edges on pins with active PWM, AND subscribe to onPwmChange to re-tick the netlist on duty changes. Fade-LED now produces a true gradient (6 distinct brightness levels across a fade cycle) instead of a binary 0/full toggle. - CircuitSimulationService.handleMcuEdge: replace single-slot pendingMcuEdge with a per-pin Map. Multiple pins toggling during the same in-flight tick used to overwrite each other; now every pin's most-recent edge replays after the tick. Fixes Traffic-Light RED→ YELLOW→GREEN sequencing. - NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with underscores in V-source names. ngspice's interactive `alter` command treats `-` as an operator and silently no-ops on hyphenated source names, so mid-simulation MCU pin transitions stopped propagating after the first solve. MixedModeScheduler.onMcuPinChange and CircuitSimulationService self-heal use the same sanitizer so names stay consistent across emit/alter/lookup. Also added a regex-based fallback in step 2 so any board pin matching `GND.\d+` canonicalises to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the per-board `groundPinNames` list missed several, leaving wires floating instead of grounded. - collectPinStates: emit V-sources only for pins in `outputPins`, not every wired board pin. Leaves INPUT pins (analog sensors on A0, pull-down dividers, etc.) free for the SPICE solver instead of being shorted to 0 V by an ideal MCU V-source. - start.ts: extended __spiceDebug to also expose outputPinsByBoard + nodeVoltages + pinNetMapEntries for the visual harness. - ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to triggerPinChange / setPinState so the new outputPins tracking fires on those boards too (was AVR-only before). - useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so outputPins clears between runs; Esp32Bridge.onPinChange passes the `'mcu'` flag in all three places it's wired. - types/board.ts: ATtiny85 FQBN `clock=internal16mhz` → `clock=16pll` (ATTinyCore 1.5.2 renamed the option). Backend - esp-idf-template/main/CMakeLists.txt: skip the `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both variants already define LED_BUILTIN in pins_arduino.h via a self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the command line expanded the static-const declaration to `static const uint8_t 2 = ...;` — a syntax error that broke every ESP32-C3 / S3 build (`expected unqualified-id before numeric constant`). Examples - examples.ts: bulk-fix 72 wire endpoints that referenced `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't exist on the canvas). Replaced with `'arduino-uno'` (the canvas board-id convention) and converted `D<n>` pin names to `GP<n>` for Pico-style boards. Affects pico-blink, pico-i2c-scanner, pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others. Tests - scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers Blink (single-pin), Button (idle-OFF invariant — catches the INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing), Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins driven). Run via `npm --prefix frontend run test:visual` against a Chrome on `:9222` + vite on `:5174` + backend on `:8001`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
// 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;
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
schedulePin(boardId, pinName, state, vcc);
});
pinSubs.set(pin, unsub);
fix(spice+pipeline): LED visualization, INPUT_PULLUP, ESP32-C3, PWM fade, examples End-to-end pipeline fixes uncovered while auditing the /examples gallery. Each bug shipped past green unit + snapshot tests because none of those run firmware + render LEDs. Added scripts/visual-led-test.mjs as a CDP-driven visual harness that loads each example, runs the simulator, samples `wokwi-led.brightness`, and asserts toggle / gradient / initial-off invariants — exits non-zero on any regression. Frontend simulator - PinManager.updatePort: new optional ddrMask param. A pin is added to `outputPins` only if the DDR bit is set, so the PORTx write that enables INPUT_PULLUP (DDR=0, PORT=1) no longer falsely marks the pin as MCU output. AVRSimulator now reads DDRB/C/D (0x24/0x27/0x2A on Uno/Nano, 0x37 on ATtiny85, per-port table on Mega) and forwards it. - AVRSimulator: pass DDR mask alongside every port-listener fire. - BasicParts pushbutton{,-6mm}: seed pin HIGH in attachEvents so `digitalRead()` returns HIGH while idle. avr8js doesn't auto-simulate INPUT_PULLUP — without this the firmware reads LOW from boot and thinks the button is permanently pressed (the "LED is always on, pressing does nothing" UX bug). - connectMcuEdgesToService: suppress synthetic digital edges on pins with active PWM, AND subscribe to onPwmChange to re-tick the netlist on duty changes. Fade-LED now produces a true gradient (6 distinct brightness levels across a fade cycle) instead of a binary 0/full toggle. - CircuitSimulationService.handleMcuEdge: replace single-slot pendingMcuEdge with a per-pin Map. Multiple pins toggling during the same in-flight tick used to overwrite each other; now every pin's most-recent edge replays after the tick. Fixes Traffic-Light RED→ YELLOW→GREEN sequencing. - NetlistBuilder: new sanitizeSpiceId() helper replaces hyphens with underscores in V-source names. ngspice's interactive `alter` command treats `-` as an operator and silently no-ops on hyphenated source names, so mid-simulation MCU pin transitions stopped propagating after the first solve. MixedModeScheduler.onMcuPinChange and CircuitSimulationService self-heal use the same sanitizer so names stay consistent across emit/alter/lookup. Also added a regex-based fallback in step 2 so any board pin matching `GND.\d+` canonicalises to net "0" — ESP32-C3 dev kits expose up to 10 GND pins and the per-board `groundPinNames` list missed several, leaving wires floating instead of grounded. - collectPinStates: emit V-sources only for pins in `outputPins`, not every wired board pin. Leaves INPUT pins (analog sensors on A0, pull-down dividers, etc.) free for the SPICE solver instead of being shorted to 0 V by an ideal MCU V-source. - start.ts: extended __spiceDebug to also expose outputPinsByBoard + nodeVoltages + pinNetMapEntries for the visual harness. - ESP32 / RP2040 / RISC-V / C3 simulators: pass `'mcu'` source flag to triggerPinChange / setPinState so the new outputPins tracking fires on those boards too (was AVR-only before). - useSimulatorStore: stopBoard/resetBoard call pm.resetPinStates() so outputPins clears between runs; Esp32Bridge.onPinChange passes the `'mcu'` flag in all three places it's wired. - types/board.ts: ATtiny85 FQBN `clock=internal16mhz` → `clock=16pll` (ATTinyCore 1.5.2 renamed the option). Backend - esp-idf-template/main/CMakeLists.txt: skip the `-DLED_BUILTIN=2` fallback for esp32c3 and esp32s3 targets. Both variants already define LED_BUILTIN in pins_arduino.h via a self-define macro (`#define LED_BUILTIN LED_BUILTIN` + `static const uint8_t LED_BUILTIN = ...;`). Pre-defining the symbol from the command line expanded the static-const declaration to `static const uint8_t 2 = ...;` — a syntax error that broke every ESP32-C3 / S3 build (`expected unqualified-id before numeric constant`). Examples - examples.ts: bulk-fix 72 wire endpoints that referenced `componentId: 'nano-rp2040'` / `'esp32-c3'` etc. (boards that don't exist on the canvas). Replaced with `'arduino-uno'` (the canvas board-id convention) and converted `D<n>` pin names to `GP<n>` for Pico-style boards. Affects pico-blink, pico-i2c-scanner, pico-i2c-rtc-read, pico-spi-loopback, c3-blink and others. Tests - scripts/visual-led-test.mjs: CDP-driven harness. Default suite covers Blink (single-pin), Button (idle-OFF invariant — catches the INPUT_PULLUP regression), Traffic-Light (multi-pin sequencing), Fade-LED (PWM gradient — ≥3 distinct levels), RGB-LED (≥3 PWM pins driven). Run via `npm --prefix frontend run test:visual` against a Chrome on `:9222` + vite on `:5174` + backend on `:8001`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:47:45 +07:00
// 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
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
}
}
function unsubscribeBoard(boardId: string): void {
const pinSubs = boardSubs.get(boardId);
if (!pinSubs) return;
for (const unsub of pinSubs.values()) unsub();
boardSubs.delete(boardId);
}
function syncBoardSubscriptions(): void {
const boards = useSimulatorStore.getState().boards;
const wanted = new Set(boards.map((b) => b.id));
for (const id of Array.from(boardSubs.keys())) {
if (!wanted.has(id)) unsubscribeBoard(id);
}
for (const b of boards) {
if (!boardSubs.has(b.id)) subscribeBoard(b.id, b.boardKind);
}
}
syncBoardSubscriptions();
const unsubBoards = useSimulatorStore.subscribe((state, prev) => {
if (state.boards !== prev.boards) syncBoardSubscriptions();
});
// Re-subscribe when the pinNetMap changes — a new wire / removed
// wire might add or drop pins that need listeners. Drop ALL subs
// and re-create from the new pinNetMap (cheap: a Map clear and
// ~10 pm.onPinChange calls).
const unsubElectrical = useElectricalStore.subscribe((state, prev) => {
if (state.pinNetMap === prev.pinNetMap) return;
const boards = useSimulatorStore.getState().boards;
for (const id of Array.from(boardSubs.keys())) unsubscribeBoard(id);
for (const b of boards) subscribeBoard(b.id, b.boardKind);
});
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
return () => {
unsubBoards();
unsubElectrical();
feat(sim): Phase 1c D1+D2 — MCU edges drive scheduler.alterSource + republish CircuitSimulationService.handleMcuEdge(boardId, pinName, state, vcc) runs the WASM alter + .op + extract path instead of rebuilding the netlist. Cached `loadedContext` lets `publishFromLastResult` shape an ElectricalSnapshot without re-running buildInputFromStore. Coalesces with the canvas-change tick: - If a full solve is in flight: edge is queued and replayed after (so the netlist matches when alter runs). - Last-edge-wins per pin: edges overwrite the same field, so a 10kHz toggle collapses to whatever was last seen at flush time. connectMcuEdgesToService.ts wires PinManager.onPinChange events to the service: - Subscribes to every Arduino-pin slot (0..63) per board. Per-pin listeners are no-cost when the pin never fires. - Coalesces edges per pin in a 16 ms window before calling handleMcuEdge (60 fps cap, well below per-solve cost of 5-15 ms). - Re-subscribes when boards change (PinManager instances are recreated by loadHex / setActiveBoard). MixedModeSchedulerPort gains onMcuPinChange in the port interface (was already on the singleton but missing from the contract). 3 new service tests cover: - initial full solve + alter + republish on edge - coalescing edges with in-flight full solves - handleMcuEdge kicks a full tick when no circuit is loaded 11 service tests + 90-test regression suite pass. tsc clean. Next: E — convergence helpers (.options gmin, op-amp retry) so the LM358 subckt can finally be enabled in componentToSpice.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 01:26:09 +07:00
for (const pinSubs of boardSubs.values()) {
for (const unsub of pinSubs.values()) unsub();
}
boardSubs.clear();
for (const entry of pending.values()) {
if (entry.timer) clearTimeout(entry.timer);
}
pending.clear();
};
}