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:
parent
ebe2ab9d5a
commit
dd86020343
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue