diff --git a/backend/app/services/boot_images/manifest.json b/backend/app/services/boot_images/manifest.json index 2d3eca1c..8a33ac32 100644 --- a/backend/app/services/boot_images/manifest.json +++ b/backend/app/services/boot_images/manifest.json @@ -22,13 +22,13 @@ { "name": "velxio-pi-rootfs-arm64.ext4", "asset_id": "velxio-pi-rootfs-arm64-zst", - "sha256": "d6d4a2747d18055493a621f28bf2552e94dc2a11b1586d64b0eeb3847c7e06be", + "sha256": "a6e2d5ea33f14803cbc66fcd7829a24f5b9b4910c26701328e301090fa4e241c", "size_bytes": 629145600, - "version": "2026.05+phase2-shims-final", + "version": "2026.06+gpiozero-core", "compressed": { "encoding": "zstd", - "sha256": "debd1c339dc8a6e0c72494a14f032d69f3579f4f55929ca45446f030415602ca", - "size_bytes": 24733697 + "sha256": "b48f227c0a996228ae1b7da1b2d19921992d77cd056493995d11a52de0296670", + "size_bytes": 25046378 } } ] diff --git a/frontend/src/components/raspberry-pi/RaspberryPiWorkspace.tsx b/frontend/src/components/raspberry-pi/RaspberryPiWorkspace.tsx index 6533e35a..8561bd9c 100644 --- a/frontend/src/components/raspberry-pi/RaspberryPiWorkspace.tsx +++ b/frontend/src/components/raspberry-pi/RaspberryPiWorkspace.tsx @@ -27,6 +27,40 @@ interface OpenFile { filename: string; } +// Inline SVG icons (house style: no emoji). currentColor lets them inherit the +// surrounding text colour. +const PlayIcon: React.FC = () => ( + +); + +const TerminalIcon: React.FC = () => ( + +); + +const MonitorIcon: React.FC = () => ( + +); + +// Indeterminate spinner using SMIL (self-contained — no global CSS keyframes). +const BootSpinner: React.FC = () => ( + +); + export const RaspberryPiWorkspace: React.FC = ({ boardId }) => { const { t } = useTranslation(); const [activePane, setActivePane] = useState<'terminal' | string>('terminal'); // string = nodeId @@ -42,6 +76,12 @@ export const RaspberryPiWorkspace: React.FC = ({ boar // a hardcoded "Raspberry Pi 3B" — the workspace serves the whole Pi family. const boardLabel = board ? boardDisplayName(board) : 'Raspberry Pi'; + // Three display states: offline (!running) → booting (running, guest Linux + // still coming up) → ready (running + booted shell). `running` flips on click + // but the guest takes 30-60s; `piBooted` is the real "shell ready" signal. + const booting = !!board?.running && !board?.piBooted; + const booted = !!board?.running && !!board?.piBooted; + // Auto-connect terminal when board starts running useEffect(() => { if (!board?.running) { @@ -138,13 +178,15 @@ export const RaspberryPiWorkspace: React.FC = ({ boar - {board?.running - ? (bridgeConnected ? t('editor.pi.connected') : t('editor.pi.starting')) - : t('editor.pi.offline')} + {booted + ? t('editor.pi.connected') + : board?.running + ? t('editor.pi.starting') + : t('editor.pi.offline')} {!board?.running ? ( @@ -153,7 +195,7 @@ export const RaspberryPiWorkspace: React.FC = ({ boar onClick={() => startBoard(boardId)} title={t('editor.pi.powerOnTitle')} > - ▶ {t('editor.pi.startPi')} + {t('editor.pi.startPi')} ) : ( <> @@ -188,7 +230,7 @@ export const RaspberryPiWorkspace: React.FC = ({ boar }} onClick={() => setActivePane('terminal')} > - ⌨ {t('editor.pi.terminal')} + {t('editor.pi.terminal')} {/* File tabs */} @@ -222,11 +264,11 @@ export const RaspberryPiWorkspace: React.FC = ({ boar {!board?.running && (
-
🖥️
+
{t('editor.pi.offlineTitle', { board: boardLabel })}
{t('editor.pi.offlineSubtitle')}
{t('editor.pi.offlineNote1')} @@ -236,6 +278,19 @@ export const RaspberryPiWorkspace: React.FC = ({ boar
)} + + {/* Booting overlay — shown while the guest Linux comes up (~30-60s). + Without it the user clicks Start and sees nothing change for a + minute and assumes it is broken. */} + {booting && ( +
+
+
+
{t('editor.pi.bootingTitle', { board: boardLabel })}
+
{t('editor.pi.bootingNote')}
+
+
+ )} {activePane === 'terminal' ? ( {t('editor.pi.loadingTerminal')}
}> @@ -401,8 +456,13 @@ const styles: Record = { textAlign: 'center' as const, }, offlineIcon: { - fontSize: 40, - lineHeight: 1, + color: '#7a8290', + lineHeight: 0, + marginBottom: 2, + }, + bootingIcon: { + lineHeight: 0, + marginBottom: 2, }, offlineTitle: { color: '#ef9a9a', diff --git a/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx b/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx index 441f48df..f9b284d2 100644 --- a/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx +++ b/frontend/src/components/raspberry-pi/VirtualFileSystem.tsx @@ -7,7 +7,22 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useVfsStore } from '../../store/useVfsStore'; import type { VfsNode } from '../../store/useVfsStore'; -import { getBoardBridge } from '../../store/useSimulatorStore'; +import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore'; + +/** Resolve true once the board's guest Linux has booted to a shell + * (board.piBooted), or false after timeoutMs. Polls the store. */ +function waitForPiBooted(boardId: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const check = (): void => { + const b = useSimulatorStore.getState().boards.find((x) => x.id === boardId); + if (b?.piBooted) return resolve(true); + if (Date.now() > deadline) return resolve(false); + setTimeout(check, 250); + }; + check(); + }); +} // ── Icons ───────────────────────────────────────── @@ -330,47 +345,56 @@ export const VirtualFileSystem: React.FC = ({ boardId, o }; const handleUpload = async () => { - const bridge = getBoardBridge(boardId); - if (!bridge || !bridge.connected) { - alert('Pi is not connected. Start the simulation first.'); - return; - } - const files = serializeForUpload(boardId); - if (files.length === 0) return; + const sim = useSimulatorStore.getState(); + const board = sim.boards.find((b) => b.id === boardId); setUploadStatus('uploading'); - const enc = new TextEncoder(); - const send = (text: string) => bridge.sendSerialBytes(Array.from(enc.encode(text))); + // Auto-start the Pi if it isn't running, then wait for the guest shell — + // no more "Pi is not connected, start it first" dead-end. waitForPiBooted + // resolves false on timeout so we surface an error instead of hanging. + if (!board?.running) sim.startBoard(boardId); + const ready = await waitForPiBooted(boardId, 75_000); + const bridge = getBoardBridge(boardId); + if (!ready || !bridge || !bridge.connected) { + setUploadStatus('error'); + setTimeout(() => setUploadStatus('idle'), 3000); + return; + } - // Ensure clean prompt and root filesystem is mounted read-write - // (init=/bin/sh boots with rootfs read-only; 'rw' in cmdline fixes it but - // we also send the remount just in case) - send('\n'); - await new Promise((r) => setTimeout(r, 200)); - send('mount -o remount,rw / 2>/dev/null; true\n'); - await new Promise((r) => setTimeout(r, 400)); + const files = serializeForUpload(boardId); + if (files.length === 0) { + setUploadStatus('idle'); + return; + } + + // Flow-controlled sends: wait for the shell prompt to return after each + // command instead of guessing with fixed delays (long lines used to drop + // on the unflow-controlled console). Ensure a clean prompt + rw rootfs. + await bridge.sendAndWaitForPrompt('\n', 4000); + await bridge.sendAndWaitForPrompt('mount -o remount,rw / 2>/dev/null; true\n', 6000); for (const { path, content } of files) { - // Create parent directory if needed + // Create parent dir (before the heredoc, so the path exists). const dir = path.substring(0, path.lastIndexOf('/')); - if (dir) { - send(`mkdir -p ${dir}\n`); - await new Promise((r) => setTimeout(r, 150)); - } + if (dir) await bridge.sendAndWaitForPrompt(`mkdir -p ${dir}\n`, 6000); - // Write file via shell heredoc. - // Use a unique random delimiter so it never collides with file content. + // Write the file via a heredoc with a unique delimiter. Open it, stream + // the body in small chunks so the console FIFO doesn't overflow on large + // files, then close it and wait for the prompt. const delim = `VELXIO_${Math.random().toString(36).slice(2, 10).toUpperCase()}`; const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - // Send the entire heredoc as one string so the shell receives it atomically - send(`cat > ${path} << '${delim}'\n${normalized}\n${delim}\n`); - await new Promise((r) => setTimeout(r, 400)); + bridge.sendSerialText(`cat > ${path} << '${delim}'\n`); + const body = `${normalized}\n`; + for (let i = 0; i < body.length; i += 256) { + bridge.sendSerialText(body.slice(i, i + 256)); + await new Promise((r) => setTimeout(r, 25)); + } + await bridge.sendAndWaitForPrompt(`${delim}\n`, 8000); - // Make scripts executable + // Make scripts executable (after the file exists). if (path.endsWith('.py') || path.endsWith('.sh')) { - send(`chmod +x ${path}\n`); - await new Promise((r) => setTimeout(r, 100)); + await bridge.sendAndWaitForPrompt(`chmod +x ${path}\n`, 5000); } } @@ -407,9 +431,11 @@ export const VirtualFileSystem: React.FC = ({ boardId, o {uploadStatus === 'done' ? 'Done!' - : uploadStatus === 'uploading' - ? 'Sending…' - : 'Upload'} + : uploadStatus === 'error' + ? 'Failed' + : uploadStatus === 'uploading' + ? 'Sending…' + : 'Upload'} diff --git a/frontend/src/components/simulator/SerialMonitor.tsx b/frontend/src/components/simulator/SerialMonitor.tsx index 7b7327f3..9911e0c5 100644 --- a/frontend/src/components/simulator/SerialMonitor.tsx +++ b/frontend/src/components/simulator/SerialMonitor.tsx @@ -256,12 +256,17 @@ export const SerialMonitor: React.FC = () => {
         {activeBoard?.serialOutput
           ? (() => {
-              // ESP-IDF and many other firmwares emit ANSI SGR escapes
-              // (`\x1b[0;32m...`). The 
 renders them literally, so
-              // users saw raw `[0;32m` mixed into their Serial.print output.
-              // Strip them before any further processing — color isn't
-              // worth a full parser here.
-              const text = activeBoard.serialOutput.replace(/\x1b\[[0-9;]*m/g, '');
+              // Firmwares and the Pi's Linux console emit ANSI escapes the
+              // 
 can't render, so they leak through as literal text:
+              // SGR colour (`\x1b[0;32m`), and — on the Pi shell — the DSR
+              // cursor-position query `\x1b[6n` plus cursor moves / clears,
+              // which showed up as a stray `[6n` next to the prompt. Strip
+              // all CSI sequences (and keypad-mode toggles); a raw log 
+              // can't act on any of them anyway. (xterm in PiTerminal still
+              // parses + answers them — this only cleans the dumb mirror.)
+              const text = activeBoard.serialOutput
+                .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
+                .replace(/\x1b[=>]/g, '');
               // ESP32 (QEMU slirp) hands out 192.168.4.x; the Pico W virtual
               // net hands out 10.13.37.x. Both reach their emulated server
               // through the same /api/gateway proxy, so linkify either subnet.
diff --git a/frontend/src/i18n/locales/de/common2.json b/frontend/src/i18n/locales/de/common2.json
index ccc32c9b..cbc524ee 100644
--- a/frontend/src/i18n/locales/de/common2.json
+++ b/frontend/src/i18n/locales/de/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "Verbunden",
       "starting": "Starte…",
+      "bootingTitle": "{{board}} wird gestartet…",
+      "bootingNote": "Linux startet — das kann beim ersten Mal bis zu einer Minute dauern.",
       "offline": "Offline",
       "powerOnTitle": "Raspberry Pi einschalten",
       "startPi": "Pi starten",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} ist ausgeschaltet",
       "offlineSubtitle": "Starten Sie den Pi, um ein Terminal zu öffnen und mit dem Linux-System zu interagieren.",
       "offlineNote1": "Hinweis: Der Dateiexplorer zeigt einen Staging-Bereich an.",
-      "offlineNote2": "Verwenden Sie „Auf Pi hochladen“ nach dem Start, um Dateien auf den laufenden Pi zu übertragen.",
+      "offlineNote2": "„Auf den Pi hochladen“ überträgt diese Dateien auf das laufende System und startet den Pi bei Bedarf automatisch.",
       "loadingTerminal": "Terminal wird geladen…",
       "selectFile": "Wählen Sie eine Datei aus dem Explorer aus, um sie zu bearbeiten."
     },
diff --git a/frontend/src/i18n/locales/en/common2.json b/frontend/src/i18n/locales/en/common2.json
index b0a5d0a2..43591003 100644
--- a/frontend/src/i18n/locales/en/common2.json
+++ b/frontend/src/i18n/locales/en/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "Connected",
       "starting": "Starting…",
+      "bootingTitle": "Booting {{board}}…",
+      "bootingNote": "Starting Linux — this can take up to a minute the first time.",
       "offline": "Offline",
       "powerOnTitle": "Power on the Raspberry Pi",
       "startPi": "Start Pi",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} is powered off",
       "offlineSubtitle": "Start the Pi to open a terminal and interact with the Linux system.",
       "offlineNote1": "Note: The file explorer shows a staging area.",
-      "offlineNote2": "Use \"Upload to Pi\" after starting to transfer files to the running Pi.",
+      "offlineNote2": "\"Upload to Pi\" sends these files to the running system, starting the Pi automatically if needed.",
       "loadingTerminal": "Loading terminal…",
       "selectFile": "Select a file from the explorer to edit it."
     },
