From e10492c6e66a23e3f7ce9c7ae7b8e34717c61c12 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Fri, 15 May 2026 15:50:09 +0200 Subject: [PATCH] feat(sim): introduce PinResolver abstraction (Phase 0 of mixed-mode rewrite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decouple per-component handlers from direct pinManager.onPinChange + getArduinoPinHelper subscriptions by introducing a small PinResolver interface. The Phase 0 default impl is functionally identical to the legacy path — it just routes through PinResolver instead of being inlined in every handler. Zero behavior change. The point is to make Phase 1 possible: swap the default impl for a SPICE-resolved version that watches node voltages and threshold- converts to digital events, without rewriting every handler. Files: - simulation/PinResolver.ts (new) — interface + default factory - parts/PartSimulationRegistry.ts — additive 5th arg to attachEvents (getPinResolver?), legacy 4-arg signatures keep working unchanged - components/DynamicComponent.tsx — assembles the PinResolver from the wire-trace logic + PinManager subscriptions + board Vcc lookup, passes it as the 5th arg to attachEvents - parts/BasicParts.ts — LED handler migrated as proof of concept (resolver-first path, legacy 4-arg path kept as fallback for tests / unmigrated harnesses) - __tests__/pin-resolver.test.ts (new) — 8 unit tests covering FLOATING / GND / HIGH / LOW / GPIO subscriptions / unsubscribe Vitest: 8/8 pin-resolver tests pass. 1300+ existing tests still pass; the one pre-existing flake (spice-rectifier-live-repro timing out >60s) is unrelated to this commit — verified by running the test on plain HEAD without these changes (same timeout). See project/sim-mixedmode/phase-00-pin-resolver.md (in the velxio-prod repo) for full phase context. --- frontend/src/__tests__/pin-resolver.test.ts | 140 ++++++++++++ frontend/src/components/DynamicComponent.tsx | 55 ++++- frontend/src/simulation/PinResolver.ts | 210 ++++++++++++++++++ frontend/src/simulation/parts/BasicParts.ts | 80 +++++-- .../parts/PartSimulationRegistry.ts | 14 +- 5 files changed, 475 insertions(+), 24 deletions(-) create mode 100644 frontend/src/__tests__/pin-resolver.test.ts create mode 100644 frontend/src/simulation/PinResolver.ts diff --git a/frontend/src/__tests__/pin-resolver.test.ts b/frontend/src/__tests__/pin-resolver.test.ts new file mode 100644 index 00000000..792ab92d --- /dev/null +++ b/frontend/src/__tests__/pin-resolver.test.ts @@ -0,0 +1,140 @@ +/** + * Unit tests for the default PinResolver. + * + * These cover the Phase-0 deliverable: a PinResolver that wraps the + * existing getArduinoPin trace + pinManager subscription. Behavior + * must be identical to the legacy path; the abstraction just makes + * the swap to a SPICE-resolved implementation possible in Phase 1. + * + * See project/sim-mixedmode/phase-00-pin-resolver.md + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + createDefaultPinResolver, + type PinResolverContext, + type PinTracer, +} from '../simulation/PinResolver'; + +function makeCtx(overrides: Partial = {}): PinResolverContext { + return { + components: [], + boards: [], + wires: [], + ownerBoard: null, + ownerBoardVcc: 5, + subscribeArduinoPin: () => () => {}, + readArduinoPin: () => null, + ...overrides, + }; +} + +describe('PinResolver — default impl', () => { + it('reports FLOATING when the pin is not wired to any board pin', () => { + const tracer: PinTracer = () => null; + const r = createDefaultPinResolver('led-1', 'A', makeCtx(), tracer); + expect(r.getCurrentState()).toBe('FLOATING'); + expect(r.getCurrentVoltage()).toBeNull(); + }); + + it('reports LOW with voltage 0 when the pin is tied to GND (tracer returns -1)', () => { + const tracer: PinTracer = () => -1; + const r = createDefaultPinResolver('led-1', 'C', makeCtx(), tracer); + expect(r.getCurrentState()).toBe('LOW'); + expect(r.getCurrentVoltage()).toBe(0); + }); + + it('reads HIGH/LOW from readArduinoPin when wired to a GPIO', () => { + const tracer: PinTracer = () => 13; + let pinValue = false; + const ctx = makeCtx({ + readArduinoPin: (pin) => (pin === 13 ? pinValue : null), + }); + const r = createDefaultPinResolver('led-1', 'A', ctx, tracer); + + pinValue = false; + expect(r.getCurrentState()).toBe('LOW'); + expect(r.getCurrentVoltage()).toBe(0); + }); + + it('synthesises voltage from owner board Vcc on HIGH', () => { + const tracer: PinTracer = () => 13; + const ctx = makeCtx({ + ownerBoardVcc: 3.3, + readArduinoPin: () => true, + }); + const r = createDefaultPinResolver('led-1', 'A', ctx, tracer); + expect(r.getCurrentState()).toBe('HIGH'); + expect(r.getCurrentVoltage()).toBeCloseTo(3.3); + }); + + it('subscribes to pin changes via subscribeArduinoPin and fires callbacks', () => { + const tracer: PinTracer = () => 7; + const subscribers: Array<(pin: number, state: boolean) => void> = []; + const unsub = vi.fn(); + const ctx = makeCtx({ + subscribeArduinoPin: (_pin, cb) => { + subscribers.push(cb); + return unsub; + }, + }); + const r = createDefaultPinResolver('led-1', 'A', ctx, tracer); + const onChange = vi.fn(); + const cancel = r.onChange(onChange); + + // Manually fire as if PinManager delivered an event + subscribers[0]!(7, true); + expect(onChange).toHaveBeenCalledWith('HIGH', 5); + subscribers[0]!(7, false); + expect(onChange).toHaveBeenCalledWith('LOW', 0); + + // Caller can unsubscribe cleanly + cancel(); + expect(unsub).toHaveBeenCalled(); + }); + + it('returns a no-op unsubscribe when the pin is unwired (tracer returns null)', () => { + const tracer: PinTracer = () => null; + const ctx = makeCtx({ + subscribeArduinoPin: vi.fn(), + }); + const r = createDefaultPinResolver('led-1', 'A', ctx, tracer); + const onChange = vi.fn(); + const cancel = r.onChange(onChange); + expect(ctx.subscribeArduinoPin).not.toHaveBeenCalled(); + expect(typeof cancel).toBe('function'); + cancel(); // should not throw + }); + + it('emits an initial LOW event asynchronously when wired to GND', async () => { + const tracer: PinTracer = () => -1; + const r = createDefaultPinResolver('led-1', 'C', makeCtx(), tracer); + const onChange = vi.fn(); + r.onChange(onChange); + + // Microtask queue flush — the resolver schedules the LOW event via + // queueMicrotask so subscribers see initial state without blocking + // construction. + await Promise.resolve(); + expect(onChange).toHaveBeenCalledWith('LOW', 0); + }); + + it('updates getCurrentState immediately after a subscribed callback fires', () => { + const tracer: PinTracer = () => 9; + const subscribers: Array<(pin: number, state: boolean) => void> = []; + const ctx = makeCtx({ + subscribeArduinoPin: (_pin, cb) => { + subscribers.push(cb); + return () => {}; + }, + readArduinoPin: () => false, + }); + const r = createDefaultPinResolver('led-1', 'A', ctx, tracer); + r.onChange(() => {}); + + expect(r.getCurrentState()).toBe('LOW'); + subscribers[0]!(9, true); + expect(r.getCurrentState()).toBe('HIGH'); + expect(r.getCurrentVoltage()).toBe(5); + }); +}); diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 05ba3163..b96e6ffb 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -17,6 +17,8 @@ import { useSimulatorStore } from '../store/useSimulatorStore'; import { useElectricalStore } from '../store/useElectricalStore'; import { PartSimulationRegistry } from '../simulation/parts'; import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping'; +import { createDefaultPinResolver, type PinResolver } from '../simulation/PinResolver'; +import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups'; // Side-effect imports: register every web component we'll create at runtime. // `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds @@ -359,7 +361,58 @@ export const DynamicComponent: React.FC = ({ return trace(id, componentPinName, 0); }; - cleanupSimulationEvents = logic.attachEvents(el, stubSimulator, getArduinoPin, id); + // PinResolver factory — Phase 0 of the mixed-mode simulator project + // (see project/sim-mixedmode/ in the velxio-prod repo). For now it + // wraps getArduinoPin + pinManager.onPinChange — zero behavioral + // change vs the legacy path. Phase 1+ will swap in a SPICE-resolved + // implementation that watches node voltages and threshold-converts + // to logic states. + const simState = useSimulatorStore.getState(); + const ownerBoard = + simState.boards.find((b) => b.id === simState.activeBoardId) ?? null; + const ownerBoardVcc = + (ownerBoard && BOARD_PIN_GROUPS[ownerBoard.boardKind as keyof typeof BOARD_PIN_GROUPS]?.vcc) ?? 5; + const getPinResolver = (componentPinName: string): PinResolver | null => { + const state = useSimulatorStore.getState(); + const pinManager = (stubSimulator as { + pinManager?: { + onPinChange?: (pin: number, cb: (pin: number, state: boolean) => void) => () => void; + getPinState?: (pin: number) => boolean | null; + }; + }).pinManager; + return createDefaultPinResolver( + id, + componentPinName, + { + components: state.components, + boards: state.boards, + wires: state.wires, + ownerBoard, + ownerBoardVcc, + subscribeArduinoPin: (pin, cb) => { + if (!pinManager?.onPinChange) return () => {}; + return pinManager.onPinChange(pin, cb); + }, + readArduinoPin: (pin) => { + if (!pinManager?.getPinState) return null; + try { + return pinManager.getPinState(pin); + } catch { + return null; + } + }, + }, + getArduinoPin, + ); + }; + + cleanupSimulationEvents = logic.attachEvents( + el, + stubSimulator, + getArduinoPin, + id, + getPinResolver, + ); } return () => { diff --git a/frontend/src/simulation/PinResolver.ts b/frontend/src/simulation/PinResolver.ts new file mode 100644 index 00000000..cbfd6ca3 --- /dev/null +++ b/frontend/src/simulation/PinResolver.ts @@ -0,0 +1,210 @@ +/** + * 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; + +/** + * 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); + }); + }, + }; +} diff --git a/frontend/src/simulation/parts/BasicParts.ts b/frontend/src/simulation/parts/BasicParts.ts index 98cd50e3..348206d4 100644 --- a/frontend/src/simulation/parts/BasicParts.ts +++ b/frontend/src/simulation/parts/BasicParts.ts @@ -140,7 +140,7 @@ PartSimulationRegistry.register('dip-switch-8', { * LED stays off regardless of the anode state. */ PartSimulationRegistry.register('led', { - attachEvents: (element, simulator, getArduinoPinHelper, componentId) => { + attachEvents: (element, simulator, getArduinoPinHelper, componentId, getPinResolver) => { const pinManager = (simulator as any).pinManager; if (!pinManager) return () => {}; @@ -148,6 +148,17 @@ PartSimulationRegistry.register('led', { const unsubs: (() => void)[] = []; let anodeHigh = false; let cathodeLow = false; + + // Phase 0 of the mixed-mode simulator project — see + // ../../../project/sim-mixedmode/phase-00-pin-resolver.md in the + // velxio-prod repo. PinResolver replaces the direct + // pinManager.onPinChange + getArduinoPinHelper pattern. For the + // Phase-0 default impl it's functionally identical; Phase 1+ will + // swap in a SPICE-resolved impl that watches node voltages and + // threshold-converts to logic states. Falls back to the legacy + // path when getPinResolver isn't available (e.g. test harnesses + // that mock attachEvents with the old 4-arg signature). + const useResolver = typeof getPinResolver === 'function'; // Last known SPICE brightness + timestamp. When a solve hasn't // landed yet (engine warm-up, between-solve gap, ngspice iteration // holes), we hold this value for 500 ms before decaying to zero — @@ -206,27 +217,54 @@ PartSimulationRegistry.register('led', { el.brightness = el.value ? 1 : 0; }; - // Cathode pin: -1 means wired to GND (always LOW), >=0 means GPIO - const cathodePin = getArduinoPinHelper('C'); - if (cathodePin === -1) { - cathodeLow = true; - } else if (cathodePin !== null && cathodePin >= 0) { - unsubs.push( - pinManager.onPinChange(cathodePin, (_: number, state: boolean) => { - cathodeLow = !state; - update(); - }), - ); - } + // Cathode + anode pin subscriptions. PinResolver path is preferred + // (gets SPICE-aware behavior for free in later phases); legacy + // direct-pinManager path is kept for builds without Phase 0. + if (useResolver) { + const cathodeResolver = getPinResolver!('C'); + const anodeResolver = getPinResolver!('A'); + if (cathodeResolver) { + // Seed initial state. -1 (wired to GND) becomes 'LOW' via the + // resolver's GND special case, which sets cathodeLow = true. + cathodeLow = cathodeResolver.getCurrentState() === 'LOW'; + unsubs.push( + cathodeResolver.onChange((state) => { + cathodeLow = state === 'LOW'; + update(); + }), + ); + } + if (anodeResolver) { + anodeHigh = anodeResolver.getCurrentState() === 'HIGH'; + unsubs.push( + anodeResolver.onChange((state) => { + anodeHigh = state === 'HIGH'; + update(); + }), + ); + } + } else { + const cathodePin = getArduinoPinHelper('C'); + if (cathodePin === -1) { + cathodeLow = true; + } else if (cathodePin !== null && cathodePin >= 0) { + unsubs.push( + pinManager.onPinChange(cathodePin, (_: number, state: boolean) => { + cathodeLow = !state; + update(); + }), + ); + } - const anodePin = getArduinoPinHelper('A'); - if (anodePin !== null && anodePin >= 0) { - unsubs.push( - pinManager.onPinChange(anodePin, (_: number, state: boolean) => { - anodeHigh = state; - update(); - }), - ); + const anodePin = getArduinoPinHelper('A'); + if (anodePin !== null && anodePin >= 0) { + unsubs.push( + pinManager.onPinChange(anodePin, (_: number, state: boolean) => { + anodeHigh = state; + update(); + }), + ); + } } // Also subscribe to electrical store changes to update brightness diff --git a/frontend/src/simulation/parts/PartSimulationRegistry.ts b/frontend/src/simulation/parts/PartSimulationRegistry.ts index 0663b9ad..53bf1d30 100644 --- a/frontend/src/simulation/parts/PartSimulationRegistry.ts +++ b/frontend/src/simulation/parts/PartSimulationRegistry.ts @@ -1,5 +1,6 @@ import { AVRSimulator } from '../AVRSimulator'; import { RP2040Simulator } from '../RP2040Simulator'; +import type { PinResolver } from '../PinResolver'; /** Any simulator that components can interact with (AVR, RP2040, or ESP32 bridge shim). */ export type AnySimulator = @@ -31,10 +32,18 @@ export interface PartSimulationLogic { * Called when the simulation starts to attach events or setup periodic tasks. * Useful for input components (buttons, potentiometers) or complex components (servos). * + * The 5th parameter `getPinResolver` is the recommended entry point for + * new code — it returns a PinResolver that hides whether the underlying + * source is the digital PinManager (Phase 0) or a SPICE-resolved net + * voltage with threshold conversion (Phase 1+). Existing handlers can + * keep using `getArduinoPinHelper` + `pinManager.onPinChange` directly; + * the migration happens incrementally per-component in Phase 5. + * * @param element The DOM element of the wokwi component * @param avrSimulator The running simulator instance - * @param getArduinoPinHelper Function to find what Arduino pin is connected to a specific component pin - * @param componentId The unique ID of this component instance (used by SensorUpdateRegistry) + * @param getArduinoPinHelper Legacy: returns the Arduino pin number controlling a component pin + * @param componentId The unique ID of this component instance + * @param getPinResolver Preferred: returns a PinResolver for a component pin * @returns A cleanup function to remove event listeners when simulation stops */ attachEvents?: ( @@ -42,6 +51,7 @@ export interface PartSimulationLogic { simulator: AnySimulator, getArduinoPinHelper: (componentPinName: string) => number | null, componentId: string, + getPinResolver?: (componentPinName: string) => PinResolver | null, ) => () => void; }