feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection

Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine.  Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.

What ships:

  - simulation/PinResolver.ts
    * `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
      list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
    * `DetailedPinTrace` / `DetailedPinTracer` types — the trace
      function now reports whether it crossed an active device, on
      top of the Arduino pin number.
    * `createSpiceResolvedPinResolver()` — new factory; reads voltages
      from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
      with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).

  - simulation/spice/MixedModeScheduler.ts (new)
    * Singleton orchestrator that holds the NgSpiceInteractive engine
      and the SpiceVoltageSource subscription registry.
    * `start()` / `stop()` / `dispose()` lifecycle.
    * `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
    * `onMcuPinChange()` placeholder for the alter+tran event loop.
    * Skeleton: subscribers register but never receive events yet.
      Phase 1b continued will wire NgSpiceInteractive into the loop.

  - components/DynamicComponent.tsx
    * Trace function extended with `traceDetailed()` that tracks
      whether the BFS crossed an active component.
    * PinResolver factory branches: active-path → SPICE-resolved (uses
      the scheduler), digital-only → existing default impl.  Default
      threshold = vcc/2 with no hysteresis; Phase 3 will replace with
      per-logic-family Vil/Vih.

Phase 0 LED behavior intact (digital path).  Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.

Tests:
  - pin-resolver-phase1b.test.ts (new) — 8/8 passing.
    Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
    metadata id; SPICE-resolved resolver state reporting, threshold
    conversion, hysteresis dead-band, unsubscribe.
  - pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
  - tsc --noEmit on the new files: clean.

No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
This commit is contained in:
davidmonterocrespo24 2026-05-15 16:38:30 +02:00
parent d41be4f48b
commit 27c59664cd
4 changed files with 564 additions and 8 deletions

View File