diff --git a/frontend/src/i18n/locales/es/common2.json b/frontend/src/i18n/locales/es/common2.json
index 0e53fdee..64710db6 100644
--- a/frontend/src/i18n/locales/es/common2.json
+++ b/frontend/src/i18n/locales/es/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "Conectado",
       "starting": "Iniciando…",
+      "bootingTitle": "Arrancando {{board}}…",
+      "bootingNote": "Iniciando Linux: puede tardar hasta un minuto la primera vez.",
       "offline": "Desconectado",
       "powerOnTitle": "Encender la Raspberry Pi",
       "startPi": "Iniciar Pi",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} está apagada",
       "offlineSubtitle": "Inicia la Pi para abrir un terminal e interactuar con el sistema Linux.",
       "offlineNote1": "Nota: El explorador de archivos muestra un área de preparación.",
-      "offlineNote2": "Usa \"Subir a Pi\" después de iniciar para transferir archivos a la Pi en ejecución.",
+      "offlineNote2": "\"Subir al Pi\" envía estos archivos al sistema en marcha; arranca el Pi automáticamente si hace falta.",
       "loadingTerminal": "Cargando terminal…",
       "selectFile": "Selecciona un archivo del explorador para editarlo."
     },
diff --git a/frontend/src/i18n/locales/fr/common2.json b/frontend/src/i18n/locales/fr/common2.json
index dd87b5bf..7430596b 100644
--- a/frontend/src/i18n/locales/fr/common2.json
+++ b/frontend/src/i18n/locales/fr/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "Connecté",
       "starting": "Démarrage…",
