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';
|
fix(spice): MCU-edge listeners detach on resubscription for QEMU boards (frozen LEDs)
connectMcuEdgesToService resubscribes its per-pin listeners whenever
pinNetMap changes. The subscription swept pin NUMBERS 0..63 and
reverse-mapped them to names ('GPIO2' on ESP32, 'GPIO17' on Pi) to match
against pinNetMap's keys — but those keys are the WIRE pin names ('2',
'4', 'A0'), so after the first solve the match failed for every pin and
the resubscription attached nothing. Any mid-run pinNetMap update then
silently killed the MCU-edge → SPICE path and the canvas froze at the
last solved state while the firmware kept toggling.
Masked until now because nothing perturbed pinNetMap mid-run on the
blink examples; pure ESP-IDF mode (#139) unmasked it — gpio_reset_pin()
leaves the internal pull-up enabled, the worker reports gpio_pull, the
handler requests an electrical resolve, pinNetMap gets a new identity,
and the ESP-IDF blink example's LED froze ON.
Fix: subscribe FROM the pinNetMap names, mapped to PinManager pins with
the same pinNameToArduinoPin the netlist collector uses (STM32 via
stm32PinNameToLinear), and hand schedulePin the netlist name so
handleMcuEdge's v_<board>_<pin> lookup hits the fast alterSource path
instead of a full rebuild per edge. The 0..63 sweep remains as the
pre-first-solve fallback. Also fixed pinNameToArduinoPin's dead 'GPIO'
branch ('GP' tested first turned 'GPIO32' into parseInt('IO32') = NaN).
2026-07-24 12:26:09 +07:00
|
|
|
|
import { stm32LinearToPinName, stm32PinNameToLinear } from '../Stm32Bridge';
|
fix(perf): un-freeze the editor during fast-toggling simulations (ESP32 clock)
Running a multiplexed 4-digit 7-segment clock on ESP32/QEMU froze the
browser for minutes after Run — evaluate probes waited 40-90 s, and before
the first fixes the sim WebSocket eventually died (code 1006) with the page
never recovering. CPU-profiled on staging; four compounding per-GPIO-edge
costs, in profile order:
updateComponentState minted a new components array per edge
------------------------------------------------------------
The store setter rebuilt `components` (and one properties object) on EVERY
edge even when the state didn't change. The breadboard is direct-wired to
13 board pins, so segment toggles produced thousands of store sets per
second; every subscriber re-rendered each time, and the canvas subscription
effect (deps: [components, ...]) re-subscribed all pin listeners in a loop.
Now a no-op guard returns prevState unchanged, and breadboards are treated
as self-managed (they have no visual on/off state to echo).
CompilationConsole re-rendered every log line per editor render
----------------------------------------------------------------
The post-compile console holds hundreds of lines; each render called
Date.toLocaleTimeString per line (~0.2 ms each — it builds a fresh Intl
formatter every call). Profile: 162 s of self time in LogLine over a 337 s
window, in ~150 ms tasks. LogLine is now memoized (entries are immutable),
timestamps go through one shared Intl.DateTimeFormat, and the console
itself is React.memo'd against parent re-renders.
Per-edge full SPICE re-solves
------------------------------
PinManager requested a FULL netlist rebuild+solve on every 'mcu' edge.
Now only the edge that newly classifies a pin as MCU-output triggers the
rebuild (that's what emits the pin's V-source); steady-state updates flow
through connectMcuEdgesToService's per-pin coalesced alterSource path.
The start.ts resolve hook is trailing-throttled (33 ms) for the other
per-edge callers (RP2040, custom chips), the service's pending-edge queue
drains on a 33 ms gap timer instead of replaying back-to-back, and new
edges arriving inside the gap queue instead of soloing a solve.
STM32 / Pi reverse pin-name mappings added to connectMcuEdgesToService so
those boards keep fine-grained updates now that the full-tick storm is
gone (PA0/PC13-style and GPIO-style names never matched before).
wokwi-7segment re-rendered per segment write
---------------------------------------------
element.values now flushes at most every 8 ms per display (trailing write
guaranteed), instead of re-rendering the 32-shape SVG per edge.
Also: CLN (colon) pin support for 7-segment clock faces — wired CLN now
drives colon/colonValue in both the attachEvents path and the QEMU
onPinStateChange path; it was silently ignored, so clock colons never lit.
Verified on staging with the failing project: main-thread probes drop from
40-90 s waits (324 long tasks, 52.6 s blocked in 150 s) to 5-11 ms
(2 long tasks, 179 ms), display shows 12:00 with the colon blinking at
1 Hz from the first seconds after Run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:15:59 +07:00
|
|
|
|
import { isStm32BoardKind, isPiBoardKind } from '../../types/board';
|
fix(spice): MCU-edge listeners detach on resubscription for QEMU boards (frozen LEDs)
connectMcuEdgesToService resubscribes its per-pin listeners whenever
pinNetMap changes. The subscription swept pin NUMBERS 0..63 and
reverse-mapped them to names ('GPIO2' on ESP32, 'GPIO17' on Pi) to match
against pinNetMap's keys — but those keys are the WIRE pin names ('2',
'4', 'A0'), so after the first solve the match failed for every pin and
the resubscription attached nothing. Any mid-run pinNetMap update then
silently killed the MCU-edge → SPICE path and the canvas froze at the
last solved state while the firmware kept toggling.
Masked until now because nothing perturbed pinNetMap mid-run on the
blink examples; pure ESP-IDF mode (#139) unmasked it — gpio_reset_pin()
leaves the internal pull-up enabled, the worker reports gpio_pull, the
handler requests an electrical resolve, pinNetMap gets a new identity,
and the ESP-IDF blink example's LED froze ON.
Fix: subscribe FROM the pinNetMap names, mapped to PinManager pins with
the same pinNameToArduinoPin the netlist collector uses (STM32 via
stm32PinNameToLinear), and hand schedulePin the netlist name so
handleMcuEdge's v_<board>_<pin> lookup hits the fast alterSource path
instead of a full rebuild per edge. The 0..63 sweep remains as the
pre-first-solve fallback. Also fixed pinNameToArduinoPin's dead 'GPIO'
branch ('GP' tested first turned 'GPIO32' into parseInt('IO32') = NaN).
2026-07-24 12:26:09 +07:00
|
|
|
|
import type { BoardKind } from '../../types/board';
|
2026-05-16 03:34:29 +07:00
|
|
|
|
import { useElectricalStore } from '../../store/useElectricalStore';
|
2026-05-16 01:26:09 +07:00
|
|
|
|
import { BOARD_PIN_GROUPS } from './boardPinGroups';
|
fix(spice): MCU-edge listeners detach on resubscription for QEMU boards (frozen LEDs)
connectMcuEdgesToService resubscribes its per-pin listeners whenever
pinNetMap changes. The subscription swept pin NUMBERS 0..63 and
reverse-mapped them to names ('GPIO2' on ESP32, 'GPIO17' on Pi) to match
against pinNetMap's keys — but those keys are the WIRE pin names ('2',
'4', 'A0'), so after the first solve the match failed for every pin and
the resubscription attached nothing. Any mid-run pinNetMap update then
silently killed the MCU-edge → SPICE path and the canvas froze at the
last solved state while the firmware kept toggling.
Masked until now because nothing perturbed pinNetMap mid-run on the
blink examples; pure ESP-IDF mode (#139) unmasked it — gpio_reset_pin()
leaves the internal pull-up enabled, the worker reports gpio_pull, the
handler requests an electrical resolve, pinNetMap gets a new identity,
and the ESP-IDF blink example's LED froze ON.
Fix: subscribe FROM the pinNetMap names, mapped to PinManager pins with
the same pinNameToArduinoPin the netlist collector uses (STM32 via
stm32PinNameToLinear), and hand schedulePin the netlist name so
handleMcuEdge's v_<board>_<pin> lookup hits the fast alterSource path
instead of a full rebuild per edge. The 0..63 sweep remains as the
pre-first-solve fallback. Also fixed pinNameToArduinoPin's dead 'GPIO'
branch ('GP' tested first turned 'GPIO32' into parseInt('IO32') = NaN).
2026-07-24 12:26:09 +07:00
|
|
|
|
import { pinNameToArduinoPin } from './collectPinStates';
|
2026-05-16 01:26:09 +07:00
|
|
|
|
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}`;
|
|
|
|
|
|
}
|
fix(perf): un-freeze the editor during fast-toggling simulations (ESP32 clock)
Running a multiplexed 4-digit 7-segment clock on ESP32/QEMU froze the
browser for minutes after Run — evaluate probes waited 40-90 s, and before
the first fixes the sim WebSocket eventually died (code 1006) with the page
never recovering. CPU-profiled on staging; four compounding per-GPIO-edge
costs, in profile order:
updateComponentState minted a new components array per edge
------------------------------------------------------------
The store setter rebuilt `components` (and one properties object) on EVERY
edge even when the state didn't change. The breadboard is direct-wired to
13 board pins, so segment toggles produced thousands of store sets per
second; every subscriber re-rendered each time, and the canvas subscription
effect (deps: [components, ...]) re-subscribed all pin listeners in a loop.
Now a no-op guard returns prevState unchanged, and breadboards are treated
as self-managed (they have no visual on/off state to echo).
CompilationConsole re-rendered every log line per editor render
----------------------------------------------------------------
The post-compile console holds hundreds of lines; each render called
Date.toLocaleTimeString per line (~0.2 ms each — it builds a fresh Intl
formatter every call). Profile: 162 s of self time in LogLine over a 337 s
window, in ~150 ms tasks. LogLine is now memoized (entries are immutable),
timestamps go through one shared Intl.DateTimeFormat, and the console
itself is React.memo'd against parent re-renders.
Per-edge full SPICE re-solves
------------------------------
PinManager requested a FULL netlist rebuild+solve on every 'mcu' edge.
Now only the edge that newly classifies a pin as MCU-output triggers the
rebuild (that's what emits the pin's V-source); steady-state updates flow
through connectMcuEdgesToService's per-pin coalesced alterSource path.
The start.ts resolve hook is trailing-throttled (33 ms) for the other
per-edge callers (RP2040, custom chips), the service's pending-edge queue
drains on a 33 ms gap timer instead of replaying back-to-back, and new
edges arriving inside the gap queue instead of soloing a solve.
STM32 / Pi reverse pin-name mappings added to connectMcuEdgesToService so
those boards keep fine-grained updates now that the full-tick storm is
gone (PA0/PC13-style and GPIO-style names never matched before).
wokwi-7segment re-rendered per segment write
---------------------------------------------
element.values now flushes at most every 8 ms per display (trailing write
guaranteed), instead of re-rendering the 32-shape SVG per edge.
Also: CLN (colon) pin support for 7-segment clock faces — wired CLN now
drives colon/colonValue in both the attachEvents path and the QEMU
onPinStateChange path; it was silently ignored, so clock colons never lit.
Verified on staging with the failing project: main-thread probes drop from
40-90 s waits (324 long tasks, 52.6 s blocked in 150 s) to 5-11 ms
(2 long tasks, 179 ms), display shows 12:00 with the colon blinking at
1 Hz from the first seconds after Run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 06:15:59 +07:00
|
|
|
|
// STM32 wires reference port-style names (PA0 / PC13); its PinManager is
|
|
|
|
|
|
// keyed on the linear pin index. Without this reverse mapping the MCU-edge
|
|
|
|
|
|
// listener never attaches ("13" ≠ "PC13") — previously masked because
|
|
|
|
|
|
// PinManager requested a full re-solve on EVERY mcu edge; now that the
|
|
|
|
|
|
// full tick only fires on first classification, this fine-grained path
|
|
|
|
|
|
// must actually cover STM32.
|
|
|
|
|
|
if (isStm32BoardKind(boardKind)) {
|
|
|
|
|
|
return stm32LinearToPinName(arduinoPin);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Raspberry Pi (Linux boards) wires use GPIO-style names like ESP32.
|
|
|
|
|
|
if (isPiBoardKind(boardKind)) {
|
|
|
|
|
|
return `GPIO${arduinoPin}`;
|
|
|
|
|
|
}
|
2026-06-19 10:15:26 +07:00
|
|
|
|
// ATtiny85 wires reference port-style names (PB0..PB5), matching the
|
|
|
|
|
|
// netlist pin names from collectPinStates. Without this, the reverse
|
|
|
|
|
|
// mapping returns "1" instead of "PB1", so the MCU-edge listener is
|
|
|
|
|
|
// never attached (pin name not in `pinsInCircuit`) and the SPICE
|
|
|
|
|
|
// V-source is never altered on digitalWrite LOW — the LED latches ON
|
|
|
|
|
|
// (and analogWrite duty changes never re-solve). See pinNameToArduinoPin.
|
|
|
|
|
|
if (boardKind === 'attiny85') {
|
|
|
|
|
|
return `PB${arduinoPin}`;
|
|
|
|
|
|
}
|
2026-05-16 01:26:09 +07:00
|
|
|
|
return String(arduinoPin);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-16 03:34:29 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-05-16 03:34:29 +07:00
|
|
|
|
const wanted = pinsInCircuit(boardId);
|
|
|
|
|
|
|
fix(spice): MCU-edge listeners detach on resubscription for QEMU boards (frozen LEDs)
connectMcuEdgesToService resubscribes its per-pin listeners whenever
pinNetMap changes. The subscription swept pin NUMBERS 0..63 and
reverse-mapped them to names ('GPIO2' on ESP32, 'GPIO17' on Pi) to match
against pinNetMap's keys — but those keys are the WIRE pin names ('2',
'4', 'A0'), so after the first solve the match failed for every pin and
the resubscription attached nothing. Any mid-run pinNetMap update then
silently killed the MCU-edge → SPICE path and the canvas froze at the
last solved state while the firmware kept toggling.
Masked until now because nothing perturbed pinNetMap mid-run on the
blink examples; pure ESP-IDF mode (#139) unmasked it — gpio_reset_pin()
leaves the internal pull-up enabled, the worker reports gpio_pull, the
handler requests an electrical resolve, pinNetMap gets a new identity,
and the ESP-IDF blink example's LED froze ON.
Fix: subscribe FROM the pinNetMap names, mapped to PinManager pins with
the same pinNameToArduinoPin the netlist collector uses (STM32 via
stm32PinNameToLinear), and hand schedulePin the netlist name so
handleMcuEdge's v_<board>_<pin> lookup hits the fast alterSource path
instead of a full rebuild per edge. The 0..63 sweep remains as the
pre-first-solve fallback. Also fixed pinNameToArduinoPin's dead 'GPIO'
branch ('GP' tested first turned 'GPIO32' into parseInt('IO32') = NaN).
2026-07-24 12:26:09 +07:00
|
|
|
|
// Resolve which (pin number, pin name) pairs to listen on.
|
|
|
|
|
|
//
|
|
|
|
|
|
// When the netlist has been solved at least once, `wanted` holds the
|
|
|
|
|
|
// EXACT pin names the wires reference ('2', 'A0', 'GP4', 'PC13', …) —
|
|
|
|
|
|
// the same names collectPinStates keyed the V-sources on. Map each of
|
|
|
|
|
|
// those through the SAME name→number function so the listener fires
|
|
|
|
|
|
// on the right PinManager pin AND `handleMcuEdge` receives the name
|
|
|
|
|
|
// whose `v_<board>_<name>` source actually exists (fast alterSource
|
|
|
|
|
|
// path, no per-edge rebuild). The previous approach reversed pin
|
|
|
|
|
|
// NUMBERS to names instead ('GPIO2' on ESP32) which never matched the
|
|
|
|
|
|
// wire names, so every resubscription after a mid-run pinNetMap
|
|
|
|
|
|
// change (e.g. a gpio_pull reported by pure ESP-IDF's gpio_reset_pin)
|
|
|
|
|
|
// silently detached all MCU-edge listeners and froze LEDs.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Before the first solve (`wanted` empty) fall back to the historical
|
|
|
|
|
|
// 0..63 sweep with the reverse-mapped names.
|
|
|
|
|
|
const listenPins: Array<{ pin: number; pinName: string }> = [];
|
|
|
|
|
|
if (wanted.size > 0) {
|
|
|
|
|
|
const isStm32 = isStm32BoardKind(boardKind);
|
|
|
|
|
|
for (const pinName of wanted) {
|
|
|
|
|
|
const pin = isStm32
|
|
|
|
|
|
? stm32PinNameToLinear(pinName)
|
|
|
|
|
|
: pinNameToArduinoPin(pinName, boardKind as BoardKind);
|
|
|
|
|
|
if (pin < 0) continue;
|
|
|
|
|
|
listenPins.push({ pin, pinName });
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for (let pin = 0; pin < 64; pin++) {
|
|
|
|
|
|
const pinName = arduinoPinToName(pin, boardKind);
|
|
|
|
|
|
if (!pinName) continue;
|
|
|
|
|
|
listenPins.push({ pin, pinName });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const { pin, pinName } of listenPins) {
|
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;
|
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
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 03:34:29 +07:00
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-16 01:26:09 +07:00
|
|
|
|
return () => {
|
|
|
|
|
|
unsubBoards();
|
2026-05-16 03:34:29 +07:00
|
|
|
|
unsubElectrical();
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|