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:
parent
eb6a59adf6
commit
03339a132a
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,13 +178,15 @@ 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'))
|
||||
: t('editor.pi.offline')}
|
||||
{booted
|
||||
? t('editor.pi.connected')
|
||||
: board?.running
|
||||
? t('editor.pi.starting')
|
||||
: t('editor.pi.offline')}
|
||||
</span>
|
||||
|
||||
{!board?.running ? (
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
// 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<VirtualFileSystemProps> = ({ boardId, o
|
|||
<span style={{ marginLeft: 4, fontSize: 10 }}>
|
||||
{uploadStatus === 'done'
|
||||
? 'Done!'
|
||||
: uploadStatus === 'uploading'
|
||||
? 'Sending…'
|
||||
: 'Upload'}
|
||||
: uploadStatus === 'error'
|
||||
? 'Failed'
|
||||
: uploadStatus === 'uploading'
|
||||
? 'Sending…'
|
||||
: 'Upload'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "エクスプローラからファイルを選択して編集します。"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "Выберите файл в проводнике для редактирования."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": "从浏览器中选择一个文件进行编辑。"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue