velxio/frontend/src/simulation/Stm32Bridge.ts

321 lines
12 KiB
TypeScript

/**
* Stm32Bridge
*
* WebSocket client from the frontend to the backend stm32_lib_manager for one
* STM32 board instance. Trimmed mirror of Esp32Bridge: GPIO + serial only (no
* WiFi, MicroPython, sensors, SPI/I2C, camera yet).
*
* Protocol (JSON frames):
* Frontend -> Backend
* { type: 'start_stm32', data: { board: BoardKind, firmware_b64?: string } }
* { type: 'stop_stm32' }
* { type: 'stm32_load_firmware', data: { firmware_b64: string } }
* { type: 'stm32_gpio_in', data: { pin: number, state: 0|1 } } // linear pin
*
* Backend -> Frontend
* { type: 'serial_output', data: { data: string, uart?: number } }
* { type: 'gpio_change', data: { pin: number, state: 0|1 } } // linear pin (port*16+pin)
* { type: 'gpio_dir', data: { pin: number, dir: 0|1 } }
* { type: 'system', data: { event: string, ... } }
* { type: 'error', data: { message: string } }
*
* Pin numbering matches the backend (hw/arm/stm32_picsimlab.c): a linear
* 0-based index, global = port_index*16 + pin. Use stm32PinNameToLinear()
* to convert a silkscreen name ('PC13') to that number and back.
*/
import type { BoardKind } from '../types/board';
import { generateUUID } from '../utils/uuid';
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 function getTabSessionId(): string {
if (typeof sessionStorage === 'undefined') return generateUUID();
const KEY = 'velxio-tab-id';
let id = sessionStorage.getItem(KEY);
if (!id) {
id = generateUUID();
sessionStorage.setItem(KEY, id);
}
return id;
}
const PORT_INDEX: Record<string, number> = { A: 0, B: 1, C: 2, D: 3, E: 4, F: 5, G: 6 };
const PORT_LETTER = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
/** 'PC13' -> 2*16+13 = 45. Returns -1 for non-GPIO names (power/reset). */
export function stm32PinNameToLinear(name: string): number {
const m = /^P([A-G])(\d{1,2})$/.exec(name.trim().toUpperCase());
if (!m) return -1;
const port = PORT_INDEX[m[1]];
const pin = parseInt(m[2], 10);
if (port === undefined || pin < 0 || pin > 15) return -1;
return port * 16 + pin;
}
/** 45 -> 'PC13'. */
export function stm32LinearToPinName(linear: number): string {
const port = Math.floor(linear / 16);
const pin = linear % 16;
return `P${PORT_LETTER[port] ?? '?'}${pin}`;
}
export class Stm32Bridge {
readonly boardId: string;
readonly boardKind: BoardKind;
onSerialData: ((char: string, uart?: number) => void) | null = null;
/** gpioPin is the linear pin (port*16+pin). */
onPinChange: ((gpioPin: number, state: boolean) => void) | null = null;
onPinChangeWithTime: ((gpioPin: number, state: boolean, timeMs: number) => void) | null = null;
onPinDir: ((gpioPin: number, dir: 0 | 1) => 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;
onCrash: ((data: Record<string, unknown>) => void) | null = null;
/** I2C/SPI device write trace + display callbacks (wired by the store). */
onI2cTrace: ((addr: number, op: string, result: number) => void) | null = null;
/** Full I2C write transaction (addr + bytes) for write-only devices (SSD1306). */
onI2cTransaction: ((addr: number, data: number[]) => void) | null = null;
onSpiBatch: ((bytes: Uint8Array) => void) | null = null;
onEpaperUpdate:
| ((componentId: string, frame: { width: number; height: number; b64: string; refreshMs: number }) => void)
| null = null;
private socket: WebSocket | null = null;
private _connected = false;
private _pendingFirmware: string | null = null;
private _pendingSensors: Array<Record<string, unknown>> = [];
constructor(boardId: string, boardKind: BoardKind) {
this.boardId = boardId;
this.boardKind = boardKind;
}
get connected(): boolean {
return this._connected;
}
get clientId(): string {
return getTabSessionId() + '::' + this.boardId;
}
connect(): void {
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return;
const base = API_BASE();
const wsProtocol = base.startsWith('https') ? 'wss:' : 'ws:';
const sessionId = getTabSessionId();
const wsUrl =
base.replace(/^https?:/, wsProtocol) +
`/simulation/ws/${encodeURIComponent(sessionId + '::' + this.boardId)}`;
const socket = new WebSocket(wsUrl);
this.socket = socket;
socket.onopen = () => {
this._connected = true;
this.onConnected?.();
this._send({
type: 'start_stm32',
data: {
board: this.boardKind,
sensors: this._pendingSensors,
...(this._pendingFirmware ? { firmware_b64: this._pendingFirmware } : {}),
},
});
};
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) ?? '';
const uart = msg.data.uart as number | undefined;
if (this.onSerialData) for (const ch of text) this.onSerialData(ch, uart);
break;
}
case 'gpio_change': {
const pin = msg.data.pin as number;
const state = (msg.data.state as number) === 1;
this.onPinChange?.(pin, state);
this.onPinChangeWithTime?.(pin, state, performance.now());
break;
}
case 'gpio_dir': {
this.onPinDir?.(msg.data.pin as number, msg.data.dir as 0 | 1);
break;
}
case 'system': {
const evt = msg.data.event as string;
if (evt === 'crash') this.onCrash?.(msg.data);
this.onSystemEvent?.(evt, msg.data);
break;
}
case 'i2c_transaction': {
// Full STOP-bounded write phase from a write-only device (SSD1306,
// PCF8574). The backend I2CWriteSink accumulates the bytes and emits
// them here so the frontend virtual device can replay + render.
const addr = msg.data.addr as number;
const data = (msg.data.data as number[]) ?? [];
this.onI2cTransaction?.(addr, data);
break;
}
case 'i2c_trace': {
// Diagnostic: one line per I2C op a real slave (BMP280, MPU6050…)
// serviced. The read result already flowed into the guest via the
// QEMU bus; this is for inspectors / debugging only.
this.onI2cTrace?.(
msg.data.addr as number,
(msg.data.op as string) ?? '',
(msg.data.result as number) ?? 0,
);
break;
}
case 'spi_batch': {
// Worker batches consecutive MOSI bytes from one SPI transaction
// into a base64 blob (see stm32_worker._flush_spi_batch_locked).
const b64 = msg.data.b64 as string;
if (b64 && this.onSpiBatch) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
this.onSpiBatch(bytes);
}
break;
}
case 'epaper_update': {
// The STM32 worker nests the frame under data.data (it emits
// {type:'epaper_update', data:{...}} and the manager re-wraps once).
const f = (msg.data.data as Record<string, unknown>) ?? msg.data;
this.onEpaperUpdate?.(f.component_id as string, {
width: f.width as number,
height: f.height as number,
b64: f.frame_b64 as string,
refreshMs: (f.refresh_ms as number) ?? 50,
});
break;
}
case 'error':
this.onError?.(msg.data.message as string);
break;
}
};
socket.onclose = () => {
this._connected = false;
this.socket = null;
this.onDisconnected?.();
};
socket.onerror = () => this.onError?.('WebSocket error');
}
disconnect(): void {
if (this.socket) {
this._send({ type: 'stop_stm32' });
this.socket.close();
this.socket = null;
}
this._connected = false;
}
hasFirmware(): boolean {
return this._pendingFirmware !== null && this._pendingFirmware !== '';
}
/** Load a compiled firmware (base64 .elf/.bin). Sent on connect, or now if live. */
loadFirmware(firmwareBase64: string): void {
this._pendingFirmware = firmwareBase64;
if (this._connected) {
this._send({ type: 'stm32_load_firmware', data: { firmware_b64: firmwareBase64 } });
}
}
/** Drive a GPIO input pin from an external source. `gpioPin` is linear. */
sendPinEvent(gpioPin: number, state: boolean): void {
this._send({ type: 'stm32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
}
/** Feed bytes into an STM32 USART RX (cross-board UART from a peer board).
* NOTE: the backend worker does not yet inject UART RX into the guest
* (qemu_picsimlab_uart_receive is unimplemented for arm), so this is a
* no-op end-to-end today; the message shape matches the other bridges so
* the Interconnect serial path binds cleanly. STM32-as-sender works fully. */
sendSerialBytes(bytes: number[], uart = 0): void {
if (bytes.length === 0) return;
this._send({ type: 'stm32_serial_input', data: { bytes, uart } });
}
// ── Generic I2C/SPI device protocol offloading ─────────────────────────────
// Mirrors Esp32Bridge: device models (BMP280, MPU6050, SSD1306, …) run inside
// the backend QEMU worker. The frontend registers them by I2C address so the
// worker builds the matching slave on the bus before firmware runs.
/**
* Pre-register devices so they are included in the start_stm32 payload.
* Sent on connect (the common case: attachEvents fires before Run). Upsert
* by `pin` so a later setSensors() from startBoard doesn't drop entries an
* earlier sendSensorAttach() (e.g. an ePaper SPI slave) already buffered.
*/
setSensors(sensors: Array<Record<string, unknown>>): void {
const merged = this._pendingSensors.slice();
for (const s of sensors) {
const pin = s['pin'];
const idx = merged.findIndex((e) => e['pin'] === pin);
if (idx >= 0) merged[idx] = s;
else merged.push(s);
}
this._pendingSensors = merged;
}
/** Register one I2C/SPI device. Buffered for start, or sent live if connected. */
sendSensorAttach(sensorType: string, pin: number, properties: Record<string, unknown>): void {
const entry = { sensor_type: sensorType, pin, ...properties };
const existing = this._pendingSensors.findIndex((s) => s['pin'] === pin);
if (existing >= 0) this._pendingSensors[existing] = entry;
else this._pendingSensors.push(entry);
if (this._connected) {
this._send({ type: 'stm32_sensor_attach', data: entry });
}
}
/** Update a live device's values (temperature, pressure, accel…). */
sendSensorUpdate(pin: number, properties: Record<string, unknown>): void {
const idx = this._pendingSensors.findIndex((s) => s['pin'] === pin);
if (idx >= 0) this._pendingSensors[idx] = { ...this._pendingSensors[idx], ...properties };
this._send({ type: 'stm32_sensor_update', data: { pin, ...properties } });
}
/** Detach a device. */
sendSensorDetach(pin: number): void {
this._pendingSensors = this._pendingSensors.filter((s) => s['pin'] !== pin);
this._send({ type: 'stm32_sensor_detach', data: { pin } });
}
private _send(payload: unknown): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));
}
}
}