/** * PinResolver — abstraction over "what's driving this component pin?". * * Background * ---------- * Until Phase 0 of the mixed-mode simulator project, every component * handler in simulation/parts/*Parts.ts wired its own subscription to * `pinManager.onPinChange(arduinoPin, callback)` after resolving the * arduinoPin via the `getArduinoPinHelper(componentPinName)` callback * passed into `attachEvents`. That couples handlers tightly to the * digital event flow and makes it impossible to swap in a SPICE-resolved * voltage check without rewriting every handler. * * This module introduces `PinResolver`: a small interface that hides the * "how do I know what state this pin is in?" question behind two methods, * `getCurrentState()` and `onChange()`. Handlers ask the resolver for the * current state and subscribe for changes; they never touch PinManager * directly. * * Phase 0 ships the DEFAULT IMPLEMENTATION, which is functionally * identical to the legacy path — it just routes through PinResolver * instead of being inlined in each handler. Zero behavior change. * * Phase 1 will add a SPICE-resolved implementation that watches * `nodeVoltages[net]` and threshold-converts to digital states. That * landing point is why the abstraction exists. * * See ../../project/sim-mixedmode/phase-00-pin-resolver.md * ../../project/sim-mixedmode/phase-01-mixed-mode-coupling.md * (in the velxio-prod repo) */ import type { Wire } from '../types/wire'; import type { Component } from '../types/components'; import type { BoardInstance } from '../types/board'; /** Default Vcc when an owner board can't be identified. Matches Arduino Uno. */ const DEFAULT_VCC = 5; /** Logic state of a pin from the perspective of the digital event flow. */ export type PinState = 'HIGH' | 'LOW' | 'FLOATING' | 'CONFLICT'; /** * Resolves "what is this component pin doing?" for one specific pin. * * - `getCurrentState()` returns the live state synchronously. * - `getCurrentVoltage()` returns the live voltage in volts (or null if * unknown — e.g. SPICE hasn't solved yet). For the Phase 0 default * impl this is synthesised as `state==='HIGH' ? vcc : 0`. * - `onChange(cb)` subscribes to state transitions. Returns an * unsubscribe function. The callback fires whenever the state * changes — NOT on every internal pin event (e.g. PWM duty bumps * don't fire as long as the digital state stays HIGH). * * Implementations must be cheap to construct and cheap to query. * One PinResolver per (componentId, pinName) is created per * `attachEvents` call. */ export interface PinResolver { getCurrentState(): PinState; getCurrentVoltage(): number | null; onChange(cb: (state: PinState, voltage: number) => void): () => void; } /** * Internal context that the default PinResolver needs to do its work. * Passed once from DynamicComponent at attachEvents time so the resolver * doesn't have to reach into Zustand stores itself (keeps it testable). */ export interface PinResolverContext { /** All components currently on the canvas — used by the wire-trace logic. */ components: Component[]; /** All boards on the canvas. */ boards: BoardInstance[]; /** All wires on the canvas. */ wires: Wire[]; /** The board that's emitting events for this component (Arduino Uno etc.). */ ownerBoard: BoardInstance | null; /** * Vcc of the owner board in volts (e.g. 5 for Arduino Uno, 3.3 for ESP32). * Used to synthesise a voltage value for the Phase 0 default impl — * `HIGH → vcc`, `LOW → 0`. Phase 1+ reads real voltages from SPICE * instead. */ ownerBoardVcc: number; /** Subscribe to a single Arduino pin's digital changes. Returns unsubscribe. */ subscribeArduinoPin: ( pin: number, cb: (pin: number, state: boolean) => void, ) => () => void; /** * Read the current digital state of an Arduino pin, synchronously. Used * by `getCurrentState()` so the resolver doesn't have to wait for the * first event after subscription to report a sensible initial state. * Return `null` if the pin's state isn't tracked (e.g. board hasn't * booted yet). */ readArduinoPin: (pin: number) => boolean | null; } /** * Pure pin-tracing logic — walks wires through 2-terminal passives (and * a few "transparent" actives like BJTs as part of the 2026-05-15 * shortcut) until it finds either a board pin or a dead end. This is * the same trace that lived inline in `getArduinoPin` inside * DynamicComponent.tsx, extracted here so the default PinResolver impl * can call it without duplicating logic. * * Returns: * - >= 0 → Arduino pin number controlling this component pin * - -1 → wired to GND (handled by caller as "always LOW") * - null → no board reached (might be wired to another component that * we don't trace through, or unwired entirely) * * Phase 1 will replace this with SPICE-net lookup via pinNetMap. The * `[C, B]` BJT shortcut here goes away when Phase 5 deletes the * legacy direct-event path. */ export type PinTracer = ( componentId: string, componentPinName: string, ) => number | null; /** * Extended tracer that ALSO reports whether the path crossed an active * device (BJT, MOSFET, op-amp, diode). Used by the PinResolver factory * to decide between digital fast-path (no active devices on the path) * and SPICE-resolved (an active device is in series, so binary HIGH/LOW * doesn't capture the real signal). * * `crossedActiveDevice` is set during the trace walk in DynamicComponent; * the legacy `PinTracer` API (returns just the number) is preserved for * backward compat. */ export interface DetailedPinTrace { /** Same convention as PinTracer: >=0 pin, -1 GND, null unreached. */ arduinoPin: number | null; /** True iff the trace passed through a BJT/MOSFET/op-amp/diode. */ crossedActiveDevice: boolean; } export type DetailedPinTracer = ( componentId: string, componentPinName: string, ) => DetailedPinTrace; /** * Active-component metadata ids whose presence on a wire path means we * should defer to the SPICE solver instead of treating the path as a * direct digital connection. Includes every BJT, MOSFET, op-amp, and * diode the simulator currently models. Logic ICs (74HC, 4000-series) * are intentionally NOT in this list because they have their own * dedicated handlers that pre-decode the I2C/SPI/digital protocol — * SPICE-routing them would be wasteful and slower. * * Phase 5 will simplify this once every active component goes through * the same SPICE-resolved path uniformly. */ export const ACTIVE_DEVICE_PREFIXES: readonly string[] = Object.freeze([ 'bjt-', 'mosfet-', 'opamp-', 'diode', // diode, diode-1n4148, etc. 'led', // technically a diode — current sense uses SPICE 'relay', // coil drive + contact behavior is electrically active '7805', '7812', '7905', 'lm317', // regulators (3-terminal active) ]); export function isActiveDevice(metadataId: string): boolean { return ACTIVE_DEVICE_PREFIXES.some((prefix) => metadataId.startsWith(prefix)); } /** * Build the default PinResolver for one (component, pin) pair. * * `tracePin` is provided by the caller (DynamicComponent) — it * encapsulates the wire-graph walk because that logic depends on * runtime board metadata DynamicComponent already has loaded. Keeps * this module dependency-free of the board/wire stores and easy to * unit test. */ export function createDefaultPinResolver( componentId: string, componentPinName: string, ctx: PinResolverContext, tracePin: PinTracer, ): PinResolver { // Resolve once at construction. The trace result is stable while the // wire topology is unchanged; if a wire is added/removed, // DynamicComponent's effect re-runs and we rebuild from scratch. const arduinoPin = tracePin(componentId, componentPinName); // Local mirror of the current state. Initialised on first subscription // (or first synchronous read) from the PinManager. let cached: PinState = 'FLOATING'; let cachedVoltage: number | null = null; let initialised = false; // Board VCC for voltage synthesis. Caller looks it up from // boardPinGroups; we just use whatever they give us (default 5V). const vcc = ctx.ownerBoardVcc ?? DEFAULT_VCC; function refresh(): void { if (arduinoPin === null) { cached = 'FLOATING'; cachedVoltage = null; return; } if (arduinoPin === -1) { // Tied to GND. cached = 'LOW'; cachedVoltage = 0; return; } const live = ctx.readArduinoPin(arduinoPin); if (live === null) { cached = 'FLOATING'; cachedVoltage = null; return; } cached = live ? 'HIGH' : 'LOW'; cachedVoltage = live ? vcc : 0; } return { getCurrentState(): PinState { if (!initialised) { refresh(); initialised = true; } return cached; }, getCurrentVoltage(): number | null { if (!initialised) { refresh(); initialised = true; } return cachedVoltage; }, onChange(cb): () => void { if (arduinoPin === null) { // Unwired or wired to something we don't trace through. No // events will ever fire — return a no-op unsubscribe. return () => {}; } if (arduinoPin === -1) { // Tied to GND. Emit one initial event so subscribers can mirror // state immediately, then no further changes. queueMicrotask(() => cb('LOW', 0)); return () => {}; } return ctx.subscribeArduinoPin(arduinoPin, (_pin, state) => { cached = state ? 'HIGH' : 'LOW'; cachedVoltage = state ? vcc : 0; cb(cached, cachedVoltage); }); }, }; } // ── SPICE-resolved variant (Phase 1b) ───────────────────────────────── /** * Subscriber registry the SPICE-resolved resolver hooks into. Lives in * MixedModeScheduler so the scheduler can fan voltage updates out to * all interested resolvers. * * We use this seam type (instead of importing MixedModeScheduler here * directly) to keep PinResolver dependency-free of the scheduler. * Phase 1b continued can swap in any implementation conforming to this * shape, including test mocks. */ export interface SpiceVoltageSource { subscribe( componentId: string, componentPinName: string, cb: (state: PinState, voltage: number) => void, ): () => void; /** Current voltage on the node connecting (componentId, pinName), * if SPICE has solved at least once. Null when unknown. */ getCurrentVoltage(componentId: string, componentPinName: string): number | null; } interface SpiceResolvedConfig { /** Voltage above which a node reads HIGH. For Schmitt-trigger * families this is Vt+ (the rising-edge threshold); for ordinary * CMOS/TTL/AVR it's the family's Vih (typical 0.7·Vcc for CMOS, * 2.0V for TTL/LVCMOS33). */ thresholdHigh: number; /** Voltage below which a node reads LOW. Set equal to thresholdHigh * for no hysteresis; set lower for Schmitt-trigger behavior (e.g. * Vt- < Vt+ on 74HC14). Inputs between thresholdLow and * thresholdHigh stay in their previous state — that's what creates * the noise-rejection dead band. */ thresholdLow: number; /** Vcc — used to synthesise a digital voltage when the resolver * reports a logic state synchronously and SPICE hasn't yet solved. */ vcc: number; } /** * Convenience: build a `SpiceResolvedConfig` from a `LogicFamily`. * Hysteresis thresholds are used when the family declares them * (Schmitt-trigger inputs), otherwise vih/vil are used. Keeps callers * from having to know whether a specific family has Schmitt behavior. * * Import-style note: this helper lives here (rather than in * LogicFamilies.ts) so PinResolver stays the single import callers * need for resolver construction. Re-exports avoid the import cycle. */ export function configFromLogicFamily(family: { vcc: number; vil: number; vih: number; vil_schmitt?: number; vih_schmitt?: number; }): SpiceResolvedConfig { return { thresholdHigh: family.vih_schmitt ?? family.vih, thresholdLow: family.vil_schmitt ?? family.vil, vcc: family.vcc, }; } /** * SPICE-resolved PinResolver — instead of mirroring an Arduino pin's * digital state, it watches a SPICE node's voltage and threshold- * converts to HIGH/LOW/FLOATING. Used on paths that pass through * active devices (BJT, MOSFET, op-amp, diode) where the digital * fast-path can't represent the real signal. * * Phase 1b scaffolding: subscribes to the provided `SpiceVoltageSource` * and translates voltage events. When the source doesn't have a * voltage yet, the resolver reports `FLOATING` (NOT the legacy "tied to * Arduino HIGH/LOW") — that's intentional, because the scheduler hasn't * yet wired in the real SPICE engine. After Phase 1b continued, real * voltages will flow through this same surface. */ export function createSpiceResolvedPinResolver( componentId: string, componentPinName: string, voltageSource: SpiceVoltageSource, config: SpiceResolvedConfig, ): PinResolver { let cached: PinState = 'FLOATING'; let cachedVoltage: number | null = null; function voltageToState(v: number | null): PinState { if (v === null || !Number.isFinite(v)) return 'FLOATING'; if (v >= config.thresholdHigh) return 'HIGH'; if (v <= config.thresholdLow) return 'LOW'; // Voltages in the dead band stay at the last known state — this is // the basic hysteresis behavior that Phase 3 will refine per-family. return cached; } // Seed initial state from whatever the voltage source currently knows. cachedVoltage = voltageSource.getCurrentVoltage(componentId, componentPinName); cached = voltageToState(cachedVoltage); return { getCurrentState(): PinState { return cached; }, getCurrentVoltage(): number | null { return cachedVoltage; }, onChange(cb): () => void { return voltageSource.subscribe(componentId, componentPinName, (_state, voltage) => { cachedVoltage = voltage; const next = voltageToState(voltage); if (next !== cached) { cached = next; cb(next, voltage); } }); }, }; }