+      "bootingTitle": "Démarrage de {{board}}…",
+      "bootingNote": "Démarrage de Linux — cela peut prendre jusqu'à une minute la première fois.",
       "offline": "Hors ligne",
       "powerOnTitle": "Allumer le Raspberry Pi",
       "startPi": "Démarrer Pi",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} est éteint",
       "offlineSubtitle": "Démarrez le Pi pour ouvrir un terminal et interagir avec le système Linux.",
       "offlineNote1": "Remarque : L'explorateur de fichiers affiche une zone de préparation.",
-      "offlineNote2": "Utilisez \"Téléverser vers Pi\" après le démarrage pour transférer des fichiers vers le Pi en cours d'exécution.",
+      "offlineNote2": "« Envoyer vers le Pi » transfère ces fichiers vers le système en cours d'exécution et démarre le Pi automatiquement si nécessaire.",
       "loadingTerminal": "Chargement du terminal…",
       "selectFile": "Sélectionnez un fichier dans l'explorateur pour l'éditer."
     },
diff --git a/frontend/src/i18n/locales/it/common2.json b/frontend/src/i18n/locales/it/common2.json
index 5e175de3..625a68da 100644
--- a/frontend/src/i18n/locales/it/common2.json
+++ b/frontend/src/i18n/locales/it/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "Connesso",
       "starting": "Avvio…",
