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).
This commit is contained in:
David Montero 2026-07-24 07:26:09 +02:00
parent 734b7d0487
commit 4ff281bd40
3 changed files with 99 additions and 14 deletions

View File

@ -0,0 +1,49 @@
/**
* Regression test for the MCU-edge listener pin-name mismatch.
*
* connectMcuEdgesToService subscribes its per-pin listeners from the
* netlist's pinNetMap, whose keys are the WIRE pin names ('2', 'A0',
* 'GP4'). It must map those names to PinManager pin numbers with the
* SAME function the netlist collector (collectPinStates) uses. The old
* code reverse-mapped pin NUMBERS to names instead producing 'GPIO2'
* on ESP32, which never matched the wire's '2' so any mid-run
* resubscription (triggered e.g. by the gpio_pull that pure ESP-IDF's
* gpio_reset_pin reports) silently detached every listener and froze
* LEDs while the firmware kept toggling the pin.
*/
import { describe, it, expect } from 'vitest';
import { pinNameToArduinoPin } from '../simulation/spice/collectPinStates';
describe('pinNameToArduinoPin — netlist name to PinManager pin', () => {
it('maps plain numeric ESP32 wire names (the esp32-idf-blink case)', () => {
expect(pinNameToArduinoPin('2', 'esp32')).toBe(2);
expect(pinNameToArduinoPin('4', 'esp32')).toBe(4);
expect(pinNameToArduinoPin('21', 'esp32')).toBe(21);
});
it('maps GPIO-prefixed names', () => {
expect(pinNameToArduinoPin('GPIO32', 'esp32')).toBe(32);
expect(pinNameToArduinoPin('GPIO2', 'esp32')).toBe(2);
});
it('maps Pico GP names', () => {
expect(pinNameToArduinoPin('GP4', 'raspberry-pi-pico')).toBe(4);
});
it('maps Uno analog names', () => {
expect(pinNameToArduinoPin('A0', 'arduino-uno')).toBe(14);
expect(pinNameToArduinoPin('A5', 'arduino-uno')).toBe(19);
});
it('maps ATtiny85 port names', () => {
expect(pinNameToArduinoPin('PB3', 'attiny85')).toBe(3);
});
it('rejects power pins so no listener attaches to rails', () => {
expect(pinNameToArduinoPin('GND', 'esp32')).toBe(-1);
expect(pinNameToArduinoPin('3V3', 'esp32')).toBe(-1);
expect(pinNameToArduinoPin('GND', 'arduino-uno')).toBe(-1);
expect(pinNameToArduinoPin('5V', 'arduino-uno')).toBe(-1);
});
});

View File

@ -18,18 +18,25 @@ import { BOARD_PIN_GROUPS } from './boardPinGroups';
* Convert a board pin name (e.g. "9", "A0", "GP26", "GPIO32") to the
* Arduino-style pin number that PinManager uses internally.
* Returns -1 if the name doesn't map to a GPIO pin.
*
* Exported so connectMcuEdgesToService can subscribe its MCU-edge
* listeners under the SAME namepin mapping the netlist collector uses
* a reverse mapping that disagrees (e.g. "GPIO2" vs the wire's "2")
* detaches every listener on resubscription and freezes LEDs mid-run.
*/
function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number {
export function pinNameToArduinoPin(pinName: string, boardKind: BoardKind): number {
const group = BOARD_PIN_GROUPS[boardKind] ?? BOARD_PIN_GROUPS.default;
if (group.gnd.includes(pinName) || group.vcc_pins.includes(pinName)) return -1;
if (pinName.startsWith('GP')) {
const n = parseInt(pinName.slice(2), 10);
return Number.isFinite(n) ? n : -1;
}
// 'GPIO' must be tested BEFORE 'GP': the GP branch used to shadow it,
// turning 'GPIO32' into parseInt('IO32') = NaN → -1 (dead branch).
if (pinName.startsWith('GPIO')) {
const n = parseInt(pinName.slice(4), 10);
return Number.isFinite(n) ? n : -1;
}
if (pinName.startsWith('GP')) {
const n = parseInt(pinName.slice(2), 10);
return Number.isFinite(n) ? n : -1;
}
if (/^A\d+$/.test(pinName)) {
return 14 + parseInt(pinName.slice(1), 10);
}

View File

@ -27,10 +27,12 @@ import {
useSimulatorStore,
getBoardPinManager,
} from '../../store/useSimulatorStore';
import { stm32LinearToPinName } from '../Stm32Bridge';
import { stm32LinearToPinName, stm32PinNameToLinear } from '../Stm32Bridge';
import { isStm32BoardKind, isPiBoardKind } from '../../types/board';
import type { BoardKind } from '../../types/board';
import { useElectricalStore } from '../../store/useElectricalStore';
import { BOARD_PIN_GROUPS } from './boardPinGroups';
import { pinNameToArduinoPin } from './collectPinStates';
import type { CircuitSimulationService } from './CircuitSimulationService';
/** How long edges per pin coalesce. 16 ms 60 fps, well below any
@ -143,14 +145,41 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): ()
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.
for (let pin = 0; pin < 64; pin++) {
const pinName = arduinoPinToName(pin, boardKind);
if (!pinName) continue;
if (wanted.size > 0 && !wanted.has(pinName)) continue;
// 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) {
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