380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|