feat(sim): introduce PinResolver abstraction (Phase 0 of mixed-mode rewrite)

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.
This commit is contained in:
davidmonterocrespo24 2026-05-15 15:50:09 +02:00
parent a91b857d8c
commit e10492c6e6
5 changed files with 475 additions and 24 deletions

View File

@ -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> = {}): 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);
});
});

View File

@ -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<DynamicComponentProps> = ({
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 () => {

View File

@ -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);
});
},
};
}

View File

@ -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

View File

@ -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;
}