diff --git a/frontend/src/components/simulator/SerialMonitor.tsx b/frontend/src/components/simulator/SerialMonitor.tsx index 7ab3d34e..f18ced5c 100644 --- a/frontend/src/components/simulator/SerialMonitor.tsx +++ b/frontend/src/components/simulator/SerialMonitor.tsx @@ -256,7 +256,9 @@ export const SerialMonitor: React.FC = () => { {/* Output area. QEMU-Linux boards get the interactive xterm (shell input, line editing, ANSI) instead of the read-only mirror — this replaced the separate RaspberryPiWorkspace as the one terminal. */} - {isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running ? ( + {isPiBoardKind(activeBoard?.boardKind ?? '') && + activeBoard?.running && + activeBoard?.engineMode !== 'instant' ? (
@@ -343,7 +345,11 @@ export const SerialMonitor: React.FC = () => { )} {/* Input row — the xterm handles Pi input itself */} - {!(isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running) && ( + {!( + isPiBoardKind(activeBoard?.boardKind ?? '') && + activeBoard?.running && + activeBoard?.engineMode !== 'instant' + ) && (
engine.decide(boardId) -> 'instant' : engine.run(boardId) + * 'linux' : boot the QEMU guest + * + * The decision is data (`EngineDecision`), not a boolean, so the UI can + * explain WHY a board needs Linux ("script.py:12 uses subprocess") instead + * of silently taking 90 s. Nothing is registered in the OSS build, so the + * QEMU path stays exactly as it was. + */ + +export interface EngineDecision { + /** Which engine should take this run. */ + engine: 'instant' | 'linux'; + /** Short, user-facing reason. Empty when 'instant' with nothing to say. */ + reason: string; + /** Optional `file:line` the reason refers to, for the tooltip. */ + where?: string; +} + +export interface InstantEngine { + /** Decide which engine runs this board's current files. */ + decide(boardId: string): EngineDecision; + /** Run the board's files in the browser. Resolves when the script ends. */ + run(boardId: string): Promise; + /** Cancel a running script (the runtime may stay warm). */ + stop(boardId: string): void; +} + +let engine: InstantEngine | null = null; +const listeners = new Set<() => void>(); + +export function registerInstantEngine(e: InstantEngine | null): void { + engine = e; + for (const l of listeners) l(); +} + +export function getInstantEngine(): InstantEngine | null { + return engine; +} + +/** Subscribe to registration (the overlay loads asynchronously). */ +export function subscribeInstantEngine(cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +/** + * Which engine should run this board, honouring a user override. + * + * `pinned` comes from the board instance (the user clicked the mode chip + * or turned on the Linux terminal); when set it wins over the detector, + * because predictability beats cleverness for a project you already know. + */ +export function decideEngine( + boardId: string, + pinned?: 'instant' | 'linux', +): EngineDecision { + if (pinned === 'linux') { + return { engine: 'linux', reason: 'Linux mode is on for this project' }; + } + if (!engine) { + return { engine: 'linux', reason: 'the instant engine is not available' }; + } + if (pinned === 'instant') { + return { engine: 'instant', reason: 'instant mode is pinned for this project' }; + } + try { + return engine.decide(boardId); + } catch { + // A detector bug must never block a run: fall back to the real machine. + return { engine: 'linux', reason: 'could not analyse the project' }; + } +} diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index da5f4b32..ff2c9989 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { decideEngine, getInstantEngine } from '../lib/instantEngine'; import { getProBoard, isProBoardSimulator, type ProBoardSimulator } from '../lib/proBoardRegistry'; import { AVRSimulator } from '../simulation/AVRSimulator'; import { RP2040Simulator } from '../simulation/RP2040Simulator'; @@ -1898,11 +1899,31 @@ export const useSimulatorStore = create((set, get) => { } if (isPiBoardKind(board.boardKind)) { - getBoardBridge(boardId)?.connect(); - // Pop the terminal open so the user watches the guest boot and gets - // the interactive shell — without it a Linux board looks frozen for - // the ~45 s boot. - set({ serialMonitorOpen: true }); + // Engine routing: most projects are a Python script driving GPIO and + // a screen, and those run in the browser in seconds instead of + // booting a Linux guest (a backend process + ~90 s). The detector + // lives in the overlay; `enginePinned` lets the user override it. + const decision = decideEngine(boardId, board.enginePinned); + set((s) => ({ + boards: s.boards.map((b) => + b.id === boardId ? { ...b, engineMode: decision.engine } : b, + ), + serialMonitorOpen: true, + })); + if (decision.engine === 'instant') { + const instant = getInstantEngine(); + void instant?.run(boardId).finally(() => { + set((s) => { + const boards = s.boards.map((b) => + b.id === boardId ? { ...b, running: false } : b, + ); + const isActive = s.activeBoardId === boardId; + return { boards, ...(isActive ? { running: false } : {}) }; + }); + }); + } else { + getBoardBridge(boardId)?.connect(); + } } else if (isEsp32Kind(board.boardKind)) { // Pre-register sensors connected to this board so the QEMU worker // has them ready before the firmware starts executing. @@ -2142,7 +2163,8 @@ export const useSimulatorStore = create((set, get) => { if (!board) return; if (isPiBoardKind(board.boardKind)) { - getBoardBridge(boardId)?.disconnect(); + if (board.engineMode === 'instant') getInstantEngine()?.stop(boardId); + else getBoardBridge(boardId)?.disconnect(); } else if (isEsp32Kind(board.boardKind)) { getEsp32Bridge(boardId)?.disconnect(); } else if (isStm32BoardKind(board.boardKind)) { diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 2eda2385..381c4fbc 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -122,6 +122,15 @@ export interface BoardInstance { // the bridge sees the boot-complete marker, and drives the "Booting…" overlay // and gates file uploads. Undefined/false for non-Pi boards and pre-boot. piBooted?: boolean; + /** QEMU-Linux boards only. Which engine this board is running on (or ran + * last): 'instant' = in-browser Python, 'linux' = the QEMU guest. Drives + * the mode chip and the terminal panel (interactive shell only exists in + * Linux mode). Undefined until the first run. */ + engineMode?: 'instant' | 'linux'; + /** User override for the engine, persisted with the project. Set by the + * mode chip or by turning on the Linux terminal; wins over the detector + * so a project doesn't silently change behaviour between runs. */ + enginePinned?: 'instant' | 'linux'; compiledProgram: string | null; // hex for AVR/RP2040, null for Pi (runs Python) serialOutput: string; serialBaudRate: number;