+      "bootingTitle": "Avvio di {{board}}…",
+      "bootingNote": "Avvio di Linux: può richiedere fino a un minuto la prima volta.",
       "offline": "Offline",
       "powerOnTitle": "Accendi il Raspberry Pi",
       "startPi": "Avvia Pi",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} è spento",
       "offlineSubtitle": "Avvia il Pi per aprire un terminale e interagire con il sistema Linux.",
       "offlineNote1": "Nota: L'esplora file mostra un'area di staging.",
-      "offlineNote2": "Usa \"Carica su Pi\" dopo l'avvio per trasferire file al Pi in esecuzione.",
+      "offlineNote2": "\"Carica sul Pi\" invia questi file al sistema in esecuzione e avvia il Pi automaticamente se necessario.",
       "loadingTerminal": "Caricamento terminale…",
       "selectFile": "Seleziona un file dall'esplora per modificarlo."
     },
diff --git a/frontend/src/i18n/locales/ja/common2.json b/frontend/src/i18n/locales/ja/common2.json
index 3a51ec11..764b657e 100644
--- a/frontend/src/i18n/locales/ja/common2.json
+++ b/frontend/src/i18n/locales/ja/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "接続済み",
       "starting": "起動中…",
+      "bootingTitle": "{{board}} を起動中…",
+      "bootingNote": "Linux を起動しています。初回は最大1分ほどかかることがあります。",
       "offline": "オフライン",
       "powerOnTitle": "Raspberry Pi の電源を入れる",
       "startPi": "Pi を起動",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} の電源がオフです",
       "offlineSubtitle": "Pi を起動して端末を開き、Linux システムと対話します。",
       "offlineNote1": "注: ファイルエクスプローラはステージング領域を表示します。",
