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",
|
"name": "velxio-pi-rootfs-arm64.ext4",
|
||||||
"asset_id": "velxio-pi-rootfs-arm64-zst",
|
"asset_id": "velxio-pi-rootfs-arm64-zst",
|
||||||
"sha256": "d6d4a2747d18055493a621f28bf2552e94dc2a11b1586d64b0eeb3847c7e06be",
|
"sha256": "a6e2d5ea33f14803cbc66fcd7829a24f5b9b4910c26701328e301090fa4e241c",
|
||||||
"size_bytes": 629145600,
|
"size_bytes": 629145600,
|
||||||
"version": "2026.05+phase2-shims-final",
|
"version": "2026.06+gpiozero-core",
|
||||||
"compressed": {
|
"compressed": {
|
||||||
"encoding": "zstd",
|
"encoding": "zstd",
|
||||||
"sha256": "debd1c339dc8a6e0c72494a14f032d69f3579f4f55929ca45446f030415602ca",
|
"sha256": "b48f227c0a996228ae1b7da1b2d19921992d77cd056493995d11a52de0296670",
|
||||||
"size_bytes": 24733697
|
"size_bytes": 25046378
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,40 @@ interface OpenFile {
|
||||||
filename: string;
|
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 }) => {
|
export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boardId }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activePane, setActivePane] = useState<'terminal' | string>('terminal'); // string = nodeId
|
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.
|
// a hardcoded "Raspberry Pi 3B" — the workspace serves the whole Pi family.
|
||||||
const boardLabel = board ? boardDisplayName(board) : 'Raspberry Pi';
|
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
|
// Auto-connect terminal when board starts running
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!board?.running) {
|
if (!board?.running) {
|
||||||
|
|
@ -138,13 +178,15 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
...styles.statusDot,
|
...styles.statusDot,
|
||||||
background: board?.running ? (bridgeConnected ? '#4caf50' : '#f59e0b') : '#6b7280',
|
background: booted ? '#4caf50' : board?.running ? '#f59e0b' : '#6b7280',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={styles.statusLabel}>
|
<span style={styles.statusLabel}>
|
||||||
{board?.running
|
{booted
|
||||||
? (bridgeConnected ? t('editor.pi.connected') : t('editor.pi.starting'))
|
? t('editor.pi.connected')
|
||||||
: t('editor.pi.offline')}
|
: board?.running
|
||||||
|
? t('editor.pi.starting')
|
||||||
|
: t('editor.pi.offline')}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{!board?.running ? (
|
{!board?.running ? (
|
||||||
|
|
@ -153,7 +195,7 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
|
||||||
onClick={() => startBoard(boardId)}
|
onClick={() => startBoard(boardId)}
|
||||||
title={t('editor.pi.powerOnTitle')}
|
title={t('editor.pi.powerOnTitle')}
|
||||||
>
|
>
|
||||||
▶ {t('editor.pi.startPi')}
|
<PlayIcon />{t('editor.pi.startPi')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -188,7 +230,7 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
|
||||||
}}
|
}}
|
||||||
onClick={() => setActivePane('terminal')}
|
onClick={() => setActivePane('terminal')}
|
||||||
>
|
>
|
||||||
⌨ {t('editor.pi.terminal')}
|
<TerminalIcon />{t('editor.pi.terminal')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* File tabs */}
|
{/* File tabs */}
|
||||||
|
|
@ -222,11 +264,11 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
|
||||||
{!board?.running && (
|
{!board?.running && (
|
||||||
<div style={styles.offlineOverlay}>
|
<div style={styles.offlineOverlay}>
|
||||||
<div style={styles.offlineBox}>
|
<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.offlineTitle}>{t('editor.pi.offlineTitle', { board: boardLabel })}</div>
|
||||||
<div style={styles.offlineSubtitle}>{t('editor.pi.offlineSubtitle')}</div>
|
<div style={styles.offlineSubtitle}>{t('editor.pi.offlineSubtitle')}</div>
|
||||||
<button style={styles.startBtn} onClick={() => startBoard(boardId)}>
|
<button style={styles.startBtn} onClick={() => startBoard(boardId)}>
|
||||||
▶ {t('editor.pi.startPi')}
|
<PlayIcon />{t('editor.pi.startPi')}
|
||||||
</button>
|
</button>
|
||||||
<div style={styles.offlineNote}>
|
<div style={styles.offlineNote}>
|
||||||
{t('editor.pi.offlineNote1')}
|
{t('editor.pi.offlineNote1')}
|
||||||
|
|
@ -236,6 +278,19 @@ export const RaspberryPiWorkspace: React.FC<RaspberryPiWorkspaceProps> = ({ boar
|
||||||
</div>
|
</div>
|
||||||
</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' ? (
|
{activePane === 'terminal' ? (
|
||||||
<Suspense fallback={<div style={styles.loading}>{t('editor.pi.loadingTerminal')}</div>}>
|
<Suspense fallback={<div style={styles.loading}>{t('editor.pi.loadingTerminal')}</div>}>
|
||||||
<PiTerminal boardId={boardId} />
|
<PiTerminal boardId={boardId} />
|
||||||
|
|
@ -401,8 +456,13 @@ const styles: Record<string, React.CSSProperties> = {
|
||||||
textAlign: 'center' as const,
|
textAlign: 'center' as const,
|
||||||
},
|
},
|
||||||
offlineIcon: {
|
offlineIcon: {
|
||||||
fontSize: 40,
|
color: '#7a8290',
|
||||||
lineHeight: 1,
|
lineHeight: 0,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
bootingIcon: {
|
||||||
|
lineHeight: 0,
|
||||||
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
offlineTitle: {
|
offlineTitle: {
|
||||||
color: '#ef9a9a',
|
color: '#ef9a9a',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,22 @@
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useVfsStore } from '../../store/useVfsStore';
|
import { useVfsStore } from '../../store/useVfsStore';
|
||||||
import type { VfsNode } 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 ─────────────────────────────────────────
|
// ── Icons ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -330,47 +345,56 @@ export const VirtualFileSystem: React.FC<VirtualFileSystemProps> = ({ boardId, o
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
const bridge = getBoardBridge(boardId);
|
const sim = useSimulatorStore.getState();
|
||||||
if (!bridge || !bridge.connected) {
|
const board = sim.boards.find((b) => b.id === boardId);
|
||||||
alert('Pi is not connected. Start the simulation first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const files = serializeForUpload(boardId);
|
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
setUploadStatus('uploading');
|
setUploadStatus('uploading');
|
||||||
|
|
||||||
const enc = new TextEncoder();
|
// Auto-start the Pi if it isn't running, then wait for the guest shell —
|
||||||
const send = (text: string) => bridge.sendSerialBytes(Array.from(enc.encode(text)));
|
// 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
|
const files = serializeForUpload(boardId);
|
||||||
// (init=/bin/sh boots with rootfs read-only; 'rw' in cmdline fixes it but
|
if (files.length === 0) {
|
||||||
// we also send the remount just in case)
|
setUploadStatus('idle');
|
||||||
send('\n');
|
return;
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
}
|
||||||
send('mount -o remount,rw / 2>/dev/null; true\n');
|
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
// 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) {
|
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('/'));
|
const dir = path.substring(0, path.lastIndexOf('/'));
|
||||||
if (dir) {
|
if (dir) await bridge.sendAndWaitForPrompt(`mkdir -p ${dir}\n`, 6000);
|
||||||
send(`mkdir -p ${dir}\n`);
|
|
||||||
await new Promise((r) => setTimeout(r, 150));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write file via shell heredoc.
|
// Write the file via a heredoc with a unique delimiter. Open it, stream
|
||||||
// Use a unique random delimiter so it never collides with file content.
|
// 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 delim = `VELXIO_${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
|
||||||
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
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
|
bridge.sendSerialText(`cat > ${path} << '${delim}'\n`);
|
||||||
send(`cat > ${path} << '${delim}'\n${normalized}\n${delim}\n`);
|
const body = `${normalized}\n`;
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
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')) {
|
if (path.endsWith('.py') || path.endsWith('.sh')) {
|
||||||
send(`chmod +x ${path}\n`);
|
await bridge.sendAndWaitForPrompt(`chmod +x ${path}\n`, 5000);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,9 +431,11 @@ export const VirtualFileSystem: React.FC<VirtualFileSystemProps> = ({ boardId, o
|
||||||
<span style={{ marginLeft: 4, fontSize: 10 }}>
|
<span style={{ marginLeft: 4, fontSize: 10 }}>
|
||||||
{uploadStatus === 'done'
|
{uploadStatus === 'done'
|
||||||
? 'Done!'
|
? 'Done!'
|
||||||
: uploadStatus === 'uploading'
|
: uploadStatus === 'error'
|
||||||
? 'Sending…'
|
? 'Failed'
|
||||||
: 'Upload'}
|
: uploadStatus === 'uploading'
|
||||||
|
? 'Sending…'
|
||||||
|
: 'Upload'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -256,12 +256,17 @@ export const SerialMonitor: React.FC = () => {
|
||||||
<pre ref={outputRef} style={styles.output}>
|
<pre ref={outputRef} style={styles.output}>
|
||||||
{activeBoard?.serialOutput
|
{activeBoard?.serialOutput
|
||||||
? (() => {
|
? (() => {
|
||||||
// ESP-IDF and many other firmwares emit ANSI SGR escapes
|
// Firmwares and the Pi's Linux console emit ANSI escapes the
|
||||||
// (`\x1b[0;32m...`). The <pre> renders them literally, so
|
// <pre> can't render, so they leak through as literal text:
|
||||||
// users saw raw `[0;32m` mixed into their Serial.print output.
|
// SGR colour (`\x1b[0;32m`), and — on the Pi shell — the DSR
|
||||||
// Strip them before any further processing — color isn't
|
// cursor-position query `\x1b[6n` plus cursor moves / clears,
|
||||||
// worth a full parser here.
|
// which showed up as a stray `[6n` next to the prompt. Strip
|
||||||
const text = activeBoard.serialOutput.replace(/\x1b\[[0-9;]*m/g, '');
|
// 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
|
// 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
|
// net hands out 10.13.37.x. Both reach their emulated server
|
||||||
// through the same /api/gateway proxy, so linkify either subnet.
|
// through the same /api/gateway proxy, so linkify either subnet.
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "Verbunden",
|
"connected": "Verbunden",
|
||||||
"starting": "Starte…",
|
"starting": "Starte…",
|
||||||
|
"bootingTitle": "{{board}} wird gestartet…",
|
||||||
|
"bootingNote": "Linux startet — das kann beim ersten Mal bis zu einer Minute dauern.",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"powerOnTitle": "Raspberry Pi einschalten",
|
"powerOnTitle": "Raspberry Pi einschalten",
|
||||||
"startPi": "Pi starten",
|
"startPi": "Pi starten",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} ist ausgeschaltet",
|
"offlineTitle": "{{board}} ist ausgeschaltet",
|
||||||
"offlineSubtitle": "Starten Sie den Pi, um ein Terminal zu öffnen und mit dem Linux-System zu interagieren.",
|
"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.",
|
"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…",
|
"loadingTerminal": "Terminal wird geladen…",
|
||||||
"selectFile": "Wählen Sie eine Datei aus dem Explorer aus, um sie zu bearbeiten."
|
"selectFile": "Wählen Sie eine Datei aus dem Explorer aus, um sie zu bearbeiten."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"starting": "Starting…",
|
"starting": "Starting…",
|
||||||
|
"bootingTitle": "Booting {{board}}…",
|
||||||
|
"bootingNote": "Starting Linux — this can take up to a minute the first time.",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"powerOnTitle": "Power on the Raspberry Pi",
|
"powerOnTitle": "Power on the Raspberry Pi",
|
||||||
"startPi": "Start Pi",
|
"startPi": "Start Pi",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} is powered off",
|
"offlineTitle": "{{board}} is powered off",
|
||||||
"offlineSubtitle": "Start the Pi to open a terminal and interact with the Linux system.",
|
"offlineSubtitle": "Start the Pi to open a terminal and interact with the Linux system.",
|
||||||
"offlineNote1": "Note: The file explorer shows a staging area.",
|
"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…",
|
"loadingTerminal": "Loading terminal…",
|
||||||
"selectFile": "Select a file from the explorer to edit it."
|
"selectFile": "Select a file from the explorer to edit it."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "Conectado",
|
"connected": "Conectado",
|
||||||
"starting": "Iniciando…",
|
"starting": "Iniciando…",
|
||||||
|
"bootingTitle": "Arrancando {{board}}…",
|
||||||
|
"bootingNote": "Iniciando Linux: puede tardar hasta un minuto la primera vez.",
|
||||||
"offline": "Desconectado",
|
"offline": "Desconectado",
|
||||||
"powerOnTitle": "Encender la Raspberry Pi",
|
"powerOnTitle": "Encender la Raspberry Pi",
|
||||||
"startPi": "Iniciar Pi",
|
"startPi": "Iniciar Pi",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} está apagada",
|
"offlineTitle": "{{board}} está apagada",
|
||||||
"offlineSubtitle": "Inicia la Pi para abrir un terminal e interactuar con el sistema Linux.",
|
"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.",
|
"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…",
|
"loadingTerminal": "Cargando terminal…",
|
||||||
"selectFile": "Selecciona un archivo del explorador para editarlo."
|
"selectFile": "Selecciona un archivo del explorador para editarlo."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "Connecté",
|
"connected": "Connecté",
|
||||||
"starting": "Démarrage…",
|
"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",
|
"offline": "Hors ligne",
|
||||||
"powerOnTitle": "Allumer le Raspberry Pi",
|
"powerOnTitle": "Allumer le Raspberry Pi",
|
||||||
"startPi": "Démarrer Pi",
|
"startPi": "Démarrer Pi",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} est éteint",
|
"offlineTitle": "{{board}} est éteint",
|
||||||
"offlineSubtitle": "Démarrez le Pi pour ouvrir un terminal et interagir avec le système Linux.",
|
"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.",
|
"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…",
|
"loadingTerminal": "Chargement du terminal…",
|
||||||
"selectFile": "Sélectionnez un fichier dans l'explorateur pour l'éditer."
|
"selectFile": "Sélectionnez un fichier dans l'explorateur pour l'éditer."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "Connesso",
|
"connected": "Connesso",
|
||||||
"starting": "Avvio…",
|
"starting": "Avvio…",
|
||||||
|
"bootingTitle": "Avvio di {{board}}…",
|
||||||
|
"bootingNote": "Avvio di Linux: può richiedere fino a un minuto la prima volta.",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"powerOnTitle": "Accendi il Raspberry Pi",
|
"powerOnTitle": "Accendi il Raspberry Pi",
|
||||||
"startPi": "Avvia Pi",
|
"startPi": "Avvia Pi",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} è spento",
|
"offlineTitle": "{{board}} è spento",
|
||||||
"offlineSubtitle": "Avvia il Pi per aprire un terminale e interagire con il sistema Linux.",
|
"offlineSubtitle": "Avvia il Pi per aprire un terminale e interagire con il sistema Linux.",
|
||||||
"offlineNote1": "Nota: L'esplora file mostra un'area di staging.",
|
"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…",
|
"loadingTerminal": "Caricamento terminale…",
|
||||||
"selectFile": "Seleziona un file dall'esplora per modificarlo."
|
"selectFile": "Seleziona un file dall'esplora per modificarlo."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "接続済み",
|
"connected": "接続済み",
|
||||||
"starting": "起動中…",
|
"starting": "起動中…",
|
||||||
|
"bootingTitle": "{{board}} を起動中…",
|
||||||
|
"bootingNote": "Linux を起動しています。初回は最大1分ほどかかることがあります。",
|
||||||
"offline": "オフライン",
|
"offline": "オフライン",
|
||||||
"powerOnTitle": "Raspberry Pi の電源を入れる",
|
"powerOnTitle": "Raspberry Pi の電源を入れる",
|
||||||
"startPi": "Pi を起動",
|
"startPi": "Pi を起動",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} の電源がオフです",
|
"offlineTitle": "{{board}} の電源がオフです",
|
||||||
"offlineSubtitle": "Pi を起動して端末を開き、Linux システムと対話します。",
|
"offlineSubtitle": "Pi を起動して端末を開き、Linux システムと対話します。",
|
||||||
"offlineNote1": "注: ファイルエクスプローラはステージング領域を表示します。",
|
"offlineNote1": "注: ファイルエクスプローラはステージング領域を表示します。",
|
||||||
"offlineNote2": "起動後、「Pi にアップロード」を使用してファイルを実行中の Pi に転送します。",
|
"offlineNote2": "「Pi にアップロード」は実行中のシステムにこれらのファイルを転送します。必要に応じて Pi を自動的に起動します。",
|
||||||
"loadingTerminal": "端末を読み込み中…",
|
"loadingTerminal": "端末を読み込み中…",
|
||||||
"selectFile": "エクスプローラからファイルを選択して編集します。"
|
"selectFile": "エクスプローラからファイルを選択して編集します。"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "Conectado",
|
"connected": "Conectado",
|
||||||
"starting": "Iniciando…",
|
"starting": "Iniciando…",
|
||||||
|
"bootingTitle": "Iniciando {{board}}…",
|
||||||
|
"bootingNote": "Iniciando o Linux — pode levar até um minuto na primeira vez.",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"powerOnTitle": "Ligar o Raspberry Pi",
|
"powerOnTitle": "Ligar o Raspberry Pi",
|
||||||
"startPi": "Iniciar Pi",
|
"startPi": "Iniciar Pi",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} está desligado",
|
"offlineTitle": "{{board}} está desligado",
|
||||||
"offlineSubtitle": "Inicie o Pi para abrir um terminal e interagir com o sistema Linux.",
|
"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.",
|
"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…",
|
"loadingTerminal": "Carregando terminal…",
|
||||||
"selectFile": "Selecione um arquivo no explorador para editá-lo."
|
"selectFile": "Selecione um arquivo no explorador para editá-lo."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "Подключено",
|
"connected": "Подключено",
|
||||||
"starting": "Запуск…",
|
"starting": "Запуск…",
|
||||||
|
"bootingTitle": "Запуск {{board}}…",
|
||||||
|
"bootingNote": "Загрузка Linux — в первый раз это может занять до минуты.",
|
||||||
"offline": "Не в сети",
|
"offline": "Не в сети",
|
||||||
"powerOnTitle": "Включить Raspberry Pi",
|
"powerOnTitle": "Включить Raspberry Pi",
|
||||||
"startPi": "Запустить Pi",
|
"startPi": "Запустить Pi",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} выключен",
|
"offlineTitle": "{{board}} выключен",
|
||||||
"offlineSubtitle": "Запустите Pi, чтобы открыть терминал и взаимодействовать с системой Linux.",
|
"offlineSubtitle": "Запустите Pi, чтобы открыть терминал и взаимодействовать с системой Linux.",
|
||||||
"offlineNote1": "Примечание: Проводник файлов показывает область подготовки.",
|
"offlineNote1": "Примечание: Проводник файлов показывает область подготовки.",
|
||||||
"offlineNote2": "Используйте «Загрузить на Pi» после запуска для передачи файлов на работающий Pi.",
|
"offlineNote2": "«Загрузить на Pi» отправляет эти файлы в работающую систему и при необходимости запускает Pi автоматически.",
|
||||||
"loadingTerminal": "Загрузка терминала…",
|
"loadingTerminal": "Загрузка терминала…",
|
||||||
"selectFile": "Выберите файл в проводнике для редактирования."
|
"selectFile": "Выберите файл в проводнике для редактирования."
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,8 @@
|
||||||
"pi": {
|
"pi": {
|
||||||
"connected": "已连接",
|
"connected": "已连接",
|
||||||
"starting": "启动中…",
|
"starting": "启动中…",
|
||||||
|
"bootingTitle": "正在启动 {{board}}…",
|
||||||
|
"bootingNote": "正在启动 Linux,首次启动最多可能需要一分钟。",
|
||||||
"offline": "离线",
|
"offline": "离线",
|
||||||
"powerOnTitle": "开启 Raspberry Pi 电源",
|
"powerOnTitle": "开启 Raspberry Pi 电源",
|
||||||
"startPi": "启动 Pi",
|
"startPi": "启动 Pi",
|
||||||
|
|
@ -282,7 +284,7 @@
|
||||||
"offlineTitle": "{{board}} 已关机",
|
"offlineTitle": "{{board}} 已关机",
|
||||||
"offlineSubtitle": "启动 Pi 以打开终端并与 Linux 系统交互。",
|
"offlineSubtitle": "启动 Pi 以打开终端并与 Linux 系统交互。",
|
||||||
"offlineNote1": "注意:文件浏览器显示暂存区域。",
|
"offlineNote1": "注意:文件浏览器显示暂存区域。",
|
||||||
"offlineNote2": "启动后使用“上传到 Pi”将文件传输到运行中的 Pi。",
|
"offlineNote2": "“上传到 Pi”会将这些文件传输到正在运行的系统,并在需要时自动启动 Pi。",
|
||||||
"loadingTerminal": "加载终端中…",
|
"loadingTerminal": "加载终端中…",
|
||||||
"selectFile": "从浏览器中选择一个文件进行编辑。"
|
"selectFile": "从浏览器中选择一个文件进行编辑。"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,19 @@ export class RaspberryPi3Bridge {
|
||||||
onDisconnected: (() => void) | null = null;
|
onDisconnected: (() => void) | null = null;
|
||||||
onError: ((msg: string) => void) | null = null;
|
onError: ((msg: string) => void) | null = null;
|
||||||
onSystemEvent: ((event: string, data: Record<string, unknown>) => 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 socket: WebSocket | null = null;
|
||||||
private _connected = false;
|
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') {
|
constructor(boardId: string, boardKind: string = 'raspberry-pi-3') {
|
||||||
this.boardId = boardId;
|
this.boardId = boardId;
|
||||||
|
|
@ -101,6 +111,7 @@ export class RaspberryPi3Bridge {
|
||||||
if (this.onSerialData) {
|
if (this.onSerialData) {
|
||||||
for (const ch of text) this.onSerialData(ch);
|
for (const ch of text) this.onSerialData(ch);
|
||||||
}
|
}
|
||||||
|
this._observeSerial(text);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'gpio_change': {
|
case 'gpio_change': {
|
||||||
|
|
@ -121,6 +132,7 @@ export class RaspberryPi3Bridge {
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
this._connected = false;
|
this._connected = false;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
|
this._resetBootState();
|
||||||
this.onDisconnected?.();
|
this.onDisconnected?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -137,6 +149,17 @@ export class RaspberryPi3Bridge {
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
this._connected = false;
|
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) */
|
/** Send a byte to the Pi's ttyAMA0 (user serial) */
|
||||||
|
|
@ -150,6 +173,63 @@ export class RaspberryPi3Bridge {
|
||||||
this._send({ type: 'serial_input', data: { bytes } });
|
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) */
|
/** Drive a GPIO pin from an external source (e.g. connected Arduino) */
|
||||||
sendPinEvent(gpioPin: number, state: boolean): void {
|
sendPinEvent(gpioPin: number, state: boolean): void {
|
||||||
this._send({ type: 'gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
|
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) => {
|
bridge.onPinChange = (_gpioPin, _state) => {
|
||||||
// Cross-board routing now handled by Interconnect (see bind below).
|
// 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);
|
bridgeMap.set(id, bridge);
|
||||||
} else if (isEsp32Kind(boardKind)) {
|
} else if (isEsp32Kind(boardKind)) {
|
||||||
const bridge = new Esp32Bridge(id, boardKind);
|
const bridge = new Esp32Bridge(id, boardKind);
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,12 @@ export interface BoardInstance {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
running: boolean;
|
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)
|
compiledProgram: string | null; // hex for AVR/RP2040, null for Pi (runs Python)
|
||||||
serialOutput: string;
|
serialOutput: string;
|
||||||
serialBaudRate: number;
|
serialBaudRate: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue