From 8f148e836df2426f9d5d362ac6097d68d1805049 Mon Sep 17 00:00:00 2001 From: David Montero Date: Sun, 23 Aug 2026 01:23:41 +0200 Subject: [PATCH] fix(sim): a net is a set of pins, and a part's own line belongs to the part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening pass on 57325aa, which fixed the reported HC-SR04 divider but did it by returning the first answer any branch produced. Five topologies people really draw break under that rule, and each one now has a test that fails on 57325aa and passes here: - a ground tie point almost always carries something else (a button's pull-down). Reaching a rail has to END the walk, or a grounded pin resolves to whatever GPIO that pull-down hangs off. Same through a breadboard rail; - two custom chips on one wire stopped sharing a chipbus net key, because a nested frame answered with its own neighbour's per-endpoint key before the root could mint the net-canonical one; - a chip one hop away ended the search before a real GPIO on the same node was ever looked at; - a node first reached deep, where the hop budget cut it short, was never re-walked when a shorter route arrived. So the walk now models what a net IS. It collects the whole NODE (every pin joined without crossing a component: the wires, and a breadboard strip's holes), then judges the set: a driven pad wins outright; a supply pad means the node IS the rail and the walk stops there; only then does it cross a passive to the next node, where a rail is the weaker answer that lets a level-shifted sensor resolve. Wires, components and breadboard groups are indexed per store array, and a driven pad short-circuits collection — the common case is now 0.08 ms/pin on a 300-wire canvas, better than before this work started, with the shared-rail worst case at ~2 ms. Second half: a part that models its own line keeps it, on every board. connectDigitalInputsToMcu thresholds the solved node into every input pin, and its `sourcedNets` gate only ever protected part-driven lines by accident — it holds while no component sits on the net, and stops the moment the user adds the pull-up or the divider real hardware needs. Then the connector pins the line an HC-SR04 or a DHT22 is answering on. simulation/partPinOwnership records what each part drives (implicitly, through the simulator it is handed) and the connector skips those pins; the ESP32 bridge answers the same question for sensors the backend or an engine emulates, through a new optional `ownsPin` seam. Parts SPICE can describe never claim anything, so a mis-wired button still reads stuck — that property is why spiceDrivenInputs exists. Also: setSensors merges per FIELD, so the coarser registration path can no longer drop the finer one's echo_pin; the ESP32 bridge's guard is narrowed to the single-wire sensor types, since an ePaper's DC pin or a keypad row must stay drivable; and the Mega and Pico W pin maps get the supply-pad guard the other boards have, where '5V' parsed as D5 and '3V3' as D3. Verified in a browser against a local build of this tree, driving the real app: HC-SR04 and DHT22, direct wiring and level-shifted, on Uno (avr8js), Pico (rp2040js), ESP32 (esp32js and QEMU) and ESP32-S3 (esp32s3js) — see scripts/sensor-wiring-matrix.mjs in velxio-prod. --- .../src/__tests__/part-pin-ownership.test.ts | 110 ++++++ .../src/__tests__/pin-trace-boards.test.ts | 364 ++++++++++++++++++ .../__tests__/pin-trace-precedence.test.ts | 234 +++++++++++ frontend/src/components/DynamicComponent.tsx | 17 +- frontend/src/simulation/Esp32Bridge.ts | 26 +- frontend/src/simulation/PinTrace.ts | Bin 15752 -> 21031 bytes frontend/src/simulation/partPinOwnership.ts | 135 +++++++ .../spice/connectDigitalInputsToMcu.ts | 37 +- frontend/src/store/useSimulatorStore.ts | 14 +- frontend/src/utils/boardPinMapping.ts | 6 + 10 files changed, 935 insertions(+), 8 deletions(-) create mode 100644 frontend/src/__tests__/part-pin-ownership.test.ts create mode 100644 frontend/src/__tests__/pin-trace-boards.test.ts create mode 100644 frontend/src/__tests__/pin-trace-precedence.test.ts create mode 100644 frontend/src/simulation/partPinOwnership.ts 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 876f6aaf513f2d41f9f964069b921a51e7bf6d19..579abd8de0703fcf9082f3b644ca945e910a8f6a 100644 GIT binary patch literal 21031 zcmcJX-*Oa3md5XSiZn4XYI&pL`O!6864+5w@JWP3JP)|9$SQdzC4-l}ed0uwP; z8+$!5&#({XC)wY3PG(iL)WX=iOUz(Ym3i{y$@BNiQ`-3a^Bdvwuv?S|bvDYwFMs<- zxOsk3j84K~mDS@gEy_?dVRph_qv~v0m3cX{r{_hTkHf6``X&@av%Eel%4`y*S<}FD z69!qR^QLHKtcD<(ye@cJ7_EGgk7ji-$|h{ib~?>!NqANn598vvm_clkUF5a#RHIYa%;HJ1`zFt3oLN;48LbK1voLS$6giEzIqWsXIPWCe zY&^Xvv$JCKB*}U>Yu*ht@2q;u#FHFZH^!^zX^%`t^pLd;>~Fr1@t-B(1n4gG37AEM}WW6~lj%GTwaq*Vz)`8_AF`uF-vlGqDM<lCgfeVqSv`9!n_5lMf0|FV zayhzKpA^R@XjmDh^Lkn#yFpony_^lLVMS4{-)}zuZYvZRVLe-w)DJ7z#Ztr-TEkY@ zj4J`B(w>FSK=N96<3{{(b}`MvdmYpwr{P05s;jfGG8}Fgjg2cqRvsiG?-9Ihs#TCR zKO}jSYG;0S2uD4F^I0)zHcW9D@gkdIZpTZ=?YpL4ndv&cUuM(ANt+`%h^P760w>na z+F*^nys0L5mj3w8dcMV+rhmyVx;ev*$YX0^JNc~X&u%VCWIn?vPKqfjtcCr5-8p!+ zb+G;9$0yIWcYoa7-udzI=KfYkbYnC}lxLdIY_#*4yFeH(AyzetH5a&AATaU)ntNko zBOt|amd~KgINbI2;7JgQ18Xr8;O@a(au4WxTIf^`JmWGMQ^hMw=dJlMLAwN+ybdLw#t>6U(~ zrt8_+v|){01NC;qQmr3rI!{V;5;>$Vlf++5t4UTDvkS<4f5cy9RbonIp4GDX>}^ra zYlh<~6i*hMsR-ey6rzYqd5u%TS2p2hlbu1hC~pqK<9|MIzT^_#O((?&@70`Oexv!! zd<9UI}rp2IS(yFTow8EsqmAoyoo)B)b!5JJ78Y&U{a=poq&lCi3 zAhTjR$=`)hQIF;dJm#U`%jt))&IK_x*wsW~MhY~%!SOV+u)Ddxzy1A|>ErI^_TIi} z<$e6hsH(?bDR7j>YvEP;hyVBWqwp}izY*G?bw9jXxxKO$R_?64Cd`DD6wTJ>&C2DM zqil*oakQ6Tth8^|J?K@F3v9>;{7KY*`LDk_eO&c?w2VSVSgno;9Ue?=>ZYbXj*P_t z?Ml~kB{?`9AebjMROwK;Z;%dQ2E0Oc~f?5u>b`WEi#B|J{yJ? zMP1AAM5tAey{-MNgZ*%ZCsBqn#e_H7oamt1*8?Jdt5`Pv$Ag#470UG}sZpmcckbLm z0`1Q$i^}xH7ax0j_sfsHy?y!-!go$}me(zN_f)dK?(OYcAEkWfV{h+%`LVaRZ+(>X z?X#;~q1WrTZ%y^0Q*jfb-rTu!-5Zv=_EmWewcEF@e{=n-`8CvJ@$0u2-!8g=b{jtQ z)~AoX`{k#PMG~Jr7?u0$gKQpH3$i8{h6c z#odm{(B}l-c}d1NI?2aFCE0;|#u9yO6%T6ir{c(cx@A4)(fhfN zGLVfIFZaLMI@s_$X~VLpxWuTg8p^kR@_V06R1#i9%I_;DIm{B@;w4q-CtT zPVH@OKj%2tZ!3?5Lr4<4Rm|t~rCv-N zk0=7E9F-W3t$H-<0XdK)iUjrgnkptGU=)7Z%QU5b`MZ;h^xVcB&c|p@kH;6AfBo^< z%iVqW{BvY;Gwf|`KJS!e7&ez^iYn4@=@cwI!JecbOr1 zD@(;NMKIHgQ#7RUiq>^dr^K#9c8)wydm%8YU&Xx-sBi>&Vq(tkhhDdrM7}D zbk2fXkVbV1jm)=vG%u;6RKa>thwcv0l}q}&dfyZXzM@)tvM8Ih7oR%?=8X9;}9kkHVWjyl>N$?eT|yo8ygd{oy^7 zH1xw8PR`uO4NABHsT)rwW|D``Yq^e&;< z^0Po#dah#Dj#ea z!=Hfb2*@)t61UZ49Nr+0Sw1wrhDY$4*8@Vz>Kl=3R}4}OX&K01HD_y6HYl(O)rJ1c zLHX}@z%$PCd}^Q?tb^`l-vg?Pai?3ckdNf##zNOVDaIp&*Tx&V1N}#Qlold*rUYw!E z_pMXaHmPShT}AOeTG~oWYC&ws?8sd$-fWt332=K8K=^E?orKXlQlMiBMF!;v@;bNL zIvuaoA>(w)byiH`A>*=U@-@?*XKlCeo21qXNG2Br*Sf8ij(Rs{a^(y)=F-*La{Q-;LW!DuLT8O1s6m>y#`Dgn968C!FL zM&tvF1B%G48)LSyV$3^|vz{LV1t#MFra@aeWX-f-YNmyO2rLrJL;=1}Zk&H1AI9fwbHY<&`O1QwIObTE!hWx4} ziQ23pIl}qje#MBq*HpeFj19TvG~pL>?@oj)sDtAA?!o4EXK~3tAnE8L_}BZ69BS*& zANb<@+;<_KlHowYha2ugT2rv~p6Og<2j-^xCZM9DQ<0wbhaUea#A>k7L%Q8F>?Eyk zX3@F9W-mWtgT2_W)Ci>9K1rM`OlnzMuBDnOD^%^FMB$QfO;OEZ!-%P}nVOIO`DdRt z?5&}UCGn7K(K$29O+2loqF-X9=Q7gX?i0xcOD5Ja>eabl43L9UNsV83L;JE}{gzvE zZBD#>##L56?KZS68E-1#WHpVDS33p8WQEAx>)!Z$!exrdK$C{p z@?;JQJ?KVLs@kjRygBJVPJGD?1`JGpCxu%y(WE+Sk)=TCtkIX%Qk*8WM4D1wO>=1)SMX3!9;X{u#hfNv!@Wt^Zl*OgRQ5r_+6|L z#Ck~~TXe2X>g?PKd0%41ZpmSxAr9LG zsbvI7Zwa@0Hw{=^m-?1z%qRK!pEGKo3#yLn!s?&S7L?YK zpx6c;@WW%>*~a@w8{J)~iyBw7UJ{T@7qbVJXo$y{3rl)Cx&w!*vyRy_hab3=Is*sr z=ke0}xGvbLihPynLIu1TgV|k4LN*im)BcNcyF99d1vn*>1TRQ9#UI!AIRbLbOeH%R zyj29{MHIRifxb6u#1;YHzTq_x+2}lr;Zz6S?cn16t@%aNzmz?!DdB#vZ6uiCfggS6 zlekB9N@|85UA|zHk+L zGY9^27(6?8@jMO-e}vDTo`H$I=eTJVsCUJE1jx{cQ9hlKsCpkt7{1eAs<;g}XocQfA?Qbj zS|A@|9vLUB5z_WZ%(Z|}^tfU~vl0r`%Exq!)n(4oEwSg3x)*<4h+TcNnVOTZI1Eyy zFPHY}$xver_qOW4AYr2Q2kSmd?(aZ0MSIAM<_^*iH_UUZTaa&8q_+s(Q8k$W$H?&8 zb}0*Z=3;(AimA6s5;Q{3_sK{AK_rYjX|D|3l*6Dv)&}e{fH|oY3XSsD6%{|d z7f_H~*|bs;{>^Lp3-Y=<+uK7XSg!&X4{cxJ%N-wV%Sa$*PZFO5NgbhRk3m^^3vZrRa}ZKsUdyOBO<`q5-5`0gi1}<*l?vfRsr9h{BB6>M zYf4}w7D?A3Q<;M)8v1N9FOJn}m~fORr7wEYPKFHI))#4a8D`K{lDK@*ESM_PMIJHvx4V3GC?51Nw2v{4Z;F7eEi10)k)QU=8#HB~0!Zb2|SB#sl zFwKlC`z4R=JLse|SHInPN>8LC}LiNGwRF#OTgi(j}fM{qV5Oi>jj9DX#6 zIkIq1EO@updb;$BT9wb%fudrck5C}PkQyB+XQ06(si_mAU0!Z(AZCr);k==X(~43( zD|3|lkR~P1*Lv4Tu$Ij6&5~=rH*8WDzy^>*NlK|}p>tvm+w_Q(>H3hnH@`p(TuOc( zyRCR=_lUasQ?Ku$3W&3)xAHxtC~`#myW3B;)O;g-HoZ8SWN+1AWjL`WrJkpw3`1G-25E z`zoO6fY){0^t7FqrakK%Bg@l%11l4Cx_b9$O$ie2xwcAh$8(aB@Wyr%l+50G)20T; z>_?jGp4+L>UnU?`o(hfDMUIsdj^vBg7?j|zU!%$WaX$?W&#sDQuOg9onB-$iw`nvM zAnL|Ck#cyIuBV!|6#){hAd!BL#qVOeL** zRyKrUuem$+zP3ZZq`Y)^sv%*A(~cnmrMn`j9SG@IchFOQd;b6TMt`0Cc{Ph5mb<*H1-Fqz(! zW~f0#hKe_iUn5}5)@1NI#msU--lHP!;liWej=nP%>fQk$4`QS7j0oUYM)nF=?f8Bl^=!NiSzCtpP^G!2K2=uIzMJeI!g?FkCt}q&lSSJ&Y@1b9Kw*fQS zTAZs(Kj${uS@=`9tpYW&;5OJRj(fcwzQC~a1|6ELyUW(lDj+1miSqZbOt0+|h8qOue z*gnQ0zLTbtRUX7Ph!$}LKC>b8{V_C6tjPV8C-O6~5vT|Kz*#IOZ7fuIrwbGaoU0iwC z#3J*0y=4?Ww0~7~XW5MP%j4ByN^hUQ&W?X-;r>3-6AYBZ2iAhvl~hrWSvEt_5amRk z=k66LSZNqVuCJn)kkS;^By(91cvo?f+|Z^$QuTQ2o0ofA?ydVfNU5W5RegKsUeQ`E z3YH@OE(z#cx{}@>Xj!KZQq_lULKqA;QmSRBute4`J#<#En-FGwKqZ>$K@-!YWK?v* zmsH+}oQ{%7O0wirJ&#QJOZ0&*)`wR`eND2^b5Th1FR;`(h&C8^u8O4JMtyRibXF~> zXj5oZVmMJ}H~Q0Gqt$0=3R66y$N7eD_6h0&a#i(){zMnm_xJr=&4(={Y$DswJ>t)D zq@9!%@mkpQn#*8MR#(67ML=`cE4{)Nko>)Ins)#3eP7Ohe}mYUE+7m0BpQyzhAj-( zJ`PQgAWq{-QvOk#&{~hW9^f7FJ-axO6x4nCBwZ>Q#GAkg9WO>75iv54R`%{6h+6Ut zB@mKz{h|RN|Ay0l)w?C`3fTuDuY@Aq4?HlEA8R`LKohg`cz{)UiS=}kFMD=KKCpFx zPDA*&bX=EehH%M*YgVyZ^Y$W~X3FSXHGEZf`0xU*N7JZn902 zmt}N`*T;_Y>FTbfn?Oub71e5B%oJ~i(2;BrebiT0jQDI4P(cV{T(3=x7uM6Iw~J-- zGGeoMdKiyDpMh810^F9lSE7QQU^H%B?i6ifjGbCKvGzV{@zT9t55d^)2Q^eQU4v9y zK|HuTThL^8zOIGo#j6$lyrQOimzfW`PO9xNrS2a+YDT78r5FH(@Weg}Gj2q`rlOWpL z0>g%tD=H&XtQT5>v!$1wMnJg~%>d6{?j3CHIsTHUIb@@D{q{p?M0xY^z$2#%`)*Z*4RlI<5F}RVf=hnN z?&iR;6E%bO7;PZE?&ozX_L;7yfgXZV(nf|hK9`vJ%6&>G%EDi_H(w@N;+*BY^;$#n z$ry)tLcc`LPBIeh@NAQ>OS#-p!e?o|-1psE!ZPjVGST{#4hK_{ge_m&vXF>r4Rc5l z3HP=Id+U~`OK<#9EQS|55Mo7q>BO$HIH#1-pQIHhh>`DJ7qcK+Q$~ADs^S2%Gj1W? zC)h^#@W!$`>9(hDj};MjNg|yC%NE;;)~=_yS#G^@ijfAKz#S}j4%(4z$GS!`C~B#} zLRJ|)28;Dy6wz4b6n1*ux|R*;0?FFR!rp1>pAhY!#)^Q|2$9W51T5 zH6ht_yn9t5(*HKx>&BOSAHQds?cKuZ*wfvi02XIL7dYOfCpDK^n7e{We;x#!^#+AwKa1X?<3!&K4FAVVu8F6jXxj~8@E*vVCYLu2)_(_-Nd<#PftThd? zH)>{i_R)+c_Y^uA8wDxQ{~=8~$6%Qzw`Ree`JUX_D0A=bo4=*d)o&iS+Ge>tPB^8Q z{CCRR1I=uM2-HSCv)12a7&s{J4%a{WEuA)&sPo_hr}ZDtZtU zXA95YE**Ps`%&7;q)UE8=az=XeYC^k&n?LSG#JPBp56EE-n40F#{duUgGM@I#I-or zr~qLAFUBWnE;^o~OAB!j8+0>sTo(EsEPhP%och3>2{$5YskOSnL#-+Io6Ens$n=e+`6;`a zb#LH5lhwd3xk^39Vc-Dln$%3HBdNQ!CVcnZ_EXpa2|lt*L-PxKW;3FhQZ)?egn0{` zouVnOoCJoOvH0OdN3K;C-s*Bbewn1yl-7@nvnju&gP2){t^M6E?t~GBaH>oTVCi4B zUc|;1(_6NipG7SFgaV9Th9rc@zqiZf4>lSOg;m%g%@0Mx6~4an;zx8?pDB#9`ZS>h zkUPvB!J-a|x2*ob0|aLNVQJil>yHW1^~ZXt?B|BBu*&gZa2R{II6_?~MqTS`dTWA; zO^KEv)Q=UWo?y9n&2lzaHn%ga6h4iD`mxaGNGa$eL0!3saO0=@{3a^Et}9IM#DrwKYZY8m%V%_8wC( z6rw1nfQ~9q$%u8<MegFUfR}w@%DPNt?e`BsO5g=~sy&+)>*U-@ z*wT^=2$z5FQj986Jxhcc(!pcz+r^S3V>Z)!zKClfjHG#{&CAiM_Ws<*=x>v+Ql0jY zk@fP^?Jv+~LZV?lECK)mFmPn=q;~G5)C|FNmmt3QPZn*D=n@877vdc1Z~eaoEm{4Vtb8FM zcbHNgHk}vpPp{8QPh_p$i%wM-pxqZJ@DN`>!j$Qn-lpaWE)V#HYW@{HR-)pp2j2?) zb_(oQ3n3#|^}p^QZ{1S+YYMEVXW_Rl zSc>bG<<4;dgGhH0%amp<+%hGR>N2%=U23saN7z87r5he)%#UK&>Hetg$wX4-;#+m7foy6Jo#@-m)vB&?hcL`{<#68n*X5PA| zyVI}76Kk^!Cj=6akX|lu;DmrnP^5?h5?ncOMF@mINCh6tBPd{wV|X6#L*-NU-) z{nV>h_0?BZ@8>^#;Cpx8oqKe!NI|NPR78o6=pWyCn|w-@AJXb>eTU){Kbt+1#ocPN zqm$txZ7^Z7G>+N|Ya-LfpRD6Y6EsCU)k~7_xVE~zOo<;*sXAS*P`{&_9g3syn2djH z=t9=fo}ox(1bLZO0cA0t@9Xe_M^m+lRxfI1I*u^EELz&hFRZ`!5Iy$rphn9o$vV|g zcXfuHeLp;$qb&8Cidt#hr4s*N4=VC&S4U;&m&Uzzhx$5IrowBFRBCk8o~YG^lZkH( z>_N@Ao5YcdGH4dFNuatL}7!@qy z7DsAX9qFbzpjPO&$;7e`n#3X0pqAgv;`ErBenf$4>OirahRXU1&Ixo|XYfkPH(53D zv(5lU4`_e4zO+)!Wk4^LR-u50oxFL`d2*;T`C*^!-q%pNREQKu8`brrOgEu)st8tx zE88k4d$a?0v9NvzYHaTm78n36aGKb~Z-k11nEJ51ABNQMn}^Pmr=bH+!dNVhoz7@e z8Uu{7Na=Q`5%*FmfL3AnmE>Huuyd`>j0#(1zLEJUyWP*oXL-dY_Ob#pF$>2Ihvh71 zCmz-r>jzn6jw5Wq%_-`28!Gi|POqA#7I>TyDnO>ODoe3h{g8dkN zv_P}={o0cKO7&XKE5OV{(8vC#T0=wi(y)d8{BGbNx`oLH~5?C_kaKmFT0Zhw=II@58F4*rbJa&cM zzM+n3o(?YFaiM+p6YdMaefQvM?iPb^@jAXKZmT7)LKJmkT`10cervY8MM14=t<{|L5*}v>ko`qIrdh^X< zSKm`n;0;-&9t;=aQ^fH6M+vDwL|L>2NETfH+FOg)Cg1|!nb!Vd@ukmF(lZ?uV0Eo# z|GRi~u#b@Bs0Ga$86A-tQ%s0Z7aEV7hls|K@Wj6S06D_%3_`XLN1j7k*KZHKiO>&B zSzKQ5A_&1Km=I0|I}9=aLg?QG9>dF#9s{&R+!H<+`l9z|&=Zs!@^qU2$NjI_=SQ_As0wi9VI|NxIurfd*>AvEh>Y>~Y&-{mNVftV15V11-wk5e>? z9nPdt+!r~0#SQ>pOHDNgogEO=s~~cQI@eEyZd`vzw4vxx@YK+kAVA;%x{0M*8v8)@ z!$PlcCm(Ka&(H%C_D7RXelABmn06(h`3d{M%ad2`i|FWDL4!1hFEH8F3H$o$9OS;U zTDSjOeZ_99y<&f|wq^glwqfV$TlV|)Df_GXrhRCA!7i;Y*&kn=v_D(FV1K{9ZqICN z*yhHw<%U9nDYLTt&uIq_oy^Dc$jJ7e8|UpKn_srcl}BxL^M$7+mUt6q>Y{i=u$2&r z^z3UD5mZBmvN;C2Ze*8+L~ zBf(Y(oFF73#10pfY07}%e-O8GeQjkgr@_*w0N{#%_X=JbF=`n>Bd2}_9V1+l{YcO# zn0&h3>!S9gz}My&jRNWnMrDgDjA9w+!{>VPdMAb*IjiWX$HAKGCDANbRmd9yR~{cr zIqJ%oQ`HU^UtB=)z5`C~2S^YZM_De=bdcYqmXJ05PK-!pQ56oioLZRcTryB;S4W)w zMZ)C*ZnujLWZo5^%PNzwa)kjOB}GGVF4?us$$^wJD@l>fhm**hS$kUc{6?I&`b$9ESyZ1UWfWa@C)^ z|Kh#=KaBul43NVB?p64b^>a1<(=N zXa-!9g1!)ek&-tMF5&qf)EcXV@LI64zx(VAHA57XxE^Tnm6fd<)Y9C(QU`S&Xi%Cl zDNz*bJQBsUZ4^!kQ0`}7@bnNpXwx5{O3O!ifen*X;jfsM5r48h2 ztRG3a?(S725cV$~3Xlh?s1P6l7D#i#EyAtQSY>~cZLN6r-Sx*k25m8zr_FmgMlgb4 zGj_1>c+L@MA36BBziaoo7ai;F#wEBKq22eiYsvVdJWNnm+1?S?Rw&)UtLV_VS;&h# zfd)e=Hz@n3H5eVNq|Lqw;nE{kpa~a8w@#k#G#C@XVpMWZvnbpPEiYXbK9NRar$_op#eQ^k#Tn7y4f*0WTozoi5Y#5zQ%2yR^Ue_c9?!B z{^ZHdJHj{igWYojSRN)~|1)up(d8JYR{$&(ozUxkIq@SX(V5;co@ZZw?u!Ta$BH9U zxXbhZMeDX57nSztXbq6Ldmj_siY6PR$W01bGsCZn0m4&^i*ILsTv$gT3Sv%S zl7WX^hHFCdW}|rE*^US9kw9)Y!VSHAA5W{!M)}*aGl6K!kIJsSmmUz^IWq<;T)c^M zGRogVr*xBFk;g#?yA|rQywMB5hnJsw^2CUQ@QD$-edStBn>}_n2;8yo5J;z*rJRKA OAFur3y`7yuKlp#J 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;