From 57325aa9a9d7c1fd0ad491ba6d6851bcb39e4650 Mon Sep 17 00:00:00 2001 From: David Montero Date: Sat, 22 Aug 2026 23:31:23 +0200 Subject: [PATCH] fix(sim): a resistor divider on a sensor line made the board pin invisible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An HC-SR04 on an ESP32-S3 answered "No echo" forever the moment its ECHO went through the 1k/2k2 divider a real 5 V sensor needs, and worked again when the divider came out (reported on Discord, project esp32-s3-distance). Nothing about the divider is wrong: three separate resolvers could not see past it. 1. The wire walk treated a net as a chain. Arriving at the divider's tap it hopped straight across the lower resistor, hit GND, and answered "tied to a rail" (-1) — never looking at the OTHER wire on that same tap, where GPIO41 was. A rail was also allowed to end the search, so a plain pull-up drawn before the signal wire hid the GPIO behind it the same way. The walk now explores the whole node (same depth, one shared visited set) and treats a supply pad as the weakest answer: remembered, returned only when no driven pin was found anywhere. 2. The walk moved to simulation/PinTrace so the store can ask the same question. It pre-registered backend sensors with its own single-wire lookup — one wire, far end must be a board — so ECHO (landing on a resistor) resolved to nothing. That entry REPLACES the part's, keyed by pin, so the echo pin was silently dropped and the QEMU worker fell back to TRIG+1: it pulsed GPIO22, which nobody was reading. Both the data pin and the extra pins now resolve through the shared walk, so a sensor behind a breadboard strip or a divider registers like any other. 3. Esp32Bridge.sendPinEvent now refuses a pad a registered sensor owns, the guard the in-browser engines already have. connectDigitalInputsToMcu thresholds the solved node, and `sourcedNets` does not hold it back once real components sit on the net — the divider makes ECHO exactly that, and it solves at ~0 V because nothing models the sensor's output. Test covers the reported circuit end to end: the walk, and the echo_pin that reaches the start_esp32 payload. --- .../src/__tests__/hcsr04-divider-echo.test.ts | 205 ++++++++++++++ frontend/src/components/DynamicComponent.tsx | 261 +----------------- frontend/src/simulation/Esp32Bridge.ts | 23 ++ frontend/src/simulation/PinTrace.ts | Bin 0 -> 15752 bytes frontend/src/store/useSimulatorStore.ts | 76 ++--- 5 files changed, 262 insertions(+), 303 deletions(-) create mode 100644 frontend/src/__tests__/hcsr04-divider-echo.test.ts create mode 100644 frontend/src/simulation/PinTrace.ts diff --git a/frontend/src/__tests__/hcsr04-divider-echo.test.ts b/frontend/src/__tests__/hcsr04-divider-echo.test.ts new file mode 100644 index 00000000..0ce05715 --- /dev/null +++ b/frontend/src/__tests__/hcsr04-divider-echo.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment jsdom +/** + * Reported bug: an HC-SR04 on an ESP32-S3 works when ECHO goes straight to the + * GPIO, and answers "No echo" forever the moment the user inserts the 1k/2k2 + * divider that the real 5 V sensor needs — the wiring you are supposed to use. + * + * The circuit below is the reported project verbatim + * (velxio.dev/martijn-kuipers/esp32-s3-distance): + * + * ECHO ──[1k]──┬──[2k2]── GND + * └──────── GPIO41 + * + * Two separate resolvers were blind to it: + * + * 1. the wire walk crossed the top resistor, crossed the bottom one, hit GND + * and reported "tied to a rail" — never looking at the OTHER wire on the + * tap, where the GPIO was; + * 2. the backend sensor pre-registration in startBoard read a single wire's + * far end, so ECHO (which lands on a resistor) resolved to nothing and the + * QEMU worker fell back to TRIG+1 — pulsing GPIO22, which nobody reads. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// ── Mocks (same shape as esp32-integration.test.ts) ───────────────────────── +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?.(); + } + open() { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(); + } + get messages(): Array<{ type: string; data: Record }> { + return this.sent.map((s) => JSON.parse(s)); + } +} + +vi.stubGlobal('WebSocket', MockWebSocket); +vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1); +vi.stubGlobal('cancelAnimationFrame', vi.fn()); + +import { useSimulatorStore, getEsp32Bridge } from '../store/useSimulatorStore'; +import { traceDetailed, traceBoardGpio } from '../simulation/PinTrace'; + +const BOARD = 'esp32-s3'; +const SENSOR = 'hc_sr04_1'; +const R_TOP = 'resistor_1k_1'; // ECHO → tap +const R_BOTTOM = 'resistor_2k2_1'; // tap → GND + +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 }, + }; +} + +/** The reported project: 5 V sensor, TRIG straight to GPIO21, ECHO through a + * 1k/2k2 divider whose tap carries the wire to GPIO41. */ +function loadDividerCircuit() { + const s = useSimulatorStore.getState(); + s.setComponents([ + { 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' } }, + ] as never); + s.setWires([ + wire('w1', [BOARD, '5V'], [SENSOR, 'VCC']), + wire('w2', [SENSOR, 'TRIG'], [BOARD, '21']), + wire('w3', [BOARD, 'GND.4'], [SENSOR, 'GND']), + wire('w4', [SENSOR, 'ECHO'], [R_TOP, '1']), + wire('w5', [R_TOP, '2'], [R_BOTTOM, '1']), + wire('w6', [BOARD, 'GND.4'], [R_BOTTOM, '2']), + wire('w7', [BOARD, '41'], [R_BOTTOM, '1']), + ] as never); +} + +describe('HC-SR04 behind a 1k/2k2 level-shifting divider', () => { + beforeEach(() => { + useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); + useSimulatorStore.getState().addBoard('esp32-s3' as never, 0, 0, BOARD); + loadDividerCircuit(); + }); + + it('traces ECHO through both resistors to GPIO41, not to the GND leg', () => { + const state = useSimulatorStore.getState(); + const echo = traceDetailed(state as never, SENSOR, 'ECHO', 0); + expect(echo.arduinoPin, 'ECHO reaches GPIO41 across the divider tap').toBe(41); + expect(echo.boardId).toBe(BOARD); + }); + + it('still traces the directly wired TRIG to GPIO21', () => { + const state = useSimulatorStore.getState(); + expect(traceDetailed(state as never, SENSOR, 'TRIG', 0).arduinoPin).toBe(21); + }); + + it('reports a rail (-1) only for a pin that reaches nothing else', () => { + const state = useSimulatorStore.getState(); + // The sensor's own GND pad: a rail is the honest answer there. + expect(traceDetailed(state as never, SENSOR, 'GND', 0).arduinoPin).toBe(-1); + // traceBoardGpio speaks only of real GPIOs, so a rail is "no GPIO". + expect(traceBoardGpio(state as never, SENSOR, 'GND')).toBeNull(); + }); + + it('pre-registers the sensor with echo_pin 41 in the start_esp32 payload', () => { + const bridge = getEsp32Bridge(BOARD)!; + useSimulatorStore.getState().startBoard(BOARD); + const socket = (bridge as unknown as { socket: MockWebSocket }).socket; + socket.open(); + const start = socket.messages.find((m) => m.type === 'start_esp32'); + expect(start, 'start_esp32 was sent').toBeDefined(); + const sensors = start!.data.sensors as Array>; + expect(sensors).toContainEqual( + expect.objectContaining({ sensor_type: 'hc-sr04', pin: 21, echo_pin: 41 }), + ); + }); +}); + +describe('a data pin whose first wire is a pull-up', () => { + beforeEach(() => { + useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); + useSimulatorStore.getState().addBoard('esp32-s3' as never, 0, 0, BOARD); + const s = useSimulatorStore.getState(); + s.setComponents([ + { id: 'dht22_1', metadataId: 'dht22', x: 0, y: 0, properties: {} }, + { id: 'r_pullup', metadataId: 'resistor-10k', x: 0, y: 0, properties: { value: '10000' } }, + ] as never); + // The pull-up wire is drawn FIRST — the walk used to answer with the 3V3 + // pad it reaches through the resistor and stop looking. + s.setWires([ + wire('p1', ['dht22_1', 'SDA'], ['r_pullup', '1']), + wire('p2', ['r_pullup', '2'], [BOARD, '3V3']), + wire('p3', ['dht22_1', 'SDA'], [BOARD, '4']), + ] as never); + }); + + it('resolves the GPIO, not the supply pad behind the resistor', () => { + const state = useSimulatorStore.getState(); + expect(traceDetailed(state as never, 'dht22_1', 'SDA', 0).arduinoPin).toBe(4); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index ba35c7f1..e7c3ad99 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -24,23 +24,18 @@ import { useEditorStore } from '../store/useEditorStore'; import { buildProjectSdImage, decodeSdFiles } from '../utils/sdCardFiles'; import { PartSimulationRegistry } from '../simulation/parts'; import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry'; -import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping'; -import { isBoardSeated } from '../utils/socketSnap'; import { isPiBoardKind } from '../types/board'; import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings'; import { createDefaultPinResolver, createSpiceResolvedPinResolver, configFromLogicFamily, - isActiveDevice, type PinResolver, } from '../simulation/PinResolver'; import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups'; -import { syntheticChipPin } from '../simulation/customChips/syntheticPins'; -import { resolveChipNetKey } from '../simulation/customChips/chipNets'; +import { traceDetailed } from '../simulation/PinTrace'; import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler'; import { getBoardLogicFamily } from '../simulation/LogicFamilies'; -import { breadboardGroupKey } from '../utils/breadboardNets'; // Side-effect imports: register every web component we'll create at runtime. // `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds @@ -50,256 +45,10 @@ import '@wokwi/elements'; import '../velxio-elements'; import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin (4-pin I2C OLED) -// Map metadataId → [pinA, pinB] for 2-terminal passives. -// "Tracing through" means: if the caller arrived on pinA, continue from pinB -// (and vice-versa). -// -// NOTE: diodes / transistors / op-amps are NOT traced through as passives — -// they have polarity / Vf / non-linear behaviour that the digital layer -// cannot interpret as "same pin". BJTs are an explicit shortcut for the -// canonical "Arduino digital pin controls a load via transistor" pattern so -// 7-segment multiplex circuits with BJT digit drivers still resolve. -const PASSIVE_PIN_PAIRS_BASE: Record = { - resistor: ['1', '2'], - 'resistor-us': ['1', '2'], - capacitor: ['1', '2'], - 'capacitor-electrolytic': ['+', '−'], - inductor: ['1', '2'], - 'analog-resistor': ['A', 'B'], - 'analog-capacitor': ['A', 'B'], - 'analog-inductor': ['A', 'B'], - 'bjt-2n2222': ['C', 'B'], - 'bjt-bc547': ['C', 'B'], - 'bjt-2n3055': ['C', 'B'], - 'bjt-2n3906': ['C', 'B'], - 'bjt-bc557': ['C', 'B'], -}; -// Preset variants of the generic passives share their parent's tag and pin -// layout. Mirrors the PASSIVE_PRESETS map in spice/componentToSpice.ts. -const PRESET_TO_BASE: Record = { - 'resistor-220': 'resistor', - 'resistor-330': 'resistor', - 'resistor-470': 'resistor', - 'resistor-1k': 'resistor', - 'resistor-2k2': 'resistor', - 'resistor-4k7': 'resistor', - 'resistor-10k': 'resistor', - 'resistor-22k': 'resistor', - 'resistor-47k': 'resistor', - 'resistor-100k': 'resistor', - 'resistor-1m': 'resistor', - 'cap-10p': 'capacitor', - 'cap-22p': 'capacitor', - 'cap-100p': 'capacitor', - 'cap-1n': 'capacitor', - 'cap-10n': 'capacitor', - 'cap-100n': 'capacitor', - 'cap-1u': 'capacitor', - 'cap-elec-1u': 'capacitor-electrolytic', - 'cap-elec-10u': 'capacitor-electrolytic', - 'cap-elec-47u': 'capacitor-electrolytic', - 'cap-elec-100u': 'capacitor-electrolytic', - 'cap-elec-470u': 'capacitor-electrolytic', - 'cap-elec-1000u': 'capacitor-electrolytic', - 'ind-100u': 'inductor', - 'ind-1m': 'inductor', - 'ind-10m': 'inductor', -}; -const PASSIVE_PIN_PAIRS: Record = { - ...PASSIVE_PIN_PAIRS_BASE, -}; -for (const [preset, base] of Object.entries(PRESET_TO_BASE)) { - PASSIVE_PIN_PAIRS[preset] = PASSIVE_PIN_PAIRS_BASE[base]; -} - -type TraceState = ReturnType; - -// Custom-chip output pins get stable synthetic pin numbers from -// simulation/customChips/syntheticPins so the chip is a first-class pin source. - -// Depth-limited BFS: trace from (fromId, fromPin) through wires, traversing -// through passive components to reach a board pin. Returns the arduino pin -// plus a `crossedActiveDevice` flag so the resolver factory can decide -// between digital fast-path and SPICE-resolved per-pin. -// -// A real board pin always wins (digital GPIO semantics are unchanged). Only -// when NO board pin is reachable do we fall back to a custom-chip pin on the -// net — either a neighbour chip pin, or (when the trace itself started at a -// chip pin) the starting chip pin — resolving it to its synthetic number. -// -// Lifted to module scope (was inside getArduinoPin) so that getPinResolver -// can call it too — the previous nested-scope version caused a runtime -// ReferenceError "traceDetailed is not defined" on the simulator page. - -/** - * Resolve a component pad through a SEATED board rather than a wire. - * - * Sockets are a real electrical connection with nothing to draw: the board's - * pads sit on the component's pads. `boardSocket` (read off the element, the - * rule-6a way) says the component is a socket; isBoardSeated says a board is - * actually in it; and the shared pad NAME is the contract that makes the two - * grids one net — which is exactly why a socket's pinInfo uses the board's own - * names. Returns null for anything that is not a seated socket pad. - */ -function traceThroughSocket( - state: TraceState, - componentId: string, - pinName: string, -): { pin: number; boardId: string } | null { - const el = document.getElementById(componentId) as - | (HTMLElement & { boardSocket?: { anchorPin: string; accepts: string[] } }) - | null; - const sock = el?.boardSocket; - if (!sock || !Array.isArray(sock.accepts)) return null; - for (const b of state.boards) { - if (!sock.accepts.some((prefix) => b.boardKind.startsWith(prefix))) continue; - if (!isBoardSeated(b.id, b.boardKind, b.x, b.y, state.components)) continue; - const pin = boardPinToNumber(b.boardKind, pinName); - if (pin !== null) return { pin, boardId: b.id }; - } - return null; -} - -export function traceDetailed( - state: TraceState, - fromId: string, - fromPin: string, - depth: number, - activeSeen = false, -): { arduinoPin: number | null; crossedActiveDevice: boolean; boardId?: string } { - if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen }; - - const wires = state.wires.filter( - (w) => - (w.start.componentId === fromId && w.start.pinName === fromPin) || - (w.end.componentId === fromId && w.end.pinName === fromPin), - ); - - // A board SEATED on a socket component is connected without any wire — that - // is what seating means, and it is how the hardware ships: a XIAO pushed - // into a shield's header, a Pi HAT dropped onto the 40-pin. So when this - // component declares a socket (boardSocket, the same contract the magnet - // reads) and a board is seated on it, a pad resolves to the SAME-NAMED pin - // of that board. Without this hop a seated shield's buttons and LEDs were - // dead until the user drew wires that the real stack does not have. - const socketPin = traceThroughSocket(state, fromId, fromPin); - if (socketPin !== null) { - return { arduinoPin: socketPin.pin, crossedActiveDevice: activeSeen, boardId: socketPin.boardId }; - } - - // Remember a custom-chip neighbour on this net (if any) as a fallback — - // a real board pin found in any branch still takes priority over it. - let chipNeighbour: { id: string; pin: string } | null = null; - - for (const w of wires) { - const selfEp = - w.start.componentId === fromId && w.start.pinName === fromPin ? w.start : w.end; - const otherEp = selfEp === w.start ? w.end : w.start; - - // A board endpoint is recognised by the LIVE boards list first. - // `isBoardComponent` matches static id prefixes ('arduino-uno', …), which - // only covers the default board — every board added at runtime (the agent - // mints UUID ids) failed the check, so tracing treated it as an unknown - // component and returned null. Symptom: an ESP32 clock whose QEMU was - // emitting hundreds of GPIO edges/second at a display that stayed dark, - // because no resolver ever attached. - const boardEp = state.boards.find((b) => b.id === otherEp.componentId); - if (boardEp || isBoardComponent(otherEp.componentId)) { - const boardKind = boardEp?.boardKind ?? otherEp.componentId; - const pin = boardPinToNumber(boardKind, otherEp.pinName); - // The board id travels with the pin: a QEMU-Linux board has no MCU - // simulator, so an input part needs to know WHICH board's bridge to - // push the level into (see the pi-aware simulator below). - if (pin !== null) - return { - arduinoPin: pin, - crossedActiveDevice: activeSeen, - boardId: boardEp?.id ?? otherEp.componentId, - }; - } else { - const comp = state.components.find((c) => c.id === otherEp.componentId); - if (!chipNeighbour && comp?.metadataId === 'custom-chip') { - chipNeighbour = { id: otherEp.componentId, pin: otherEp.pinName }; - } - const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId]; - if (pair) { - const [p1, p2] = pair; - const otherPin = otherEp.pinName === p1 ? p2 : p1; - const nowActive = - activeSeen || (comp ? isActiveDevice(comp.metadataId) : false); - const result = traceDetailed( - state, - otherEp.componentId, - otherPin, - depth + 1, - nowActive, - ); - if (result.arduinoPin !== null) return result; - } - - // Breadboards join N holes per internal group (5-hole strip / power - // rail), which the 2-terminal PASSIVE_PIN_PAIRS map can't express. - // Continue the trace from every OTHER wired hole in the same group. - // - // Exclusion is by INCOMING WIRE, not by hole name: two wires may - // legitimately share one hole (a seated pin plus a jumper landing in - // that same hole — the agent bridges strips straight into the seat - // hole). Excluding the arrival hole made those stacked connections - // invisible: an ESP32 clock with QEMU firing hundreds of GPIO edges - // per second sat dark because every segment's bridge landed on its - // resistor's own seat hole and the trace dead-ended there. - const bbGroup = comp && breadboardGroupKey(comp.metadataId, otherEp.pinName); - if (bbGroup && comp) { - const groupPins = new Set(); - for (const gw of state.wires) { - if (gw.id === w.id) continue; // never bounce back on the same wire - for (const ep of [gw.start, gw.end]) { - if ( - ep.componentId === comp.id && - breadboardGroupKey(comp.metadataId, ep.pinName) === bbGroup - ) { - groupPins.add(ep.pinName); - } - } - } - for (const groupPin of groupPins) { - const result = traceDetailed(state, comp.id, groupPin, depth + 1, activeSeen); - if (result.arduinoPin !== null) return result; - } - } - } - } - - // No board pin reachable. Multi-chip digital bus (chipbus flag, Phase 0 of - // project/multichip-bus/): when this net has two or more chip endpoints and - // no board pin, collapse every endpoint onto ONE net-canonical synthetic key - // so a write on one chip is visible to another through the synchronous - // PinManager fan-out (fixes root cause A: per-endpoint keys never matching). - // resolveChipNetKey returns null when the flag is off, when a board owns the - // net, or when there is a single chip endpoint — so the chip-to-component - // rules below (2 and 3) are left exactly as-is. Scoped to depth 0 (the - // starting chip pin); the key is net-bound, so a pin flipping INPUT<->OUTPUT - // keeps the same key with no re-trace. - if (depth === 0) { - const netKey = resolveChipNetKey(state, fromId, fromPin); - if (netKey !== null) { - return { arduinoPin: netKey, crossedActiveDevice: activeSeen }; - } - } - - // No board pin reachable. Fall back to a custom-chip pin on this net so the - // chip can still drive / read it through the synthetic-pin PinManager key. - if (chipNeighbour) { - return { - arduinoPin: syntheticChipPin(chipNeighbour.id, chipNeighbour.pin), - crossedActiveDevice: activeSeen, - }; - } - if (depth === 0 && state.components.find((c) => c.id === fromId)?.metadataId === 'custom-chip') { - return { arduinoPin: syntheticChipPin(fromId, fromPin), crossedActiveDevice: activeSeen }; - } - return { arduinoPin: null, crossedActiveDevice: activeSeen }; -} +// The wire-graph walk that answers "which board pin owns this component pin" +// lives in simulation/PinTrace so the store can ask the SAME question when it +// pre-registers backend sensors. Re-exported here: it was this module's API. +export { traceDetailed }; interface DynamicComponentProps { id: string; diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 68616e13..da7373a4 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -653,8 +653,31 @@ export class Esp32Bridge { this._send({ type: 'esp32_serial_input', data: { bytes, uart } }); } + /** + * True when a backend-emulated single-wire sensor OWNS this pad — its data + * line, or an HC-SR04's ECHO. The QEMU worker drives those itself, and a + * host-injected level outranks it, so whoever pushes one silences the sensor. + * + * The caller that makes this matter is connectDigitalInputsToMcu, + * thresholding the solved SPICE node. Its `sourcedNets` gate is meant to + * leave part-managed pins alone, but a net stops being "unsourced" the + * moment a real component sits on it: an HC-SR04 whose ECHO reaches the pad + * through the 1k/2k2 divider a real 5 V sensor needs solves at ~0 V (nothing + * models the sensor's output) and pinned the echo pad LOW between trigger + * 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 { + for (const s of this._pendingSensors) { + if (s['pin'] === gpioPin) return true; + if (s['echo_pin'] === gpioPin) return true; + } + return false; + } + /** Drive a GPIO pin from an external source (e.g. connected Arduino) */ sendPinEvent(gpioPin: number, state: boolean): void { + if (this.sensorOwnsPin(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 new file mode 100644 index 0000000000000000000000000000000000000000..876f6aaf513f2d41f9f964069b921a51e7bf6d19 GIT binary patch literal 15752 zcmb7L?QYx1lHK2ViXLMzmUAu1_GD(T&NzYN=uWhg*j`JSyNe(gQ)FA>m?8<1vLa*x z++U0ReX-B54>nJ-=Tvo*l>8Cj07)dWySlo%>eN@O|Mck-^VFQj>9`0&YySNE|C+7k zEDmR8k_AO%@;EhdX{s6i3A1^gr8ceP=`t>CWUB1t7ATO&cxDYBnrRW_GtNr0$^<5y z1t5}LnLLZrs#|4U`CyVby)F+i;B+wNXnSLe6-kp=<%nVFB1p@)if?SPGQWewt_jkJ zbl2DkgVcmU0SfaBJXAA$2eYI_rg~rxYb=B08q>?AEy`VUl@%OgIyI?XuD`P@%c}r> zJ|CWuT4l0jT9RB`VQ01yS%JAQPNx`9WQ*y{9CRyN%;PjjOdgaam@Z8xFoiASvchT* zL}`l{Ph+8F6B|}V90mzCCp*o9f)cJW;US8raRp*Yu(E~lrRz)z8$`-riTQcCOU@`4 z&q-j)EW8Grfjdd;zO+GwGt1H*MsvcjGK*495vOru!Co0hwvlYvf4xeBc^sa2S@){) zQ;+lJ*$pPnEM#2@ucXI4Vmd?*va*5E@w*t*_dr~atp+9BvD)j)53xg>d&VjtRTi&w#16&%}IB3vG4_C7w$iCU#nGa z&1W9eI;ZA~xhk@`+3xlFLZg3c$o6wjo2nILR}YDZlASK*6L8cbxTxZ!?2F=J z#JeDexlQjNH&RW#HB)tZlm_|wq~nkP@!Helr*kPkuXjGd%z8e0ciXtK-q2A=(cY z5apZ`%6>f`bJqw1CCDmAVa+wJ)(C_;fODVp`vy`pb6W*bPzA%t{Q2MiFdrb+W5Kdl z9|h&^z@;ANZV~od0Ja56fX71=mhXsCztu zE(Bf%Q1pJE&ri?BgF{$b7QvwV4$-Mw{Fde2V4jy)!*U?shFGe`v9du@cqfvB^l6g7 zt2|4BBCb{-^XnDgRC zw)eMp&Gy0eN4OcY?Y&udQEqR(41*jBg`?ekv0cCEs?*Do6>LZd{Dsv2{GWd)eL(dh zyo*8rx0+4iI@FoU)MHM4Xq&t;g`5QVDfv_JxCAmN8X*Z$@ASL{QlXGcfUGF zzuWu%`+ML0+ujd%zXQqdH%WeZPE9z+R-u(Q(D5L};lmeFkEb?83=uYVQO;;Qcoi3T z$oOF!aZ@lAUjPLpEkqF6qUxD%@!8!9rF!Jms8N@LgFQ%~{=U7gOy7O?(A#G}KJ<3~`T@cR*F4MnmVI_j+28ke zfA2xc2M@h{_Txiu_xBzoy+6Or74*8hznAkxqv9e&zBxF!?+uo^_f>iiwf(*O-`xLd zaSt_Ge0P8S?K%w9+t8tVk01N&$H$L_BpyE)D);!op#1gGfE^s5j;kR^b}nz^$nSW* zxA_9-0Cxm8M9*>;&_5dEd%a!_0*G1~FR}Xsi!eVzZp0-%z&`?u5Zwi({YWG_oBWR4 zr3bKKKGQj0!-Sn3aUr*8J2D^PVs5<#pFjEX#BWjJRE8;bR3UxA%nMsBigb+l z^(VdrFSjDp-qcny`~_kpCo6IDSIlyS+J!CQUWLv6)2HUe(O#2Ym=1Q149Z7($YJB$ zrH0*oQ!es6S@nNDeGRyc5TP&Nd>1Jq#&Bk%9)oOyct!$&^Ef@k011Tukde>vk}Hl2 z#82^+V!A{c#T}{lG2rm%;t?xo64WpNb2r(Eg$jOlVV~sr|Ae2;?SuO$~$(KcX z8uNC&=wuMmG>Ek-u(YHqOvK@e0?GOLYE81H)7?kXtM!O1MQ&FHD}-oLjbI86a#c)$ zWtdw8ae|-?!oOOi$Oo8-m9H5fr8tBRHob8WJBK0(5OL?DQ4f_%UVL+Oq(}ShxAOhP zk=gV6XUYW)Vz`B+YhOuyz9Xt=Q=}*XDkLOxp1G9Ky6UCr zFw=pOs3Kxv#-36FA#cSZvE3iQZ?HnnN%61IIP;CMur>q$8V1;#{3+CVpV)%J*jr@VnU5Q{?#Mmko*VkiIRWREZ1D3N@ z%^&w4r^ECrBVZ^cCpQ*%$RCOw76MX)p(Mu}kaa4~2(%%O z>o|~%vTz}3#S!#+dx3HR>MAzc-hsx6&U3ef4MlvL0BZdl;^{y&yH3hR_w1cb( zlMGhvycxfH>xP+c!Dma)fFPeCMif?xb8_$C1R4?Ayh6sQ4?cVZ|6g_-!F7%(X7FT_ zm%SEYa?D+RBd`DX!+djG6v3(&m-2syk9%$vxTTG~wp`qVKq+UV)=E$LH7GWKavpM`QyT`}zw=%Of%;H32HrPQwUid8n)9BRYoFS+JsRkv)Tuz?d)i zyCy`qYUCFvW>qsJ-}9IyM%GyYO!V4XBIvNDG{pM+AI$B*juj;dgs8=0EHhaE8>#u1 zu87)L2dp+DLZU$Jkp=wsOl`7+2j_ZeCM1?83xd?;9q#hdTqVepw1Nq`S}QV$5|Dtd zq>>rg5Jf`Lnb>M+QNr}8{Z&v_UF7a88AfJwK0FyPIi*hkl-Z(7&M=X4kI8hR@+4m- zNPwu!=TlUOJwWzCoZd*sw*Qj*GiH+_BQdKmMC&W;va_vVE zwber?$Kb#bD2#Z#M-*LY`Sd1+AGM5rMz(SVlNY;BfWc#m;Gzj3i9kZsyf8nstY|$7 zOcIpN?h4vk%fra9G6Xq>_%6;+5n~a~dqjzX=<)C@TSDxR9{LU1XNmJJ4-+-DWh!wO zHBgCB8(3*?s@HVvP%$lyp^}XH;@M9u>%-2~6BjmI6ewrAWuzM*^x!C+pNY$h49@h(3)^s|+B*?(Ad6$sQfN7W zh2&eUB@KWo)GmPE0jyJt`fLWVfj}*kwp4|-0%`HVyOa0kDXg|>G*OUzL#Y--0%_Q2 zuLZB_~}F4|C1%WRP#bi%x18DpEU)W`@p$ul)SK7*(L6BHOguGu>}3Dh^IX6EHf zY;)PxwT|GlqC2gPMd2fJ`LCZ<%fI}KemalSFP9V*)sRGcv!KKWnb0^rIN-IUW7k3ppEM?m0o zRP|fzamW1eM?(k=j|a){{ar+8*bpoJ6~z)4=OcBXqK{@9pF`WjUKxbbwE-qkNn=Q; zdc~e8<5ML_9c7Hj(2xKp^>kGuU>)AFJw-)B#2YOU^`fARt^oU>|2aH9Lj$Np4(Vv6 zP)hJKi)|7idYvIqw*`oyVQbzTqX=ANc@CZsT&VUw+Y_gQj6$6RGbN=}3*5k=p=wUs zcX7MvhzvA9)!wJrKlV%yt_U#?roa}Z#e@OT5f$4zVQkC=!w_MN^dXR7UV)xgsQeKC zx{UCVIeTc5^b`^kCVGZ}6Xh#O9x#P$YB#8s!(;)pf;p0bw}aP^I67a-TEtvt0r#1R zkP+t>kP?ha)wJ#fh&P7H0uiGO{1H#sQfS$xv6oOyM{CEu%g|D=x!Vg8`qwGvG8U#I43VUSV4yy zf6{)VUY7~%u9*QHxEtuS@>N(?_^^wujS`1AY3u=!9vRV?5nI-X4t4*$j#Tk}PPhZm z(Qk^>1(o9zIXlZ$7#5IcY5GqH3xjDjU{_)PEM!SE; zy_lDNzB$Av_zxb158sa>WmDTW&p>iSaT|LSFGNYaRx#_1+7t4Pj9>O%X9f zW`cVkIK{VU)F}d%xQc;exQdHX3`U4tZnoJkQ4Oh3Z3OJ1nt)~y(lgCR@u;)y{Cszj zX6Ru3`S<_xWi;84r~|5LA?;ElBoqSzmP6IJ>C)V}$}J$cey|E1D+sM{${q3?pbl6YD4FVHMlR4UZ+xmQDXJ+iRP&d zV>rtJJu_O(a|EmKn5h|z&c8b_VFLJsS@Yt=e+=HeXC2e?Ws#7IBc1`PP-BuHPHe_T zQ;TjqjsgdVy)kqwbM(n@AovQouCQhl6xTE%+2q8A0gwgWf)>q050^*y4;r{y`<0QZ znj%V77#4h7Q30`08g)7oS2n=0QIDK*wd0vqhVCh$xUs1Rox5gw6Y;7m^O^obc^xfR zLXn3EJMW;Py6;I4?(ZrsP(8G;;7MR)tH2;)8$$_XU1}+G0GDu>`Re5r1^hq`uKO03 z3O>29#1aS^p}GiFD3$(Fji?h8AWjLsXnIy~VLV;H4pqtg?alDyjZy+gPf#hG0xy{Y zxU*!SC8j5!&r1MVlN{x77dXk_%r7Vf^Cj=@$R9mofc83uY3E#rv}0`uMU{xJzLEhp z14U+1eW92K`AZgI?mjI)NlG+agqgrb`MXI3z_&kMj^p9_FohxbHx8eX$0|_c=<6w8 zP=JF8d|nd-4VK6#ie6i9>6ASbWe>^y!^-X|V{7pN86w4(z1AfclG$!WG$>+vh9KW| zcmb|7zVP~4bH}g^J3p+I-)XK!P?RAmTl`NnZ^T@Ld9GZ@s)_i+)FhOGl(2aO)CuSl4IZ<}M(jHN;BNSg;aGEJewN99NLoAMv0fHt*Wd5H#_=oygZEgG(lf zmvD}i*G0G)xtSp8uP_gYM$|%5y9_;C=%rPCKrV7d-kk(iI*!!Je)sJh7l^k@Kzcr+ zcYaw@A836_IBG|+dFf8Oc_DR)=4(@uX`XS5{+q+++m`0p?KJ?TsetKYDZAC-8&9*5JFZcot?*a6fIaRB)Ui!o%frkygF$*-*?5P*}ch; zkmW3&HF!e*;X_YBfYwFCTgoZ-k8me%yW(v-@P`XWDjXomiNh+PBKf%HVj8A4aEd$0 z#25i$hh@TPL#m-U_!Ocwz{V=T2!Z(U^yKW_@bqW%x8cP=Ym9g*^ise-Fdl_KNkRze z4V?s+nGqICdZ~~n>6T0cxV!QN%)+-K98%LkND(}3Xld+8XCu?Kxa4AO7sfE==bdl< z7s$n730?%d5mz^~NC99k+mwYkgk{kioS%@FychKrICNkrY7mjHbU`W_LyPxoWx$(J zH^Vpyx{?qAO!%sl^mDM2fUb!k4^Q z4|1|A?<-YJSQ6?Y`=V3jeRZ!1>RcuhxdYq+&D*zwH{k~D5KBB32Xf+&}{HKznprw-{VV5?5D;9{a zCq;KYq}N*4N=^L(Xk+aH5dl6yk{{c9;uJE_qQFNsZznSn_lfU84EXK08)F|ManN&q z1;Hd7i<9yirA@Ysf)qt9)$10G20h6vdz$`F^SSxd5{W-N)+7Aqx>Kw)RIwCaZ+RXK zRm4N%oFGxcC^+0jT^2nlLnqSmfe6A7bJY691t{!xWUmW7%2Vj%TND0CU(v`*CB%Ql ze?Zetc`1cgZd?+FGehu++?bt7+&<-vX01W`p63M3*$zW?eqc9t*PJ8ubJesx$~iAG z-ZksXeP|BtVr(B>IMIX^ z@bTk;Hb@B1P6sTFcHx!W1@F2|iA`g_*9ebM*uec56gn*-iiI{=8Cq=5oy_SYZ8@WeBMW z0fZ$@1$Q`U7Aa07A(CGdXe|Zfd9$T%=t>hD{32Okc@(GT>-qpzDG=#yB`bh<{Y2yi zm<11qHQ{vE1TKkzYpnAHfB030R7y@;ki3buG@S#f^?ZlwGpHu^ia`KkL6oV2vK!-4 z&IrEU=Ni#7h<3jHtlE3UdK z7pMHmg%?CqI?zi+?z``|0Uv7HYXieyA8}hjk5KA}{=|h@{@zU64gx)hLK(o4-wk8K zNupHB4jF&(=q(M$>X|3GQ-Axx%iOgPKI5eMr^)KjrtlX;PcRWA1;=Uvg70f2>B4q= zI`F76P<`d5Q8FfWqCbs;y{FKk>b3K@%8B3R!yDYx*hlIdHSW#@h(nK}!COZ9W7RQ` zQ*}u1nl4kCyp&yPd|_cnF0HKNob+bI9RWxJUifpJBwXEN%d%BbBJ3*#;5HXPa0wlC zzaxbz3M|Y5{D=@&Co(_-)hhmDg~0&VGi2i>r%F^LSept`v6>aIAiUzK^qs^QOhoZWj@rj}Q8eURD-;jx74+i-j& zi?!|n%O#k#Yt2%CQ{Y3b@S(T|9x|<5^^NQ7nmq|{sN$H~iex0x;}O<3YWRgHdB@nF s%pNNS8x(M9_nx2U-AU8!QzD}J5(+JjWFP&~4ZO92Gl6v$d8PRO0Ss>zL;wH) literal 0 HcmV?d00001 diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 4b8bc397..5748bb04 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -66,6 +66,7 @@ import { setInterconnectRuntime, } from '../simulation/Interconnect'; import { SENSOR_CONTROLS, getSensorControl } from '../simulation/sensorControlConfig'; +import { traceBoardGpio } from '../simulation/PinTrace'; import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry'; // ── Sensor pre-registration ────────────────────────────────────────────────── @@ -2384,58 +2385,39 @@ export const useSimulatorStore = create((set, get) => { // has them ready before the firmware starts executing. const esp32Bridge = getEsp32Bridge(boardId); if (esp32Bridge) { - const { components, wires } = get(); + const traceState = get(); + const { components } = traceState; const sensors: Array> = []; for (const comp of components) { const sensorDef = SENSOR_COMPONENT_MAP[comp.metadataId]; if (!sensorDef) continue; - // Find the wire connecting this component's data pin to the board - for (const w of wires) { - const compEndpoint = - w.start.componentId === comp.id && w.start.pinName === sensorDef.dataPinName - ? w.start - : w.end.componentId === comp.id && w.end.pinName === sensorDef.dataPinName - ? w.end - : null; - if (!compEndpoint) continue; - const boardEndpoint = compEndpoint === w.start ? w.end : w.start; - if (!isBoardComponent(boardEndpoint.componentId)) continue; - // Resolve GPIO pin number - const gpioPin = boardPinToNumber(board.boardKind, boardEndpoint.pinName); - if (gpioPin === null || gpioPin < 0) continue; - // Collect sensor properties from the component - const props: Record = { - sensor_type: sensorDef.sensorType, - pin: gpioPin, - }; - for (const key of sensorDef.propertyKeys) { - const val = comp.properties[key]; - if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val; - } - // Resolve extra pins (e.g. echo_pin for HC-SR04) from wires - if (sensorDef.extraPins) { - for (const [propName, compPinName] of Object.entries(sensorDef.extraPins)) { - for (const ew of wires) { - const epComp = - ew.start.componentId === comp.id && ew.start.pinName === compPinName - ? ew.start - : ew.end.componentId === comp.id && ew.end.pinName === compPinName - ? ew.end - : null; - if (!epComp) continue; - const epBoard = epComp === ew.start ? ew.end : ew.start; - if (!isBoardComponent(epBoard.componentId)) continue; - const extraGpio = boardPinToNumber(board.boardKind, epBoard.pinName); - if (extraGpio !== null && extraGpio >= 0) { - props[propName] = extraGpio; - } - break; - } - } - } - sensors.push(props); - break; // only one data pin per sensor + // Resolved with the SAME wire walk the parts use, not by reading + // one wire's far end. A sensor pin reaches the board through a + // breadboard strip or a level-shifting divider just as truly as + // through a jumper landing on the pad, and this entry REPLACES the + // one the part registered (setSensors is keyed by pin) — so every + // pin this fails to resolve is silently lost. That is what left an + // HC-SR04 behind a 1k/2k2 divider with no echo pin at all: the + // backend fell back to TRIG+1 and pulsed a GPIO nobody was reading. + const gpioPin = traceBoardGpio(traceState, comp.id, sensorDef.dataPinName, boardId); + if (gpioPin === null) continue; + // Collect sensor properties from the component + const props: Record = { + sensor_type: sensorDef.sensorType, + pin: gpioPin, + }; + for (const key of sensorDef.propertyKeys) { + const val = comp.properties[key]; + if (val !== undefined) props[key] = typeof val === 'string' ? parseFloat(val) : val; } + // Extra pins (e.g. echo_pin for HC-SR04) resolve the same way + if (sensorDef.extraPins) { + for (const [propName, compPinName] of Object.entries(sensorDef.extraPins)) { + const extraGpio = traceBoardGpio(traceState, comp.id, compPinName, boardId); + if (extraGpio !== null) props[propName] = extraGpio; + } + } + sensors.push(props); } // Pre-register I2C sensors (virtual pin = 200 + i2c_addr, no wire resolution needed)