{activeBoard?.serialOutput
? (() => {
- // ESP-IDF and many other firmwares emit ANSI SGR escapes
- // (`\x1b[0;32m...`). The renders them literally, so
- // users saw raw `[0;32m` mixed into their Serial.print output.
- // Strip them before any further processing — color isn't
- // worth a full parser here.
- const text = activeBoard.serialOutput.replace(/\x1b\[[0-9;]*m/g, '');
+ // Firmwares and the Pi's Linux console emit ANSI escapes the
+ // can't render, so they leak through as literal text:
+ // SGR colour (`\x1b[0;32m`), and — on the Pi shell — the DSR
+ // cursor-position query `\x1b[6n` plus cursor moves / clears,
+ // which showed up as a stray `[6n` next to the prompt. Strip
+ // all CSI sequences (and keypad-mode toggles); a raw log
+ // can't act on any of them anyway. (xterm in PiTerminal still
+ // parses + answers them — this only cleans the dumb mirror.)
+ const text = activeBoard.serialOutput
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
+ .replace(/\x1b[=>]/g, '');
// ESP32 (QEMU slirp) hands out 192.168.4.x; the Pico W virtual
// net hands out 10.13.37.x. Both reach their emulated server
// through the same /api/gateway proxy, so linkify either subnet.
diff --git a/frontend/src/i18n/locales/de/common2.json b/frontend/src/i18n/locales/de/common2.json
index ccc32c9b..cbc524ee 100644
--- a/frontend/src/i18n/locales/de/common2.json
+++ b/frontend/src/i18n/locales/de/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "Verbunden",
"starting": "Starte…",
+ "bootingTitle": "{{board}} wird gestartet…",
+ "bootingNote": "Linux startet — das kann beim ersten Mal bis zu einer Minute dauern.",
"offline": "Offline",
"powerOnTitle": "Raspberry Pi einschalten",
"startPi": "Pi starten",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} ist ausgeschaltet",
"offlineSubtitle": "Starten Sie den Pi, um ein Terminal zu öffnen und mit dem Linux-System zu interagieren.",
"offlineNote1": "Hinweis: Der Dateiexplorer zeigt einen Staging-Bereich an.",
- "offlineNote2": "Verwenden Sie „Auf Pi hochladen“ nach dem Start, um Dateien auf den laufenden Pi zu übertragen.",
+ "offlineNote2": "„Auf den Pi hochladen“ überträgt diese Dateien auf das laufende System und startet den Pi bei Bedarf automatisch.",
"loadingTerminal": "Terminal wird geladen…",
"selectFile": "Wählen Sie eine Datei aus dem Explorer aus, um sie zu bearbeiten."
},
diff --git a/frontend/src/i18n/locales/en/common2.json b/frontend/src/i18n/locales/en/common2.json
index b0a5d0a2..43591003 100644
--- a/frontend/src/i18n/locales/en/common2.json
+++ b/frontend/src/i18n/locales/en/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "Connected",
"starting": "Starting…",
+ "bootingTitle": "Booting {{board}}…",
+ "bootingNote": "Starting Linux — this can take up to a minute the first time.",
"offline": "Offline",
"powerOnTitle": "Power on the Raspberry Pi",
"startPi": "Start Pi",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} is powered off",
"offlineSubtitle": "Start the Pi to open a terminal and interact with the Linux system.",
"offlineNote1": "Note: The file explorer shows a staging area.",
- "offlineNote2": "Use \"Upload to Pi\" after starting to transfer files to the running Pi.",
+ "offlineNote2": "\"Upload to Pi\" sends these files to the running system, starting the Pi automatically if needed.",
"loadingTerminal": "Loading terminal…",
"selectFile": "Select a file from the explorer to edit it."
},
diff --git a/frontend/src/i18n/locales/es/common2.json b/frontend/src/i18n/locales/es/common2.json
index 0e53fdee..64710db6 100644
--- a/frontend/src/i18n/locales/es/common2.json
+++ b/frontend/src/i18n/locales/es/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "Conectado",
"starting": "Iniciando…",
+ "bootingTitle": "Arrancando {{board}}…",
+ "bootingNote": "Iniciando Linux: puede tardar hasta un minuto la primera vez.",
"offline": "Desconectado",
"powerOnTitle": "Encender la Raspberry Pi",
"startPi": "Iniciar Pi",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} está apagada",
"offlineSubtitle": "Inicia la Pi para abrir un terminal e interactuar con el sistema Linux.",
"offlineNote1": "Nota: El explorador de archivos muestra un área de preparación.",
- "offlineNote2": "Usa \"Subir a Pi\" después de iniciar para transferir archivos a la Pi en ejecución.",
+ "offlineNote2": "\"Subir al Pi\" envía estos archivos al sistema en marcha; arranca el Pi automáticamente si hace falta.",
"loadingTerminal": "Cargando terminal…",
"selectFile": "Selecciona un archivo del explorador para editarlo."
},
diff --git a/frontend/src/i18n/locales/fr/common2.json b/frontend/src/i18n/locales/fr/common2.json
index dd87b5bf..7430596b 100644
--- a/frontend/src/i18n/locales/fr/common2.json
+++ b/frontend/src/i18n/locales/fr/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "Connecté",
"starting": "Démarrage…",
+ "bootingTitle": "Démarrage de {{board}}…",
+ "bootingNote": "Démarrage de Linux — cela peut prendre jusqu'à une minute la première fois.",
"offline": "Hors ligne",
"powerOnTitle": "Allumer le Raspberry Pi",
"startPi": "Démarrer Pi",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} est éteint",
"offlineSubtitle": "Démarrez le Pi pour ouvrir un terminal et interagir avec le système Linux.",
"offlineNote1": "Remarque : L'explorateur de fichiers affiche une zone de préparation.",
- "offlineNote2": "Utilisez \"Téléverser vers Pi\" après le démarrage pour transférer des fichiers vers le Pi en cours d'exécution.",
+ "offlineNote2": "« Envoyer vers le Pi » transfère ces fichiers vers le système en cours d'exécution et démarre le Pi automatiquement si nécessaire.",
"loadingTerminal": "Chargement du terminal…",
"selectFile": "Sélectionnez un fichier dans l'explorateur pour l'éditer."
},
diff --git a/frontend/src/i18n/locales/it/common2.json b/frontend/src/i18n/locales/it/common2.json
index 5e175de3..625a68da 100644
--- a/frontend/src/i18n/locales/it/common2.json
+++ b/frontend/src/i18n/locales/it/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "Connesso",
"starting": "Avvio…",
+ "bootingTitle": "Avvio di {{board}}…",
+ "bootingNote": "Avvio di Linux: può richiedere fino a un minuto la prima volta.",
"offline": "Offline",
"powerOnTitle": "Accendi il Raspberry Pi",
"startPi": "Avvia Pi",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} è spento",
"offlineSubtitle": "Avvia il Pi per aprire un terminale e interagire con il sistema Linux.",
"offlineNote1": "Nota: L'esplora file mostra un'area di staging.",
- "offlineNote2": "Usa \"Carica su Pi\" dopo l'avvio per trasferire file al Pi in esecuzione.",
+ "offlineNote2": "\"Carica sul Pi\" invia questi file al sistema in esecuzione e avvia il Pi automaticamente se necessario.",
"loadingTerminal": "Caricamento terminale…",
"selectFile": "Seleziona un file dall'esplora per modificarlo."
},
diff --git a/frontend/src/i18n/locales/ja/common2.json b/frontend/src/i18n/locales/ja/common2.json
index 3a51ec11..764b657e 100644
--- a/frontend/src/i18n/locales/ja/common2.json
+++ b/frontend/src/i18n/locales/ja/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "接続済み",
"starting": "起動中…",
+ "bootingTitle": "{{board}} を起動中…",
+ "bootingNote": "Linux を起動しています。初回は最大1分ほどかかることがあります。",
"offline": "オフライン",
"powerOnTitle": "Raspberry Pi の電源を入れる",
"startPi": "Pi を起動",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} の電源がオフです",
"offlineSubtitle": "Pi を起動して端末を開き、Linux システムと対話します。",
"offlineNote1": "注: ファイルエクスプローラはステージング領域を表示します。",
- "offlineNote2": "起動後、「Pi にアップロード」を使用してファイルを実行中の Pi に転送します。",
+ "offlineNote2": "「Pi にアップロード」は実行中のシステムにこれらのファイルを転送します。必要に応じて Pi を自動的に起動します。",
"loadingTerminal": "端末を読み込み中…",
"selectFile": "エクスプローラからファイルを選択して編集します。"
},
diff --git a/frontend/src/i18n/locales/pt-br/common2.json b/frontend/src/i18n/locales/pt-br/common2.json
index f713fbf2..c64ad49f 100644
--- a/frontend/src/i18n/locales/pt-br/common2.json
+++ b/frontend/src/i18n/locales/pt-br/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "Conectado",
"starting": "Iniciando…",
+ "bootingTitle": "Iniciando {{board}}…",
+ "bootingNote": "Iniciando o Linux — pode levar até um minuto na primeira vez.",
"offline": "Offline",
"powerOnTitle": "Ligar o Raspberry Pi",
"startPi": "Iniciar Pi",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} está desligado",
"offlineSubtitle": "Inicie o Pi para abrir um terminal e interagir com o sistema Linux.",
"offlineNote1": "Nota: O explorador de arquivos mostra uma área de preparação.",
- "offlineNote2": "Use \"Carregar para o Pi\" após iniciar para transferir arquivos para o Pi em execução.",
+ "offlineNote2": "\"Enviar para o Pi\" transfere esses arquivos para o sistema em execução e inicia o Pi automaticamente se necessário.",
"loadingTerminal": "Carregando terminal…",
"selectFile": "Selecione um arquivo no explorador para editá-lo."
},
diff --git a/frontend/src/i18n/locales/ru/common2.json b/frontend/src/i18n/locales/ru/common2.json
index 85ce68aa..78c69598 100644
--- a/frontend/src/i18n/locales/ru/common2.json
+++ b/frontend/src/i18n/locales/ru/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "Подключено",
"starting": "Запуск…",
+ "bootingTitle": "Запуск {{board}}…",
+ "bootingNote": "Загрузка Linux — в первый раз это может занять до минуты.",
"offline": "Не в сети",
"powerOnTitle": "Включить Raspberry Pi",
"startPi": "Запустить Pi",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} выключен",
"offlineSubtitle": "Запустите Pi, чтобы открыть терминал и взаимодействовать с системой Linux.",
"offlineNote1": "Примечание: Проводник файлов показывает область подготовки.",
- "offlineNote2": "Используйте «Загрузить на Pi» после запуска для передачи файлов на работающий Pi.",
+ "offlineNote2": "«Загрузить на Pi» отправляет эти файлы в работающую систему и при необходимости запускает Pi автоматически.",
"loadingTerminal": "Загрузка терминала…",
"selectFile": "Выберите файл в проводнике для редактирования."
},
diff --git a/frontend/src/i18n/locales/zh-cn/common2.json b/frontend/src/i18n/locales/zh-cn/common2.json
index d2c42286..87ad5bb3 100644
--- a/frontend/src/i18n/locales/zh-cn/common2.json
+++ b/frontend/src/i18n/locales/zh-cn/common2.json
@@ -270,6 +270,8 @@
"pi": {
"connected": "已连接",
"starting": "启动中…",
+ "bootingTitle": "正在启动 {{board}}…",
+ "bootingNote": "正在启动 Linux,首次启动最多可能需要一分钟。",
"offline": "离线",
"powerOnTitle": "开启 Raspberry Pi 电源",
"startPi": "启动 Pi",
@@ -282,7 +284,7 @@
"offlineTitle": "{{board}} 已关机",
"offlineSubtitle": "启动 Pi 以打开终端并与 Linux 系统交互。",
"offlineNote1": "注意:文件浏览器显示暂存区域。",
- "offlineNote2": "启动后使用“上传到 Pi”将文件传输到运行中的 Pi。",
+ "offlineNote2": "“上传到 Pi”会将这些文件传输到正在运行的系统,并在需要时自动启动 Pi。",
"loadingTerminal": "加载终端中…",
"selectFile": "从浏览器中选择一个文件进行编辑。"
},
diff --git a/frontend/src/simulation/RaspberryPi3Bridge.ts b/frontend/src/simulation/RaspberryPi3Bridge.ts
index 352c8dd3..7b2acdd0 100644
--- a/frontend/src/simulation/RaspberryPi3Bridge.ts
+++ b/frontend/src/simulation/RaspberryPi3Bridge.ts
@@ -56,9 +56,19 @@ export class RaspberryPi3Bridge {
onDisconnected: (() => void) | null = null;
onError: ((msg: string) => void) | null = null;
onSystemEvent: ((event: string, data: Record) => void) | null = null;
+ /** Fires once when the guest Linux has finished booting and reached an
+ * interactive shell prompt. `connected` only means the WebSocket is open
+ * (~1s); the guest still takes 30-60s to boot. Drives the "booting" UI and
+ * gates file uploads. */
+ onBooted: (() => void) | null = null;
private socket: WebSocket | null = null;
private _connected = false;
+ private _booted = false;
+ /** Rolling, escape-stripped tail of recent guest output, used to detect the
+ * boot-complete marker and shell prompts for flow-controlled sends. */
+ private _serialTail = '';
+ private _promptWaiters: Array<() => void> = [];
constructor(boardId: string, boardKind: string = 'raspberry-pi-3') {
this.boardId = boardId;
@@ -101,6 +111,7 @@ export class RaspberryPi3Bridge {
if (this.onSerialData) {
for (const ch of text) this.onSerialData(ch);
}
+ this._observeSerial(text);
break;
}
case 'gpio_change': {
@@ -121,6 +132,7 @@ export class RaspberryPi3Bridge {
socket.onclose = () => {
this._connected = false;
this.socket = null;
+ this._resetBootState();
this.onDisconnected?.();
};
@@ -137,6 +149,17 @@ export class RaspberryPi3Bridge {
this.socket = null;
}
this._connected = false;
+ this._resetBootState();
+ }
+
+ /** Clear boot/prompt state and release any pending prompt waiters so an
+ * in-flight upload resolves (rather than hanging) when the Pi stops. */
+ private _resetBootState(): void {
+ this._booted = false;
+ this._serialTail = '';
+ const waiters = this._promptWaiters;
+ this._promptWaiters = [];
+ for (const w of waiters) w();
}
/** Send a byte to the Pi's ttyAMA0 (user serial) */
@@ -150,6 +173,63 @@ export class RaspberryPi3Bridge {
this._send({ type: 'serial_input', data: { bytes } });
}
+ /** Write a UTF-8 string to the guest shell. */
+ sendSerialText(text: string): void {
+ this.sendSerialBytes(Array.from(new TextEncoder().encode(text)));
+ }
+
+ /**
+ * Send a line to the guest shell and resolve once the shell prompt returns
+ * (i.e. the command has been consumed and executed), or after `timeoutMs`.
+ * This replaces fixed setTimeout pacing for uploads so long lines are not
+ * dropped by the unflow-controlled console. Always resolves (never rejects)
+ * so a missed prompt degrades to a time-bounded wait rather than a hang.
+ */
+ sendAndWaitForPrompt(text: string, timeoutMs = 5000): Promise {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = (): void => {
+ if (done) return;
+ done = true;
+ clearTimeout(timer);
+ const i = this._promptWaiters.indexOf(waiter);
+ if (i >= 0) this._promptWaiters.splice(i, 1);
+ resolve();
+ };
+ const waiter = finish;
+ const timer = setTimeout(finish, timeoutMs);
+ // Forget any prompt already on screen so we wait for the NEXT one,
+ // which only reappears after this command has run.
+ this._serialTail = '';
+ this._promptWaiters.push(waiter);
+ this.sendSerialText(text);
+ });
+ }
+
+ private static _stripAnsi(s: string): string {
+ return s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/\x1b[=>]/g, '');
+ }
+
+ /** Update the rolling tail, fire onBooted once, and release prompt waiters. */
+ private _observeSerial(text: string): void {
+ this._serialTail = (this._serialTail + text).slice(-512);
+ const clean = RaspberryPi3Bridge._stripAnsi(this._serialTail);
+ if (!this._booted && (/login on 'hvc0'/.test(clean) || /:~[#$]/.test(clean))) {
+ this._booted = true;
+ this.onBooted?.();
+ }
+ // A shell prompt ends in "# " / "$ " (trailing space, no newline). Trim
+ // only trailing spaces/tabs (not newlines) so command OUTPUT ending in
+ // "#\n" is NOT mistaken for a prompt; heredoc continuation "> " is also
+ // excluded since it ends in ">".
+ const promptTail = clean.replace(/[ \t]+$/, '');
+ if (this._promptWaiters.length && (promptTail.endsWith('#') || promptTail.endsWith('$'))) {
+ const waiters = this._promptWaiters;
+ this._promptWaiters = [];
+ for (const w of waiters) w();
+ }
+ }
+
/** Drive a GPIO pin from an external source (e.g. connected Arduino) */
sendPinEvent(gpioPin: number, state: boolean): void {
this._send({ type: 'gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts
index 15753789..f3bb12ca 100644
--- a/frontend/src/store/useSimulatorStore.ts
+++ b/frontend/src/store/useSimulatorStore.ts
@@ -1057,6 +1057,23 @@ export const useSimulatorStore = create((set, get) => {
bridge.onPinChange = (_gpioPin, _state) => {
// Cross-board routing now handled by Interconnect (see bind below).
};
+ // Guest Linux finished booting (shell prompt reached). Flip piBooted so
+ // the workspace swaps the "Booting…" overlay for the live terminal and
+ // uploads know the shell is ready.
+ bridge.onBooted = () => {
+ set((s) => ({
+ boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)),
+ }));
+ };
+ bridge.onDisconnected = () => {
+ set((s) => {
+ const boards = s.boards.map((b) =>
+ b.id === id ? { ...b, running: false, piBooted: false } : b,
+ );
+ const isActive = s.activeBoardId === id;
+ return { boards, ...(isActive ? { running: false } : {}) };
+ });
+ };
bridgeMap.set(id, bridge);
} else if (isEsp32Kind(boardKind)) {
const bridge = new Esp32Bridge(id, boardKind);
diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts
index d97c949c..96ef65bb 100644
--- a/frontend/src/types/board.ts
+++ b/frontend/src/types/board.ts
@@ -83,6 +83,12 @@ export interface BoardInstance {
x: number;
y: number;
running: boolean;
+ // QEMU-Linux (Raspberry Pi 3/4/5/Zero/1/2) only. `running` flips true the
+ // instant the user clicks Start (the WebSocket opens in ~1s), but the guest
+ // Linux still takes 30-60s to reach a shell. `piBooted` flips true only when
+ // the bridge sees the boot-complete marker, and drives the "Booting…" overlay
+ // and gates file uploads. Undefined/false for non-Pi boards and pre-boot.
+ piBooted?: boolean;
compiledProgram: string | null; // hex for AVR/RP2040, null for Pi (runs Python)
serialOutput: string;
serialBaudRate: number;