321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
/**
|
||
* Esp32Bridge
|
||
*
|
||
* Manages the WebSocket connection from the frontend to the backend
|
||
* QEMU manager for one ESP32/ESP32-S3/ESP32-C3 board instance.
|
||
*
|
||
* Protocol (JSON frames):
|
||
* Frontend → Backend
|
||
* { type: 'start_esp32', data: { board: BoardKind, firmware_b64?: string } }
|
||
* { type: 'stop_esp32' }
|
||
* { type: 'load_firmware', data: { firmware_b64: string } }
|
||
* { type: 'esp32_serial_input', data: { bytes: number[], uart?: number } }
|
||
* { type: 'esp32_gpio_in', data: { pin: number, state: 0 | 1 } }
|
||
* { type: 'esp32_adc_set', data: { channel: number, millivolts: number } }
|
||
* { type: 'esp32_i2c_response', data: { addr: number, response: number } }
|
||
* { type: 'esp32_spi_response', data: { response: number } }
|
||
* { type: 'esp32_sensor_attach', data: { sensor_type: string, pin: number, ... } }
|
||
* { type: 'esp32_sensor_update', data: { pin: number, ... } }
|
||
* { type: 'esp32_sensor_detach', data: { pin: number } }
|
||
*
|
||
* Backend → Frontend
|
||
* { type: 'serial_output', data: { data: string, uart?: number } }
|
||
* { type: 'gpio_change', data: { pin: number, state: 0 | 1 } }
|
||
* { type: 'gpio_dir', data: { pin: number, dir: 0 | 1 } }
|
||
* { type: 'ledc_update', data: { channel: number, duty: number, duty_pct: number } }
|
||
* { type: 'ws2812_update', data: { channel: number, pixels: [number, number, number][] } }
|
||
* { type: 'i2c_event', data: { addr: number, data: number } }
|
||
* { type: 'spi_event', data: { data: number } }
|
||
* { type: 'system', data: { event: string, ... } }
|
||
* { type: 'error', data: { message: string } }
|
||
*/
|
||
|
||
import type { BoardKind } from '../types/board';
|
||
|
||
/**
|
||
* Map any ESP32-family board kind to the 3 base QEMU machine types understood
|
||
* by the backend esp_qemu_manager.
|
||
*/
|
||
function toQemuBoardType(kind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32-c3' {
|
||
if (kind === 'esp32-s3' || kind === 'xiao-esp32-s3' || kind === 'arduino-nano-esp32') return 'esp32-s3';
|
||
if (kind === 'esp32-c3' || kind === 'xiao-esp32-c3' || kind === 'aitewinrobot-esp32c3-supermini') return 'esp32-c3';
|
||
return 'esp32'; // esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite
|
||
}
|
||
|
||
const API_BASE = (): string =>
|
||
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api';
|
||
|
||
/** Returns a stable UUID for this browser tab (persists across reloads, resets on new tab). */
|
||
export function getTabSessionId(): string {
|
||
// sessionStorage is not available in Node/test environments
|
||
if (typeof sessionStorage === 'undefined') return crypto.randomUUID();
|
||
const KEY = 'velxio-tab-id';
|
||
let id = sessionStorage.getItem(KEY);
|
||
if (!id) {
|
||
id = crypto.randomUUID();
|
||
sessionStorage.setItem(KEY, id);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
export interface Ws2812Pixel { r: number; g: number; b: number }
|
||
export interface LedcUpdate { channel: number; duty: number; duty_pct: number; gpio?: number }
|
||
export interface WifiStatus { status: string; ssid?: string; ip?: string }
|
||
export interface BleStatus { status: string }
|
||
|
||
export class Esp32Bridge {
|
||
readonly boardId: string;
|
||
readonly boardKind: BoardKind;
|
||
|
||
/** Set to true before connect() to enable WiFi NIC in QEMU. */
|
||
wifiEnabled = false;
|
||
|
||
// Callbacks wired up by useSimulatorStore
|
||
onSerialData: ((char: string, uart?: number) => void) | null = null;
|
||
onPinChange: ((gpioPin: number, state: boolean) => void) | null = null;
|
||
onPinDir: ((gpioPin: number, dir: 0 | 1) => void) | null = null;
|
||
onLedcUpdate: ((update: LedcUpdate) => void) | null = null;
|
||
onWs2812Update: ((channel: number, pixels: Ws2812Pixel[]) => void) | null = null;
|
||
onI2cEvent: ((addr: number, data: number) => void) | null = null;
|
||
onSpiEvent: ((data: number) => 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;
|
||
onWifiStatus: ((status: WifiStatus) => void) | null = null;
|
||
onBleStatus: ((status: BleStatus) => 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;
|
||
console.log(`[Esp32Bridge:${this.boardId}] WebSocket connected → sending start_esp32 (firmware: ${this._pendingFirmware ? `${Math.round(this._pendingFirmware.length * 0.75 / 1024)}KB` : 'none'})`);
|
||
this.onConnected?.();
|
||
this._send({
|
||
type: 'start_esp32',
|
||
data: {
|
||
board: toQemuBoardType(this.boardKind),
|
||
...(this._pendingFirmware ? { firmware_b64: this._pendingFirmware } : {}),
|
||
sensors: this._pendingSensors,
|
||
wifi_enabled: this.wifiEnabled,
|
||
},
|
||
});
|
||
};
|
||
|
||
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;
|
||
console.log(`[Esp32Bridge:${this.boardId}] gpio_change pin=${pin} state=${state ? 'HIGH' : 'LOW'}`);
|
||
this.onPinChange?.(pin, state);
|
||
break;
|
||
}
|
||
case 'gpio_dir': {
|
||
const pin = msg.data.pin as number;
|
||
const dir = msg.data.dir as 0 | 1;
|
||
this.onPinDir?.(pin, dir);
|
||
break;
|
||
}
|
||
case 'ledc_update': {
|
||
console.log(`[Esp32Bridge:${this.boardId}] ledc_update ch=${msg.data.channel} duty=${msg.data.duty_pct}% gpio=${msg.data.gpio}`);
|
||
this.onLedcUpdate?.(msg.data as unknown as LedcUpdate);
|
||
break;
|
||
}
|
||
case 'ws2812_update': {
|
||
const channel = msg.data.channel as number;
|
||
const raw = msg.data.pixels as [number, number, number][];
|
||
const pixels: Ws2812Pixel[] = raw.map(([r, g, b]) => ({ r, g, b }));
|
||
this.onWs2812Update?.(channel, pixels);
|
||
break;
|
||
}
|
||
case 'i2c_event': {
|
||
const addr = msg.data.addr as number;
|
||
const data = msg.data.data as number;
|
||
this.onI2cEvent?.(addr, data);
|
||
break;
|
||
}
|
||
case 'spi_event': {
|
||
const data = msg.data.data as number;
|
||
this.onSpiEvent?.(data);
|
||
break;
|
||
}
|
||
case 'system': {
|
||
const evt = msg.data.event as string;
|
||
console.log(`[Esp32Bridge:${this.boardId}] system event: ${evt}`, msg.data);
|
||
if (evt === 'crash') {
|
||
this.onCrash?.(msg.data);
|
||
}
|
||
this.onSystemEvent?.(evt, msg.data);
|
||
break;
|
||
}
|
||
case 'wifi_status': {
|
||
const wifiStatus = msg.data as unknown as WifiStatus;
|
||
console.log(`[Esp32Bridge:${this.boardId}] wifi_status: ${wifiStatus.status} ssid=${wifiStatus.ssid ?? ''} ip=${wifiStatus.ip ?? ''}`);
|
||
this.onWifiStatus?.(wifiStatus);
|
||
break;
|
||
}
|
||
case 'ble_status': {
|
||
const bleStatus = msg.data as unknown as BleStatus;
|
||
console.log(`[Esp32Bridge:${this.boardId}] ble_status: ${bleStatus.status}`);
|
||
this.onBleStatus?.(bleStatus);
|
||
break;
|
||
}
|
||
case 'error':
|
||
console.error(`[Esp32Bridge:${this.boardId}] error: ${msg.data.message as string}`);
|
||
this.onError?.(msg.data.message as string);
|
||
break;
|
||
}
|
||
};
|
||
|
||
socket.onclose = (ev) => {
|
||
console.log(`[Esp32Bridge:${this.boardId}] WebSocket closed (code=${ev?.code ?? '?'})`);
|
||
this._connected = false;
|
||
this.socket = null;
|
||
this.onDisconnected?.();
|
||
};
|
||
|
||
socket.onerror = (ev) => {
|
||
console.error(`[Esp32Bridge:${this.boardId}] WebSocket error`, ev);
|
||
this.onError?.('WebSocket error');
|
||
};
|
||
}
|
||
|
||
disconnect(): void {
|
||
if (this.socket) {
|
||
this._send({ type: 'stop_esp32' });
|
||
this.socket.close();
|
||
this.socket = null;
|
||
}
|
||
this._connected = false;
|
||
}
|
||
|
||
/**
|
||
* Pre-register sensors so they are included in the start_esp32 payload.
|
||
* This ensures sensors are ready in the QEMU worker BEFORE the firmware
|
||
* begins executing, preventing race conditions where pulseIn() times out
|
||
* because the sensor handler hasn't been registered yet.
|
||
*/
|
||
setSensors(sensors: Array<Record<string, unknown>>): void {
|
||
this._pendingSensors = sensors;
|
||
}
|
||
|
||
/** Returns true if a firmware has been loaded and is ready to send. */
|
||
hasFirmware(): boolean {
|
||
return this._pendingFirmware !== null && this._pendingFirmware !== '';
|
||
}
|
||
|
||
/**
|
||
* Load a compiled firmware (base64-encoded .bin) into the running ESP32.
|
||
* If not yet connected, the firmware will be sent on next connect().
|
||
*/
|
||
loadFirmware(firmwareBase64: string): void {
|
||
this._pendingFirmware = firmwareBase64;
|
||
if (this._connected) {
|
||
this._send({ type: 'load_firmware', data: { firmware_b64: firmwareBase64 } });
|
||
}
|
||
}
|
||
|
||
/** Send a byte to the ESP32 UART0 (or UART1/2) */
|
||
sendSerialByte(byte: number, uart = 0): void {
|
||
this._send({ type: 'esp32_serial_input', data: { bytes: [byte], uart } });
|
||
}
|
||
|
||
/** Send multiple bytes at once */
|
||
sendSerialBytes(bytes: number[], uart = 0): void {
|
||
if (bytes.length === 0) return;
|
||
this._send({ type: 'esp32_serial_input', data: { bytes, uart } });
|
||
}
|
||
|
||
/** Drive a GPIO pin from an external source (e.g. connected Arduino) */
|
||
sendPinEvent(gpioPin: number, state: boolean): void {
|
||
this._send({ type: 'esp32_gpio_in', data: { pin: gpioPin, state: state ? 1 : 0 } });
|
||
}
|
||
|
||
/** Set an ADC channel voltage (millivolts, 0–3300) */
|
||
setAdc(channel: number, millivolts: number): void {
|
||
this._send({ type: 'esp32_adc_set', data: { channel, millivolts } });
|
||
}
|
||
|
||
/** Configure the byte an I2C device at addr returns */
|
||
setI2cResponse(addr: number, response: number): void {
|
||
this._send({ type: 'esp32_i2c_response', data: { addr, response } });
|
||
}
|
||
|
||
/** Configure the MISO byte returned during an SPI transaction */
|
||
setSpiResponse(response: number): void {
|
||
this._send({ type: 'esp32_spi_response', data: { response } });
|
||
}
|
||
|
||
// ── Generic sensor protocol offloading ────────────────────────────────────
|
||
// Sensors call these to delegate their protocol to the backend QEMU.
|
||
// The sensor type (e.g. 'dht22', 'hc-sr04') tells the backend which
|
||
// protocol handler to use. Sensor-specific properties (temperature,
|
||
// humidity, distance …) are passed as a generic Record.
|
||
|
||
/** Register a sensor on a GPIO pin — backend handles its protocol */
|
||
sendSensorAttach(sensorType: string, pin: number, properties: Record<string, unknown>): void {
|
||
this._send({ type: 'esp32_sensor_attach', data: { sensor_type: sensorType, pin, ...properties } });
|
||
}
|
||
|
||
/** Update sensor properties (temperature, humidity, distance, etc.) */
|
||
sendSensorUpdate(pin: number, properties: Record<string, unknown>): void {
|
||
this._send({ type: 'esp32_sensor_update', data: { pin, ...properties } });
|
||
}
|
||
|
||
/** Detach a sensor from a GPIO pin */
|
||
sendSensorDetach(pin: number): void {
|
||
this._send({ type: 'esp32_sensor_detach', data: { pin } });
|
||
}
|
||
|
||
private _send(payload: unknown): void {
|
||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||
this.socket.send(JSON.stringify(payload));
|
||
}
|
||
}
|
||
}
|