velxio/frontend/src/simulation/RaspberryPi3Bridge.ts

295 lines
10 KiB
TypeScript
Raw Normal View History

/**
* RaspberryPi3Bridge
*
* Manages the WebSocket connection from the frontend to the backend
* QEMU manager for one Raspberry Pi 3B board instance.
*
* Protocol (JSON frames):
* Frontend Backend
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
* { type: 'start_pi', data: { board: 'raspberry-pi-3'|'raspberry-pi-4'|'raspberry-pi-5' } }
* { type: 'stop_pi' }
* { type: 'serial_input', data: { bytes: number[] } }
* { type: 'gpio_in', data: { pin: number, state: 0 | 1 } }
* { type: 'pi_attach_slave', data: {
* bus_kind: 'i2c'|'spi'|'uart',
* bus_num: number,
* address?: number, // i2c
* cs?: number, // spi
* model_id: string, // e.g. 'bme280'
* config?: Record<string, unknown>,
* }}
* { type: 'pi_detach_slave', data: { bus_kind, bus_num, address?|cs? } }
*
* Backend Frontend
* { type: 'serial_output', data: { data: string } }
* { type: 'gpio_change', data: { pin: number, state: 0 | 1 } }
* { type: 'system', data: { event: string, ... } }
* { type: 'error', data: { message: string } }
*/
const API_BASE = (): string => {
// The desktop shell injects the sidecar URL at runtime (random port) via
// window.__VELXIO_API_BASE__; honor it first so the QEMU-board WebSocket
// reaches the local Python sidecar instead of the build-time / dev
// default. Without this, ESP32 / Pi / STM32 simulations never start in
// the desktop app (the WS dialed localhost:8001, not the sidecar port).
if (typeof window !== 'undefined') {
const injected = (window as { __VELXIO_API_BASE__?: string }).__VELXIO_API_BASE__;
if (typeof injected === 'string' && injected) {
return injected.replace(/\/+$/, '');
}
}
return (import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api';
};
export class RaspberryPi3Bridge {
readonly boardId: string;
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
/** Pi family member: 'raspberry-pi-3' | 'raspberry-pi-4' | 'raspberry-pi-5'.
* The backend uses this to pick the QEMU -cpu / -m. Defaults to Pi 3 for
* back-compat with code paths that don't know the kind yet. */
readonly boardKind: string;
// Callbacks wired up by useSimulatorStore
onSerialData: ((char: string) => void) | null = null;
onPinChange: ((gpioPin: number, state: boolean) => void) | null = null;
onConnected: (() => void) | null = null;
onDisconnected: (() => void) | null = null;
onError: ((msg: string) => void) | null = null;
onSystemEvent: ((event: string, data: Record<string, unknown>) => void) | null = null;
/** Guest display command (opaque base64 payload from the DISP protocol
* op). Overlay boards with built-in screens render it on their element. */
onDisplay: ((data: string) => void) | null = null;
/** Guest PWM activity (PWM_START / PWM_CHANGE / PWM_STOP). Overlay boards
* use it for built-in buzzers/speakers. */
onGpioPwm:
| ((pin: number, frequency: number, dutyCycle: number, event: string) => 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> = [];
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
constructor(boardId: string, boardKind: string = 'raspberry-pi-3') {
this.boardId = boardId;
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
this.boardKind = boardKind;
}
get connected(): boolean {
return this._connected;
}
connect(): void {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return;
const base = API_BASE();
const wsProtocol = base.startsWith('https') ? 'wss:' : 'ws:';
const wsUrl =
base.replace(/^https?:/, wsProtocol) + `/simulation/ws/${encodeURIComponent(this.boardId)}`;
const socket = new WebSocket(wsUrl);
this.socket = socket;
socket.onopen = () => {
this._connected = true;
this.onConnected?.();
feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m.
2026-05-18 20:41:18 +07:00
// Tell the backend which Pi family member to boot.
this._send({ type: 'start_pi', data: { board: this.boardKind } });
};
socket.onmessage = (event: MessageEvent) => {
let msg: { type: string; data: Record<string, unknown> };
try {
msg = JSON.parse(event.data as string);
} catch {
return;
}
switch (msg.type) {
case 'serial_output': {
const text = (msg.data.data as string) ?? '';
if (this.onSerialData) {
for (const ch of text) this.onSerialData(ch);
}
this._observeSerial(text);
break;
}
case 'gpio_change': {
const pin = msg.data.pin as number;
const state = (msg.data.state as number) === 1;
this.onPinChange?.(pin, state);
break;
}
case 'system':
this.onSystemEvent?.(msg.data.event as string, msg.data);
break;
case 'display':
this.onDisplay?.((msg.data.data as string) ?? '');
break;
case 'gpio_pwm':
this.onGpioPwm?.(
(msg.data.pin as number) ?? 0,
(msg.data.frequency as number) ?? 0,
(msg.data.duty_cycle as number) ?? 0,
(msg.data.event as string) ?? 'change',
);
break;
case 'error':
this.onError?.(msg.data.message as string);
break;
}
};
socket.onclose = () => {
this._connected = false;
this.socket = null;
this._resetBootState();
this.onDisconnected?.();
};
socket.onerror = () => {
this.onError?.('WebSocket error');
};
}
disconnect(): void {
if (this.socket) {
// Tell backend to stop Pi before closing
this._send({ type: 'stop_pi' });
this.socket.close();
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) */
sendSerialByte(byte: number): void {
this._send({ type: 'serial_input', data: { bytes: [byte] } });
}
/** Send multiple bytes at once */
sendSerialBytes(bytes: number[]): void {
if (bytes.length === 0) return;
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<void> {
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 } });
}
/** Push canvas-fed named values (built-in sensors/buttons of overlay
* boards). The guest polls them via SENS protocol requests. */
setSensorState(values: Record<string, number>): void {
this._send({ type: 'pi_sensor_state', data: { values } });
}
/**
* Attach an I2C/SPI/UART slave model to the running Pi. The backend
* pro overlay turns this into a PiSlaveRegistry entry that the
* protocol dispatcher consults on each guest read. OSS images
* silently drop the message.
*/
attachSlave(spec: {
bus_kind: 'i2c' | 'spi' | 'uart';
bus_num: number;
address?: number;
cs?: number;
model_id: string;
config?: Record<string, unknown>;
}): void {
this._send({ type: 'pi_attach_slave', data: spec });
}
detachSlave(spec: {
bus_kind: 'i2c' | 'spi' | 'uart';
bus_num: number;
address?: number;
cs?: number;
}): void {
this._send({ type: 'pi_detach_slave', data: spec });
}
private _send(payload: unknown): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));
}
}
}