diff --git a/frontend/src/__tests__/mcu-edge-pin-name-mapping.test.ts b/frontend/src/__tests__/mcu-edge-pin-name-mapping.test.ts new file mode 100644 index 00000000..4e01ded9 --- /dev/null +++ b/frontend/src/__tests__/mcu-edge-pin-name-mapping.test.ts @@ -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); + }); +}); diff --git a/frontend/src/simulation/spice/collectPinStates.ts b/frontend/src/simulation/spice/collectPinStates.ts index 6981463f..e6f55303 100644 --- a/frontend/src/simulation/spice/collectPinStates.ts +++ b/frontend/src/simulation/spice/collectPinStates.ts @@ -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 name→pin 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); } diff --git a/frontend/src/simulation/spice/connectMcuEdgesToService.ts b/frontend/src/simulation/spice/connectMcuEdgesToService.ts index 6a6cc721..9ec9ac6e 100644 --- a/frontend/src/simulation/spice/connectMcuEdgesToService.ts +++ b/frontend/src/simulation/spice/connectMcuEdgesToService.ts @@ -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__` 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