351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
/**
|
|
* 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
|
|
* { 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 } }
|
|
*/
|
|
|
|
import { getTabSessionId } from './Esp32Bridge';
|
|
|
|
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;
|
|
/** 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;
|
|
/** Extra fields merged into the `start_pi` payload (an overlay uses it
|
|
* to declare what this session needs materialised, e.g. packages). */
|
|
startPayload: Record<string, unknown> = {};
|
|
/** quietBoot (ProBoardDef): while true, guest serial output is withheld
|
|
* from onSerialData (boot detection still runs) and a neutral Velxio
|
|
* progress line + dots show instead. The run path calls setQuiet(false)
|
|
* to reveal the shell right before the script starts. */
|
|
quietBootDefault = false;
|
|
/** Board label for the quiet-boot progress line. */
|
|
quietBootLabel = '';
|
|
private _quiet = false;
|
|
private _quietTimer: ReturnType<typeof setInterval> | null = null;
|
|
/** 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> = [];
|
|
|
|
constructor(boardId: string, boardKind: string = 'raspberry-pi-3') {
|
|
this.boardId = boardId;
|
|
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:';
|
|
// Scope the backend client_id to THIS tab. With the bare boardId, two
|
|
// tabs (or two users) opening the same example shared one QEMU
|
|
// instance: serial output went to whichever WebSocket connected last,
|
|
// keystrokes interleaved, and one tab's stop killed the other's guest.
|
|
const clientId = `${this.boardId}--${getTabSessionId()}`;
|
|
const wsUrl =
|
|
base.replace(/^https?:/, wsProtocol) + `/simulation/ws/${encodeURIComponent(clientId)}`;
|
|
|
|
const socket = new WebSocket(wsUrl);
|
|
this.socket = socket;
|
|
|
|
socket.onopen = () => {
|
|
this._connected = true;
|
|
this.onConnected?.();
|
|
// Tell the backend which Pi family member to boot.
|
|
this._send({
|
|
type: 'start_pi',
|
|
data: { board: this.boardKind, ...this.startPayload },
|
|
});
|
|
if (this.quietBootDefault) {
|
|
this._quiet = true;
|
|
const label = this.quietBootLabel || this.boardKind;
|
|
this._emitLocal(`[Velxio] Booting ${label} (Linux guest)`);
|
|
this._quietTimer = setInterval(() => this._emitLocal('.', false), 4000);
|
|
}
|
|
};
|
|
|
|
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) ?? '';
|
|
// quietBoot: keep observing (boot marker + prompt waiters drive
|
|
// guestSetup and the upload) but withhold the branded chatter.
|
|
if (!this._quiet && 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 = '';
|
|
this._quiet = false;
|
|
if (this._quietTimer) {
|
|
clearInterval(this._quietTimer);
|
|
this._quietTimer = null;
|
|
}
|
|
const waiters = this._promptWaiters;
|
|
this._promptWaiters = [];
|
|
for (const w of waiters) w();
|
|
}
|
|
|
|
/** Feed locally-generated status text to the serial consumers. */
|
|
private _emitLocal(text: string, newline = true): void {
|
|
if (!this.onSerialData) return;
|
|
const chunk = newline ? `\r\n${text}` : text;
|
|
for (const ch of chunk) this.onSerialData(ch);
|
|
}
|
|
|
|
/** Reveal (or re-hide) the guest's serial stream. Turning quiet off ends
|
|
* the progress dots and prints a ready line. */
|
|
setQuiet(on: boolean): void {
|
|
if (this._quiet === on) return;
|
|
this._quiet = on;
|
|
if (!on) {
|
|
if (this._quietTimer) {
|
|
clearInterval(this._quietTimer);
|
|
this._quietTimer = null;
|
|
}
|
|
this._emitLocal(' ready.\r\n');
|
|
}
|
|
}
|
|
|
|
/** 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));
|
|
}
|
|
}
|
|
}
|