diff --git a/frontend/src/__tests__/part-pin-ownership.test.ts b/frontend/src/__tests__/part-pin-ownership.test.ts index d0caf48d..d1ca282c 100644 --- a/frontend/src/__tests__/part-pin-ownership.test.ts +++ b/frontend/src/__tests__/part-pin-ownership.test.ts @@ -108,3 +108,31 @@ describe('part pin ownership', () => { expect(wrapped.readValue()).toBe(7); // `this` is the target, not the proxy }); }); + +describe('sensor records on the ESP32 bridge', () => { + it('merges the two registration paths per field, same kind on the same pin', async () => { + const { upsertSensorRecords } = await import('../simulation/Esp32Bridge'); + // The store knows the distance, the part knows the echo pin. Neither may + // erase what the other contributed. + const a = upsertSensorRecords([], [{ sensor_type: 'hc-sr04', pin: 21, echo_pin: 41 }]); + const b = upsertSensorRecords(a, [{ sensor_type: 'hc-sr04', pin: 21, distance: 30 }]); + expect(b).toEqual([{ sensor_type: 'hc-sr04', pin: 21, echo_pin: 41, distance: 30 }]); + }); + + it('replaces outright when a different kind lands on the pin', async () => { + const { upsertSensorRecords, sensorRecordOwnsPin } = await import('../simulation/Esp32Bridge'); + const a = upsertSensorRecords([], [{ sensor_type: 'hc-sr04', pin: 4, echo_pin: 5 }]); + const b = upsertSensorRecords(a, [{ sensor_type: 'dht22', pin: 4, temperature: 22 }]); + expect(b).toEqual([{ sensor_type: 'dht22', pin: 4, temperature: 22 }]); + // ...so the old echo pin is handed back to the host, not guarded forever. + expect(sensorRecordOwnsPin(b[0], 5)).toBe(false); + expect(sensorRecordOwnsPin(b[0], 4)).toBe(true); + }); + + it('only single-wire kinds own pads', async () => { + const { sensorRecordOwnsPin } = await import('../simulation/Esp32Bridge'); + // An ePaper panel registers real GPIOs too; the host must keep driving them. + expect(sensorRecordOwnsPin({ sensor_type: 'epaper-ssd168x', pin: 10, dc_pin: 9 }, 9)).toBe(false); + expect(sensorRecordOwnsPin({ sensor_type: 'hc-sr04', pin: 21, echo_pin: 41 }, 41)).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/pin-trace-precedence.test.ts b/frontend/src/__tests__/pin-trace-precedence.test.ts index e1870399..5a4f0fa8 100644 --- a/frontend/src/__tests__/pin-trace-precedence.test.ts +++ b/frontend/src/__tests__/pin-trace-precedence.test.ts @@ -20,7 +20,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; import { useSimulatorStore } from '../store/useSimulatorStore'; -import { traceDetailed } from '../simulation/PinTrace'; +import { traceDetailed, traceBoardGpio } from '../simulation/PinTrace'; const BOARD = 'arduino-uno'; @@ -232,3 +232,25 @@ describe('cost of walking the net', () => { expect(perRail).toBeLessThan(8); }); }); + +describe('a net shared by two boards', () => { + it('answers each board its own pad', () => { + // One sensor line wired to a GPIO on BOTH boards. Whoever pre-registers + // the sensor for board B must learn B's pad, whatever order the wires + // were drawn in — the walk must not stop at A's pad and report "not on B". + useSimulatorStore.setState({ boards: [], components: [], wires: [] } as never); + useSimulatorStore.getState().addBoard('arduino-uno' as never, 0, 0, 'uno-a'); + useSimulatorStore.getState().addBoard('esp32' as never, 0, 0, 'esp-b'); + const s = useSimulatorStore.getState(); + s.setComponents([]); + s.setWires([ + wire('x1', ['sensor', 'ECHO'], ['uno-a', '7']), + wire('x2', ['sensor', 'ECHO'], ['esp-b', '19']), + ] as never); + const state = useSimulatorStore.getState(); + expect(traceBoardGpio(state as never, 'sensor', 'ECHO', 'uno-a')).toBe(7); + expect(traceBoardGpio(state as never, 'sensor', 'ECHO', 'esp-b')).toBe(19); + // And a board the net never reaches is still "not here". + expect(traceBoardGpio(state as never, 'sensor', 'ECHO', 'nope')).toBeNull(); + }); +}); diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index aed607e3..6da600d2 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -66,8 +66,52 @@ export function toQemuBoardType(kind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32- /** 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']); + * the host. Exported so an overlay engine answers the same question with the + * same list instead of a copy that drifts. */ +export const SINGLE_WIRE_SENSOR_TYPES: ReadonlySet = new Set(['dht22', 'hc-sr04']); + +/** True for a sensor record (`{ sensor_type, pin, ... }`) of a single-wire kind. */ +export function isSingleWireSensorRecord(rec: Record): boolean { + return SINGLE_WIRE_SENSOR_TYPES.has(String(rec['sensor_type'] ?? '')); +} + +/** Pads a single-wire sensor record owns: its data pin and any extra pin it + * names (`echo_pin` for an HC-SR04). */ +export function sensorRecordOwnsPin(rec: Record, gpioPin: number): boolean { + if (!isSingleWireSensorRecord(rec)) return false; + if (rec['pin'] === gpioPin) return true; + for (const [k, v] of Object.entries(rec)) { + if ((k.endsWith('_pin') || k.endsWith('Pin')) && v === gpioPin) return true; + } + return false; +} + +/** + * Upsert sensor records by `pin` — the one merge every bridge uses. + * + * Same kind on the same pin merges per FIELD. Two registration paths describe + * one sensor knowing different halves: the part resolves its extra pins through + * the wire walk, the store's pre-registration knows the component's properties. + * Replacing the object let the coarser path silently drop `echo_pin`, and 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. + * + * A DIFFERENT kind on the same pin replaces outright: a DHT22 swapped for an + * HC-SR04 must not inherit a stale `echo_pin` that ownsSensorPin would then + * keep guarding against the host. + */ +export function upsertSensorRecords( + existing: ReadonlyArray>, + incoming: ReadonlyArray>, +): Array> { + const merged = existing.slice(); + for (const s of incoming) { + const idx = merged.findIndex((e) => e['pin'] === s['pin']); + if (idx < 0) merged.push(s); + else merged[idx] = merged[idx]['sensor_type'] === s['sensor_type'] ? { ...merged[idx], ...s } : s; + } + return merged; +} const API_BASE = (): string => { // The desktop shell injects the sidecar URL at runtime (random port) via @@ -622,21 +666,7 @@ export class Esp32Bridge { * 5.65" UC8159c panel sat unresponsive while its firmware busy-waited. */ setSensors(sensors: Array>): void { - const merged = this._pendingSensors.slice(); - for (const s of sensors) { - const pin = s['pin']; - const idx = merged.findIndex((e) => e['pin'] === pin); - // 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; + this._pendingSensors = upsertSensorRecords(this._pendingSensors, sensors); } /** Returns true if a firmware has been loaded and is ready to send. */ @@ -681,18 +711,13 @@ export class Esp32Bridge { * the pin correctly. Same shape as the in-browser engines' ownsPin guard. */ 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; - } - return false; + // 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. + return this._pendingSensors.some((s) => sensorRecordOwnsPin(s, gpioPin)); } /** Drive a GPIO pin from an external source (e.g. connected Arduino) */ @@ -800,12 +825,7 @@ export class Esp32Bridge { // before the WebSocket opens (the common case when attachEvents fires // before the user clicks Run). const entry = { sensor_type: sensorType, pin, ...properties }; - const existing = this._pendingSensors.findIndex((s) => s['pin'] === pin); - if (existing >= 0) { - this._pendingSensors[existing] = entry; - } else { - this._pendingSensors.push(entry); - } + this._pendingSensors = upsertSensorRecords(this._pendingSensors, [entry]); // Also send immediately if already connected (re-attach on hot reload) if (this._connected) { this._send({ type: 'esp32_sensor_attach', data: entry }); diff --git a/frontend/src/simulation/PinTrace.ts b/frontend/src/simulation/PinTrace.ts index 579abd8d..6d1d6d89 100644 --- a/frontend/src/simulation/PinTrace.ts +++ b/frontend/src/simulation/PinTrace.ts @@ -271,18 +271,35 @@ export function traceDetailed( state: TraceState, fromId: string, fromPin: string, - depth: number, + depth = 0, activeSeen = false, - visited?: Map, ): TraceResult { - // The ROOT call is the only one allowed to mint a chip-net / own-chip-pin - // fallback (see rule 5) — the guard `depth === 0` used to give that, and - // no longer can now that a node is collected rather than walked. - const isRoot = visited === undefined; - // Visits are recorded WITH their depth: a node first reached deep, where the - // hop budget cut its continuations short, must still be walkable when a - // shorter route arrives later. - const seen = visited ?? new Map(); + return walk(state, fromId, fromPin, depth, activeSeen, new Map(), true, undefined); +} + +/** + * The recursion behind traceDetailed / traceBoardGpio. + * + * - `seen` records each visit WITH its depth: a node first reached deep, + * where the hop budget cut its continuations short, must still be walkable + * when a shorter route arrives later. + * - `isRoot` marks the outermost frame, the only one allowed to mint a + * chip-net / own-chip-pin fallback (rule 5) — `depth === 0` used to say + * that, and cannot now that a node is collected rather than walked. + * - `preferBoard` breaks a tie between driven pads of DIFFERENT boards on one + * node, so a caller asking about a specific board gets that board's pad + * when the net reaches both (a sensor shared by two MCUs). + */ +function walk( + state: TraceState, + fromId: string, + fromPin: string, + depth: number, + activeSeen: boolean, + seen: Map, + isRoot: boolean, + preferBoard: string | undefined, +): TraceResult { const selfKey = pinKey(fromId, fromPin); const seenAt = seen.get(selfKey); if (seenAt !== undefined && seenAt <= depth) { @@ -290,7 +307,7 @@ export function traceDetailed( } if (depth > MAX_HOPS) return { arduinoPin: null, crossedActiveDevice: activeSeen }; - const node = collectNode(state, fromId, fromPin, seen, depth); + const node = collectNode(state, fromId, fromPin, seen, depth, preferBoard); // ── Rules 1 and 2: the pads on this node ───────────────────────────────── if (node.drivenHit) return { ...node.drivenHit, crossedActiveDevice: activeSeen }; @@ -306,7 +323,7 @@ export function traceDetailed( if (!pair || !p.comp) continue; const farPin = p.pinName === pair[0] ? pair[1] : pair[0]; const nowActive = activeSeen || isActiveDevice(p.comp.metadataId); - const across = traceDetailed(state, p.comp.id, farPin, depth + 1, nowActive, seen); + const across = walk(state, p.comp.id, farPin, depth + 1, nowActive, seen, false, preferBoard); if (isBoardPin(across.arduinoPin)) return across; if (across.arduinoPin === RAIL) crossedRail ??= across; else if (across.arduinoPin !== null) chipHit ??= across; @@ -359,11 +376,22 @@ function collectNode( fromPin: string, seen: Map, depth: number, + preferBoard: string | undefined, ): NodeInfo { const pins: NodeInfo['pins'] = []; const boardHits: TraceResult[] = []; + // A driven pad of the preferred board ends collection at once; one of any + // other board is kept and only wins if the preferred board never shows up. let drivenHit: TraceResult | null = null; let chipPin: NodeInfo['chipPin'] = null; + const takeDriven = (hit: TraceResult): boolean => { + if (preferBoard === undefined || hit.boardId === preferBoard) { + drivenHit = hit; + return true; + } + drivenHit ??= hit; + return false; + }; const local = new Set(); const queue: Array<{ componentId: string; pinName: string }> = [ { componentId: fromId, pinName: fromPin }, @@ -391,8 +419,11 @@ function collectNode( const socket = traceThroughSocket(state, cur.componentId, cur.pinName); if (socket) { const hit = { arduinoPin: socket.pin, crossedActiveDevice: false, boardId: socket.boardId }; - if (isBoardPin(socket.pin)) return { pins, boardHits, drivenHit: hit, chipPin }; - boardHits.push(hit); + if (isBoardPin(socket.pin)) { + if (takeDriven(hit)) return { pins, boardHits, drivenHit, chipPin }; + } else { + boardHits.push(hit); + } } // Breadboards join N holes per internal group (5-hole strip / power rail): @@ -437,8 +468,11 @@ function collectNode( }; // A driven pad ends the search for this node — and, on a big shared // rail, ends a walk of every leg hanging off it. - if (isBoardPin(pin)) return { pins, boardHits, drivenHit: hit, chipPin }; - boardHits.push(hit); + if (isBoardPin(pin)) { + if (takeDriven(hit)) return { pins, boardHits, drivenHit, chipPin }; + } else { + boardHits.push(hit); + } } continue; } @@ -459,6 +493,11 @@ function collectNode( * supply pad, a custom-chip net, or nothing at all. This is the shape a caller * that wants to talk to REAL hardware wants: a rail and a synthetic chip pin * are both "not a GPIO on this board". + * + * `boardId` is a PREFERENCE the walk honours on the node, not just a filter on + * the way out: when one net reaches two boards' GPIOs (a sensor shared by two + * MCUs) the caller asking about board A gets A's pad, and only a net that + * truly never reaches A answers null for it. */ export function traceBoardGpio( state: TraceState, @@ -466,7 +505,7 @@ export function traceBoardGpio( pinName: string, boardId?: string, ): number | null { - const hit = traceDetailed(state, componentId, pinName, 0); + const hit = walk(state, componentId, pinName, 0, false, new Map(), true, boardId); if (hit.arduinoPin === null) return null; if (hit.arduinoPin < 0 || hit.arduinoPin >= SYNTHETIC_CHIP_PIN_BASE) return null; if (boardId !== undefined && hit.boardId !== undefined && hit.boardId !== boardId) return null; diff --git a/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts b/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts index 78977fdb..6502ab9e 100644 --- a/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts +++ b/frontend/src/simulation/spice/connectDigitalInputsToMcu.ts @@ -98,10 +98,14 @@ export function connectDigitalInputsToMcu(): () => void { // 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 + if (isPartOwnedPin(board.id, gpio) || sim.ownsPin?.(gpio)) { + // Forget what we last pushed here. While the part drove the line the + // guest's level was whatever the part made it, so the moment the + // part lets go (rewire, unmount, detach) the next solve must emit + // even if the circuit happens to agree with our stale memory. + lastLevel.delete(`${board.id}:${gpio}`); + continue; + } // 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