-      "offlineNote2": "起動後、「Pi にアップロード」を使用してファイルを実行中の Pi に転送します。",
+      "offlineNote2": "「Pi にアップロード」は実行中のシステムにこれらのファイルを転送します。必要に応じて Pi を自動的に起動します。",
       "loadingTerminal": "端末を読み込み中…",
       "selectFile": "エクスプローラからファイルを選択して編集します。"
     },
diff --git a/frontend/src/i18n/locales/pt-br/common2.json b/frontend/src/i18n/locales/pt-br/common2.json
index f713fbf2..c64ad49f 100644
--- a/frontend/src/i18n/locales/pt-br/common2.json
+++ b/frontend/src/i18n/locales/pt-br/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "Conectado",
       "starting": "Iniciando…",
+      "bootingTitle": "Iniciando {{board}}…",
+      "bootingNote": "Iniciando o Linux — pode levar até um minuto na primeira vez.",
       "offline": "Offline",
       "powerOnTitle": "Ligar o Raspberry Pi",
       "startPi": "Iniciar Pi",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} está desligado",
       "offlineSubtitle": "Inicie o Pi para abrir um terminal e interagir com o sistema Linux.",
       "offlineNote1": "Nota: O explorador de arquivos mostra uma área de preparação.",
-      "offlineNote2": "Use \"Carregar para o Pi\" após iniciar para transferir arquivos para o Pi em execução.",
+      "offlineNote2": "\"Enviar para o Pi\" transfere esses arquivos para o sistema em execução e inicia o Pi automaticamente se necessário.",
       "loadingTerminal": "Carregando terminal…",
       "selectFile": "Selecione um arquivo no explorador para editá-lo."
     },
diff --git a/frontend/src/i18n/locales/ru/common2.json b/frontend/src/i18n/locales/ru/common2.json
index 85ce68aa..78c69598 100644
--- a/frontend/src/i18n/locales/ru/common2.json
+++ b/frontend/src/i18n/locales/ru/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "Подключено",
       "starting": "Запуск…",
+      "bootingTitle": "Запуск {{board}}…",
+      "bootingNote": "Загрузка Linux — в первый раз это может занять до минуты.",
       "offline": "Не в сети",
       "powerOnTitle": "Включить Raspberry Pi",
       "startPi": "Запустить Pi",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} выключен",
       "offlineSubtitle": "Запустите Pi, чтобы открыть терминал и взаимодействовать с системой Linux.",
       "offlineNote1": "Примечание: Проводник файлов показывает область подготовки.",
-      "offlineNote2": "Используйте «Загрузить на Pi» после запуска для передачи файлов на работающий Pi.",
+      "offlineNote2": "«Загрузить на Pi» отправляет эти файлы в работающую систему и при необходимости запускает Pi автоматически.",
       "loadingTerminal": "Загрузка терминала…",
       "selectFile": "Выберите файл в проводнике для редактирования."
     },
