feat(serial): hardware-serial seams for the pro Web Serial monitor

appendHardwareSerial pushes real-UART bytes through the existing
per-frame serial batcher (no SerialMonitor changes), and
lib/proHardwareSerial lets the overlay intercept a board's serial
input while a physical monitor is attached — TX and RX switch to the
hardware together, detaching hands the tab back to the simulator.
Also adds @types/w3c-web-serial (esptool-js references it but only
dev-depends on it).
This commit is contained in:
David Montero Crespo 2026-08-11 21:09:40 +02:00
parent def450f4f1
commit 1980d70942
4 changed files with 85 additions and 1 deletions

View File

@ -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",

View File

@ -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();
});
});

View File

@ -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<string, SerialTxInterceptor>();
/**
* 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;
}

View File

@ -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<SimulatorState>((set, get) => {
// Initialise runtime objects for the default board
@ -3756,6 +3767,13 @@ export const useSimulatorStore = create<SimulatorState>((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) {