feat(pi): gpiozero core + boot feedback, terminal and upload UX

- boot_images manifest: bump arm64 rootfs (gpiozero/colorzero baked in,
  hostname applied at boot, reworded MOTD)
- RaspberryPi3Bridge: onBooted shell-ready detector + sendAndWaitForPrompt
  flow control (resets on disconnect)
- RaspberryPiWorkspace: distinct Booting overlay + piBooted-driven status,
  inline SVG icons replacing emoji glyphs
- SerialMonitor: strip CSI/DSR escapes so the dumb console no longer shows
  a literal [6n next to the prompt
- VirtualFileSystem: upload auto-starts the Pi and waits for the shell, then
  flow-controls each command (no more dropped lines on large files)
- i18n: bootingTitle/bootingNote + reworded offlineNote2 across 9 locales
This commit is contained in:
David Montero 2026-06-22 20:21:06 +02:00
parent eb6a59adf6
commit 03339a132a
16 changed files with 274 additions and 62 deletions

View File

@ -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
}
}
]

View File

@ -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 = () => (
<svg width="11" height="11" viewBox="0 0 10 10" aria-hidden="true" style={{ marginRight: 5, verticalAlign: -1 }}>
<path d="M2 1.2 L8.5 5 L2 8.8 Z" fill="currentColor" />
</svg>
);
const TerminalIcon: React.FC = () => (
<svg width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" style={{ marginRight: 4, verticalAlign: -1 }}>
<rect x="1" y="2.5" width="14" height="11" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path d="M4 6 L6.5 8 L4 10" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="8" y1="10.3" x2="11.5" y2="10.3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
);
const MonitorIcon: React.FC = () => (
<svg width="40" height="40" viewBox="0 0 24 24" aria-hidden="true">
<rect x="2.5" y="3.5" width="19" height="13" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
<line x1="9" y1="20.5" x2="15" y2="20.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="12" y1="16.5" x2="12" y2="20.5" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
// Indeterminate spinner using SMIL (self-contained — no global CSS keyframes).
const BootSpinner: React.FC = () => (
<svg width="34" height="34" viewBox="0 0 44 44" aria-hidden="true">
<circle cx="22" cy="22" r="18" fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="4" />
<path d="M22 4 a18 18 0 0 1 18 18" fill="none" stroke="#4fc3f7" strokeWidth="4" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 22 22" to="360 22 22" dur="0.9s" repeatCount="indefinite" />
</path>
</svg>
);
export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boardId }) => {
const { t } = useTranslation();
const [activePane, setActivePane] = useState<'terminal' | string>('terminal'); // string = nodeId
@ -42,6 +76,12 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ 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,12 +178,14 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
<span
style={{
...styles.statusDot,
background: board?.running ? (bridgeConnected ? '#4caf50' : '#f59e0b') : '#6b7280',
background: booted ? '#4caf50' : board?.running ? '#f59e0b' : '#6b7280',
}}
/>
<span style={styles.statusLabel}>
{board?.running
? (bridgeConnected ? t('editor.pi.connected') : t('editor.pi.starting'))
{booted
? t('editor.pi.connected')
: board?.running
? t('editor.pi.starting')
: t('editor.pi.offline')}
</span>
@ -153,7 +195,7 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
onClick={() => startBoard(boardId)}
title={t('editor.pi.powerOnTitle')}
>
▶ {t('editor.pi.startPi')}
<PlayIcon />{t('editor.pi.startPi')}
</button>
) : (
<>
@ -188,7 +230,7 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
}}
onClick={() => setActivePane('terminal')}
>
⌨ {t('editor.pi.terminal')}
<TerminalIcon />{t('editor.pi.terminal')}
</button>
{/* File tabs */}
@ -222,11 +264,11 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
{!board?.running && (
<div style={styles.offlineOverlay}>
<div style={styles.offlineBox}>
<div style={styles.offlineIcon}>🖥️</div>
<div style={styles.offlineIcon}><MonitorIcon /></div>
<div style={styles.offlineTitle}>{t('editor.pi.offlineTitle', { board: boardLabel })}</div>
<div style={styles.offlineSubtitle}>{t('editor.pi.offlineSubtitle')}</div>
<button style={styles.startBtn} onClick={() => startBoard(boardId)}>
▶ {t('editor.pi.startPi')}
<PlayIcon />{t('editor.pi.startPi')}
</button>
<div style={styles.offlineNote}>
{t('editor.pi.offlineNote1')}
@ -236,6 +278,19 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
</div>
</div>
)}
{/* 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 && (
<div style={styles.offlineOverlay}>
<div style={styles.offlineBox}>
<div style={styles.bootingIcon}><BootSpinner /></div>
<div style={styles.offlineTitle}>{t('editor.pi.bootingTitle', { board: boardLabel })}</div>
<div style={styles.offlineSubtitle}>{t('editor.pi.bootingNote')}</div>
</div>
</div>
)}
{activePane === 'terminal' ? (
<Suspense fallback={<div style={styles.loading}>{t('editor.pi.loadingTerminal')}</div>}>
<PiTerminal boardId={boardId} />
@ -401,8 +456,13 @@ const styles: Record<string, React.CSSProperties> = {
textAlign: 'center' as const,
},
offlineIcon: {
fontSize: 40,
lineHeight: 1,
color: '#7a8290',
lineHeight: 0,
marginBottom: 2,
},
bootingIcon: {
lineHeight: 0,
marginBottom: 2,
},
offlineTitle: {
color: '#ef9a9a',

View File

@ -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<boolean> {
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<VirtualFileSystemProps> = ({ 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)));
// 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));
for (const { path, content } of files) {
// Create parent directory if needed
const dir = path.substring(0, path.lastIndexOf('/'));
if (dir) {
send(`mkdir -p ${dir}\n`);
await new Promise((r) => setTimeout(r, 150));
// 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;
}
// Write file via shell heredoc.
// Use a unique random delimiter so it never collides with file content.
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 dir (before the heredoc, so the path exists).
const dir = path.substring(0, path.lastIndexOf('/'));
if (dir) await bridge.sendAndWaitForPrompt(`mkdir -p ${dir}\n`, 6000);
// 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,6 +431,8 @@ export const VirtualFileSystem: React.FC<VirtualFileSystemProps> = ({ boardId, o
<span style={{ marginLeft: 4, fontSize: 10 }}>
{uploadStatus === 'done'
? 'Done!'
: uploadStatus === 'error'
? 'Failed'
: uploadStatus === 'uploading'
? 'Sending…'
: 'Upload'}

View File

@ -256,12 +256,17 @@ export const SerialMonitor: React.FC = () => {
<pre ref={outputRef} style={styles.output}>
{activeBoard?.serialOutput
? (() => {
// ESP-IDF and many other firmwares emit ANSI SGR escapes
// (`\x1b[0;32m...`). The <pre> 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
// <pre> 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 <pre>
// 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "エクスプローラからファイルを選択して編集します。"
},

View File

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

View File

@ -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": "Выберите файл в проводнике для редактирования."
},

View File

@ -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": "从浏览器中选择一个文件进行编辑。"
},

View File

@ -56,9 +56,19 @@ export class RaspberryPi3Bridge {
onDisconnected: (() => void) | null = null;
onError: ((msg: string) => void) | null = null;
onSystemEvent: ((event: string, data: Record<string, unknown>) => 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<void> {
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 } });

View File

@ -1057,6 +1057,23 @@ export const useSimulatorStore = create<SimulatorState>((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);

View File

@ -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;