diff --git a/frontend/src/i18n/locales/zh-cn/common2.json b/frontend/src/i18n/locales/zh-cn/common2.json
index d2c42286..87ad5bb3 100644
--- a/frontend/src/i18n/locales/zh-cn/common2.json
+++ b/frontend/src/i18n/locales/zh-cn/common2.json
@@ -270,6 +270,8 @@
     "pi": {
       "connected": "已连接",
       "starting": "启动中…",
+      "bootingTitle": "正在启动 {{board}}…",
+      "bootingNote": "正在启动 Linux,首次启动最多可能需要一分钟。",
       "offline": "离线",
       "powerOnTitle": "开启 Raspberry Pi 电源",
       "startPi": "启动 Pi",
@@ -282,7 +284,7 @@
       "offlineTitle": "{{board}} 已关机",
       "offlineSubtitle": "启动 Pi 以打开终端并与 Linux 系统交互。",
       "offlineNote1": "注意:文件浏览器显示暂存区域。",
-      "offlineNote2": "启动后使用“上传到 Pi”将文件传输到运行中的 Pi。",
+      "offlineNote2": "“上传到 Pi”会将这些文件传输到正在运行的系统,并在需要时自动启动 Pi。",
       "loadingTerminal": "加载终端中…",
       "selectFile": "从浏览器中选择一个文件进行编辑。"
     },
