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 { getBoardBridge, useSimulatorStore } from '../../store/useSimulatorStore';
import { showConfirmDialog } from '../../store/useMessageDialogStore';
import { uploadFilesToPi } from '../../utils/piUpload';
/** Resolve true once the board's guest Linux has booted to a shell
* (board.piBooted), or false after timeoutMs. Polls the store. */
@ -381,35 +382,7 @@ export const VirtualFileSystem: React.FC<VirtualFileSystemProps> = ({ boardId, o
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);
}
}
await uploadFilesToPi(bridge, files);
setUploadStatus('done');
setTimeout(() => setUploadStatus('idle'), 2500);

View File

@ -266,7 +266,11 @@ export const SerialMonitor: React.FC = () => {
// parses + answers them — this only cleans the dumb mirror.)
const text = activeBoard.serialOutput
.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
// net hands out 10.13.37.x. Both reach their emulated server
// 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
* image, e.g. set its own hostname/PS1 and clear the stock motd. */
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.
* When omitted, the canvas renders `<tag id=... style=absolute@x,y>`. */
render?: (props: { id: string; x: number; y: number; running: boolean }) => React.ReactNode;

View File

@ -1249,19 +1249,36 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// it runs before piBooted flips so the VFS upload (gated on piBooted)
// cannot interleave with it.
bridge.onBooted = () => {
const setup = getProBoard(boardKind)?.guestSetup;
const proDef = getProBoard(boardKind);
const setup = proDef?.guestSetup;
const flip = () =>
set((s) => ({
boards: s.boards.map((b) => (b.id === id ? { ...b, piBooted: true } : b)),
}));
if (setup) {
void bridge
.sendAndWaitForPrompt(setup.endsWith('\n') ? setup : setup + '\n')
.then(flip);
} else {
flip();
// Overlay boards may declare autoRun: after boot (+setup) the VFS
// is uploaded and the command executed, so one click on Run boots,
// uploads and starts the user's script — same UX as every other
// board's compile-and-run.
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();
await autoRun();
})();
};
bridge.onDisconnected = () => {
set((s) => {
const boards = s.boards.map((b) =>
@ -1388,9 +1405,14 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (get().activeBoardId === 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)) {
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 ──────────
icBindBoard(id, boardKind);

View File

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