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 { 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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1249,18 +1249,35 @@ 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 {
|
||||
// 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) => {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]: {
|
||||
id: scriptId,
|
||||
name: 'script.py',
|
||||
type: 'file',
|
||||
content: DEFAULT_PY_CONTENT,
|
||||
parentId: piId,
|
||||
},
|
||||
[shellId]: {
|
||||
[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,
|
||||
};
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -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