feat(pi-family): one-click run UX — autoRun + guestHome + clean serial mirror

- ProBoardDef.autoRun: after boot (+guestSetup) the VFS uploads itself
  and the command runs, so a single Run click boots, uploads and starts
  the user's script (same UX as compiled boards)
- ProBoardDef.guestHome: VFS home dir override ('/root' for guests that
  log in as root); those boards drop the historic hello.sh sample
- upload sequence extracted to utils/piUpload (shared by the VFS panel
  button and autoRun)
- serial monitor strips DEL/C0 control echoes (backspace showed tofu)
This commit is contained in:
David Montero Crespo 2026-07-28 16:28:02 +02:00
parent ebe2ab9d5a
commit dd86020343
6 changed files with 135 additions and 68 deletions

View File

@ -10,6 +10,7 @@ import { useVfsStore } from '../../store/useVfsStore';
import type { VfsNode } from '../../store/useVfsStore'; import type { VfsNode } from '../../store/useVfsStore';
import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore'; import { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore';
import { showConfirmDialog } from '../../store/useMessageDialogStore'; import { showConfirmDialog } from '../../store/useMessageDialogStore';
import { uploadFilesToPi } from '../../utils/piUpload';
/** Resolve true once the board's guest Linux has booted to a shell /** Resolve true once the board's guest Linux has booted to a shell
* (board.piBooted), or false after timeoutMs. Polls the store. */ * (board.piBooted), or false after timeoutMs. Polls the store. */
@ -381,35 +382,7 @@ export const VirtualFileSystem: React.FC<VirtualFileSystemProps> = ({ boardId, o
return; return;
} }
// Flow-controlled sends: wait for the shell prompt to return after each await uploadFilesToPi(bridge, files);
// 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');
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 (after the file exists).
if (path.endsWith('.py') || path.endsWith('.sh')) {
await bridge.sendAndWaitForPrompt(`chmod +x ${path}\n`, 5000);
}
}
setUploadStatus('done'); setUploadStatus('done');
setTimeout(() => setUploadStatus('idle'), 2500); setTimeout(() => setUploadStatus('idle'), 2500);

View File

