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;