diff --git a/frontend/src/__tests__/dual-pico-serial-passthrough.test.ts b/frontend/src/__tests__/dual-pico-serial-passthrough.test.ts index 5762d7fc..e5cb1dc3 100644 --- a/frontend/src/__tests__/dual-pico-serial-passthrough.test.ts +++ b/frontend/src/__tests__/dual-pico-serial-passthrough.test.ts @@ -296,6 +296,13 @@ describe('Dual Pico W — Serial1 passthrough across wires', () => { // Wire the Pi3B's UART0 TX (BCM14, physical pin 8) to Uno.D0 (USART0 RX). // This is now the only path — the previous broadcast behaviour has been // replaced by wire-aware routing. + // + // A QEMU-Linux board has TWO serial streams and only one of them is on + // the header: `onUartTx` is what the script writes to /dev/serial0, while + // `onSerialData` is the console (boot chatter, the shell prompt, the + // script's stdout). This test used to feed the console and expect the + // peer to see it, which is exactly the leak that got fixed — the Uno was + // being handed the guest's login banner as if it were data. fullReset(); const store = useSimulatorStore.getState(); const arduinoId = store.boards[0].id; @@ -306,15 +313,37 @@ describe('Dual Pico W — Serial1 passthrough across wires', () => { const arduinoSim = getBoardSimulator(arduinoId) as any; const piBridge = getBoardBridge(piId) as any; - expect(typeof piBridge.onSerialData).toBe('function'); + expect(typeof piBridge.onUartTx).toBe('function'); - piBridge.onSerialData('A'); + piBridge.onUartTx('A'); - const fed = + const fedFromUart = () => (arduinoSim.feedUart as any).mock.calls.some( (c: any[]) => c[0] === 0 && c[1] === 'A', ) || (arduinoSim.serialWrite as any).mock.calls.some((c: any[]) => c[0] === 'A'); - expect(fed).toBe(true); + expect(fedFromUart()).toBe(true); + }); + + it("the Pi's console output stays off the wire", () => { + fullReset(); + const store = useSimulatorStore.getState(); + const arduinoId = store.boards[0].id; + const piId = store.addBoard('raspberry-pi-3', 300, 100); + setWires(useSimulatorStore, [ + { fromBoard: piId, fromPin: '8', toBoard: arduinoId, toPin: 'D0' }, + ]); + + const arduinoSim = getBoardSimulator(arduinoId) as any; + const piBridge = getBoardBridge(piId) as any; + + // What the guest prints to its terminal ('login:' during boot) must not + // reach the peer board, even though the TX wire is there. + piBridge.onSerialData?.('Q'); + + const leaked = + (arduinoSim.feedUart as any).mock.calls.some((c: any[]) => c[1] === 'Q') || + (arduinoSim.serialWrite as any).mock.calls.some((c: any[]) => c[0] === 'Q'); + expect(leaked).toBe(false); }); }); diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 08c01aa0..b50a4a8c 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -4,7 +4,7 @@ import { getBoardBridge, getBoardSimulator, } from '../../store/useSimulatorStore'; -import { getProBoard } from '../../lib/proBoardRegistry'; +import { getBoardBuiltins } from '../../lib/proBoardRegistry'; import { useElectricalStore } from '../../store/useElectricalStore'; import { openDeviceGateway } from '../../lib/openDeviceGateway'; import React, { useEffect, useState, useRef, useCallback } from 'react'; @@ -253,14 +253,14 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { useSimulatorStore .getState() .boards.forEach((board) => { - const proDef = getProBoard(board.boardKind); - if (!proDef?.attachBuiltins || !board.running) return; + const attachBuiltins = getBoardBuiltins(board.boardKind); + if (!attachBuiltins || !board.running) return; const timeout = setTimeout(() => { const el = document.getElementById(board.id); if (!el) return; try { cleanups.push( - proDef.attachBuiltins!({ + attachBuiltins({ el, sim: getBoardSimulator(board.id), // ESP32-family boards get their QEMU/JS bridge; QEMU-Linux diff --git a/frontend/src/lib/proBoardRegistry.ts b/frontend/src/lib/proBoardRegistry.ts index c0836f73..64fc90f4 100644 --- a/frontend/src/lib/proBoardRegistry.ts +++ b/frontend/src/lib/proBoardRegistry.ts @@ -175,6 +175,31 @@ export function getGuestSetup(kind: string): string | undefined { return registry.get(kind)?.guestSetup ?? guestSetups.get(kind); } +// ── Built-in peripheral attachment for boards the OSS tree already owns ── +// +// Same reasoning as the guest setups above: an overlay may need to wire +// something to a run of an OSS-rendered board — the Pi family has no +// built-in screen, but an overlay can route the guest's display frames to +// a panel wired on the canvas. Carrying that through a stub ProBoardDef +// would take over the board's artwork, so it gets its own registry. +type BuiltinsAttach = (ctx: { + el: HTMLElement; + sim: unknown; + bridge: unknown; +}) => () => void; + +const boardBuiltins = new Map(); + +export function registerBoardBuiltins(kind: string, attach: BuiltinsAttach): void { + boardBuiltins.set(kind, attach); +} + +/** How to attach built-in peripherals for a board kind: an overlay board's + * own `attachBuiltins` first, then anything registered for an OSS kind. */ +export function getBoardBuiltins(kind: string): BuiltinsAttach | undefined { + return registry.get(kind)?.attachBuiltins ?? boardBuiltins.get(kind); +} + export function listProBoards(): ProBoardDef[] { return Array.from(registry.values()); } diff --git a/frontend/src/simulation/RaspberryPi3Bridge.ts b/frontend/src/simulation/RaspberryPi3Bridge.ts index 4428f2b1..b63d282f 100644 --- a/frontend/src/simulation/RaspberryPi3Bridge.ts +++ b/frontend/src/simulation/RaspberryPi3Bridge.ts @@ -105,8 +105,14 @@ export class RaspberryPi3Bridge { } connect(): void { - const state = this.socket?.readyState; - if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return; + // Numeric readyState, and only when a socket actually exists: reading + // WebSocket.OPEN off the global made `undefined === undefined` true + // whenever the constants were missing (any stand-in socket), so connect() + // returned without opening anything and the bridge sat there dead. + if (this.socket) { + const state = this.socket.readyState; + if (state === 0 /* CONNECTING */ || state === 1 /* OPEN */) return; + } // A socket in CLOSING is on its way out, not usable: the old guard // treated it as live and returned, so "restart in Linux mode" right // after a run silently did nothing — no boot, and the toolbar still