2026-03-03 10:20:49 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* PinManager - Manages Arduino pin states and notifies listeners
|
|
|
|
|
|
*
|
2026-03-09 20:08:14 +07:00
|
|
|
|
* Maps AVR PORT registers to Arduino pin numbers.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Arduino Uno / Nano (ATmega328P):
|
2026-03-03 10:20:49 +07:00
|
|
|
|
* - PORTB (0x25) → Digital pins 8-13
|
|
|
|
|
|
* - PORTC (0x28) → Analog pins A0-A5 (14-19)
|
|
|
|
|
|
* - PORTD (0x2B) → Digital pins 0-7
|
2026-03-05 04:27:14 +07:00
|
|
|
|
*
|
2026-03-09 20:08:14 +07:00
|
|
|
|
* Arduino Mega 2560 (ATmega2560): uses explicit per-bit pin maps
|
|
|
|
|
|
* for non-linear port ↔ Arduino-pin relationships.
|
|
|
|
|
|
*
|
2026-03-05 04:27:14 +07:00
|
|
|
|
* Also supports:
|
|
|
|
|
|
* - Analog voltage injection (for potentiometers, sensors)
|
|
|
|
|
|
* - PWM duty cycle tracking (for servos, RGB LEDs, buzzers)
|
2026-03-03 10:20:49 +07:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
export type PinState = boolean;
|
|
|
|
|
|
export type PinChangeCallback = (pin: number, state: PinState) => void;
|
2026-03-05 04:27:14 +07:00
|
|
|
|
export type AnalogCallback = (pin: number, voltage: number) => void;
|
|
|
|
|
|
export type PwmCallback = (pin: number, dutyCycle: number) => void;
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
|
|
|
|
|
export class PinManager {
|
|
|
|
|
|
private listeners: Map<number, Set<PinChangeCallback>> = new Map();
|
2026-03-05 04:27:14 +07:00
|
|
|
|
private pwmListeners: Map<number, Set<PwmCallback>> = new Map();
|
|
|
|
|
|
private analogListeners: Map<number, Set<AnalogCallback>> = new Map();
|
2026-03-03 10:20:49 +07:00
|
|
|
|
private pinStates: Map<number, boolean> = new Map();
|
2026-03-05 04:27:14 +07:00
|
|
|
|
private pwmValues: Map<number, number> = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
// ── Digital pin API ──────────────────────────────────────────────────────
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-05 04:27:14 +07:00
|
|
|
|
* Register callback for digital pin state changes.
|
|
|
|
|
|
* Returns unsubscribe function.
|
2026-03-03 10:20:49 +07:00
|
|
|
|
*/
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-05 04:27:14 +07:00
|
|
|
|
* Update port register and notify digital pin listeners.
|
2026-03-09 20:08:14 +07:00
|
|
|
|
*
|
|
|
|
|
|
* @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.
|
2026-03-03 10:20:49 +07:00
|
|
|
|
*/
|
2026-03-09 20:08:14 +07:00
|
|
|
|
updatePort(portName: string, newValue: number, oldValue: number = 0, pinMap?: number[]) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const legacyOffsets: Record<string, number> = { PORTB: 8, PORTC: 14, PORTD: 0 };
|
2026-03-03 10:20:49 +07:00
|
|
|
|
|
|
|
|
|
|
for (let bit = 0; bit < 8; bit++) {
|
|
|
|
|
|
const mask = 1 << bit;
|
|
|
|
|
|
const oldState = (oldValue & mask) !== 0;
|
|
|
|
|
|
const newState = (newValue & mask) !== 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (oldState !== newState) {
|
2026-03-09 20:08:14 +07:00
|
|
|
|
const arduinoPin = pinMap ? pinMap[bit] : (legacyOffsets[portName] ?? 0) + bit;
|
|
|
|
|
|
if (arduinoPin < 0) continue; // unmapped bit
|
|
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
|
this.pinStates.set(arduinoPin, newState);
|
|
|
|
|
|
|
|
|
|
|
|
const callbacks = this.listeners.get(arduinoPin);
|
|
|
|
|
|
if (callbacks) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
callbacks.forEach((cb) => cb(arduinoPin, newState));
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getPinState(arduinoPin: number): boolean {
|
|
|
|
|
|
return this.pinStates.get(arduinoPin) || false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 02:46:44 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 05:28:33 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
callbacks.forEach((cb) => cb(pin, state));
|
2026-03-05 05:28:33 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 04:27:14 +07:00
|
|
|
|
// ── 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);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
|
/**
|
2026-03-05 04:27:14 +07:00
|
|
|
|
* Called by AVRSimulator each frame when an OCR register changes.
|
2026-03-03 10:20:49 +07:00
|
|
|
|
*/
|
2026-03-05 04:27:14 +07:00
|
|
|
|
updatePwm(pin: number, dutyCycle: number): void {
|
|
|
|
|
|
this.pwmValues.set(pin, dutyCycle);
|
|
|
|
|
|
const callbacks = this.pwmListeners.get(pin);
|
|
|
|
|
|
if (callbacks) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
callbacks.forEach((cb) => cb(pin, dutyCycle));
|
2026-03-05 04:27:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 23:54:44 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Broadcast PWM duty to ALL registered PWM listeners.
|
|
|
|
|
|
* Used when the LEDC channel→GPIO 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);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
callbacks.forEach((cb) => cb(pin, dutyCycle));
|
2026-03-24 23:54:44 +07:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-05 04:27:14 +07:00
|
|
|
|
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) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
callbacks.forEach((cb) => cb(arduinoPin, voltage));
|
2026-03-05 04:27:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Utility ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-03-03 10:20:49 +07:00
|
|
|
|
getListenersCount(): number {
|
|
|
|
|
|
let count = 0;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.listeners.forEach((set) => (count += set.size));
|
2026-03-03 10:20:49 +07:00
|
|
|
|
return count;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
clearAllListeners() {
|
|
|
|
|
|
this.listeners.clear();
|
2026-03-05 04:27:14 +07:00
|
|
|
|
this.pwmListeners.clear();
|
|
|
|
|
|
this.analogListeners.clear();
|
2026-03-03 10:20:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|