/** * Cross-board interconnect router. * * Reactive subsystem that watches `useSimulatorStore.wires` and * `useSimulatorStore.boards` and propagates digital pin transitions * between boards along the wires the user drew. UART, I2C, SPI, and * SoftwareSerial protocols all "just work" on top of pin propagation * because each board's hardware peripherals decode the actual * transitions. For cross-process boards (ESP32 backend QEMU, Pi3B * QEMU) it additionally enables a byte-level shortcut on hardware * UART pins so that high-baud links don't drop bytes when the * WebSocket round-trip would be too slow for bit-level transport. * * Design: * - Singleton `interconnect`. The store calls `bindBoard` / * `unbindBoard` from `addBoard` / `removeBoard`, and the wires * array drives route resolution via `updateWires`. * - For browser-side simulators (AVR, RP2040, Esp32C3, RiscV) we * subscribe to each board's `PinManager.onPinChange` and forward * to the other endpoint's `setPinState`. * - For ESP32 / Pi3B bridges, we install fan-out callbacks on * `bridge.onPinChange` and `bridge.onSerialData` (overwriting * the bridge's single-callback slot — the store's serial-monitor * plumbing is preserved by chaining the previous callback). * - Re-entrancy guard: a `Set` of `${boardId}:${pin}` keys flagged * during synchronous propagation prevents the reverse hop from * firing a feedback echo. */ import type { BoardKind } from '../types/board'; import type { Wire } from '../types/wire'; import { boardPinToNumber } from '../utils/boardPinMapping'; import { classifyPin, isUartWire } from '../utils/boardProtocols'; // ── Bridge / sim runtime references ────────────────────────────────────────── // // Provided by the store via setRuntimeAccessors() to avoid a circular // import. The store exports `getBoardSimulator`, `getBoardPinManager`, // `getBoardBridge`, `getEsp32Bridge` — we need them at runtime. interface RuntimeAccessors { getBoardSimulator: (id: string) => any | undefined; getBoardPinManager: (id: string) => any | undefined; getBoardBridge: (id: string) => any | undefined; // Pi3B getEsp32Bridge: (id: string) => any | undefined; } let runtime: RuntimeAccessors | null = null; export function setInterconnectRuntime(r: RuntimeAccessors): void { runtime = r; } // ── Internal types ─────────────────────────────────────────────────────────── type BoardKindOrId = string; interface BoardEntry { id: string; kind: BoardKind; /** Original onSerialData (so we don't clobber the store's serial-monitor) */ origSerialCallback?: ((ch: string, uart?: number) => void) | null; /** Original bridge.onPinChange (so we don't clobber whatever the store wired) */ origPinChangeCallback?: ((pin: number, state: boolean) => void) | null; /** Per-pin fan-out map (used for bridges where only one onPinChange slot exists) */ pinChangeFanout: Map void>>; /** Per-uart fan-out for serial output bytes from this board */ serialFanout: Map void>>; /** Pin propagation listeners we installed on PinManager — call to unsubscribe */ pinUnsubs: Array<() => void>; } interface RouteHandle { wireId: string; teardown: () => void; } const boards = new Map(); const routes = new Map(); const propagatingPins = new Set(); // re-entrancy guard // ── Helpers ────────────────────────────────────────────────────────────────── function isBrowserSim(boardKind: string): boolean { // Browser-side simulators expose a `setPinState` method directly on the // simulator instance (AVR, RP2040, RiscV/ESP32-C3, Esp32BridgeShim). return ( boardKind === 'arduino-uno' || boardKind === 'arduino-nano' || boardKind === 'arduino-mega' || boardKind === 'attiny85' || boardKind === 'raspberry-pi-pico' || boardKind === 'pi-pico-w' || boardKind === 'esp32-c3' || boardKind === 'xiao-c3' || boardKind === 'c3-supermini' ); } function isEsp32Bridge(boardKind: string): boolean { return ( boardKind === 'esp32' || boardKind === 'esp32-s3' || boardKind === 'esp32-devkit-c-v4' || boardKind === 'esp32-cam' || boardKind === 'wemos-lolin32-lite' ); } function isPi3Bridge(boardKind: string): boolean { return boardKind === 'raspberry-pi-3'; } /** Resolve `(componentId, pinName)` to a `(boardId, pinNumber)` pair. */ function resolveEndpoint( componentId: string, pinName: string, ): { boardId: string; pin: number } | null { const entry = boards.get(componentId); if (!entry) return null; const pin = boardPinToNumber(entry.kind, pinName); if (pin === null || pin < 0) return null; // null = unknown; -1 = power return { boardId: componentId, pin }; } // ── Pin propagation primitives ─────────────────────────────────────────────── /** Drive a pin state on the receiving board (one-way). */ function pushPinState(boardId: string, pin: number, state: boolean): void { if (!runtime) return; const entry = boards.get(boardId); if (!entry) return; // Re-entrancy guard const key = `${boardId}:${pin}`; if (propagatingPins.has(key)) return; propagatingPins.add(key); try { if (isBrowserSim(entry.kind)) { const sim = runtime.getBoardSimulator(boardId); if (sim?.setPinState) sim.setPinState(pin, state); } else if (isEsp32Bridge(entry.kind)) { const bridge = runtime.getEsp32Bridge(boardId); bridge?.sendPinEvent?.(pin, state); } else if (isPi3Bridge(entry.kind)) { const bridge = runtime.getBoardBridge(boardId); bridge?.sendPinEvent?.(pin, state); } } finally { propagatingPins.delete(key); } } /** Push a UART byte into the receiving board's UART RX. */ function pushSerialByte(boardId: string, ch: string, uart: number): void { if (!runtime) return; const entry = boards.get(boardId); if (!entry) return; if (isBrowserSim(entry.kind)) { const sim = runtime.getBoardSimulator(boardId); // RP2040Simulator doesn't yet expose feedUart per-UART — fall back // to serialWrite (which feeds UART0) for uart === 0. if (sim?.feedUart) { sim.feedUart(uart, ch); } else if (uart === 0 && sim?.serialWrite) { sim.serialWrite(ch); } } else if (isEsp32Bridge(entry.kind)) { const bridge = runtime.getEsp32Bridge(boardId); bridge?.sendSerialBytes?.([ch.charCodeAt(0)], uart); } else if (isPi3Bridge(entry.kind)) { const bridge = runtime.getBoardBridge(boardId); bridge?.sendSerialBytes?.([ch.charCodeAt(0)]); } } // ── Pin-change fan-in (browser sims) ───────────────────────────────────────── // // For browser sims we subscribe to PinManager.onPinChange directly per // pin; PinManager handles fan-out internally so we don't need our own // fanout map for these. function installBrowserPinSubscription( fromBoardId: string, fromPin: number, toBoardId: string, toPin: number, ): () => void { if (!runtime) return () => {}; const pm = runtime.getBoardPinManager(fromBoardId); if (!pm?.onPinChange) return () => {}; const unsub = pm.onPinChange(fromPin, (_p: number, state: boolean) => { pushPinState(toBoardId, toPin, state); }); return typeof unsub === 'function' ? unsub : () => {}; } // ── Pin-change fan-in (bridges) ────────────────────────────────────────────── // // Bridges expose a single `onPinChange` slot. We take ownership of it // once per board and fan out through `pinChangeFanout`. function ensureBridgePinHook(entry: BoardEntry): void { if (!runtime) return; const bridge = isEsp32Bridge(entry.kind) ? runtime.getEsp32Bridge(entry.id) : runtime.getBoardBridge(entry.id); if (!bridge) return; // Already installed? if ((bridge as any).__icPinHookInstalled) return; (bridge as any).__icPinHookInstalled = true; // Save whatever was there before so we can chain it. entry.origPinChangeCallback = bridge.onPinChange ?? null; // Capture a stable "get current entry" closure — survives Interconnect // resets (where the entry object is replaced) by re-resolving via the // boards Map at call time. const boardId = entry.id; bridge.onPinChange = (pin: number, state: boolean) => { const liveEntry = boards.get(boardId); // First, let the existing callback (e.g. PinManager.triggerPinChange // installed by the store for sensor wiring) run. liveEntry?.origPinChangeCallback?.(pin, state); // Then fan out to all wired endpoints. const subs = liveEntry?.pinChangeFanout.get(pin); if (subs) for (const cb of subs) cb(state); }; } function installBridgePinFanout( fromBoardId: string, fromPin: number, toBoardId: string, toPin: number, ): () => void { const entry = boards.get(fromBoardId); if (!entry) return () => {}; ensureBridgePinHook(entry); let set = entry.pinChangeFanout.get(fromPin); if (!set) { set = new Set(); entry.pinChangeFanout.set(fromPin, set); } const cb = (state: boolean) => pushPinState(toBoardId, toPin, state); set.add(cb); return () => { entry.pinChangeFanout.get(fromPin)?.delete(cb); }; } // ── Serial fan-in (browser sims and bridges) ───────────────────────────────── function ensureSerialHook(entry: BoardEntry): void { if (!runtime) return; const boardId = entry.id; // Browser sims: wrap sim.onSerialData if (isBrowserSim(entry.kind)) { const sim = runtime.getBoardSimulator(entry.id); if (!sim) return; if ((sim as any).__icSerialHookInstalled) return; (sim as any).__icSerialHookInstalled = true; entry.origSerialCallback = sim.onSerialData ?? null; sim.onSerialData = (ch: string, uart?: number) => { const liveEntry = boards.get(boardId); liveEntry?.origSerialCallback?.(ch, uart); // Browser sims (e.g. RP2040) currently lump UART0 + UART1 into the // same callback. Default to UART0 for routing. const u = uart ?? 0; const subs = liveEntry?.serialFanout.get(u); if (subs) for (const cb of subs) cb(ch); }; return; } // Bridges: same pattern on bridge.onSerialData const bridge = isEsp32Bridge(entry.kind) ? runtime.getEsp32Bridge(entry.id) : runtime.getBoardBridge(entry.id); if (!bridge) return; if ((bridge as any).__icSerialHookInstalled) return; (bridge as any).__icSerialHookInstalled = true; entry.origSerialCallback = bridge.onSerialData ?? null; bridge.onSerialData = (ch: string, uart?: number) => { const liveEntry = boards.get(boardId); liveEntry?.origSerialCallback?.(ch, uart); const u = uart ?? 0; const subs = liveEntry?.serialFanout.get(u); if (subs) for (const cb of subs) cb(ch); }; } function installSerialFanout( fromBoardId: string, fromUart: number, toBoardId: string, toUart: number, ): () => void { const entry = boards.get(fromBoardId); if (!entry) return () => {}; ensureSerialHook(entry); let set = entry.serialFanout.get(fromUart); if (!set) { set = new Set(); entry.serialFanout.set(fromUart, set); } const cb = (ch: string) => pushSerialByte(toBoardId, ch, toUart); set.add(cb); return () => { entry.serialFanout.get(fromUart)?.delete(cb); }; } // ── Route building per wire ────────────────────────────────────────────────── function buildRouteForWire(wire: Wire): RouteHandle | null { const aEntry = boards.get(wire.start.componentId); const bEntry = boards.get(wire.end.componentId); if (!aEntry || !bEntry) return null; const aRes = resolveEndpoint(wire.start.componentId, wire.start.pinName); const bRes = resolveEndpoint(wire.end.componentId, wire.end.pinName); if (!aRes || !bRes) return null; // Power pins / GND short-circuit (already filtered to >= 0 by resolveEndpoint). const teardowns: Array<() => void> = []; // ─ Digital pin propagation A → B ───────────────────────────────────────── if (isBrowserSim(aEntry.kind)) { teardowns.push( installBrowserPinSubscription(aEntry.id, aRes.pin, bEntry.id, bRes.pin), ); } else { teardowns.push(installBridgePinFanout(aEntry.id, aRes.pin, bEntry.id, bRes.pin)); } // ─ Digital pin propagation B → A ───────────────────────────────────────── if (isBrowserSim(bEntry.kind)) { teardowns.push( installBrowserPinSubscription(bEntry.id, bRes.pin, aEntry.id, aRes.pin), ); } else { teardowns.push(installBridgePinFanout(bEntry.id, bRes.pin, aEntry.id, aRes.pin)); } // ─ Optional UART byte-level shortcut ──────────────────────────────────── // Enable when at least one side is a cross-process bridge (latency // would drop bit-level transport) AND when both pins classify as // matching UART TX/RX endpoints. const aIsCross = isEsp32Bridge(aEntry.kind) || isPi3Bridge(aEntry.kind); const bIsCross = isEsp32Bridge(bEntry.kind) || isPi3Bridge(bEntry.kind); const uartInfo = isUartWire(aEntry.kind, wire.start.pinName, bEntry.kind, wire.end.pinName); // Always wire the byte-level shortcut for hardware-UART pin pairs — // even browser-only cases benefit: AVR/RP2040 sims emit per-byte // events that cleanly arrive at the other side without depending on // bit-level pin replay timing. if (uartInfo) { const aRoleIsTx = classifyPin(aEntry.kind, wire.start.pinName).kind === 'uart-tx'; const aUart = uartInfo.uartA; const bUart = uartInfo.uartB; if (aRoleIsTx) { // A.TX → B.RX teardowns.push(installSerialFanout(aEntry.id, aUart, bEntry.id, bUart)); } else { // A.RX → B.TX (the wire's "start" was the RX side) teardowns.push(installSerialFanout(bEntry.id, bUart, aEntry.id, aUart)); } void aIsCross; void bIsCross; } return { wireId: wire.id, teardown: () => { for (const t of teardowns) t(); }, }; } // ── Public API used by the store ───────────────────────────────────────────── export function bindBoard(boardId: string, kind: BoardKind | string): void { if (boards.has(boardId)) return; boards.set(boardId, { id: boardId, kind: kind as BoardKind, pinChangeFanout: new Map(), serialFanout: new Map(), pinUnsubs: [], }); // After binding, any wires referencing this board can be re-resolved. // The store will call updateWires() with the latest list. } export function unbindBoard(boardId: string): void { // Tear down any routes that touch this board for (const [wireId, route] of routes.entries()) { // We don't keep wire→endpoint mapping; clearing all routes that // mention this board requires a re-scan. The store calls // updateWires(currentWires) right after removeBoard which will // rebuild from scratch. Just drop the entry. void wireId; void route; } // Remove the board entry; subsequent updateWires() will re-resolve. boards.delete(boardId); } let lastWireSnapshot: string = ''; /** * Idempotent: rebuilds route table to match the supplied wires array. * Called by the store on every wire mutation and also on board * add/remove. */ export function updateWires(wires: readonly Wire[]): void { // Quick skip if nothing changed (compare by composite identity). const sig = wires .map( (w) => `${w.id}|${w.start.componentId}:${w.start.pinName}|${w.end.componentId}:${w.end.pinName}`, ) .join(','); if (sig === lastWireSnapshot && wires.length === routes.size) { // Nothing changed in the routing-relevant fields. return; } lastWireSnapshot = sig; // Tear down all existing routes first (simplest correct strategy). for (const r of routes.values()) r.teardown(); routes.clear(); // Build fresh routes for each wire whose endpoints both resolve. for (const w of wires) { const r = buildRouteForWire(w); if (r) routes.set(w.id, r); } } /** For tests: reset all internal state. */ export function resetInterconnect(): void { for (const r of routes.values()) r.teardown(); routes.clear(); for (const e of boards.values()) { e.pinChangeFanout.clear(); e.serialFanout.clear(); for (const u of e.pinUnsubs) u(); e.pinUnsubs = []; } boards.clear(); propagatingPins.clear(); lastWireSnapshot = ''; } /** Diagnostic: return route count (for tests). */ export function getRouteCount(): number { return routes.size; } export function getBoundBoardIds(): string[] { return Array.from(boards.keys()); }