diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index b8c16198..e0749c9a 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useEditorStore, chipFileGroupId } from '../../store/useEditorStore'; -import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { useSimulatorStore, piRerunScript } from '../../store/useSimulatorStore'; import { useElectricalStore } from '../../store/useElectricalStore'; import { type VerificationResult } from '../../simulation/verify/circuitVerifier'; import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore'; @@ -814,6 +814,33 @@ export const EditorToolbar = ({ // QEMU boards: auto-compile if no firmware available yet if (isQemuBoard) { console.log('[handleRun] QEMU path'); + // QEMU-Linux boards (Raspberry Pi family + overlay piFamily kinds) + // boot straight from the rootfs — there is no firmware to compile, + // and handleCompile's Pi early-return never sets compiledProgram, so + // the gate below would surface a bogus "Compilation produced no + // firmware" error. Power the board on directly; if the guest is + // ALREADY booted, skip the ~45 s reboot: interrupt the running + // script, re-upload the edited files and run again. This branch must + // stay ABOVE the generic stop-then-boot restart below, which would + // otherwise power-cycle the live guest. + if (isPiBoardKind(board?.boardKind ?? '')) { + trackRunSimulation(board?.boardKind); + reportRun(board?.boardKind); + if (board?.running && board?.piBooted) { + console.log('[handleRun] → piRerunScript (booted, no reboot)', activeBoardId); + await piRerunScript(activeBoardId, board.boardKind); + } else { + if (board?.running) { + // Running but never reached the shell (stuck/zombie): power-cycle. + stopBoard(activeBoardId); + await new Promise((resolve) => setTimeout(resolve, 300)); + } + console.log('[handleRun] → startBoard (QEMU-Linux, no firmware)', activeBoardId); + startBoard(activeBoardId); + } + setMessage(null); + return; + } // Clean restart when the board is already running. Esp32Bridge.connect() // is a no-op while the socket is non-CLOSED, so startBoard() on a live // session does NOTHING — and if the backend QEMU session has since died @@ -827,19 +854,6 @@ export const EditorToolbar = ({ stopBoard(activeBoardId); await new Promise((resolve) => setTimeout(resolve, 300)); } - // QEMU-Linux boards (Raspberry Pi family + overlay piFamily kinds) - // boot straight from the rootfs — there is no firmware to compile, - // and handleCompile's Pi early-return never sets compiledProgram, so - // the gate below would surface a bogus "Compilation produced no - // firmware" error. Power the board on directly instead. - if (isPiBoardKind(board?.boardKind ?? '')) { - trackRunSimulation(board?.boardKind); - reportRun(board?.boardKind); - console.log('[handleRun] → startBoard (QEMU-Linux, no firmware)', activeBoardId); - startBoard(activeBoardId); - setMessage(null); - return; - } if (!board?.compiledProgram || codeChangedSinceLastCompile) { console.log('[handleRun] auto-compile + run'); autoRunAfterCompile.current = true; diff --git a/frontend/src/components/editor/FileExplorer.tsx b/frontend/src/components/editor/FileExplorer.tsx index 1c0d28d2..bf5186f5 100644 --- a/frontend/src/components/editor/FileExplorer.tsx +++ b/frontend/src/components/editor/FileExplorer.tsx @@ -9,7 +9,7 @@ import { DEFAULT_CHIP_PROGRAM_C, } from '../../services/romCompileService'; import type { BoardKind } from '../../types/board'; -import { boardDisplayName } from '../../types/board'; +import { boardDisplayName, isPiBoardKind } from '../../types/board'; import { importProjectFile, PROJECT_FILE_ACCEPT } from '../../utils/importProject'; import { showMessageDialog, showConfirmDialog } from '../../store/useMessageDialogStore'; import './FileExplorer.css'; @@ -761,7 +761,9 @@ export const FileExplorer: React.FC = ({ onSaveClick, onNewCl (compile scope), grouped with the board's code so it is clear which board it belongs to. There is one per board. Clicking switches to the board and opens the Library - Manager on its list. */} + Manager on its list. QEMU-Linux boards run Python in a + guest OS — no arduino-cli manifest, so no row. */} + {!isPiBoardKind(board.boardKind) && (
= ({ onSaveClick, onNewCl {board.libraries?.length ?? 0}
+ )} )} diff --git a/frontend/src/components/raspberry-pi/PiTerminal.tsx b/frontend/src/components/raspberry-pi/PiTerminal.tsx index 08f8e400..22e470fb 100644 --- a/frontend/src/components/raspberry-pi/PiTerminal.tsx +++ b/frontend/src/components/raspberry-pi/PiTerminal.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useRef } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; -import { getBoardBridge } from '../../store/useSimulatorStore'; +import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore'; import '@xterm/xterm/css/xterm.css'; interface PiTerminalProps { @@ -58,6 +58,13 @@ export const PiTerminal: React.FC = ({ boardId }) => { termRef.current = term; fitAddonRef.current = fitAddon; + // Seed with the session's accumulated output so mounting mid-boot (the + // panel opens on demand) shows the boot log instead of a blank screen. + const history = useSimulatorStore + .getState() + .boards.find((b) => b.id === boardId)?.serialOutput; + if (history) term.write(history.slice(-8000)); + // Wire terminal input → Pi bridge const onDataDispose = term.onData((data) => { const bridge = getBoardBridge(boardId); diff --git a/frontend/src/components/simulator/SerialMonitor.tsx b/frontend/src/components/simulator/SerialMonitor.tsx index b7cc1268..7ab3d34e 100644 --- a/frontend/src/components/simulator/SerialMonitor.tsx +++ b/frontend/src/components/simulator/SerialMonitor.tsx @@ -9,7 +9,8 @@ import { useSimulatorStore } from '../../store/useSimulatorStore'; import { getTabSessionId } from '../../simulation/Esp32Bridge'; import { openDeviceGateway } from '../../lib/openDeviceGateway'; import type { BoardKind } from '../../types/board'; -import { boardDisplayName } from '../../types/board'; +import { boardDisplayName, isPiBoardKind } from '../../types/board'; +import { PiTerminal } from '../raspberry-pi/PiTerminal'; // Short labels for tabs const BOARD_SHORT_LABEL: Partial> = { @@ -252,7 +253,14 @@ export const SerialMonitor: React.FC = () => { - {/* Output area */} + {/* 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 ? ( +
+ +
+ ) : (
         {activeBoard?.serialOutput
           ? (() => {
@@ -332,8 +340,10 @@ export const SerialMonitor: React.FC = () => {
             ? t('editor.serial.waitingData') + '\n'
             : t('editor.serial.startSim') + '\n'}
       
+ )} - {/* Input row */} + {/* Input row — the xterm handles Pi input itself */} + {!(isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running) && (
{ {t('editor.serial.send')}
+ )} ); }; diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 0e2d00b4..826bd759 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -2,7 +2,7 @@ * Editor Page — main editor + simulator with resizable panels */ -import React, { useRef, useState, useCallback, useEffect, lazy, Suspense } from 'react'; +import React, { useRef, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { startSimulation } from '../simulation/spice/start'; import { useSEO } from '../utils/useSEO'; @@ -13,11 +13,6 @@ import { EditorToolbar } from '../components/editor/EditorToolbar'; import { FileExplorer } from '../components/editor/FileExplorer'; // Lazy-load Pi workspace so xterm.js isn't in the main bundle -const RaspberryPiWorkspace = lazy(() => - import('../components/raspberry-pi/RaspberryPiWorkspace').then((m) => ({ - default: m.RaspberryPiWorkspace, - })), -); import { CompilationConsole } from '../components/editor/CompilationConsole'; import { SimulatorCanvas } from '../components/simulator/SimulatorCanvas'; import { SerialMonitor } from '../components/simulator/SerialMonitor'; @@ -33,7 +28,6 @@ import { useProjectStore } from '../store/useProjectStore'; import { showConfirmDialog } from '../store/useMessageDialogStore'; import { useAutoSaveProject } from '../hooks/useAutoSaveProject'; import type { CompilationLog } from '../utils/compilationLogger'; -import { isPiBoardKind } from '../types/board'; import '../App.css'; const MOBILE_BREAKPOINT = 768; @@ -77,12 +71,6 @@ export const EditorPage: React.FC = () => { const resizingRef = useRef(false); const serialMonitorOpen = useSimulatorStore((s) => s.serialMonitorOpen); const activeBoardId = useSimulatorStore((s) => s.activeBoardId); - const activeBoardKind = useSimulatorStore( - (s) => s.boards.find((b) => b.id === s.activeBoardId)?.boardKind, - ); - // Pi 3/4/5 and Zero/1/2 all run the QEMU Linux workspace (terminal + Python), - // not the Arduino/Monaco editor. Pico (RP2040) is browser-emulated, not a Pi here. - const isLinuxPi = isPiBoardKind(activeBoardKind ?? ''); const oscilloscopeOpen = useOscilloscopeStore((s) => s.open); const [consoleOpen, setConsoleOpen] = useState(false); // compileLogs live in a Zustand store so the velxio-pro agent overlay @@ -599,21 +587,14 @@ export const EditorPage: React.FC = () => { )} - {/* Editor area: Pi workspace or Monaco editor */} + {/* Editor area — Monaco for every board, QEMU-Linux included. + Pi boards edit script.py here like any other board edits its + sketch; their interactive terminal lives in the bottom serial + panel (SerialMonitor renders an xterm for Pi kinds). The old + RaspberryPiWorkspace (own file tree + own editor + upload + button) confused users with three competing file surfaces. */}
- {isLinuxPi && activeBoardId ? ( - - Loading Pi workspace… -
- } - > - - - ) : ( - - )} + {/* Console */} diff --git a/frontend/src/store/useEditorStore.ts b/frontend/src/store/useEditorStore.ts index a43f7603..051b5f4e 100644 --- a/frontend/src/store/useEditorStore.ts +++ b/frontend/src/store/useEditorStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { generateUUID } from '../utils/uuid'; +import { isPiBoardKind } from '../types/board'; export interface WorkspaceFile { id: string; @@ -384,11 +385,13 @@ export const useEditorStore = create((set, get) => ({ })); } else { // Determine default file by group name convention or language mode. - // All Linux Pi boards (Zero/1/2/3/4/5) default to script.py; the Pico - // (RP2040) is browser-emulated and not a Linux Pi. Mirrors - // isPiBoardKind() in types/board.ts, adapted to the `group-` convention. - const isPi = - groupId.includes('raspberry-pi-') && !groupId.includes('raspberry-pi-pico'); + // All QEMU-Linux boards (Pi Zero/1/2/3/4/5 plus overlay piFamily + // kinds like the UNIHIKER) default to script.py. Group ids follow + // `group-` and the first board of a kind uses the kind as + // its id ('-N' suffix for later instances), so strip both and ask + // isPiBoardKind — the same predicate every other Pi code path uses. + const boardIdPart = groupId.replace(/^group-/, '').replace(/-\d+$/, ''); + const isPi = isPiBoardKind(boardIdPart); const isMicroPython = languageMode === 'micropython'; const mainId = `${groupId}-main`; let fileName: string; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 5786b456..c0b908de 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -762,6 +762,36 @@ const stm32BridgeMap = new Map(); export const getBoardSimulator = (id: string) => simulatorMap.get(id); export const getBoardPinManager = (id: string) => pinManagerMap.get(id); export const getBoardBridge = (id: string) => bridgeMap.get(id); + +/** Upload a QEMU-Linux board's editor file group into the guest home and run + * its script (guestHome/autoRun overridable per overlay board). Used by the + * boot auto-run and by Run on an already-booted board. */ +export async function piSyncAndRunScript(boardId: string, boardKind: string): Promise { + const bridge = bridgeMap.get(boardId); + if (!bridge || !bridge.connected) return; + const proDef = getProBoard(boardKind); + const home = (proDef?.guestHome ?? '/home/pi').replace(/\/+$/, ''); + const board = useSimulatorStore.getState().boards.find((b) => b.id === boardId); + const groupId = board?.activeFileGroupId ?? `group-${boardId}`; + const files = useEditorStore + .getState() + .getGroupFiles(groupId) + .map((f) => ({ path: `${home}/${f.name}`, content: f.content })); + const { uploadFilesToPi } = await import('../utils/piUpload'); + await uploadFilesToPi(bridge, files); + const cmd = proDef?.autoRun ?? `python3 ${home}/script.py`; + bridge.sendSerialText(cmd.endsWith('\n') ? cmd : cmd + '\n'); +} + +/** Re-run on a BOOTED QEMU-Linux board without rebooting: interrupt the + * running script (Ctrl-C), re-upload the file group, run again. */ +export async function piRerunScript(boardId: string, boardKind: string): Promise { + const bridge = bridgeMap.get(boardId); + if (!bridge || !bridge.connected) return; + bridge.sendSerialBytes([0x03]); + await new Promise((r) => setTimeout(r, 400)); + await piSyncAndRunScript(boardId, boardKind); +} export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id); export const getStm32Bridge = (id: string) => stm32BridgeMap.get(id); @@ -1255,28 +1285,21 @@ export const useSimulatorStore = create((set, get) => { set((s) => ({ boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)), })); - // Overlay boards may declare autoRun: after boot (+setup) the VFS - // is uploaded and the command executed, so one click on Run boots, - // uploads and starts the user's script — same UX as every other - // board's compile-and-run. - const autoRun = async (): Promise => { - const cmd = proDef?.autoRun; - if (!cmd) return; - try { - const files = useVfsStore.getState().serializeForUpload(id); - const { uploadFilesToPi } = await import('../utils/piUpload'); - await uploadFilesToPi(bridge, files); - bridge.sendSerialText(cmd.endsWith('\n') ? cmd : cmd + '\n'); - } catch (e) { - console.warn(`[${boardKind}] autoRun failed:`, e); - } - }; + // After boot (+setup) the board's editor file group is uploaded + // into the guest home and the run command executed, so one click + // on Run boots, uploads and starts the user's script — same UX as + // every other board's compile-and-run. Overlay boards can override + // home/command via guestHome/autoRun. void (async () => { if (setup) { await bridge.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n'); } flip(); - await autoRun(); + try { + await piSyncAndRunScript(id, boardKind); + } catch (e) { + console.warn(`[${boardKind}] auto-run failed:`, e); + } })(); }; bridge.onDisconnected = () => { @@ -1865,6 +1888,10 @@ 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 }); } else if (isEsp32Kind(board.boardKind)) { // Pre-register sensors connected to this board so the QEMU worker // has them ready before the firmware starts executing. diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index 86c81701..ca08061f 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -176,6 +176,18 @@ export async function loadExample( } if (eb.vfsFiles && isPiBoardKind(eb.boardKind)) { + // Pi example scripts go into the board's REGULAR file group — they + // are edited in Monaco like any other board's code, and the run + // path uploads the group into the guest home. (The old separate + // VFS tree + panel + editor confused users with three file + // surfaces; the VFS store is still updated for back-compat with + // anything reading it, but the editor group is the source of truth.) + const groupFiles = Object.entries(eb.vfsFiles).map(([name, content]) => ({ + name, + content, + })); + useEditorStore.getState().setActiveGroup(board.activeFileGroupId); + useEditorStore.getState().loadFiles(groupFiles); const vfsState = useVfsStore.getState(); const tree = vfsState.getTree(boardId); for (const [nodeId, node] of Object.entries(tree)) {