@ -0,0 +1,176 @@
/**
* Phase 1b unit tests for PinResolver:
* - isActiveDevice predicate against the full active-device list
* - createSpiceResolvedPinResolver threshold conversion behavior
* - SpiceVoltageSource subscription wiring
*
* Doesn't exercise actual SPICE because Phase 1b is the skeleton.
* Phase 1b continued will add tests against MixedModeScheduler with a
* real (mocked) NgSpiceInteractive.
*/
import { describe, it, expect, vi } from 'vitest';
import {
isActiveDevice,
createSpiceResolvedPinResolver,
type SpiceVoltageSource,
} from '../simulation/PinResolver';
function mockSource(opts: { initialVoltage?: number | null } = {}): {
source: SpiceVoltageSource;
fire: (state: 'HIGH' | 'LOW' | 'FLOATING' | 'CONFLICT', voltage: number) => void;
subscribers: Array<{ componentId: string; pinName: string; cb: (state: string, v: number) => void }>;
} {
const subscribers: Array<{ componentId: string; pinName: string; cb: (state: string, v: number) => void }> = [];
let voltage = opts.initialVoltage ?? null;
const source: SpiceVoltageSource = {
subscribe(componentId, componentPinName, cb) {
const entry = { componentId, pinName: componentPinName, cb: cb as (s: string, v: number) => void };
subscribers.push(entry);
return () => {
const idx = subscribers.indexOf(entry);
if (idx >= 0) subscribers.splice(idx, 1);
};
},
getCurrentVoltage() {
return voltage;
},
};
return {
source,
subscribers,
fire(state, v) {
voltage = v;
for (const s of subscribers) s.cb(state, v);
},
};
}
describe('isActiveDevice', () => {
it('reports true for every BJT/MOSFET/op-amp/diode part-number', () => {
expect(isActiveDevice('bjt-2n2222')).toBe(true);
expect(isActiveDevice('bjt-bc547')).toBe(true);
expect(isActiveDevice('mosfet-2n7000')).toBe(true);
expect(isActiveDevice('mosfet-irf540')).toBe(true);
expect(isActiveDevice('opamp-lm358')).toBe(true);
expect(isActiveDevice('opamp-ideal')).toBe(true);
expect(isActiveDevice('diode-1n4148')).toBe(true);
expect(isActiveDevice('led')).toBe(true);
expect(isActiveDevice('relay')).toBe(true);
expect(isActiveDevice('7805')).toBe(true);
expect(isActiveDevice('lm317')).toBe(true);
});
it('reports false for passives and digital ICs', () => {
expect(isActiveDevice('resistor')).toBe(false);
expect(isActiveDevice('resistor-220')).toBe(false);
expect(isActiveDevice('capacitor')).toBe(false);
expect(isActiveDevice('cap-1u')).toBe(false);
expect(isActiveDevice('inductor')).toBe(false);
// Digital ICs are NOT routed through SPICE — they have their own
// pre-decoded handlers
expect(isActiveDevice('ic-74hc14')).toBe(false);
expect(isActiveDevice('74hc595')).toBe(false);
});
});
describe('createSpiceResolvedPinResolver', () => {
it('reports FLOATING when the voltage source has no value yet', () => {
const { source } = mockSource({ initialVoltage: null });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
expect(r.getCurrentState()).toBe('FLOATING');
expect(r.getCurrentVoltage()).toBeNull();
});
it('reports HIGH when voltage is above thresholdHigh', () => {
const { source } = mockSource({ initialVoltage: 4.5 });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
expect(r.getCurrentState()).toBe('HIGH');
expect(r.getCurrentVoltage()).toBeCloseTo(4.5);
});
it('reports LOW when voltage is below thresholdLow', () => {
const { source } = mockSource({ initialVoltage: 0.3 });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
expect(r.getCurrentState()).toBe('LOW');
expect(r.getCurrentVoltage()).toBeCloseTo(0.3);
});
it('emits state changes via onChange when the voltage crosses a threshold', () => {
const { source, fire } = mockSource({ initialVoltage: 0 });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
const cb = vi.fn();
r.onChange(cb);
fire('LOW', 0.5);
expect(cb).not.toHaveBeenCalled(); // still LOW, no transition
fire('HIGH', 4.5);
expect(cb).toHaveBeenCalledWith('HIGH', 4.5);
fire('HIGH', 3.0);
expect(cb).toHaveBeenCalledTimes(1); // already HIGH, no new transition
fire('LOW', 0.5);
expect(cb).toHaveBeenCalledTimes(2);
expect(cb).toHaveBeenLastCalledWith('LOW', 0.5);
});
it('respects the hysteresis dead band (different thresholds)', () => {
const { source, fire } = mockSource({ initialVoltage: 0 });
const r = createSpiceResolvedPinResolver('schmitt-1', 'A', source, {
thresholdHigh: 3.0,
thresholdLow: 1.5,
vcc: 5,
});
const cb = vi.fn();
r.onChange(cb);
fire('LOW', 2.0); // in the dead band — stays LOW
expect(cb).not.toHaveBeenCalled();
fire('HIGH', 3.5); // crosses upper threshold → HIGH
expect(cb).toHaveBeenCalledWith('HIGH', 3.5);
cb.mockClear();
fire('HIGH', 2.0); // back in dead band — stays HIGH (hysteresis)
expect(cb).not.toHaveBeenCalled();
fire('LOW', 1.0); // crosses lower threshold → LOW
expect(cb).toHaveBeenCalledWith('LOW', 1.0);
});
it('returns an unsubscribe handle that detaches from the source', () => {
const { source, subscribers, fire } = mockSource({ initialVoltage: 0 });
const r = createSpiceResolvedPinResolver('bjt-1', 'C', source, {
thresholdHigh: 2.5,
thresholdLow: 2.5,
vcc: 5,
});
const cb = vi.fn();
const cancel = r.onChange(cb);
expect(subscribers.length).toBe(1);
cancel();
expect(subscribers.length).toBe(0);
fire('HIGH', 4.5);
expect(cb).not.toHaveBeenCalled();
});
});

View File

