diff --git a/frontend/src/__tests__/part-pin-ownership.test.ts b/frontend/src/__tests__/part-pin-ownership.test.ts new file mode 100644 index 00000000..d0caf48d --- /dev/null +++ b/frontend/src/__tests__/part-pin-ownership.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +/** + * A part that models its own line must keep it — on every board. + * + * connectDigitalInputsToMcu thresholds the solved node into each input pin, and + * its `sourcedNets` gate is not a real guard: it only holds while NOTHING + * electrical sits on the net. Wire the sensor the way the hardware needs it — a + * pull-up on a DHT22, the 1k/2k2 divider a 5 V HC-SR04 needs to reach a 3.3 V + * pad — and the net becomes component-backed, solves at what the passives say, + * and the connector starts pinning the very line the sensor is pulsing. + * + * partPinOwnership is the rule that fixes it for every board at once, and this + * test pins both halves of it: the unmodelled part keeps its pin, and the part + * SPICE does model (a button) still reads the real circuit. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + claimPartPin, + releasePartPins, + isPartOwnedPin, + clearPartPinOwnership, + withPartPinOwnership, +} from '../simulation/partPinOwnership'; + +describe('part pin ownership', () => { + beforeEach(() => clearPartPinOwnership()); + + it('claims and releases per component', () => { + claimPartPin('uno', 10, 'sr04-1'); + expect(isPartOwnedPin('uno', 10)).toBe(true); + expect(isPartOwnedPin('uno', 11)).toBe(false); + expect(isPartOwnedPin('esp32', 10)).toBe(false); // per board, not global + releasePartPins('sr04-1'); + expect(isPartOwnedPin('uno', 10)).toBe(false); + }); + + it('keeps a pin owned while a second component still claims it', () => { + claimPartPin('uno', 4, 'a'); + claimPartPin('uno', 4, 'b'); + releasePartPins('a'); + expect(isPartOwnedPin('uno', 4)).toBe(true); + releasePartPins('b'); + expect(isPartOwnedPin('uno', 4)).toBe(false); + }); + + it('claims what an unmodelled part drives, through the wrapper', () => { + const calls: Array<[string, unknown[]]> = []; + const sim = { + setPinState: (...a: unknown[]) => calls.push(['setPinState', a]), + schedulePinChange: (...a: unknown[]) => calls.push(['schedulePinChange', a]), + registerSensor: (...a: unknown[]) => { + calls.push(['registerSensor', a]); + return true; + }, + }; + // hc-sr04 has no SPICE mapper: nothing but the part describes ECHO. + const wrapped = withPartPinOwnership(sim, 'esp32-s3', 'sr04-1', 'hc-sr04') as typeof sim; + + wrapped.setPinState(41, false); + expect(isPartOwnedPin('esp32-s3', 41)).toBe(true); + + wrapped.schedulePinChange(7, true, 1000); + expect(isPartOwnedPin('esp32-s3', 7)).toBe(true); + + // A backend sensor owns its data pin AND every extra pin its props name. + wrapped.registerSensor('hc-sr04', 21, { distance: 30, echo_pin: 41 }); + expect(isPartOwnedPin('esp32-s3', 21)).toBe(true); + + // Everything still reached the real simulator. + expect(calls.map((c) => c[0])).toEqual([ + 'setPinState', + 'schedulePinChange', + 'registerSensor', + ]); + }); + + it('never claims for a part the netlist already describes', () => { + const sim = { setPinState: vi.fn() }; + // A pushbutton IS in SPICE. The solved circuit stays the truth for it — + // that is what makes a mis-wired button read stuck instead of "working". + const wrapped = withPartPinOwnership(sim, 'uno', 'btn-1', 'pushbutton') as typeof sim; + wrapped.setPinState(2, false); + expect(sim.setPinState).toHaveBeenCalledWith(2, false); + expect(isPartOwnedPin('uno', 2)).toBe(false); + }); + + it('leaves lazy getters and method binding intact', () => { + let built = 0; + const sim = { + pinManager: { id: 'pm' }, + _spi: null as null | { tag: string }, + get spi() { + if (!this._spi) { + built += 1; + this._spi = { tag: 'adapter' }; + } + return this._spi; + }, + value: 7, + readValue(this: { value: number }) { + return this.value; + }, + }; + const wrapped = withPartPinOwnership(sim, 'uno', 'x-1', 'hc-sr04') as typeof sim; + expect(wrapped.pinManager).toEqual({ id: 'pm' }); + expect(wrapped.spi).toBe(wrapped.spi); // memoised on the real object, once + expect(built).toBe(1); + expect(wrapped.readValue()).toBe(7); // `this` is the target, not the proxy + }); +}); diff --git a/frontend/src/__tests__/pin-trace-boards.test.ts b/frontend/src/__tests__/pin-trace-boards.test.ts new file mode 100644 index 00000000..dbba3a73 --- /dev/null +++ b/frontend/src/__tests__/pin-trace-boards.test.ts @@ -0,0 +1,364 @@ +// @vitest-environment jsdom +/** + * Per-board regression net for the wire walk (simulation/PinTrace). + * + * The HC-SR04 divider bug (see hcsr04-divider-echo.test.ts) was reported on an + * ESP32-S3, but nothing about it is ESP32-specific: the walk is shared by every + * board family, and the two behaviours the fix introduced — exploring the whole + * NODE instead of following a chain, and demoting a supply pad to the weakest + * possible answer — can regress on one family while the reported board keeps + * working. Each board here re-plays the same shapes through its own + * boardPinToNumber naming ('7', 'GP15', 'PC13', bare GPIO numbers). + * + * The shapes, and why each one is here: + * + * - the level-shifting divider ECHO ─[1k]─┬─[2k2]─ GND + * └─────── GPIO + * the tap carries TWO wires; the walk must look at the other one instead of + * answering "tied to a rail" with the first branch that reaches GND. + * + * - the pull-up drawn BEFORE the signal wire: same demotion, without a + * divider, and with the rail sitting on the FIRST wire the walk sees. + * + * - a pad that genuinely reaches nothing but a rail must STILL answer -1 + * (an LED cathode returning a GPIO would be a worse bug than the one fixed), + * and a diode must not be traced through the way a resistor is. + * + * - a breadboard strip: the pre-existing multi-hole hop, guarded here so the + * node-walk rewrite cannot quietly cost us the ESP32 clock circuit. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// ── Mocks (same shape as hcsr04-divider-echo.test.ts) ─────────────────────── +// addBoard builds a real simulator / bridge per family; none of them is under +// test here, and the AVR and RP2040 ones pull in WASM. +vi.mock('../simulation/AVRSimulator', () => ({ + AVRSimulator: vi.fn(function (this: any) { + this.onSerialData = null; + this.onBaudRateChange = null; + this.onPinChangeWithTime = null; + this.start = vi.fn(); + this.stop = vi.fn(); + this.reset = vi.fn(); + this.loadHex = vi.fn(); + this.addI2CDevice = vi.fn(); + this.setPinState = vi.fn(); + }), +})); + +vi.mock('../simulation/RP2040Simulator', () => ({ + RP2040Simulator: vi.fn(function (this: any) { + this.onSerialData = null; + this.onPinChangeWithTime = null; + this.start = vi.fn(); + this.stop = vi.fn(); + this.reset = vi.fn(); + this.loadBinary = vi.fn(); + this.addI2CDevice = vi.fn(); + this.attachPioPeripheral = vi.fn(); + this.spi = { onByte: null, completeTransfer: vi.fn() }; + }), +})); + +vi.mock('../simulation/PinManager', () => ({ + PinManager: vi.fn(function (this: any) { + this.updatePort = vi.fn(); + this.onPinChange = vi.fn().mockReturnValue(() => {}); + this.getListenersCount = vi.fn().mockReturnValue(0); + this.hardResetPinStates = vi.fn(); + this.resetPinStates = vi.fn(); + this.getOutputPins = vi.fn().mockReturnValue(new Set()); + }), +})); + +vi.mock('../store/useOscilloscopeStore', () => ({ + useOscilloscopeStore: { + getState: vi.fn().mockReturnValue({ channels: [], pushSample: vi.fn() }), + }, +})); + +class MockWebSocket { + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + readyState = MockWebSocket.OPEN; + onopen: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + sent: string[] = []; + send(data: string) { + this.sent.push(data); + } + close() { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(); + } +} + +vi.stubGlobal('WebSocket', MockWebSocket); +vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1); +vi.stubGlobal('cancelAnimationFrame', vi.fn()); + +import { useSimulatorStore } from '../store/useSimulatorStore'; +import { traceDetailed, traceBoardGpio } from '../simulation/PinTrace'; +import { boardPinToNumber } from '../utils/boardPinMapping'; +import { isStm32BoardKind } from '../types/board'; + +function wire( + id: string, + a: [string, string], + b: [string, string], +): Record { + return { + id, + waypoints: [], + color: '#000', + start: { componentId: a[0], pinName: a[1], x: 0, y: 0 }, + end: { componentId: b[0], pinName: b[1], x: 0, y: 0 }, + }; +} + +/** Fresh canvas with exactly one board. The store is a module singleton, so a + * leftover board from the previous describe would give the walk a second + * place to land. */ +function freshCanvas(boardKind: string, boardId: string) { + useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); + useSimulatorStore.getState().addBoard(boardKind as never, 0, 0, boardId); +} + +function trace(componentId: string, pinName: string) { + return traceDetailed(useSimulatorStore.getState() as never, componentId, pinName, 0); +} + +/** The reported divider, parameterised by board: the sensor's ECHO goes into + * the top resistor, the bottom one goes to GND, and the GPIO hangs off the + * tap between them. Returns the wires so a caller can append its own. */ +function dividerWires( + boardId: string, + gndPad: string, + gpioPad: string, +): Array> { + return [ + wire('d1', ['sensor', 'ECHO'], ['r_top', '1']), + wire('d2', ['r_top', '2'], ['r_bottom', '1']), + wire('d3', [boardId, gndPad], ['r_bottom', '2']), + wire('d4', [boardId, gpioPad], ['r_bottom', '1']), + ]; +} + +const DIVIDER_PARTS = [ + { id: 'sensor', metadataId: 'hc-sr04', x: 0, y: 0, properties: {} }, + { id: 'r_top', metadataId: 'resistor-1k', x: 0, y: 0, properties: { value: '1000' } }, + { id: 'r_bottom', metadataId: 'resistor-2k2', x: 0, y: 0, properties: { value: '2200' } }, +]; + +// ── Arduino Uno (AVR, bare digital pin names) ─────────────────────────────── +describe('arduino-uno', () => { + const BOARD = 'arduino-uno'; + + beforeEach(() => { + freshCanvas('arduino-uno', BOARD); + const s = useSimulatorStore.getState(); + s.setComponents([ + ...DIVIDER_PARTS, + { id: 'led1', metadataId: 'led', x: 0, y: 0, properties: {} }, + { id: 'r_led', metadataId: 'resistor-220', x: 0, y: 0, properties: { value: '220' } }, + ] as never); + s.setWires([ + wire('u1', [BOARD, '5V'], ['sensor', 'VCC']), + wire('u2', ['sensor', 'TRIG'], [BOARD, '9']), + wire('u3', [BOARD, 'GND.1'], ['sensor', 'GND']), + ...dividerWires(BOARD, 'GND.1', '7'), + // A perfectly ordinary LED: anode on D13, cathode to GND through 220R. + wire('u4', [BOARD, '13'], ['led1', 'A']), + wire('u5', ['led1', 'C'], ['r_led', '1']), + wire('u6', ['r_led', '2'], [BOARD, 'GND.1']), + ] as never); + }); + + it('traces ECHO across the 1k/2k2 divider to D7', () => { + const echo = trace('sensor', 'ECHO'); + expect(echo.arduinoPin, 'ECHO must reach D7 through the tap').toBe(7); + expect(echo.boardId).toBe(BOARD); + }); + + it('traces the directly wired TRIG to D9', () => { + expect(trace('sensor', 'TRIG').arduinoPin).toBe(9); + }); + + it('still answers -1 for an LED cathode that only reaches GND', () => { + // Two ways this could regress: the walk crossing the LED (a diode is not a + // passive the digital layer may traverse, so D13 must NOT come back), or + // railHit being dropped now that it no longer returns inline. + const cathode = trace('led1', 'C'); + expect(cathode.arduinoPin, 'the cathode net is the GND rail, nothing else').toBe(-1); + expect(traceBoardGpio(useSimulatorStore.getState() as never, 'led1', 'C')).toBeNull(); + }); +}); + +// ── Raspberry Pi Pico (RP2040, 'GPnn' pad names) ──────────────────────────── +describe('raspberry-pi-pico', () => { + const BOARD = 'raspberry-pi-pico'; + + beforeEach(() => { + freshCanvas('raspberry-pi-pico', BOARD); + const s = useSimulatorStore.getState(); + s.setComponents(DIVIDER_PARTS as never); + s.setWires([ + wire('p1', [BOARD, 'VSYS'], ['sensor', 'VCC']), + wire('p2', ['sensor', 'TRIG'], [BOARD, 'GP14']), + wire('p3', [BOARD, 'GND.3'], ['sensor', 'GND']), + // The pad is named 'GP15' here; the walk must hand the NAME to + // boardPinToNumber rather than assume a bare number like the ESP32 pads. + ...dividerWires(BOARD, 'GND.3', 'GP15'), + ] as never); + }); + + it('traces ECHO across the divider to GP15', () => { + const echo = trace('sensor', 'ECHO'); + expect(echo.arduinoPin).toBe(15); + expect(echo.boardId).toBe(BOARD); + }); + + it('traces the directly wired TRIG to GP14', () => { + expect(trace('sensor', 'TRIG').arduinoPin).toBe(14); + }); +}); + +// ── A pull-up drawn before the signal wire ────────────────────────────────── +// Wire ORDER is the whole point: the first wire on the pin leads to a supply +// pad through a resistor, so a walk that returns the first non-null answer +// reports "rail" and the GPIO on the second wire is never seen. +describe('a pull-up drawn before the signal wire', () => { + const CASES: Array<{ kind: string; id: string; rail: string; gpio: string; expect: number }> = [ + { kind: 'arduino-uno', id: 'arduino-uno', rail: '5V', gpio: '2', expect: 2 }, + { kind: 'esp32', id: 'esp32', rail: '3V3', gpio: '4', expect: 4 }, + ]; + + for (const c of CASES) { + it(`resolves the GPIO, not the ${c.rail} pad behind the resistor (${c.kind})`, () => { + freshCanvas(c.kind, c.id); + const s = useSimulatorStore.getState(); + s.setComponents([ + { id: 'dht', metadataId: 'dht22', x: 0, y: 0, properties: {} }, + { id: 'r_pu', metadataId: 'resistor-10k', x: 0, y: 0, properties: { value: '10000' } }, + ] as never); + s.setWires([ + wire('a1', ['dht', 'SDA'], ['r_pu', '1']), + wire('a2', ['r_pu', '2'], [c.id, c.rail]), + wire('a3', ['dht', 'SDA'], [c.id, c.gpio]), + ] as never); + const hit = trace('dht', 'SDA'); + expect(hit.arduinoPin).toBe(c.expect); + expect(hit.boardId).toBe(c.id); + }); + } +}); + +// ── STM32 ('PA0' / 'PC13' port-pin names) ─────────────────────────────────── +// The family only reaches the walk if boardPinToNumber knows its pad names; +// check that first so a missing mapping reads as "skipped", not as a trace bug. +const STM32_KIND = 'stm32-bluepill'; +const STM32_PA0 = boardPinToNumber(STM32_KIND, 'PA0'); +const STM32_PC13 = boardPinToNumber(STM32_KIND, 'PC13'); +const STM32_MAPPED = + isStm32BoardKind(STM32_KIND) && STM32_PA0 !== null && STM32_PC13 !== null; + +describe.skipIf(!STM32_MAPPED)('stm32-bluepill', () => { + const BOARD = 'stm32-bluepill'; + + beforeEach(() => { + freshCanvas(STM32_KIND, BOARD); + const s = useSimulatorStore.getState(); + s.setComponents(DIVIDER_PARTS as never); + s.setWires([ + wire('s1', [BOARD, '5V'], ['sensor', 'VCC']), + wire('s2', ['sensor', 'TRIG'], [BOARD, 'PA0']), + wire('s3', [BOARD, 'GND.1'], ['sensor', 'GND']), + ...dividerWires(BOARD, 'GND.1', 'PC13'), + ] as never); + }); + + it('traces ECHO across the divider to PC13', () => { + // Linear numbering, port*16+pin — the same number Stm32Bridge speaks. + expect(trace('sensor', 'ECHO').arduinoPin).toBe(STM32_PC13); + }); + + it('traces the directly wired TRIG to PA0', () => { + // PA0 is linear 0: a legitimate driven pin, and the one value that a + // truthiness check instead of `>= 0` would silently drop. + expect(STM32_PA0).toBe(0); + expect(trace('sensor', 'TRIG').arduinoPin).toBe(0); + }); +}); + +// ── A pin that only ever reaches a supply pad ─────────────────────────────── +describe('a pin that only reaches a supply pad', () => { + const BOARD = 'esp32'; + + beforeEach(() => { + freshCanvas('esp32', BOARD); + const s = useSimulatorStore.getState(); + s.setComponents([ + { id: 'sensor', metadataId: 'hc-sr04', x: 0, y: 0, properties: {} }, + { id: 'r_pu', metadataId: 'resistor-10k', x: 0, y: 0, properties: { value: '10000' } }, + ] as never); + s.setWires([ + // Straight to GND. + wire('g1', ['sensor', 'GND'], [BOARD, 'GND.4']), + // And to 3V3 through a resistor, with no signal wire anywhere: the + // demoted rail is the ONLY answer left, so it has to survive the walk. + wire('g2', ['sensor', 'VCC'], ['r_pu', '1']), + wire('g3', ['r_pu', '2'], [BOARD, '3V3']), + ] as never); + }); + + it('answers -1 for a GND pad', () => { + expect(trace('sensor', 'GND').arduinoPin).toBe(-1); + }); + + it('answers -1 for a 3V3 pad behind a resistor', () => { + expect(trace('sensor', 'VCC').arduinoPin).toBe(-1); + }); + + it('reports both as "no GPIO" through traceBoardGpio', () => { + const st = useSimulatorStore.getState() as never; + expect(traceBoardGpio(st, 'sensor', 'GND')).toBeNull(); + expect(traceBoardGpio(st, 'sensor', 'VCC')).toBeNull(); + }); +}); + +// ── Through a breadboard strip ────────────────────────────────────────────── +describe('through a breadboard strip', () => { + const BOARD = 'arduino-uno'; + + beforeEach(() => { + freshCanvas('arduino-uno', BOARD); + const s = useSimulatorStore.getState(); + s.setComponents([ + { id: 'sensor', metadataId: 'hc-sr04', x: 0, y: 0, properties: {} }, + { id: 'bb', metadataId: 'breadboard', x: 0, y: 0, properties: {} }, + ] as never); + s.setWires([ + // Two different holes of the same 5-hole strip (column 18, top half): + // internally shorted, with no component to trace "through". + wire('b1', ['sensor', 'ECHO'], ['bb', '18t.a']), + wire('b2', ['bb', '18t.e'], [BOARD, '6']), + ] as never); + }); + + it('resolves a pin that reaches the board through a strip', () => { + expect(trace('sensor', 'ECHO').arduinoPin).toBe(6); + }); + + it('does not invent a connection to a different column', () => { + // col17t is a separate net; nothing is wired there, so the answer is + // "unreached" (null) rather than a neighbouring GPIO. + useSimulatorStore.getState().setWires([ + wire('b1', ['sensor', 'ECHO'], ['bb', '17t.a']), + wire('b2', ['bb', '18t.e'], [BOARD, '6']), + ] as never); + expect(trace('sensor', 'ECHO').arduinoPin).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/pin-trace-precedence.test.ts b/frontend/src/__tests__/pin-trace-precedence.test.ts new file mode 100644 index 00000000..e1870399 --- /dev/null +++ b/frontend/src/__tests__/pin-trace-precedence.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment jsdom +/** + * What the wire walk answers when a net offers more than one answer. + * + * These are the cases that decide whether walking the WHOLE node (rather than + * a single chain) is safe. Each topology below is one a user really draws, and + * each one broke a plausible implementation of the divider fix: + * + * - a ground tie point almost always has something else on it (a button's + * pull-down, a decoupling cap). If reaching a rail did not END the walk, + * a grounded pin would resolve to whatever GPIO the pull-down hangs off; + * - a resistor lead used as a tie point can put a custom chip one hop away + * from a pin that also reaches a real GPIO. The board pin has to win, even + * when the chip is found first; + * - the depth budget is spent by CROSSING parts, so a long chain explored + * first must not make a shorter route to the same pin unreachable. + * + * Every case here fails on the first cut of the fix (57325aa), which returned + * the first non-null answer any branch produced. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSimulatorStore } from '../store/useSimulatorStore'; +import { traceDetailed } from '../simulation/PinTrace'; + +const BOARD = 'arduino-uno'; + +const wire = (id: string, a: [string, string], b: [string, string]) => ({ + id, + waypoints: [], + color: '#000', + start: { componentId: a[0], pinName: a[1], x: 0, y: 0 }, + end: { componentId: b[0], pinName: b[1], x: 0, y: 0 }, +}); + +function load(components: unknown[], wires: unknown[]) { + useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); + useSimulatorStore.getState().addBoard('arduino-uno' as never, 0, 0, BOARD); + const s = useSimulatorStore.getState(); + s.setComponents(components as never); + s.setWires(wires as never); + return useSimulatorStore.getState(); +} + +const R = (id: string, metadataId = 'resistor-1k') => ({ + id, + metadataId, + x: 0, + y: 0, + properties: { value: '1000' }, +}); + +describe('what wins on a net', () => { + beforeEach(() => useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never)); + + it('a node that touches a rail IS the rail, whatever hangs off it', () => { + // The LED's cathode is soldered to the same lead as a button's pull-down, + // and that lead goes to GND. The cathode is grounded — it is not "wired to + // D2" just because the pull-down's other leg is. + const state = load( + [{ id: 'led1', metadataId: 'led', x: 0, y: 0, properties: {} }, R('r-pulldown')], + [ + wire('g1', ['led1', 'C'], ['r-pulldown', '2']), + wire('g2', ['r-pulldown', '2'], [BOARD, 'GND']), + wire('g3', ['r-pulldown', '1'], [BOARD, '2']), + ], + ); + expect(traceDetailed(state as never, 'led1', 'C', 0).arduinoPin).toBe(-1); + }); + + it('but a rail BEHIND a component never hides a GPIO on the near side', () => { + // The divider shape: the walk crosses the top resistor, and the tap it + // lands on carries both the lower resistor (to GND) and the wire to D7. + const state = load( + [R('r-top'), R('r-bot', 'resistor-2k2')], + [ + wire('d1', ['sensor', 'ECHO'], ['r-top', '1']), + wire('d2', ['r-top', '2'], ['r-bot', '1']), + wire('d3', ['r-bot', '2'], [BOARD, 'GND']), + wire('d4', ['r-bot', '1'], [BOARD, '7']), + ], + ); + expect(traceDetailed(state as never, 'sensor', 'ECHO', 0).arduinoPin).toBe(7); + }); + + it('a real board pin beats a custom chip found first', () => { + // Wire order is the trap: the chip branch is drawn first, so a walk that + // returns the first non-null answer hands back a synthetic chip pin and + // never looks at the wire to D5 on the very same node. + const state = load( + [R('r-tie'), { id: 'chip1', metadataId: 'custom-chip', x: 0, y: 0, properties: {} }], + [ + wire('c1', ['part1', 'OUT'], ['r-tie', '1']), + wire('c2', ['r-tie', '1'], ['chip1', 'PIN1']), + wire('c3', ['part1', 'OUT'], [BOARD, '5']), + ], + ); + expect(traceDetailed(state as never, 'part1', 'OUT', 0).arduinoPin).toBe(5); + }); + + it('still falls back to the chip pin when no board pin is reachable', () => { + const state = load( + [R('r-tie'), { id: 'chip1', metadataId: 'custom-chip', x: 0, y: 0, properties: {} }], + [ + wire('c1', ['part1', 'OUT'], ['r-tie', '1']), + wire('c2', ['r-tie', '1'], ['chip1', 'PIN1']), + ], + ); + const hit = traceDetailed(state as never, 'part1', 'OUT', 0).arduinoPin; + expect(hit).not.toBeNull(); + expect(hit).toBeGreaterThanOrEqual(100000); // synthetic chip-pin space + }); + + it('a long dead end explored first does not hide a shorter route', () => { + // Six resistors in series reach the tie point at the very edge of the + // depth budget, so its own continuation is cut. The same tie point is one + // resistor away from the start — that shorter route must still be walked, + // and it reaches D4. + const chain = ['r1', 'r2', 'r3', 'r4', 'r5', 'r6']; + const comps = [...chain.map((id) => R(id)), R('r-short'), R('r-far')]; + const wires = [wire('s0', ['part1', 'OUT'], ['r1', '1'])]; + for (let i = 0; i < chain.length - 1; i++) { + wires.push(wire(`s${i + 1}`, [chain[i], '2'], [chain[i + 1], '1'])); + } + // The long chain lands on the tie point (r-far's near lead) at max depth. + wires.push(wire('s-tie', ['r6', '2'], ['r-far', '1'])); + // The short route to the SAME tie point, drawn last on purpose. + wires.push(wire('t1', ['part1', 'OUT'], ['r-short', '1'])); + wires.push(wire('t2', ['r-short', '2'], ['r-far', '1'])); + // And the tie point reaches D4 by crossing one more resistor. + wires.push(wire('t3', ['r-far', '2'], [BOARD, '4'])); + const state = load(comps, wires); + expect(traceDetailed(state as never, 'part1', 'OUT', 0).arduinoPin).toBe(4); + }); +}); + +describe('nets whose answer is not a board pin', () => { + beforeEach(() => useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never)); + + it('two chips on one wire share ONE net key', async () => { + // The chip bus exists so a write on one chip is visible to the other + // through the same PinManager key. A walk that answers with the chip it + // happened to reach first hands each endpoint its own key, and the two + // chips stop hearing each other — with every symptom pointing at the + // chips rather than at the walk. + const { setChipBusEnabledForTest, resolveChipNetKey } = await import( + '../simulation/customChips/chipNets' + ); + setChipBusEnabledForTest(true); + try { + useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); + const s = useSimulatorStore.getState(); + s.setComponents([ + { id: 'driver', metadataId: 'custom-chip', x: 0, y: 0, properties: {} }, + { id: 'reader', metadataId: 'custom-chip', x: 0, y: 0, properties: {} }, + ] as never); + s.setWires([wire('bus', ['driver', 'D0'], ['reader', 'D0'])] as never); + const state = useSimulatorStore.getState(); + const a = traceDetailed(state as never, 'driver', 'D0', 0).arduinoPin; + const b = traceDetailed(state as never, 'reader', 'D0', 0).arduinoPin; + expect(a).toBe(b); + expect(a).toBe(resolveChipNetKey(state as never, 'driver', 'D0')); + } finally { + setChipBusEnabledForTest(null); + } + }); + + it('a ground plane on a breadboard rail is still ground', () => { + // Same rule as the tie point, through the shape people actually build: the + // strip joins the cathode, the board's GND and a pull-down whose far leg + // is a GPIO. Which hole the walk happens to reach first must not decide it. + const state = load( + [ + { id: 'seg7', metadataId: '7segment', x: 0, y: 0, properties: {} }, + { id: 'bb', metadataId: 'breadboard', x: 0, y: 0, properties: {} }, + R('r-pd', 'resistor-10k'), + ], + [ + wire('b1', ['seg7', 'COM'], ['bb', 'bn.1']), + wire('b2', ['r-pd', '2'], ['bb', 'bn.20']), + wire('b3', ['r-pd', '1'], [BOARD, '2']), + wire('b4', [BOARD, 'GND'], ['bb', 'bn.10']), + ], + ); + expect(traceDetailed(state as never, 'seg7', 'COM', 0).arduinoPin).toBe(-1); + }); +}); + +describe('cost of walking the net', () => { + // Walking the whole node is not free, and the shape that hurts is the one + // every real canvas has: a ground rail with dozens of legs on it. This is a + // guard rail, not a benchmark — it exists so a future change that turns the + // walk quadratic again shows up here instead of as "the canvas feels slow". + it('stays well under a millisecond per pin on a 300-wire canvas', () => { + useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); + useSimulatorStore.getState().addBoard('arduino-uno' as never, 0, 0, BOARD); + const comps: unknown[] = [{ id: 'bb', metadataId: 'breadboard', x: 0, y: 0, properties: {} }]; + const wires: unknown[] = [wire('gnd', [BOARD, 'GND'], ['bb', 'bn.1'])]; + const N = 100; + for (let i = 0; i < N; i++) { + comps.push({ id: `led${i}`, metadataId: 'led', x: 0, y: 0, properties: {} }); + comps.push({ id: `r${i}`, metadataId: 'resistor-1k', x: 0, y: 0, properties: { value: '1000' } }); + wires.push(wire(`w${i}a`, [`led${i}`, 'C'], [`r${i}`, '1'])); + wires.push(wire(`w${i}b`, [`r${i}`, '2'], ['bb', `bn.${(i % 28) + 2}`])); + wires.push(wire(`w${i}c`, [`led${i}`, 'A'], [BOARD, String((i % 12) + 2)])); + } + const s = useSimulatorStore.getState(); + s.setComponents(comps as never); + s.setWires(wires as never); + const state = useSimulatorStore.getState(); + + // The anodes go straight to a GPIO — the common case, and the one the + // early exit is for. + let t0 = performance.now(); + for (let i = 0; i < N; i++) { + expect(traceDetailed(state as never, `led${i}`, 'A', 0).arduinoPin).toBe((i % 12) + 2); + } + const perDriven = (performance.now() - t0) / N; + + // The cathodes all land on the same rail — the worst case, where the whole + // node has to be collected before "ground" is certain. + t0 = performance.now(); + for (let i = 0; i < N; i++) { + expect(traceDetailed(state as never, `led${i}`, 'C', 0).arduinoPin).toBe(-1); + } + const perRail = (performance.now() - t0) / N; + + console.log( + `[pin-trace] 301 wires: ${perDriven.toFixed(3)} ms/pin driven, ` + + `${perRail.toFixed(3)} ms/pin on the shared rail`, + ); + expect(perDriven).toBeLessThan(3); + expect(perRail).toBeLessThan(8); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index e7c3ad99..005a278a 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -34,6 +34,7 @@ import { } from '../simulation/PinResolver'; import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups'; import { traceDetailed } from '../simulation/PinTrace'; +import { withPartPinOwnership, releasePartPins } from '../simulation/partPinOwnership'; import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler'; import { getBoardLogicFamily } from '../simulation/LogicFamilies'; @@ -638,9 +639,22 @@ export const DynamicComponent: React.FC = ({ } } + // Every pin this part drives itself becomes ITS pin for as long as it is + // attached, so the SPICE-threshold input path leaves that line alone (see + // simulation/partPinOwnership). Claims are implicit — a part that never + // drives a pin never takes one — and released in the cleanup below, so a + // rewire or an unmount hands the pin straight back to the circuit. + releasePartPins(id); + const ownedSimulator = withPartPinOwnership( + stubSimulator, + piBoardId ?? wiredBoardId ?? useSimulatorStore.getState().activeBoardId ?? null, + id, + metadata.id, + ); + cleanupSimulationEvents = logic.attachEvents( el, - stubSimulator, + ownedSimulator, getArduinoPin, id, getPinResolver, @@ -649,6 +663,7 @@ export const DynamicComponent: React.FC = ({ return () => { if (cleanupSimulationEvents) cleanupSimulationEvents(); + releasePartPins(id); el.removeEventListener('button-press', onButtonPress); el.removeEventListener('button-release', onButtonRelease); diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index da7373a4..aed607e3 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -63,6 +63,12 @@ export function toQemuBoardType(kind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32- return 'esp32'; // esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite } +/** Sensor kinds whose model drives the pad itself for the whole exchange — a + * DHT22's DATA line, an HC-SR04's TRIG/ECHO pair. Anything else registered + * through the sensor channel (ePaper, keypad, I2C devices) leaves its GPIOs to + * the host. */ +const SINGLE_WIRE_SENSOR_TYPES = new Set(['dht22', 'hc-sr04']); + const API_BASE = (): string => { // The desktop shell injects the sidecar URL at runtime (random port) via // window.__VELXIO_API_BASE__; honor it first so the QEMU-board WebSocket @@ -620,7 +626,14 @@ export class Esp32Bridge { for (const s of sensors) { const pin = s['pin']; const idx = merged.findIndex((e) => e['pin'] === pin); - if (idx >= 0) merged[idx] = s; + // FIELD-level merge, not object replacement. The two registration paths + // see different amounts: the part resolves the sensor's extra pins + // through the full wire walk, while the store's pre-registration knows + // the component's properties. Replacing the whole entry let the coarser + // one silently drop `echo_pin` — the QEMU worker then fell back to + // TRIG+1 and pulsed a pin nobody was reading (the 1k/2k2 divider + // report). Whoever writes last still wins per FIELD. + if (idx >= 0) merged[idx] = { ...merged[idx], ...s }; else merged.push(s); } this._pendingSensors = merged; @@ -667,8 +680,15 @@ export class Esp32Bridge { * and reply — pulseIn() then timed out forever while the worker was pulsing * the pin correctly. Same shape as the in-browser engines' ownsPin guard. */ - private sensorOwnsPin(gpioPin: number): boolean { + ownsSensorPin(gpioPin: number): boolean { for (const s of this._pendingSensors) { + // ONLY the single-wire sensors own a pad. The same channel registers + // plenty of other things — an ePaper panel's DC/BUSY pins, a membrane + // keypad's rows, every I2C device on a virtual 200+addr pin — and those + // still need the host to drive their real GPIOs. Blocking those was the + // difference between this guard and the in-browser engines' narrow + // SingleWireSensorHub.ownsPin, which is the behaviour to match. + if (!SINGLE_WIRE_SENSOR_TYPES.has(String(s['sensor_type']))) continue; if (s['pin'] === gpioPin) return true; if (s['echo_pin'] === gpioPin) return true; } @@ -677,7 +697,7 @@ export class Esp32Bridge { /** Drive a GPIO pin from an external source (e.g. connected Arduino) */ sendPinEvent(gpioPin: number, state: boolean): void { - if (this.sensorOwnsPin(gpioPin)) return; + if (this.ownsSensorPin(gpioPin)) return; this._send({ type: 'esp32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } }); } diff --git a/frontend/src/simulation/PinTrace.ts b/frontend/src/simulation/PinTrace.ts index 876f6aaf..579abd8d 100644 Binary files a/frontend/src/simulation/PinTrace.ts and b/frontend/src/simulation/PinTrace.ts differ diff --git a/frontend/src/simulation/partPinOwnership.ts b/frontend/src/simulation/partPinOwnership.ts new file mode 100644 index 00000000..7a83918f --- /dev/null +++ b/frontend/src/simulation/partPinOwnership.ts @@ -0,0 +1,135 @@ +/** + * partPinOwnership — which board pins a PART drives itself. + * + * Some parts are not in the SPICE netlist at all: an HC-SR04 answers a trigger + * with a timed ECHO pulse, a DHT22 bit-bangs a frame on its DATA line, a rotary + * encoder and a keypad emit edges from their own event model. They drive those + * pins by calling the simulator directly, and nothing electrical describes them. + * + * connectDigitalInputsToMcu thresholds the SOLVED node voltage into every input + * pin, and its `sourcedNets` gate was the only thing keeping it off those pins: + * a net with no real component on it is "unsourced", so a part-managed line was + * left alone. That gate is wrong as soon as the user wires the part the way real + * hardware needs it — a pull-up on a DHT22, or the 1k/2k2 divider a 5 V HC-SR04 + * needs to talk to a 3.3 V board. Then the net IS backed by components, solves + * at whatever the passive network says (~0 V, since nothing models the sensor's + * output), and the connector pins the line the part is trying to pulse. The + * sensor answers and the MCU never sees it. + * + * So ownership is explicit and board-agnostic: while a part is attached and has + * driven a pin, that pin is ITS pin, and the SPICE-threshold path leaves it + * alone — on AVR, RP2040, STM32, ESP32 and every overlay board alike. The + * in-browser ESP32 engines already had a private version of this rule + * (SingleWireSensorHub.ownsPin); this is the same rule where every board can + * reach it. + * + * Claims are made implicitly: DynamicComponent hands each part a simulator + * whose setPinState / schedulePinChange / registerSensor claim the pin they + * touch, keyed by the component id so a re-attach or an unmount releases + * exactly what that component took. A part that never drives a pin (a button, + * an LED — things SPICE models properly) never claims anything and keeps + * reading the real circuit. + */ + +import { isSpiceMapped } from './spice/componentToSpice'; + +/** boardId -> pin -> set of component ids currently claiming it. */ +const claims = new Map>>(); + +/** Reverse index so releasing a component is exact and cheap. */ +const byOwner = new Map>(); + +/** Record that `ownerId` (a component) drives `pin` on `boardId`. */ +export function claimPartPin(boardId: string, pin: number, ownerId: string): void { + if (!boardId || !Number.isFinite(pin) || pin < 0) return; + let board = claims.get(boardId); + if (!board) claims.set(boardId, (board = new Map())); + let owners = board.get(pin); + if (!owners) board.set(pin, (owners = new Set())); + if (owners.has(ownerId)) return; + owners.add(ownerId); + const list = byOwner.get(ownerId); + if (list) list.push({ boardId, pin }); + else byOwner.set(ownerId, [{ boardId, pin }]); +} + +/** Drop every claim made by one component (unmount, re-attach, rewire). */ +export function releasePartPins(ownerId: string): void { + const list = byOwner.get(ownerId); + if (!list) return; + byOwner.delete(ownerId); + for (const { boardId, pin } of list) { + const board = claims.get(boardId); + const owners = board?.get(pin); + if (!owners) continue; + owners.delete(ownerId); + if (owners.size === 0) board!.delete(pin); + if (board!.size === 0) claims.delete(boardId); + } +} + +/** True when a part drives this pin itself, so no other layer should. */ +export function isPartOwnedPin(boardId: string, pin: number): boolean { + return !!claims.get(boardId)?.has(pin); +} + +/** Test/reset seam — also used when the whole workspace is replaced. */ +export function clearPartPinOwnership(): void { + claims.clear(); + byOwner.clear(); +} + +/** + * Wrap the simulator handed to a part so every pin it drives is claimed for + * `ownerId`. Kept as a Proxy rather than a copy because parts reach deep into + * the object (`pinManager`, the lazy `spi` getter, `registerSensor`, board + * specific extras) and those must keep their own `this`. + * + * `registerSensor(type, pin, props)` counts as driving: the sensor protocol + * then runs in the backend QEMU worker (or an in-browser engine's sensor hub) + * and owns both its data pin and any extra pin the props name — `echo_pin` for + * an HC-SR04, which is exactly the line the divider report was about. + */ +export function withPartPinOwnership( + sim: T, + boardId: string | null, + ownerId: string, + metadataId: string, +): T { + // The rule lives HERE so no caller can forget half of it: a component the + // netlist can describe does NOT get to own its pins. A button, a slide + // switch, a pot are all in SPICE — the solved circuit is the truth for them, + // which is the whole point of spiceDrivenInputs (a mis-wired button must + // read stuck, not "work anyway"). Ownership is for the parts SPICE says + // nothing about: the sensors, the encoders, the keypads, whose only model is + // their own event code. The day one of them gets a mapper, it stops claiming + // and the circuit takes over, with nothing to update here. + if (!sim || !boardId || isSpiceMapped(metadataId)) return sim; + const claim = (pin: unknown) => { + if (typeof pin === 'number') claimPartPin(boardId, pin, ownerId); + }; + return new Proxy(sim as object, { + get(target, prop, _recv) { + // The receiver is the TARGET on purpose: lazy getters (`spi`) memoise on + // the real object, and methods must not see the proxy as `this`. + const value = Reflect.get(target, prop, target); + if (typeof value !== 'function') return value; + if (prop === 'setPinState' || prop === 'schedulePinChange') { + return (pin: number, ...rest: unknown[]) => { + claim(pin); + return (value as (...a: unknown[]) => unknown).call(target, pin, ...rest); + }; + } + if (prop === 'registerSensor') { + return (type: string, pin: number, props?: Record) => { + claim(pin); + for (const [k, v] of Object.entries(props ?? {})) { + if (k.endsWith('_pin') || k.endsWith('Pin')) claim(v); + } + return (value as (...a: unknown[]) => unknown).call(target, type, pin, props); + }; + } + return (value as (...a: unknown[]) => unknown).bind(target); + }, + }) as T; +} diff --git a/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts b/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts index e7422ec7..78977fdb 100644 --- a/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts +++ b/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts @@ -16,10 +16,26 @@ * (custom-chip path): it knows ONLY the electrical store shape. * * Only pins the MCU is NOT actively driving as outputs are injected, so we - * never fight a `digitalWrite`. Other boards (AVR / RP2040) keep the legacy - * part-seed path; only the ESP32 QEMU bridge opts in (`spiceDrivenInputs`). + * never fight a `digitalWrite`. Every simulator that opts in with + * `spiceDrivenInputs` takes this path — AVR, RP2040, STM32 and the ESP32 + * bridges all do today. + * + * TWO kinds of pin are left alone, and the reason is the same for both: some + * parts have no electrical model at all, so the solved node says nothing about + * what they are doing. + * - pins a PART drives itself (simulation/partPinOwnership): an HC-SR04's + * ECHO pulse, a DHT22's frame, a rotary encoder's edges; + * - pins the board's own bridge reports as sensor-owned (`ownsPin`), which is + * what a backend- or engine-emulated sensor registers. + * The older `sourcedNets` gate below tried to cover this by skipping nets no + * component sits on, and that works right up until the user wires the sensor + * the way real hardware needs it — a pull-up, or the level divider a 5 V + * HC-SR04 needs to talk to a 3.3 V pad. Then the net IS component-backed, it + * solves at whatever the passives say (~0 V, since nothing models the sensor's + * output), and this connector pins the very line the sensor is answering on. */ import { useSimulatorStore, getBoardSimulator, getBoardPinManager } from '../../store/useSimulatorStore'; +import { isPartOwnedPin } from '../partPinOwnership'; import { useElectricalStore } from '../../store/useElectricalStore'; import { isStm32BoardKind } from '../../types/board'; import type { BoardKind } from '../../types/board'; @@ -56,7 +72,12 @@ export function connectDigitalInputsToMcu(): () => void { const { boards } = useSimulatorStore.getState(); for (const board of boards) { const sim = getBoardSimulator(board.id) as - | { setPinState?: (pin: number, state: boolean) => void; spiceDrivenInputs?: boolean } + | { + setPinState?: (pin: number, state: boolean) => void; + spiceDrivenInputs?: boolean; + /** Optional seam: pins the simulator/bridge drives itself. */ + ownsPin?: (pin: number) => boolean; + } | null; if (!sim?.spiceDrivenInputs || typeof sim.setPinState !== 'function') continue; const pm = getBoardPinManager(board.id); @@ -71,6 +92,16 @@ export function connectDigitalInputsToMcu(): () => void { const gpio = isStm32 ? stm32PinNameToLinear(pinName) : gpioFromPinName(pinName, board.boardKind); if (gpio < 0) continue; if (driven.has(gpio)) continue; // the MCU drives this pin (digitalWrite) + // A part that models this line ITSELF (an HC-SR04's ECHO pulse, a + // DHT22's frame, an encoder's edges) owns it — see partPinOwnership. + // The `sourcedNets` gate below cannot protect those: the moment the + // user adds the pull-up or the level divider real hardware needs, the + // net is component-backed and solves at whatever the passives say, + // which is not what the part is driving. + // Nothing is cached for a skipped pin on purpose: the connector must + // stay free to emit the moment the part lets go of the line. + if (isPartOwnedPin(board.id, gpio)) continue; + if (sim.ownsPin?.(gpio)) continue; // a backend sensor drives this pad // Only drive pins whose net is backed by a real source/element (rail, // pull, button switch, divider, cross-board output, …). A net that is // only floating (an event-driven part like a rotary encoder / keypad diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 5748bb04..bb31f9bc 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -41,7 +41,6 @@ import { useEditorStore } from './useEditorStore'; import { fingerprintSources } from '../utils/sourceFingerprint'; import { useVfsStore } from './useVfsStore'; import { buildProjectSdImage, decodeSdFiles, bytesToB64 } from '../utils/sdCardFiles'; -import { boardPinToNumber, isBoardComponent } from '../utils/boardPinMapping'; import { autoWireColor, DEFAULT_WIRE_COLOR, @@ -346,6 +345,19 @@ class Esp32BridgeShim { return true; // backend handles the protocol } + /** Pins a backend-emulated single-wire sensor drives itself. The generic + * seam connectDigitalInputsToMcu asks before thresholding a pin into the + * guest — see simulation/partPinOwnership for the same rule on the part + * side. Both exist because they know different halves: the part layer knows + * what the canvas attached, the bridge knows what the worker was told. */ + ownsPin(pin: number): boolean { + // Optional call on purpose: this shim wraps whichever bridge the build + // installed (the OSS QEMU one, or an overlay's in-browser engine), and a + // missing answer must degrade to "not owned", never to a TypeError thrown + // inside the SPICE subscription. + return this.bridge.ownsSensorPin?.(pin) ?? false; + } + /** * Expose the underlying Esp32Bridge so simulation parts can subscribe to * board-specific WS events (e.g. `onEpaperUpdate` for the ePaper backend diff --git a/frontend/src/utils/boardPinMapping.ts b/frontend/src/utils/boardPinMapping.ts index 15b1a128..a7cc5c90 100644 --- a/frontend/src/utils/boardPinMapping.ts +++ b/frontend/src/utils/boardPinMapping.ts @@ -306,6 +306,10 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul } if (boardId === 'arduino-mega') { + // Supply pads BEFORE the numeric parse, or '5V' reads as D5 and '3.3V' as + // D3 — a wire to the supply would drive a real pin, and a walk looking for + // "what drives this net" would stop at a rail believing it found a GPIO. + if (POWER_PAD_RE.test(pinName)) return -1; // Digital pins D0–D53 parsed numerically const num = parseInt(pinName, 10); if (!isNaN(num) && num >= 0 && num <= 53) return num; @@ -362,6 +366,8 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul // Pi Pico W — same GPIO mapping as Raspberry Pi Pico (GP0-GP28 → 0-28) if (boardId === 'pi-pico-w') { + // Same trap as the Mega below/above: '3V3' would parse as 3 and '5V' as 5. + if (POWER_PAD_RE.test(pinName)) return -1; if (pinName.startsWith('GP')) { const n = parseInt(pinName.substring(2), 10); if (!isNaN(n)) return n;