/** * PinTrace — "which board pin is this component pin wired to?" * * The wire graph is not a chain of point-to-point links: a NET is every pin * the wires transitively join, and a component pin can carry more than one * wire. This walk answers, for one (component, pin), the board GPIO that owns * its net — tracing through 2-terminal passives (a resistor in series is * electrically transparent to the digital layer), through breadboard strips, * and through a socket a board is seated on. * * It used to live inside components/DynamicComponent.tsx. It moved here so the * store can use the SAME answer when it pre-registers backend sensors: two * copies of "find the board pin" that disagreed is exactly how an HC-SR04 * behind a level-shifting divider ended up with an echo pin nobody had wired * (see the sensor pre-registration in useSimulatorStore.startBoard). * * Dependency-light on purpose (no React, no store VALUE import) so both the * renderer and the store can import it. */ import type { useSimulatorStore } from '../store/useSimulatorStore'; import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping'; import { isBoardSeated } from '../utils/socketSnap'; import { isActiveDevice } from './PinResolver'; import { breadboardGroupKey } from '../utils/breadboardNets'; import { syntheticChipPin, SYNTHETIC_CHIP_PIN_BASE } from './customChips/syntheticPins'; import { resolveChipNetKey } from './customChips/chipNets'; // 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', }; export 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]; } export type TraceState = ReturnType; export interface TraceResult { /** >=0 board pin (or a synthetic chip pin), -1 supply/GND pad, null unreached. */ arduinoPin: number | null; /** True iff the trace passed through a BJT/MOSFET/op-amp/diode. */ crossedActiveDevice: boolean; /** Id of the board the pin belongs to, when one was reached. */ boardId?: string; } /** A driven pin — a real GPIO or a custom-chip pin — is a stronger answer than * a supply/GND pad (-1), which only says "this net is tied to a rail". */ function isDrivenPin(pin: number | null): pin is number { return pin !== null && pin >= 0; } /** * 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 { if (typeof document === 'undefined') return 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; } // Depth-limited walk: 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. export function traceDetailed( state: TraceState, fromId: string, fromPin: string, depth: number, activeSeen = false, visited?: Set, ): TraceResult { if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen }; // The ROOT call is the only one allowed to mint a chip-net / own-chip-pin // fallback (see the rules at the bottom) — the same guard `depth === 0` // gave before same-node hops existed, which recurse at the SAME depth. const isRoot = visited === undefined; // A net is walked, not a chain: the tap of a divider carries the wire to // the MCU *and* the wire to the lower resistor, and two pins of one net // would otherwise hand each other back and forth forever. const seen = visited ?? new Set(); const selfKey = `${fromId}${fromPin}`; if (seen.has(selfKey)) return { arduinoPin: null, crossedActiveDevice: activeSeen }; seen.add(selfKey); 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; // A supply / GND pad answers -1. That used to return immediately, which is // what made a level-shifting divider invisible: from the sensor's output the // walk crossed the top resistor, crossed the bottom one, hit GND and // reported "tied to a rail" — while the GPIO sat on the tap between them, // one wire away. A rail is now the weakest answer: remembered, and only // returned once every branch has failed to find a driven pin. let railHit: TraceResult | 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). const hit: TraceResult = { arduinoPin: pin, crossedActiveDevice: activeSeen, boardId: boardEp?.id ?? otherEp.componentId, }; if (isDrivenPin(pin)) return hit; if (pin !== null && !railHit) railHit = hit; } else { const comp = state.components.find((c) => c.id === otherEp.componentId); if (!chipNeighbour && comp?.metadataId === 'custom-chip') { chipNeighbour = { id: otherEp.componentId, pin: otherEp.pinName }; } // Same node, no component crossed: the pin this wire lands on may carry // OTHER wires (a divider tap wired to both the MCU and the lower // resistor, a jumper daisy-chained off a resistor lead). Those continue // the same net, so they are walked at the SAME depth — the `seen` set, // shared for the whole walk, is what makes that terminate. const junction = traceDetailed( state, otherEp.componentId, otherEp.pinName, depth, activeSeen, seen, ); if (isDrivenPin(junction.arduinoPin)) return junction; if (junction.arduinoPin !== null && !railHit) railHit = junction; 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, seen, ); if (isDrivenPin(result.arduinoPin)) return result; if (result.arduinoPin !== null && !railHit) railHit = 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, seen); if (isDrivenPin(result.arduinoPin)) return result; if (result.arduinoPin !== null && !railHit) railHit = result; } } } } // No driven 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 the root call (the // starting chip pin); the key is net-bound, so a pin flipping INPUT<->OUTPUT // keeps the same key with no re-trace. if (isRoot) { const netKey = resolveChipNetKey(state, fromId, fromPin); if (netKey !== null) { return { arduinoPin: netKey, crossedActiveDevice: activeSeen }; } } // A rail beats the chip fallbacks below, as it did when it returned inline. if (railHit) return railHit; // 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 (isRoot && state.components.find((c) => c.id === fromId)?.metadataId === 'custom-chip') { return { arduinoPin: syntheticChipPin(fromId, fromPin), crossedActiveDevice: activeSeen }; } return { arduinoPin: null, crossedActiveDevice: activeSeen }; } /** * The board GPIO owning this component pin, or null when the walk reached a * 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". */ export function traceBoardGpio( state: TraceState, componentId: string, pinName: string, boardId?: string, ): number | null { const hit = traceDetailed(state, componentId, pinName, 0); 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; return hit.arduinoPin; }