velxio/frontend/src/simulation/PinManager.ts

214 lines
7.2 KiB
TypeScript
Raw Normal View History

/**
* 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)
*/
export type PinState = boolean;
export type PinChangeCallback = (pin: number, state: PinState) => void;
export type AnalogCallback = (pin: number, voltage: number) => void;
export type PwmCallback = (pin: number, dutyCycle: 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();
// ── 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:
* PORTB8, PORTC14, PORTD0.
*/
updatePort(portName: string, newValue: number, oldValue: number = 0, pinMap?: number[]) {
const legacyOffsets: Record<string, number> = { PORTB: 8, PORTC: 14, PORTD: 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);
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.
*/
setPinState(pin: number, state: boolean): void {
this.triggerPinChange(pin, state);
}
/**
* 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): void {
const current = this.pinStates.get(pin);
if (current === state) return; // no change
this.pinStates.set(pin, state);
const callbacks = this.listeners.get(pin);
if (callbacks) {
callbacks.forEach((cb) => cb(pin, state));
}
}
feat(multi-board): add wire-aware cross-board interconnect router Fixes the user-reported bug where two RPi Pico W boards wired GP0↔GP1 running SerialPassthrough don't communicate. Replaces the broken broadcast-style cross-board logic in addBoard (only routed AVR↔Pi3B, ignored wires entirely, no RP2040↔anything path) with a wire-aware Interconnect singleton. Architecture: digital pin transitions are the lowest-common-denominator abstraction. Each simulator's hardware peripherals (UART/I2C/SPI) and bit-banging libraries (SoftwareSerial, software I2C) decode the transitions naturally — propagate the pin and the protocols come for free. For cross-process boards (ESP32 backend QEMU, Pi3B QEMU) a byte-level shortcut is additionally enabled on hardware-UART pin pairs to handle high-baud links over WebSocket latency. Implementation: - New simulation/Interconnect.ts singleton subscribes to wire/board changes via the Zustand store. Handlers per tier: browser-sim → pinManager.onPinChange, ESP32 → Esp32Bridge.sendPinEvent, Pi3B → bridge.sendPinEvent. Re-entrancy guard via per-(board,pin) Set. - New utils/boardProtocols.ts classifies pins (uart-tx, i2c-sda, etc.) per board kind, used as optimization hint for the byte shortcut. - types/wire.ts: added signalType field, exports WireSignalType / WireColorMap (fixes a pre-existing TS import error in wireColors). - Deleted the bridgeMap/simulatorMap broadcast forEach blocks in addBoard. Initial board + future boards register with Interconnect via setInterconnectRuntime + store subscription. - PinManager.resetPinStates() helper for test isolation. Tests (16 new files, 96 tests, all passing): - Per-pair × per-protocol matrix: dual-arduino-digital, dual-pico-digital, arduino-pico-digital, triple-pico-digital-chain, dual-arduino-hw-uart, dual-arduino-software-serial, arduino-pico-mixed-uart, arduino-esp32-uart, dual-esp32-uart, pi3-pico-uart, arduino-pico-i2c, arduino-arduino-spi, interconnect-routing, dual-arduino-multi-protocol (UART+I2C+SPI+ digital + concurrent), dual-pico-multi-protocol (UART0+UART1 alt+ I2C0+I2C1+SPI0+digital + 3-Pico star topology) - Updated dual-pico-serial-passthrough to assert correct behaviour - Backend test/multi_board_esp32/test_dual_esp32_serial.py for two real QEMU instances (skip-graceful when lcgamboa lib absent) Verified: 1107/1107 tests pass, zero regressions, vite build OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 05:47:28 +07:00
/**
* Clear cached pin states (without removing listeners).
* Useful in tests so that a fresh `triggerPinChange` after a reset
* isn't suppressed by the same-state early-return.
*/
resetPinStates(): void {
this.pinStates.clear();
}
// ── 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 each frame when an OCR register changes.
*/
updatePwm(pin: number, dutyCycle: number): void {
this.pwmValues.set(pin, dutyCycle);
const callbacks = this.pwmListeners.get(pin);
if (callbacks) {
callbacks.forEach((cb) => cb(pin, dutyCycle));
}
}
/**
* Broadcast PWM duty to ALL registered PWM listeners.
* Used when the LEDC channelGPIO mapping is unknown (gpio=-1).
* Components filter by duty range (e.g., servo accepts 0.01-0.20).
*/
broadcastPwm(dutyCycle: number): void {
this.pwmListeners.forEach((callbacks, pin) => {
this.pwmValues.set(pin, dutyCycle);
callbacks.forEach((cb) => cb(pin, dutyCycle));
});
}
fix(esp32): multi-servo blink — don't broadcast LEDC duty across consumers User-reported bug (project 5218f9e3, solar-tracker with 2× ESP32 servos): when LDR values change the servos visibly oscillate between two positions instead of moving smoothly. Root cause in useSimulatorStore.makeLedcUpdateHandler. When the backend emits a ledc_update with gpio=-1 (the per-channel gpio_out_sel map isn't populated yet on the very first duty change after attach), the handler called PinManager.broadcastPwm(duty). broadcastPwm fans the same duty out to ALL registered PWM consumers — for a project with two servos both subscribed in the 0.01-0.20 duty range, each broadcast made BOTH servos mirror whichever channel was last written. Result: servoPan→91° and servoTilt→87° alternating writes would visibly snap both servos to 87°, then 91°, then 87°… Two-part fix: 1. PinManager grows `pwmListenerPinCount()` — number of distinct pins with at least one PWM consumer registered. 2. makeLedcUpdateHandler now keeps a per-board memo of {ledc_channel → last-known-good-gpio}. On a gpio=-1 update: - if the channel has a remembered gpio, route there; - else, only broadcast when there's at most ONE consumer (single-LED / single-servo setups still work); - otherwise drop the update — the backend's GPIO out_sel poll repopulates the map within a few ms and the next ledc_update arrives with a real gpio. The drop is correct because the same LEDC channel keeps emitting duty changes every Servo.write() call (~33 Hz at 30 ms loop delay), so missing one transient gpio=-1 frame is invisible. Tests: 1853 pass. The existing esp32-servo-pot tests already cover the gpio>=0 happy path; the new memo path is exercised indirectly through that handler.
2026-05-17 09:22:11 +07:00
/**
* Count of distinct GPIO pins that currently have at least one PWM
* listener registered. Used by the ledc_update router so it can
* skip a gpio=-1 broadcast when multiple consumers exist sending
* the same duty to two servos would corrupt the second one
* (`servo blinks between two positions` symptom). With a single
* consumer the broadcast is unambiguous and useful.
*/
pwmListenerPinCount(): number {
let n = 0;
this.pwmListeners.forEach((cbs) => {
if (cbs.size > 0) n++;
});
return n;
}
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();
}
}