velxio/frontend/src/simulation/PinManager.ts

314 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<number, Set<PinChangeCallback>> = new Map();
private pwmListeners: Map<number, Set<PwmCallback>> = new Map();
private analogListeners: Map<number, Set<AnalogCallback>> = new Map();
private pinStates: Map<number, boolean> = new Map();
private pwmValues: Map<number, number> = 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<number> = 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<number, 0 | 1 | 2> = 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<string, number> = { 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<number> {
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.
*/
setPinPull(pin: number, pull: 0 | 1 | 2): void {
if (pull === 0) this.pinPulls.delete(pin);
else this.pinPulls.set(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.01.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 (05V) 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();
}
}