velxio/frontend/src/simulation/PinTrace.ts

349 lines
15 KiB
TypeScript
Raw Normal View History

fix(sim): a resistor divider on a sensor line made the board pin invisible 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.
2026-08-23 04:31:23 +07:00
/**
* 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<string, [string, string]> = {
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<string, string> = {
'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<string, [string, string]> = {
...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<typeof useSimulatorStore.getState>;
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<string>,
): 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<string>();
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<string>();
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;
}