diff --git a/frontend/package.json b/frontend/package.json index 84409185..bfd2c1be 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,7 +33,6 @@ "@google/genai": "^0.6.0", "@monaco-editor/react": "^4.7.0", "@tanstack/react-table": "^8.20.5", - "monaco-editor": "0.55.1", "@types/react-syntax-highlighter": "^15.5.13", "@wokwi/elements": "1.9.2", "@xterm/addon-fit": "^0.11.0", @@ -47,6 +46,7 @@ "jszip": "^3.10.1", "littlefs": "^0.1.0", "lucide-react": "^0.460.0", + "monaco-editor": "0.55.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-i18next": "^15.1.0", @@ -65,6 +65,7 @@ "@types/node": "^24.11.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/w3c-web-serial": "^1.0.8", "@vitejs/plugin-react": "^5.1.1", "@vitest/coverage-v8": "^4.0.18", "@vitest/ui": "^4.0.18", diff --git a/frontend/src/__tests__/pro-hardware-serial-seam.test.ts b/frontend/src/__tests__/pro-hardware-serial-seam.test.ts new file mode 100644 index 00000000..c5b23de2 --- /dev/null +++ b/frontend/src/__tests__/pro-hardware-serial-seam.test.ts @@ -0,0 +1,28 @@ +/** + * The hardware-serial TX seam (lib/proHardwareSerial.ts): while a real + * board's monitor is attached, serial input routes to the interceptor; + * detaching restores simulator routing. Per-board, inert by default. + */ +import { describe, expect, it, vi } from 'vitest'; +import { + getSerialTxInterceptor, + installSerialTxInterceptor, +} from '../lib/proHardwareSerial'; + +describe('proHardwareSerial seam', () => { + it('has no interceptor by default (pure OSS build)', () => { + expect(getSerialTxInterceptor('board-1')).toBeNull(); + }); + + it('routes per board and uninstalls with null', () => { + const toHardware = vi.fn(); + installSerialTxInterceptor('board-1', toHardware); + + expect(getSerialTxInterceptor('board-2')).toBeNull(); + getSerialTxInterceptor('board-1')?.('hello\n'); + expect(toHardware).toHaveBeenCalledWith('hello\n'); + + installSerialTxInterceptor('board-1', null); + expect(getSerialTxInterceptor('board-1')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/proHardwareSerial.ts b/frontend/src/lib/proHardwareSerial.ts new file mode 100644 index 00000000..1fed8f4a --- /dev/null +++ b/frontend/src/lib/proHardwareSerial.ts @@ -0,0 +1,37 @@ +/** + * Pro hardware-serial TX registry. + * + * The hardware serial monitor (pro overlay) mirrors a REAL board's UART + * into the existing SerialMonitor. Incoming bytes need no seam — the + * overlay appends them via `appendHardwareSerial` (useSimulatorStore). + * Outgoing text does: `serialWriteToBoard` normally targets the board's + * simulator, so while a hardware monitor is attached to a board, the + * overlay installs an interceptor here and the user's input goes to the + * physical port instead. + * + * Mirrors the other OSS->Pro seams (`proWebFlash.ts`, `proBoardGate.ts`). + * No imports on purpose: the store consults this module, never the other + * way around, so there is no cycle. + */ + +type SerialTxInterceptor = (text: string) => void; + +const _interceptors = new Map(); + +/** + * Route a board's serial input to the given sink instead of its simulator. + * Pass null to restore the default (simulator) routing. Installed by the + * pro overlay while its hardware serial monitor is connected. + */ +export function installSerialTxInterceptor( + boardId: string, + fn: SerialTxInterceptor | null, +): void { + if (fn) _interceptors.set(boardId, fn); + else _interceptors.delete(boardId); +} + +/** The interceptor for a board, or null when input should go to the sim. */ +export function getSerialTxInterceptor(boardId: string): SerialTxInterceptor | null { + return _interceptors.get(boardId) ?? null; +} diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 2cca724b..61fc752b 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -28,6 +28,7 @@ import type { Wire, WireInProgress, WireEndpoint } from '../types/wire'; import type { BoardKind, BoardInstance, LanguageMode, WifiStatus } from '../types/board'; import { BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, isStm32BoardKind } from '../types/board'; import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../lib/proBoardGate'; +import { getSerialTxInterceptor } from '../lib/proHardwareSerial'; import { calculatePinPosition } from '../utils/pinPositionCalculator'; import { useOscilloscopeStore } from './useOscilloscopeStore'; import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge'; @@ -1291,6 +1292,16 @@ const { append: appendSerial } = createSerialBatcher((perBoard) => { }); }); +/** + * Pro overlay seam: append bytes coming from a REAL board's UART (Web + * Serial hardware monitor) into a board's serial output. Goes through the + * same per-frame batcher as simulator bytes, so the SerialMonitor needs no + * changes and hardware chatter cannot trigger the per-byte set() storm. + */ +export function appendHardwareSerial(boardId: string, chunk: string): void { + appendSerial(boardId, chunk); +} + // ── Store ───────────────────────────────────────────────────────────────── export const useSimulatorStore = create((set, get) => { // Initialise runtime objects for the default board @@ -3756,6 +3767,13 @@ export const useSimulatorStore = create((set, get) => { serialWriteToBoard: (boardId: string, text: string) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; + // A connected hardware serial monitor (pro overlay) takes the input + // instead of the board's simulator. + const hardwareTx = getSerialTxInterceptor(boardId); + if (hardwareTx) { + hardwareTx(text); + return; + } if (isPiBoardKind(board.boardKind)) { const bridge = getBoardBridge(boardId); if (bridge) {