@ -266,7 +266,11 @@ export const SerialMonitor: React.FC = () => {
// parses + answers them — this only cleans the dumb mirror.) // parses + answers them — this only cleans the dumb mirror.)
const text = activeBoard.serialOutput const text = activeBoard.serialOutput
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
.replace(/\x1b[=>]/g, ''); .replace(/\x1b[=>]/g, '')
// Line-editing bytes the guest shell echoes (DEL on
// backspace, BEL, other C0 controls) render as tofu boxes
// in a <pre>; strip everything except \t \n \r.
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/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.

View File

@ -63,6 +63,14 @@ export interface ProBoardDef {
* prompt (piFamily boards only). Lets a board de-brand the generic * prompt (piFamily boards only). Lets a board de-brand the generic
* image, e.g. set its own hostname/PS1 and clear the stock motd. */ * image, e.g. set its own hostname/PS1 and clear the stock motd. */
guestSetup?: string; guestSetup?: string;
/** Home directory of the guest user for the VFS panel and uploads
* (piFamily boards only; default '/home/pi'). Boards whose guest logs
* in as root pass '/root'; these also drop the hello.sh sample. */
guestHome?: string;
/** Shell command run automatically after boot: the VFS is uploaded and
* this line executed, so a single click on Run boots, uploads and
* starts the user's script (piFamily boards only). */
autoRun?: string;
/** Canvas renderer. Receives the placed board's props; return a React node. /** Canvas renderer. Receives the placed board's props; return a React node.
* When omitted, the canvas renders `<tag id=... style=absolute@x,y>`. */ * When omitted, the canvas renders `<tag id=... style=absolute@x,y>`. */
render?: (props: { id: string; x: number; y: number; running: boolean }) => React.ReactNode; render?: (props: { id: string; x: number; y: number; running: boolean }) => React.ReactNode;

View File

@ -1249,18 +1249,35 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// it runs before piBooted flips so the VFS upload (gated on piBooted) // it runs before piBooted flips so the VFS upload (gated on piBooted)
// cannot interleave with it. // cannot interleave with it.
bridge.onBooted = () => { bridge.onBooted = () => {
const setup = getProBoard(boardKind)?.guestSetup; const proDef = getProBoard(boardKind);
const setup = proDef?.guestSetup;
const flip = () => const flip = () =>
set((s) => ({ set((s) => ({
boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)), boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)),
})); }));
if (setup) { // Overlay boards may declare autoRun: after boot (+setup) the VFS
void bridge // is uploaded and the command executed, so one click on Run boots,
.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n') // uploads and starts the user's script — same UX as every other
.then(flip); // board's compile-and-run.
} else { const autoRun = async (): Promise<void> => {
const cmd = proDef?.autoRun;
if (!cmd) return;
try {
const files = useVfsStore.getState().serializeForUpload(id);
const { uploadFilesToPi } = await import('../utils/piUpload');
await uploadFilesToPi(bridge, files);
bridge.sendSerialText(cmd.endsWith('\n') ? cmd : cmd + '\n');
} catch (e) {
console.warn(`[${boardKind}] autoRun failed:`, e);
}
};
void (async () => {
if (setup) {
await bridge.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n');
}
flip(); flip();
} await autoRun();
})();
}; };
bridge.onDisconnected = () => { bridge.onDisconnected = () => {
set((s) => { set((s) => {
@ -1388,9 +1405,14 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (get().activeBoardId === id) { if (get().activeBoardId === id) {
useEditorStore.getState().setActiveGroup(`group-${id}`); useEditorStore.getState().setActiveGroup(`group-${id}`);
} }
// Init VFS for Raspberry Pi 3 boards // Init VFS for QEMU-Linux boards. Overlay boards may declare their
// guest home (e.g. '/root' when the guest logs in as root); those
// also drop the historic hello.sh sample.
if (isPiBoardKind(boardKind)) { if (isPiBoardKind(boardKind)) {
useVfsStore.getState().initBoardVfs(id); const home = getProBoard(boardKind)?.guestHome;
useVfsStore
.getState()
.initBoardVfs(id, home ? { home, withShellSample: false } : undefined);
} }
// ── Interconnect: register the board and rebuild routes ────────── // ── Interconnect: register the board and rebuild routes ──────────
icBindBoard(id, boardKind); icBindBoard(id, boardKind);

View File

@ -36,38 +36,54 @@ const DEFAULT_SH_CONTENT = `#!/bin/bash
echo "Hello from Pi!" echo "Hello from Pi!"
`; `;
function makeDefaultTree(): { tree: VfsTree; rootId: string } { export interface VfsInitOptions {
const rootId = nanoid(8); /** Home directory path for the default tree (default '/home/pi'). Overlay
const homeId = nanoid(8); * boards whose guest logs in as root pass '/root'. */
const piId = nanoid(8); home?: string;
const scriptId = nanoid(8); /** Include the hello.sh shell sample (default true — historic Pi VFS). */
const shellId = nanoid(8); withShellSample?: boolean;
}
function makeDefaultTree(opts?: VfsInitOptions): { tree: VfsTree; rootId: string } {
const home = (opts?.home ?? '/home/pi').replace(/^\/+|\/+$/g, '');
const withShell = opts?.withShellSample ?? true;
const segments = home.split('/').filter(Boolean);
const rootId = nanoid(8);
const tree: VfsTree = { const tree: VfsTree = {
[rootId]: { id: rootId, name: '/', type: 'directory', children: [homeId], parentId: null }, [rootId]: { id: rootId, name: '/', type: 'directory', children: [], parentId: null },
[homeId]: { id: homeId, name: 'home', type: 'directory', children: [piId], parentId: rootId }, };
[piId]: {
id: piId, // Build the home directory chain (e.g. home/pi, or just root).
name: 'pi', let parentId = rootId;
type: 'directory', for (const name of segments) {
children: [scriptId, shellId], const dirId = nanoid(8);
parentId: homeId, tree[dirId] = { id: dirId, name, type: 'directory', children: [], parentId };
}, tree[parentId].children!.push(dirId);
[scriptId]: { parentId = dirId;
id: scriptId, }
name: 'script.py',
type: 'file', const scriptId = nanoid(8);
content: DEFAULT_PY_CONTENT, tree[scriptId] = {
parentId: piId, id: scriptId,
}, name: 'script.py',
[shellId]: { type: 'file',
content: DEFAULT_PY_CONTENT,
parentId,
};
tree[parentId].children!.push(scriptId);
if (withShell) {
const shellId = nanoid(8);
tree[shellId] = {
id: shellId, id: shellId,
name: 'hello.sh', name: 'hello.sh',
type: 'file', type: 'file',
content: DEFAULT_SH_CONTENT, content: DEFAULT_SH_CONTENT,
parentId: piId, parentId,
}, };
}; tree[parentId].children!.push(shellId);
}
return { tree, rootId }; return { tree, rootId };
} }
@ -78,7 +94,7 @@ interface VfsState {
// Per-board: boardId → selected nodeId (for editor focus) // Per-board: boardId → selected nodeId (for editor focus)
selectedNodeId: Record<string, string | null>; selectedNodeId: Record<string, string | null>;
initBoardVfs: (boardId: string) => void; initBoardVfs: (boardId: string, opts?: VfsInitOptions) => void;
getTree: (boardId: string) => VfsTree; getTree: (boardId: string) => VfsTree;
getRootId: (boardId: string) => string | null; getRootId: (boardId: string) => string | null;
getNode: (boardId: string, nodeId: string) => VfsNode | null; getNode: (boardId: string, nodeId: string) => VfsNode | null;
@ -131,9 +147,9 @@ export const useVfsStore = create<VfsState>((set, get) => ({
boards: {}, boards: {},
selectedNodeId: {}, selectedNodeId: {},
initBoardVfs: (boardId) => { initBoardVfs: (boardId, opts) => {
if (get().boards[boardId]) return; // already initialized if (get().boards[boardId]) return; // already initialized
const { tree, rootId } = makeDefaultTree(); const { tree, rootId } = makeDefaultTree(opts);
set((s) => ({ set((s) => ({
boards: { ...s.boards, [boardId]: { tree, rootId } }, boards: { ...s.boards, [boardId]: { tree, rootId } },
selectedNodeId: { ...s.selectedNodeId, [boardId]: null }, selectedNodeId: { ...s.selectedNodeId, [boardId]: null },

View File

@ -0,0 +1,44 @@
/**
* piUpload flow-controlled file upload into a running QEMU-Linux guest
* over the serial console (heredocs, prompt-gated). Extracted from the
* VirtualFileSystem panel so the auto-run path (ProBoardDef.autoRun) can
* reuse the exact same sequence.
*/
import type { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge';
export async function uploadFilesToPi(
bridge: RaspberryPi3Bridge,
files: Array<{ path: string; content: string }>,
): Promise<void> {
if (files.length === 0) 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');
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 (after the file exists).
if (path.endsWith('.py') || path.endsWith('.sh')) {
await bridge.sendAndWaitForPrompt(`chmod +x ${path}\n`, 5000);
}
}
}