791 lines
29 KiB
TypeScript
791 lines
29 KiB
TypeScript
/**
|
|
* 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;
|
|
getStm32Bridge: (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<number, Set<(state: boolean) => void>>;
|
|
/** Per-uart fan-out for serial output bytes from this board */
|
|
serialFanout: Map<number, Set<(ch: string) => void>>;
|
|
/** Pin propagation listeners we installed on PinManager — call to unsubscribe */
|
|
pinUnsubs: Array<() => void>;
|
|
}
|
|
|
|
interface RouteHandle {
|
|
wireId: string;
|
|
teardown: () => void;
|
|
}
|
|
|
|
const boards = new Map<string, BoardEntry>();
|
|
const routes = new Map<string, RouteHandle>();
|
|
const propagatingPins = new Set<string>(); // re-entrancy guard
|
|
|
|
/**
|
|
* Cross-board I2C bridges installed when two boards share a wired
|
|
* (SDA, SCL) pair on a given (busA, busB). Keyed by a deterministic
|
|
* "boardA:busA<->boardB:busB" string so we don't double-install when
|
|
* `updateWires` is called repeatedly. The value is the teardown that
|
|
* detaches both halves of the bidirectional bridge.
|
|
*/
|
|
const i2cBridges = new Map<string, () => void>();
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function isBrowserSim(boardKind: string): boolean {
|
|
// Browser-side simulators expose a `setPinState` method directly on the
|
|
// simulator instance (AVR, RP2040). ESP32-C3 family was historically
|
|
// here when Esp32C3Simulator ran in-browser, but per the store's
|
|
// ESP32_RISCV_KINDS routing the c3 boards now go through the same
|
|
// Esp32Bridge (qemu-system-riscv32 via libqemu-riscv32.dll) as the
|
|
// Xtensa ESP32s — so they belong on the bridge side.
|
|
return (
|
|
boardKind === 'arduino-uno' ||
|
|
boardKind === 'arduino-nano' ||
|
|
boardKind === 'arduino-mega' ||
|
|
boardKind === 'attiny85' ||
|
|
boardKind === 'raspberry-pi-pico' ||
|
|
boardKind === 'pi-pico-w'
|
|
);
|
|
}
|
|
|
|
function isEsp32Bridge(boardKind: string): boolean {
|
|
return (
|
|
boardKind === 'esp32' ||
|
|
boardKind === 'esp32-s3' ||
|
|
boardKind === 'esp32-devkit-c-v4' ||
|
|
boardKind === 'esp32-cam' ||
|
|
boardKind === 'wemos-lolin32-lite' ||
|
|
boardKind === 'xiao-esp32-s3' ||
|
|
boardKind === 'arduino-nano-esp32' ||
|
|
// RISC-V ESP32-C3 family — same Esp32Bridge plumbing, just a
|
|
// different QEMU binary on the backend (libqemu-riscv32).
|
|
boardKind === 'esp32-c3' ||
|
|
boardKind === 'xiao-esp32-c3' ||
|
|
boardKind === 'aitewinrobot-esp32c3-supermini' ||
|
|
boardKind === 'xiao-c3' ||
|
|
boardKind === 'c3-supermini'
|
|
);
|
|
}
|
|
|
|
function isPi3Bridge(boardKind: string): boolean {
|
|
// Pi Zero / 1 / 2 / 3 / 4 / 5 all use the same backend bridge
|
|
// (QEMU virt + virtio-serial). Exclude raspberry-pi-pico (RP2040).
|
|
return boardKind.startsWith('raspberry-pi-') && boardKind !== 'raspberry-pi-pico';
|
|
}
|
|
|
|
function isStm32Bridge(boardKind: string): boolean {
|
|
// STM32 family via libqemu-arm (Stm32Bridge). Same single-slot
|
|
// onPinChange/onSerialData + sendPinEvent/sendSerialBytes shape as Esp32Bridge.
|
|
return boardKind === 'stm32-bluepill' || boardKind.startsWith('stm32-');
|
|
}
|
|
|
|
/** 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 (isStm32Bridge(entry.kind)) {
|
|
const bridge = runtime.getStm32Bridge(boardId);
|
|
bridge?.sendPinEvent?.(pin, state);
|
|
} else if (isPi3Bridge(entry.kind)) {
|
|
const bridge = runtime.getBoardBridge(boardId);
|
|
bridge?.sendPinEvent?.(pin, state);
|
|
}
|
|
} finally {
|
|
propagatingPins.delete(key);
|
|
}
|
|
}
|
|
|
|
// ── Serial seam for boards driven from outside the sim/bridge pair ───────
|
|
//
|
|
// A QEMU-Linux board can also run its Python in the tab (no WebSocket, no
|
|
// bridge object). Such a board still has UART wires on the canvas, so it
|
|
// needs both directions of the routing: a sink to receive bytes, and a way
|
|
// to announce the ones it sends. Both are plain callbacks — nothing here
|
|
// knows what is on the other end.
|
|
const serialSinks = new Map<string, (ch: string, uart: number) => void>();
|
|
|
|
/** Receive UART bytes addressed to this board. Returns an unregister fn. */
|
|
export function registerSerialSink(
|
|
boardId: string,
|
|
sink: (ch: string, uart: number) => void,
|
|
): () => void {
|
|
serialSinks.set(boardId, sink);
|
|
return () => {
|
|
if (serialSinks.get(boardId) === sink) serialSinks.delete(boardId);
|
|
};
|
|
}
|
|
|
|
/** Announce a UART byte this board just transmitted, so the wires route it. */
|
|
export function feedBoardSerialOut(boardId: string, ch: string, uart = 0): void {
|
|
const subs = boards.get(boardId)?.serialFanout.get(uart);
|
|
if (subs) for (const cb of subs) cb(ch);
|
|
}
|
|
|
|
/** 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;
|
|
|
|
const sink = serialSinks.get(boardId);
|
|
if (sink) {
|
|
sink(ch, uart);
|
|
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 (isStm32Bridge(entry.kind)) {
|
|
const bridge = runtime.getStm32Bridge(boardId);
|
|
bridge?.sendSerialBytes?.([ch.charCodeAt(0)], uart);
|
|
} else if (isPi3Bridge(entry.kind)) {
|
|
const bridge = runtime.getBoardBridge(boardId) as
|
|
| { sendUartBytes?: (b: number[]) => void; sendSerialBytes?: (b: number[]) => void }
|
|
| undefined;
|
|
// The header UART is a different pipe from the console: typing a
|
|
// peer's bytes into the shell used to be the only option, and it
|
|
// meant the guest's own boot chatter went out on the wire while the
|
|
// data a script wrote never did.
|
|
if (bridge?.sendUartBytes) bridge.sendUartBytes([ch.charCodeAt(0)]);
|
|
else 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)
|
|
: isStm32Bridge(entry.kind)
|
|
? runtime.getStm32Bridge(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)
|
|
: isStm32Bridge(entry.kind)
|
|
? runtime.getStm32Bridge(entry.id)
|
|
: runtime.getBoardBridge(entry.id);
|
|
if (!bridge) return;
|
|
|
|
// A QEMU-Linux board has TWO serial streams: the console (the shell)
|
|
// and the header UART. Only the second one is on the wire — hooking the
|
|
// console here would send the guest's boot chatter and shell prompt to
|
|
// the peer board, which is what used to happen for lack of anything
|
|
// better.
|
|
const piBridge = bridge as unknown as { onUartTx?: ((t: string) => void) | null };
|
|
if (isPi3Bridge(entry.kind) && 'onUartTx' in piBridge) {
|
|
if ((bridge as unknown as { __icUartHook?: boolean }).__icUartHook) return;
|
|
(bridge as unknown as { __icUartHook?: boolean }).__icUartHook = true;
|
|
const prevUart = piBridge.onUartTx ?? null;
|
|
piBridge.onUartTx = (text: string) => {
|
|
prevUart?.(text);
|
|
const subs = boards.get(boardId)?.serialFanout.get(0);
|
|
if (subs) for (const ch of text) for (const cb of subs) cb(ch);
|
|
};
|
|
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 = '';
|
|
|
|
// ── Cross-board I2C bus bridges ─────────────────────────────────────────────
|
|
//
|
|
// On top of the bit-level GPIO propagation each wire already installs,
|
|
// when two boards have BOTH SDA and SCL wired together on a (busA,
|
|
// busB) pair we install a transaction-level bridge between their
|
|
// `I2CBusManager` instances. This lets one board act as I2C master
|
|
// and the OTHER as the slave responder — something neither avr8js
|
|
// AVRTWI nor rp2040js RPI2C supports natively (both are master-only
|
|
// peripherals; they do not sample GPIO to decode an incoming
|
|
// transaction as a slave).
|
|
//
|
|
// The bridge is symmetric: either side may initiate. Addresses are
|
|
// resolved against the peer's locally-registered virtual devices, so a
|
|
// PCF8574 (or any I2CDevice) registered on board B's bus is reachable
|
|
// from board A's master without any extra glue.
|
|
|
|
/** Group i2c-classified wires by (boardA, busA, boardB, busB) and pin role. */
|
|
function collectI2CWirePairs(
|
|
wires: readonly Wire[],
|
|
): Map<
|
|
string,
|
|
{
|
|
aBoard: string;
|
|
aBus: number;
|
|
bBoard: string;
|
|
bBus: number;
|
|
sda: boolean;
|
|
scl: boolean;
|
|
}
|
|
> {
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
aBoard: string;
|
|
aBus: number;
|
|
bBoard: string;
|
|
bBus: number;
|
|
sda: boolean;
|
|
scl: boolean;
|
|
}
|
|
>();
|
|
|
|
for (const w of wires) {
|
|
const aEntry = boards.get(w.start.componentId);
|
|
const bEntry = boards.get(w.end.componentId);
|
|
if (!aEntry || !bEntry) continue;
|
|
|
|
const aRole = classifyPin(aEntry.kind, w.start.pinName);
|
|
const bRole = classifyPin(bEntry.kind, w.end.pinName);
|
|
if (aRole.kind !== bRole.kind) continue;
|
|
if (aRole.kind !== 'i2c-sda' && aRole.kind !== 'i2c-scl') continue;
|
|
|
|
// Normalize ordering so (boardA < boardB) lexicographically. The
|
|
// bridge is symmetric, but the map key must be deterministic.
|
|
const swap = aEntry.id > bEntry.id;
|
|
const A = swap ? bEntry : aEntry;
|
|
const B = swap ? aEntry : bEntry;
|
|
const aRoleN = swap ? bRole : aRole;
|
|
const bRoleN = swap ? aRole : bRole;
|
|
|
|
if (aRoleN.kind !== 'i2c-sda' && aRoleN.kind !== 'i2c-scl') continue;
|
|
const aBus =
|
|
'bus' in aRoleN && typeof aRoleN.bus === 'number' ? aRoleN.bus : 0;
|
|
const bBus =
|
|
'bus' in bRoleN && typeof bRoleN.bus === 'number' ? bRoleN.bus : 0;
|
|
|
|
const key = `${A.id}#${aBus}<->${B.id}#${bBus}`;
|
|
let entry = groups.get(key);
|
|
if (!entry) {
|
|
entry = {
|
|
aBoard: A.id,
|
|
aBus,
|
|
bBoard: B.id,
|
|
bBus,
|
|
sda: false,
|
|
scl: false,
|
|
};
|
|
groups.set(key, entry);
|
|
}
|
|
if (aRoleN.kind === 'i2c-sda') entry.sda = true;
|
|
else entry.scl = true;
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* Look up the `I2CBusManager` for `(boardId, bus)`. Returns null
|
|
* silently if the board does not expose `getI2CBus` (e.g. cross-process
|
|
* bridges, RiscV, ESP32 backend), if the bus has not been constructed
|
|
* yet (firmware not loaded), or if the runtime accessors are missing.
|
|
*/
|
|
function getI2CBusFor(boardId: string, bus: number): unknown {
|
|
if (!runtime) return null;
|
|
const sim = runtime.getBoardSimulator(boardId);
|
|
if (!sim || typeof sim.getI2CBus !== 'function') return null;
|
|
try {
|
|
return sim.getI2CBus(bus as 0 | 1) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconcile the bridge map with the latest wire layout. Installs new
|
|
* bridges, tears down stale ones, and is idempotent against repeated
|
|
* calls with the same wires.
|
|
*/
|
|
function updateI2CBridges(wires: readonly Wire[]): void {
|
|
const desired = collectI2CWirePairs(wires);
|
|
|
|
// Tear down bridges that no longer have both SDA and SCL wired.
|
|
for (const [key, teardown] of [...i2cBridges.entries()]) {
|
|
const want = desired.get(key);
|
|
if (!want || !(want.sda && want.scl)) {
|
|
teardown();
|
|
i2cBridges.delete(key);
|
|
}
|
|
}
|
|
|
|
// Install bridges that newly have both SDA and SCL wired.
|
|
for (const [key, want] of desired.entries()) {
|
|
if (!want.sda || !want.scl) continue;
|
|
if (i2cBridges.has(key)) continue;
|
|
|
|
const busA = getI2CBusFor(want.aBoard, want.aBus) as
|
|
| {
|
|
attachBridge(p: unknown): void;
|
|
detachBridge(p: unknown): void;
|
|
}
|
|
| null;
|
|
const busB = getI2CBusFor(want.bBoard, want.bBus) as
|
|
| {
|
|
attachBridge(p: unknown): void;
|
|
detachBridge(p: unknown): void;
|
|
}
|
|
| null;
|
|
if (!busA || !busB) continue; // one side does not expose a bus yet
|
|
|
|
busA.attachBridge(busB);
|
|
busB.attachBridge(busA);
|
|
|
|
// ── Cross-architecture proxy sync ─────────────────────────────────────
|
|
// When one side of the bridge is an ESP32 board, the I2CBusManager
|
|
// alone is not enough: ESP32 firmware runs in backend QEMU, and its
|
|
// Wire master reads land inside the QEMU thread synchronously. A
|
|
// WebSocket round-trip to look up the peer device per byte would
|
|
// deadlock the I2C cycle. Instead, snapshot the peer's local
|
|
// devices into a backend `ProxySlave` per address — QEMU then
|
|
// responds locally without leaving the worker.
|
|
if (isEsp32Bridge(boards.get(want.aBoard)?.kind ?? '')) {
|
|
const simA = runtime?.getBoardSimulator(want.aBoard);
|
|
if (simA?.syncProxyFromPeer) {
|
|
try { simA.syncProxyFromPeer(busB); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
if (isEsp32Bridge(boards.get(want.bBoard)?.kind ?? '')) {
|
|
const simB = runtime?.getBoardSimulator(want.bBoard);
|
|
if (simB?.syncProxyFromPeer) {
|
|
try { simB.syncProxyFromPeer(busA); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
i2cBridges.set(key, () => {
|
|
try {
|
|
busA.detachBridge(busB);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
busB.detachBridge(busA);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
// Remove ONLY the proxy slaves this bridge installed. Per-peer
|
|
// teardown so concurrent bridges to the same ESP32 (e.g. ESP32
|
|
// wired to both an Uno and a Pico simultaneously) retain their
|
|
// own proxies — addresses owned by another peer survive.
|
|
if (isEsp32Bridge(boards.get(want.aBoard)?.kind ?? '')) {
|
|
const simA = runtime?.getBoardSimulator(want.aBoard);
|
|
if (simA?.clearProxiesForPeer) {
|
|
try { simA.clearProxiesForPeer(busB); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
if (isEsp32Bridge(boards.get(want.bBoard)?.kind ?? '')) {
|
|
const simB = runtime?.getBoardSimulator(want.bBoard);
|
|
if (simB?.clearProxiesForPeer) {
|
|
try { simB.clearProxiesForPeer(busA); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
// After per-wire pin/UART routes are in place, reconcile the
|
|
// higher-level I2C bridges that need BOTH SDA and SCL present.
|
|
updateI2CBridges(wires);
|
|
}
|
|
|
|
/**
|
|
* Called by the store when a board's simulator finishes initialising
|
|
* (firmware loaded, peripherals constructed). At that point the
|
|
* I2CBusManager finally exists, so we re-evaluate which bridges can
|
|
* be installed. Safe to call repeatedly.
|
|
*/
|
|
export function notifyBoardReady(_boardId: string, wires: readonly Wire[]): void {
|
|
updateI2CBridges(wires);
|
|
}
|
|
|
|
/**
|
|
* Re-attempt the serial hook for a board whose sim/bridge did not exist
|
|
* when the routes were built. Routes are installed at page load; a Pi's
|
|
* bridge is created at Run — so the TX hook silently no-opped and the
|
|
* guest's UART bytes never reached the wire. The store calls this when
|
|
* the bridge connects; ensureSerialHook is idempotent via its flag.
|
|
*/
|
|
export function reensureSerialHooks(boardId: string): void {
|
|
const entry = boards.get(boardId);
|
|
if (!entry || entry.serialFanout.size === 0) return;
|
|
// The hook lives on the sim/bridge INSTANCE and marks it with a flag.
|
|
// A fresh instance carries no flag, so ensureSerialHook installs on it;
|
|
// this call is what makes that happen after a compile or a reconnect.
|
|
ensureSerialHook(entry);
|
|
}
|
|
|
|
/** For tests: reset all internal state. */
|
|
export function resetInterconnect(): void {
|
|
for (const r of routes.values()) r.teardown();
|
|
routes.clear();
|
|
for (const teardown of i2cBridges.values()) teardown();
|
|
i2cBridges.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());
|
|
}
|