@ -17,8 +17,14 @@ 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 {
createDefaultPinResolver,
createSpiceResolvedPinResolver,
isActiveDevice,
type PinResolver,
} from '../simulation/PinResolver';
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
// Side-effect imports: register every web component we'll create at runtime.
// `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds
@ -322,8 +328,23 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
// Depth-limited BFS: trace from (fromId, fromPin) through wires,
// traversing through passive components to reach a board pin.
//
// Phase 1b: the legacy `trace()` returns just the pin number
// (backward compat); a sibling `traceDetailed()` returns the
// same pin plus a `crossedActiveDevice` flag so the resolver
// factory can decide between digital fast-path and SPICE-
// resolved per-pin.
const trace = (fromId: string, fromPin: string, depth: number): number | null => {
if (depth > 6) return null;
return traceDetailed(fromId, fromPin, depth).arduinoPin;
};
const traceDetailed = (
fromId: string,
fromPin: string,
depth: number,
activeSeen = false,
): { arduinoPin: number | null; crossedActiveDevice: boolean } => {
if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen };
const wires = state.wires.filter(
(w) =>
@ -337,25 +358,30 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
const otherEp = selfEp === w.start ? w.end : w.start;
if (isBoardComponent(otherEp.componentId)) {
// Direct board connection
const boardKind =
state.boards.find((b) => b.id === otherEp.componentId)?.boardKind ??
otherEp.componentId;
const pin = boardPinToNumber(boardKind, otherEp.pinName);
if (pin !== null) return pin;
if (pin !== null) return { arduinoPin: pin, crossedActiveDevice: activeSeen };
} else {
// Intermediate passive component — traverse through it
const comp = state.components.find((c) => c.id === otherEp.componentId);
const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId];
if (pair) {
const [p1, p2] = pair;
const otherPin = otherEp.pinName === p1 ? p2 : p1;
const result = trace(otherEp.componentId, otherPin, depth + 1);
if (result !== null) return result;
const nowActive =
activeSeen || (comp ? isActiveDevice(comp.metadataId) : false);
const result = traceDetailed(
otherEp.componentId,
otherPin,
depth + 1,
nowActive,
);
if (result.arduinoPin !== null) return result;
}
}
}
return null;
return { arduinoPin: null, crossedActiveDevice: activeSeen };
};
return trace(id, componentPinName, 0);
@ -380,6 +406,27 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
getPinState?: (pin: number) => boolean | null;
};
}).pinManager;
// Phase 1b: detect whether the path between this component pin and
// an Arduino pin passes through any active device (BJT, MOSFET,
// op-amp, diode, regulator). If yes → use the SPICE-resolved
// resolver flavor so the digital state is derived from real node
// voltages (handles transistor inversion, op-amp gain, diode
// forward-drop, etc.). If no → use the legacy digital fast-path
// (zero SPICE cost, identical to Phase 0 behavior).
const detailed = traceDetailed(id, componentPinName, 0);
if (detailed.crossedActiveDevice) {
const scheduler = getMixedModeScheduler();
// Threshold = vcc/2 with no hysteresis. Phase 3 will replace
// this with per-logic-family Vil/Vih + Schmitt.
const half = ownerBoardVcc / 2;
return createSpiceResolvedPinResolver(id, componentPinName, scheduler, {
thresholdHigh: half,
thresholdLow: half,
vcc: ownerBoardVcc,
});
}
return createDefaultPinResolver(
id,
componentPinName,

View File

@ -121,6 +121,55 @@ export type PinTracer = (
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.
*
@ -208,3 +257,94 @@ export function createDefaultPinResolver(
},
};
}
// ── 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 {
/** Vcc/2 by default — the threshold above which a voltage reads HIGH.
* Phase 3 will replace this with per-logic-family thresholds. */
thresholdHigh: number;
/** Below this voltage reads LOW. Hysteresis between low/high prevents
* oscillation; Phase 3 will refine per Schmitt-trigger inputs. */
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;
}
/**
* 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);
}
});
},
};
}

View File

@ -0,0 +1,193 @@
/**
* MixedModeScheduler — orchestrates the digital ↔ SPICE coupling for
* Phase 1b of the mixed-mode simulator project.
*
* Architecture in three layers:
*
* ┌────────────────────────────┐
* │ MCU sim (AVR / RP2040 / │ fires PinManager.onPinChange()
* │ ESP32 bridge) │ events on every digitalWrite()
* └─────────────┬──────────────┘
* │ pin edge
* ▼
* ┌────────────────────────────┐
* │ MixedModeScheduler │ batches edges, builds netlist via
* │ • alter V_pin sources │ NetlistBuilder, drives ngspice via
* │ • short tran advance │ NgSpiceInteractive
* │ • read v(node) for each │
* │ component pin │
* └─────────────┬──────────────┘
* │ node voltage event
* ▼
* ┌────────────────────────────┐
* │ SpiceResolvedPinResolver │ threshold-converts v → HIGH/LOW,
* │ │ fires component handler callback
* └────────────────────────────┘
*
* Phase 1a vendored NgSpiceInteractive and the WASM build. This file is
* the Phase 1b skeleton — the API and lifecycle are in place, but the
* actual `alter + tran + readVec` loop is marked TODO because
* (a) the WASM is single-threaded, so `bg_run` is not useful and we
* need the short-tran workaround, and
* (b) the netlist build flow needs to be re-wired from the existing
* 200 ms polling in `subscribeToStore.ts` to event-driven.
*
* For now, the scheduler exposes the API surface that component
* handlers and DynamicComponent will use, plus a `start()` /
* `stop()` lifecycle controlled by `useSimulatorStore.boards[*].running`.
* When `start()` is called the scheduler logs "started" and components
* subscribing to it get FLOATING resolutions — i.e. behavior
* indistinguishable from "SPICE not available". Phase 1b's next
* sub-task replaces the stub data flow with real readVec calls.
*
* See:
* project/sim-mixedmode/phase-01-mixed-mode-coupling.md
* simulation/spice/wasm/NgSpiceInteractive.ts
*/
import { NgSpiceInteractive } from './wasm/NgSpiceInteractive';
import type { PinState, SpiceVoltageSource } from '../PinResolver';
/**
* Identity of a "pin of interest" — a place a SpiceResolvedPinResolver
* is watching for voltage changes. The (boardId, pinName) → SPICE-net
* mapping is built lazily as components register.
*/
export interface NodeSubscription {
componentId: string;
componentPinName: string;
cb: (state: PinState, voltage: number) => void;
}
type SubscriptionToken = number;
/**
* Singleton-style scheduler. Multiple components use the same SPICE
* engine instance; there's no value in running parallel solvers.
*
* Phase 1b: the scheduler holds the engine + the subscription registry
* but does NOT yet drive real SPICE solves on pin edges. Phase 1b
* continued: implement the alter+tran+readVec loop, hook NetlistBuilder.
*/
class MixedModeSchedulerImpl implements SpiceVoltageSource {
private engine: NgSpiceInteractive | null = null;
private nextToken: SubscriptionToken = 1;
private subscriptions = new Map<SubscriptionToken, NodeSubscription>();
private running = false;
private initPromise: Promise<void> | null = null;
/** True while the scheduler is actively driving the SPICE engine. */
isRunning(): boolean {
return this.running;
}
/**
* Start the scheduler. Lazy-loads the WASM engine on first call. No-op
* if already running. Called from `useSimulatorStore` when any board
* transitions to running.
*/
async start(): Promise<void> {
if (this.running) return;
if (!this.engine) {
this.engine = new NgSpiceInteractive();
}
if (!this.initPromise) {
this.initPromise = this.engine.init();
}
await this.initPromise;
this.running = true;
// TODO Phase 1b — wire up the alter+tran+readVec loop here.
// Build initial netlist via NetlistBuilder, send to engine via
// loadNetlist, then enter the event-driven update cycle.
}
/**
* Stop the scheduler. Components stay subscribed but stop receiving
* SPICE-resolved events until the next start().
*/
stop(): void {
if (!this.running) return;
this.running = false;
// TODO Phase 1b — pause the SPICE driver loop. Engine instance is
// intentionally kept warm so restart is cheap; dispose only on
// unmount or shutdown.
}
/**
* Tear down the engine entirely. Used on app unmount; in normal flow
* we just stop() + start() to avoid re-paying the ~2-5 s WASM init
* cost.
*/
dispose(): void {
this.running = false;
if (this.engine) {
this.engine.dispose();
this.engine = null;
}
this.initPromise = null;
this.subscriptions.clear();
}
/**
* Register a component pin to receive SPICE-resolved voltage events.
* Implements the SpiceVoltageSource contract used by
* `createSpiceResolvedPinResolver`. Returns an unsubscribe handle.
*
* Phase 1b: stub — no events ever fire. The caller's resolver will
* report whatever its fallback state is (typically FLOATING) and
* never transition. Phase 1b continued: actually emit events when
* SPICE solves complete.
*/
subscribe(
componentId: string,
componentPinName: string,
cb: (state: PinState, voltage: number) => void,
): () => void {
const token = this.nextToken++;
this.subscriptions.set(token, { componentId, componentPinName, cb });
return () => {
this.subscriptions.delete(token);
};
}
/**
* Look up the latest known voltage on a component pin's SPICE net.
* Phase 1b skeleton: always returns null (we haven't started solving
* yet). Phase 1b continued: query the NgSpiceInteractive engine's
* last `readVec` cache and return the latest sample.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getCurrentVoltage(_componentId: string, _componentPinName: string): number | null {
return null;
}
/**
* Notify the scheduler that an MCU pin changed state. Phase 1b will
* translate this into `alter V_<board>_<pin> dc <value>` + a short
* `tran` step, then read affected nodes and dispatch to subscribers.
*
* Phase 1b skeleton: no-op. Component handlers continue to receive
* events from the legacy PinManager path until Phase 1b continued.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onMcuPinChange(_boardId: string, _pinName: string, _state: boolean, _vcc: number): void {
// intentionally empty
}
}
/** The one and only scheduler. Lazily constructed. */
let instance: MixedModeSchedulerImpl | null = null;
export function getMixedModeScheduler(): MixedModeSchedulerImpl {
if (!instance) instance = new MixedModeSchedulerImpl();
return instance;
}
/** Test helper — drops the singleton so test runs don't pollute each
* other. NEVER call from production code. */
export function __resetMixedModeScheduler(): void {
if (instance) instance.dispose();
instance = null;
}
export type MixedModeScheduler = MixedModeSchedulerImpl;