diff --git a/frontend/src/simulation/RaspberryPi3Bridge.ts b/frontend/src/simulation/RaspberryPi3Bridge.ts
index 352c8dd3..7b2acdd0 100644
--- a/frontend/src/simulation/RaspberryPi3Bridge.ts
+++ b/frontend/src/simulation/RaspberryPi3Bridge.ts
@@ -56,9 +56,19 @@ export class RaspberryPi3Bridge {
   onDisconnected: (() => void) | null = null;
   onError: ((msg: string) => void) | null = null;
   onSystemEvent: ((event: string, data: Record) => void) | null = null;
+  /** Fires once when the guest Linux has finished booting and reached an
+   * interactive shell prompt. `connected` only means the WebSocket is open
+   * (~1s); the guest still takes 30-60s to boot. Drives the "booting" UI and
+   * gates file uploads. */
+  onBooted: (() => void) | null = null;
 
   private socket: WebSocket | null = null;
   private _connected = false;
+  private _booted = false;
+  /** Rolling, escape-stripped tail of recent guest output, used to detect the
+   * boot-complete marker and shell prompts for flow-controlled sends. */
+  private _serialTail = '';
+  private _promptWaiters: Array<() => void> = [];
 
   constructor(boardId: string, boardKind: string = 'raspberry-pi-3') {
     this.boardId = boardId;
@@ -101,6 +111,7 @@ export class RaspberryPi3Bridge {
           if (this.onSerialData) {
             for (const ch of text) this.onSerialData(ch);
           }
+          this._observeSerial(text);
           break;
         }
         case 'gpio_change': {
@@ -121,6 +132,7 @@ export class RaspberryPi3Bridge {
     socket.onclose = () => {
       this._connected = false;
       this.socket = null;
+      this._resetBootState();
       this.onDisconnected?.();
     };
 
@@ -137,6 +149,17 @@ export class RaspberryPi3Bridge {
       this.socket = null;
     }
     this._connected = false;
+    this._resetBootState();
+  }
+
+  /** Clear boot/prompt state and release any pending prompt waiters so an
+   * in-flight upload resolves (rather than hanging) when the Pi stops. */
+  private _resetBootState(): void {
+    this._booted = false;
+    this._serialTail = '';
+    const waiters = this._promptWaiters;
+    this._promptWaiters = [];
+    for (const w of waiters) w();
   }
 
   /** Send a byte to the Pi's ttyAMA0 (user serial) */
@@ -150,6 +173,63 @@ export class RaspberryPi3Bridge {
     this._send({ type: 'serial_input', data: { bytes } });
   }
 
+  /** Write a UTF-8 string to the guest shell. */
+  sendSerialText(text: string): void {
+    this.sendSerialBytes(Array.from(new TextEncoder().encode(text)));
+  }
+
+  /**
+   * Send a line to the guest shell and resolve once the shell prompt returns
+   * (i.e. the command has been consumed and executed), or after `timeoutMs`.
+   * This replaces fixed setTimeout pacing for uploads so long lines are not
+   * dropped by the unflow-controlled console. Always resolves (never rejects)
+   * so a missed prompt degrades to a time-bounded wait rather than a hang.
+   */
+  sendAndWaitForPrompt(text: string, timeoutMs = 5000): Promise {
+    return new Promise((resolve) => {
+      let done = false;
+      const finish = (): void => {
+        if (done) return;
+        done = true;
+        clearTimeout(timer);
+        const i = this._promptWaiters.indexOf(waiter);
+        if (i >= 0) this._promptWaiters.splice(i, 1);
+        resolve();
+      };
+      const waiter = finish;
+      const timer = setTimeout(finish, timeoutMs);
+      // Forget any prompt already on screen so we wait for the NEXT one,
+      // which only reappears after this command has run.
+      this._serialTail = '';
+      this._promptWaiters.push(waiter);
+      this.sendSerialText(text);
+    });
+  }
+
+  private static _stripAnsi(s: string): string {
+    return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\x1b[=>]/g, '');
+  }
+
+  /** Update the rolling tail, fire onBooted once, and release prompt waiters. */
+  private _observeSerial(text: string): void {
+    this._serialTail = (this._serialTail + text).slice(-512);
+    const clean = RaspberryPi3Bridge._stripAnsi(this._serialTail);
+    if (!this._booted && (/login on 'hvc0'/.test(clean) || /:~[#$]/.test(clean))) {
+      this._booted = true;
+      this.onBooted?.();
+    }
+    // A shell prompt ends in "# " / "$ " (trailing space, no newline). Trim
+    // only trailing spaces/tabs (not newlines) so command OUTPUT ending in
+    // "#\n" is NOT mistaken for a prompt; heredoc continuation "> " is also
+    // excluded since it ends in ">".
+    const promptTail = clean.replace(/[ \t]+$/, '');
+    if (this._promptWaiters.length && (promptTail.endsWith('#') || promptTail.endsWith('$'))) {
+      const waiters = this._promptWaiters;
+      this._promptWaiters = [];
+      for (const w of waiters) w();
+    }
+  }
+
   /** Drive a GPIO pin from an external source (e.g. connected Arduino) */
   sendPinEvent(gpioPin: number, state: boolean): void {
     this._send({ type: 'gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts
index 15753789..f3bb12ca 100644
--- a/frontend/src/store/useSimulatorStore.ts
+++ b/frontend/src/store/useSimulatorStore.ts
@@ -1057,6 +1057,23 @@ export const useSimulatorStore = create((set, get) => {
         bridge.onPinChange = (_gpioPin, _state) => {
           // Cross-board routing now handled by Interconnect (see bind below).
         };
+        // Guest Linux finished booting (shell prompt reached). Flip piBooted so
+        // the workspace swaps the "Booting…" overlay for the live terminal and
+        // uploads know the shell is ready.
+        bridge.onBooted = () => {
+          set((s) => ({
+            boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)),
+          }));
+        };
+        bridge.onDisconnected = () => {
+          set((s) => {
+            const boards = s.boards.map((b) =>
+              b.id === id ? { ...b, running: false, piBooted: false } : b,
+            );
+            const isActive = s.activeBoardId === id;
+            return { boards, ...(isActive ? { running: false } : {}) };
+          });
+        };
         bridgeMap.set(id, bridge);
       } else if (isEsp32Kind(boardKind)) {
         const bridge = new Esp32Bridge(id, boardKind);
diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts
index d97c949c..96ef65bb 100644
--- a/frontend/src/types/board.ts
+++ b/frontend/src/types/board.ts
@@ -83,6 +83,12 @@ export interface BoardInstance {
   x: number;
   y: number;
   running: boolean;
+  // QEMU-Linux (Raspberry Pi 3/4/5/Zero/1/2) only. `running` flips true the
+  // instant the user clicks Start (the WebSocket opens in ~1s), but the guest
+  // Linux still takes 30-60s to reach a shell. `piBooted` flips true only when
+  // 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;
   compiledProgram: string | null; // hex for AVR/RP2040, null for Pi (runs Python)
   serialOutput: string;
   serialBaudRate: number;