/** * PinManager - Manages Arduino pin states and notifies listeners * * Maps AVR PORT registers to Arduino pin numbers. * * Arduino Uno / Nano (ATmega328P): * - PORTB (0x25) → Digital pins 8-13 * - PORTC (0x28) → Analog pins A0-A5 (14-19) * - PORTD (0x2B) → Digital pins 0-7 * * Arduino Mega 2560 (ATmega2560): uses explicit per-bit pin maps * for non-linear port ↔ Arduino-pin relationships. * * Also supports: * - Analog voltage injection (for potentiometers, sensors) * - PWM duty cycle tracking (for servos, RGB LEDs, buzzers) */ import { requestElectricalResolve } from './spice/electricalResolveHook'; export type PinState = boolean; export type PinChangeCallback = (pin: number, state: PinState) => void; export type AnalogCallback = (pin: number, voltage: number) => void; // timeMs (optional) is the precise simulated time of the duty-cycle change // (cpu.cycles / 16000). Parts that schedule audio/output use it for // sample-accurate timing instead of the per-frame delivery instant. export type PwmCallback = (pin: number, dutyCycle: number, timeMs?: number) => void; export class PinManager { private listeners: Map> = new Map(); private pwmListeners: Map> = new Map(); private analogListeners: Map> = new Map(); private pinStates: Map = new Map(); private pwmValues: Map = new Map(); // Pins the MCU has driven (digitalWrite / PWM / port-listener fire). // Consumed by collectPinStates.ts to emit a SPICE V-source only for // real outputs — leaving INPUT pins floating so external sensors // (NTC + divider on A0, photoresistor, etc.) don't get clamped to // the MCU's idle V-source. private outputPins: Set = new Set(); // Internal pull config the MCU programmed per pin: 0=none, 1=up, 2=down. // Used by the SPICE collector to add a weak pull resistor so INPUT_PULLUP // inputs read the right idle level (the ESP32's internal pulls live inside // QEMU and are otherwise invisible to the netlist). private pinPulls: Map = new Map(); // ── Digital pin API ────────────────────────────────────────────────────── /** * Register callback for digital pin state changes. * Returns unsubscribe function. */ onPinChange(arduinoPin: number, callback: PinChangeCallback): () => void { if (!this.listeners.has(arduinoPin)) { this.listeners.set(arduinoPin, new Set()); } this.listeners.get(arduinoPin)!.add(callback); return () => { this.listeners.get(arduinoPin)?.delete(callback); }; } /** * Update port register and notify digital pin listeners. * * @param portName Human-readable port name for log output (e.g. 'PORTB'). * @param newValue New 8-bit port value. * @param oldValue Previous 8-bit port value (default 0). * @param pinMap Optional per-bit Arduino pin numbers (length 8). * Use -1 for bits that are not exposed as Arduino pins. * When omitted the legacy Uno/Nano fixed offsets are used: * PORTB→8, PORTC→14, PORTD→0. * @param ddrMask Optional DDR register value (8 bits). When provided, * a pin is added to `outputPins` only if its DDR bit is * 1 (the AVR is actively driving it as OUTPUT). Without * this guard, the PORTx write that activates INPUT_PULLUP * (DDR=0, PORT=1) falsely marks the pin as MCU output * and emits an ideal V-source on the SPICE side, fighting * the real external circuit (button, sensor, pull-down). */ updatePort( portName: string, newValue: number, oldValue: number = 0, pinMap?: number[], ddrMask?: number, ) { const legacyOffsets: Record = { PORTB: 8, PORTC: 14, PORTD: 0 }; // AVR internal pull-up: a pin configured as INPUT (DDR bit 0) with its PORT // bit set enables the ~35k internal pull-up. Surface it as a pin pull so the // SPICE netlist stamps the pull resistor and an INPUT_PULLUP input reads the // correct idle level (HIGH) under spice-driven inputs — without this, the // canonical button-to-GND would float LOW. AVR has no internal pull-down. // Runs over all 8 bits (not just changed ones) so DDR/PORT edits both apply. if (ddrMask !== undefined) { for (let bit = 0; bit < 8; bit++) { const mask = 1 << bit; const arduinoPin = pinMap ? pinMap[bit] : (legacyOffsets[portName] ?? 0) + bit; if (arduinoPin < 0) continue; const isInput = (ddrMask & mask) === 0; this.setPinPull(arduinoPin, isInput && (newValue & mask) !== 0 ? 1 : 0); } } for (let bit = 0; bit < 8; bit++) { const mask = 1 << bit; const oldState = (oldValue & mask) !== 0; const newState = (newValue & mask) !== 0; if (oldState !== newState) { const arduinoPin = pinMap ? pinMap[bit] : (legacyOffsets[portName] ?? 0) + bit; if (arduinoPin < 0) continue; // unmapped bit this.pinStates.set(arduinoPin, newState); // Only mark as MCU-output if DDR bit is set (or DDR unknown → legacy). if (ddrMask === undefined || (ddrMask & mask) !== 0) { this.outputPins.add(arduinoPin); } const callbacks = this.listeners.get(arduinoPin); if (callbacks) { callbacks.forEach((cb) => cb(arduinoPin, newState)); } } } } getPinState(arduinoPin: number): boolean { return this.pinStates.get(arduinoPin) || false; } /** * Set a single pin state and notify listeners. * Alias for triggerPinChange — used by ESP32-C3, RISC-V, and RP2040 simulators. * * `source` distinguishes MCU GPIO writes (mark pin as output for SPICE) * from external actors like buttons or sensor parts (don't mark). */ setPinState(pin: number, state: boolean, source: 'mcu' | 'external' = 'external'): void { this.triggerPinChange(pin, state, source); } /** * Directly fire pin change callbacks for a specific pin. * Used by RP2040Simulator which has individual GPIO listeners instead of PORT registers. */ triggerPinChange(pin: number, state: boolean, source: 'mcu' | 'external' = 'external'): void { // A full-netlist re-solve is only needed when this edge RE-CLASSIFIES the // pin (first MCU write → the netlist must grow a V-source for it). Once // the pin is a known output, per-edge voltage updates flow through // connectMcuEdgesToService (per-pin coalesced alterSource — no rebuild). // Requesting a full tick on EVERY edge froze the browser on multiplexed // circuits: a 7-segment clock over QEMU emits thousands of GPIO edges per // second, and back-to-back rebuild+solve+publish cycles starved the main // thread until the sim WebSocket timed out. const newlyClassified = source === 'mcu' && !this.outputPins.has(pin); const current = this.pinStates.get(pin); if (current === state) { if (source === 'mcu') this.outputPins.add(pin); if (newlyClassified) requestElectricalResolve(); return; } this.pinStates.set(pin, state); if (source === 'mcu') this.outputPins.add(pin); const callbacks = this.listeners.get(pin); if (callbacks) { callbacks.forEach((cb) => cb(pin, state)); } // WS-backed boards (ESP32 / STM32 / Raspberry Pi) reach the electrical sim // ONLY through here; the first write per pin triggers the rebuild that // emits its V-source, after which connectMcuEdgesToService owns updates. // Gated to 'mcu' so the solver's own input feedback (source 'external') // can't create a solve loop. if (newlyClassified) requestElectricalResolve(); } /** Pins the MCU has actively driven this session. */ getOutputPins(): ReadonlySet { return this.outputPins; } /** * Record the internal pull the MCU programmed for a pin (from the guest's * IO_MUX / pad config): 0 = none, 1 = pull-up, 2 = pull-down. The SPICE * collector reads this back via `getPinPull` to stamp a weak resistor. */ /** Fired on pull-state TRANSITIONS (not repeats). Simulators use it to * seed the pin input to the pull's resting level the moment the firmware * enables it — closing the boot window where INPUT_PULLUP read LOW until * the first SPICE solve (~400 ms): 8/8 setup() reads plus the first two * loop() passes returned 0 in the deterministic repro, which is exactly * the phantom emergency-stop latch of the 2026-07 audit. */ onPullChange: ((pin: number, pull: 0 | 1 | 2) => void) | null = null; setPinPull(pin: number, pull: 0 | 1 | 2): void { const prev = this.pinPulls.get(pin) ?? 0; if (pull === 0) this.pinPulls.delete(pin); else this.pinPulls.set(pin, pull); if (prev !== pull) this.onPullChange?.(pin, pull); } /** Internal pull config for a pin: 0 = none, 1 = pull-up, 2 = pull-down. */ getPinPull(pin: number): 0 | 1 | 2 { return this.pinPulls.get(pin) ?? 0; } /** * Drop only the MCU-output classification (SPICE side). Used by * paths that need to forget which pins were driven this session * without disturbing the cached pin states or notifying listeners. * For the user-facing Stop / Reset / firmware-reload flows use * `hardResetPinStates` — those are cold boots and the next Run * must start from setup() with every visual cleared. */ resetPinStates(): void { this.outputPins.clear(); } /** * Hard reset for resetBoard / firmware reload: wipe every cached * state AND notify listeners that previously-HIGH pins are now LOW, * so stateful displays redraw cleanly to all-off. Reset implies the * MCU is restarting from 0 — there's no "resume" race to worry * about; the firmware will re-drive every pin from setup() once it * boots. */ hardResetPinStates(): void { const wereHigh: number[] = []; for (const [pin, state] of this.pinStates) { if (state) wereHigh.push(pin); } this.pinStates.clear(); this.outputPins.clear(); this.pinPulls.clear(); for (const pin of wereHigh) { const callbacks = this.listeners.get(pin); if (callbacks) { callbacks.forEach((cb) => cb(pin, false)); } } } // ── PWM duty cycle API ─────────────────────────────────────────────────── /** * Register callback for PWM duty cycle changes on a pin. * dutyCycle is 0.0–1.0. */ onPwmChange(pin: number, callback: PwmCallback): () => void { if (!this.pwmListeners.has(pin)) { this.pwmListeners.set(pin, new Set()); } this.pwmListeners.get(pin)!.add(callback); return () => { this.pwmListeners.get(pin)?.delete(callback); }; } /** * Called by AVRSimulator when an OCR register changes (polled sub-frame). * timeMs is the precise simulated time of the change for accurate audio. */ updatePwm(pin: number, dutyCycle: number, timeMs?: number): void { this.pwmValues.set(pin, dutyCycle); if (dutyCycle > 0) this.outputPins.add(pin); const callbacks = this.pwmListeners.get(pin); if (callbacks) { // Backward-compatible dispatch: the original PwmCallback contract is // (pin, dutyCycle). Only listeners that actually declare a 3rd parameter // (the buzzer, which needs the precise onset time for sample-accurate // audio) receive timeMs. Plain 2-arg listeners — and the existing tests // that assert toHaveBeenCalledWith(pin, dutyCycle) — see an unchanged // 2-arg call instead of a spurious trailing arg. callbacks.forEach((cb) => (cb.length >= 3 ? cb(pin, dutyCycle, timeMs) : cb(pin, dutyCycle))); } } getPwmValue(pin: number): number { return this.pwmValues.get(pin) ?? 0; } // ── Analog voltage API ─────────────────────────────────────────────────── /** * Register callback when external code sets an analog voltage on a pin. */ onAnalogChange(pin: number, callback: AnalogCallback): () => void { if (!this.analogListeners.has(pin)) { this.analogListeners.set(pin, new Set()); } this.analogListeners.get(pin)!.add(callback); return () => { this.analogListeners.get(pin)?.delete(callback); }; } /** * Inject a simulated analog voltage (0–5V) on an Arduino pin. * Notifies any registered analog listeners. */ setAnalogVoltage(arduinoPin: number, voltage: number): void { const callbacks = this.analogListeners.get(arduinoPin); if (callbacks) { callbacks.forEach((cb) => cb(arduinoPin, voltage)); } } // ── Utility ────────────────────────────────────────────────────────────── getListenersCount(): number { let count = 0; this.listeners.forEach((set) => (count += set.size)); return count; } clearAllListeners() { this.listeners.clear(); this.pwmListeners.clear(); this.analogListeners.clear(); this.outputPins.clear(); } }