856 lines
35 KiB
TypeScript
856 lines
35 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_duty', data: { channel: number, duty_pct: number } }
|
||
* { type: 'gpio_routing', data: { gpio: number, signal_id: number } }
|
||
* { type: 'gpio_routing_clear', data: { gpio: number } }
|
||
* { type: 'ws2812_update', data: { channel: number, pixels: [number, number, number][] } }
|
||
* { type: 'i2c_event', data: { addr: number, data: number } }
|
||
* { type: 'i2c_transaction', 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';
|
||
import { generateUUID } from '../utils/uuid';
|
||
|
||
/**
|
||
* Map any ESP32-family board kind to the 3 base QEMU machine types understood
|
||
* by the backend esp_qemu_manager.
|
||
*/
|
||
export 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 generateUUID();
|
||
const KEY = 'velxio-tab-id';
|
||
let id = sessionStorage.getItem(KEY);
|
||
if (!id) {
|
||
id = generateUUID();
|
||
sessionStorage.setItem(KEY, id);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
export interface Ws2812Pixel {
|
||
r: number;
|
||
g: number;
|
||
b: number;
|
||
}
|
||
/** LEDC duty event — channel + duty only. The frontend resolves
|
||
* channel→signal_id→pin via its SignalRouter mirror. */
|
||
export interface LedcDuty {
|
||
channel: number;
|
||
duty_pct: number;
|
||
}
|
||
/** GPIO Matrix routing event — `gpio_out_sel[gpio]` was set to
|
||
* `signal_id`. Maintained by the backend SignalRouter; emitted on
|
||
* every observed change so the frontend mirror stays in lock-step. */
|
||
export interface GpioRouting {
|
||
gpio: number;
|
||
signal_id: 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;
|
||
/**
|
||
* Timestamped version of onPinChange — wired to the oscilloscope so the
|
||
* scope can render ESP32 GPIO activity at the same resolution as AVR /
|
||
* RP2040 boards. Also receives the synthesized UART TX frame bits from
|
||
* `emitUartTxFrame` so a scope on GPIO1 / GPIO43 / etc. shows real bit-
|
||
* level UART waveforms during `Serial.print`, matching real silicon.
|
||
*
|
||
* QEMU virtual time isn't exposed cleanly across the WebSocket, so the
|
||
* timestamps come from `performance.now()` (wall-clock). At 1× sim
|
||
* speed this matches the AVR / RP2040 simulator-time within ~1 ms which
|
||
* is invisible on any practical sweep.
|
||
*/
|
||
onPinChangeWithTime: ((gpioPin: number, state: boolean, timeMs: number) => void) | null = null;
|
||
onPinDir: ((gpioPin: number, dir: 0 | 1) => void) | null = null;
|
||
/**
|
||
* Override baud rate used to space synthesized UART bits. QEMU
|
||
* transmits bytes "instantly" so the backend doesn't surface a real
|
||
* baud rate, but for the scope to show a realistic frame we need a
|
||
* bit period. Defaults to 115200 (Arduino default). The store
|
||
* updates this when the firmware's `Serial.begin(N)` is observable.
|
||
*/
|
||
uartBaudRate: number = 115200;
|
||
/** Wired by the store to `makeLedcDutyHandler` which routes
|
||
* channel→pin via the per-board SignalRouter mirror. */
|
||
onLedcDuty: ((duty: LedcDuty) => void) | null = null;
|
||
/** Fires whenever the backend observes a write to `gpio_out_sel[N]`.
|
||
* The store's handler updates the per-board SignalRouter mirror so
|
||
* subsequent `onLedcDuty` events can resolve channel→pin correctly. */
|
||
onGpioRouting: ((routing: GpioRouting) => void) | null = null;
|
||
/** Pin is no longer routed to any peripheral (firmware reset the
|
||
* matrix entry). */
|
||
onGpioRoutingClear: ((gpio: number) => void) | null = null;
|
||
onWs2812Update: ((channel: number, pixels: Ws2812Pixel[]) => void) | null = null;
|
||
/**
|
||
* ePaper SSD168x backend rendering. Backend decodes SPI traffic in
|
||
* `Ssd168xEpaperSlave` and emits this event on every 0x20
|
||
* MASTER_ACTIVATION with a base64-encoded palette buffer (1 byte/pixel:
|
||
* 0=black, 1=white, 2=red). One subscriber per `componentId`; multiple
|
||
* panels on the same board are routed by ID.
|
||
*/
|
||
onEpaperUpdate:
|
||
| ((
|
||
componentId: string,
|
||
frame: { width: number; height: number; b64: string; refreshMs: number },
|
||
) => void)
|
||
| null = null;
|
||
onI2cEvent: ((addr: number, data: number) => void) | null = null;
|
||
onI2cTransaction: ((addr: number, data: number[]) => void) | null = null;
|
||
/**
|
||
* Fires when the backend's `ProxySlave` emits a completed write
|
||
* transaction (one full master write phase, terminated by STOP or
|
||
* repeated-START). Used by Interconnect / Esp32BridgeShim to
|
||
* replay the bytes onto the actual frontend peer device so its
|
||
* state stays consistent with what the ESP32 firmware "wrote".
|
||
*/
|
||
onProxyI2cComplete: ((addr: number, data: number[]) => void) | null = null;
|
||
onSpiEvent: ((data: number) => void) | null = null;
|
||
/** Same as onSpiEvent but more explicit (a single MOSI byte). */
|
||
onSpiByte: ((mosi: number) => void) | null = null;
|
||
/** Fires on every CS line change emitted by the SoC's SPI peripheral.
|
||
* `csIdx` is the index of the CS pin within the SPI bus (0-3 typical),
|
||
* `low` is true when CS goes LOW (slave selected), false when HIGH. */
|
||
onSpiCsChange: ((csIdx: number, low: 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;
|
||
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>> = [];
|
||
|
||
// MicroPython REPL injection — 4-stage state machine
|
||
// idle → banner_seen → prompt_seen → raw_repl_entered → done
|
||
// Each stage waits for a specific string in the serial buffer before
|
||
// proceeding. This avoids the race where code is sent before raw REPL
|
||
// mode is confirmed and ends up echoed by the normal REPL.
|
||
private _pendingMicroPythonCode: string | null = null;
|
||
private _serialBuffer = '';
|
||
private _replState: 'idle' | 'banner_seen' | 'prompt_seen' | 'raw_repl_entered' = 'idle';
|
||
micropythonMode = false;
|
||
|
||
constructor(boardId: string, boardKind: BoardKind) {
|
||
this.boardId = boardId;
|
||
this.boardKind = boardKind;
|
||
}
|
||
|
||
get connected(): boolean {
|
||
return this._connected;
|
||
}
|
||
|
||
/**
|
||
* Default UART0 TX GPIO for each ESP32 family variant. The actual pin
|
||
* is selectable via the GPIO Matrix at runtime, but exposing the live
|
||
* matrix state across the WebSocket isn't worth it — these defaults
|
||
* match what the IO_MUX picks up for the standard `Serial` port and
|
||
* are what every Arduino-ESP32 sketch ends up using unless the user
|
||
* explicitly remaps via `Serial.setPins()`.
|
||
*/
|
||
private uart0TxPin(): number {
|
||
switch (this.boardKind) {
|
||
case 'esp32-s3':
|
||
case 'xiao-esp32-s3':
|
||
case 'arduino-nano-esp32':
|
||
return 43;
|
||
case 'esp32-c3':
|
||
case 'xiao-esp32-c3':
|
||
case 'aitewinrobot-esp32c3-supermini':
|
||
return 21;
|
||
default:
|
||
// esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite, …
|
||
return 1;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bit-level UART frame synthesis on the TX GPIO. QEMU's UART
|
||
* peripheral transmits bytes "instantly" at the virtual-time layer
|
||
* and never toggles the SoC pad — same gap closed in AVRSimulator
|
||
* and RP2040Simulator. We rebuild the standard 8N1 frame (start
|
||
* LOW + 8 data LSB-first + stop HIGH) at `this.uartBaudRate`, stamp
|
||
* each transition with wall-clock-spaced timestamps starting now,
|
||
* and push them through `onPinChangeWithTime` so the oscilloscope
|
||
* draws the waveform a real ESP32 would put on the pin.
|
||
*
|
||
* Only UART0 is synthesized today — UART1 / UART2 would need their
|
||
* own per-board GPIO mapping which Velxio doesn't currently track.
|
||
*/
|
||
private emitUartTxFrame(byte: number, uart: number = 0): void {
|
||
if (uart !== 0) return; // UART0 only for now
|
||
if (!this.onPinChangeWithTime) return;
|
||
const baud = this.uartBaudRate || 115200;
|
||
if (baud <= 0) return;
|
||
|
||
const txPin = this.uart0TxPin();
|
||
const bitMs = 1000 / baud;
|
||
const startMs = performance.now();
|
||
|
||
// Seed idle HIGH right before the start bit so the scope renders the
|
||
// start-bit transition against a HIGH baseline, matching how the line
|
||
// sits between bytes on real hardware.
|
||
this.onPinChangeWithTime(txPin, true, Math.max(0, startMs - bitMs));
|
||
|
||
// 8N1: start LOW, then 8 data bits LSB-first, then stop HIGH.
|
||
const bits: boolean[] = [false];
|
||
for (let i = 0; i < 8; i++) bits.push(((byte >> i) & 1) !== 0);
|
||
bits.push(true);
|
||
|
||
let prev = true;
|
||
for (let i = 0; i < bits.length; i++) {
|
||
if (bits[i] !== prev) {
|
||
this.onPinChangeWithTime(txPin, bits[i], startMs + i * bitMs);
|
||
prev = bits[i];
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
// Synthesize the per-byte UART waveform on the TX GPIO so the
|
||
// oscilloscope shows a real frame, matching how a real ESP32
|
||
// drives the pin. Falls back to UART0 when no uart index is
|
||
// provided (which is the case for all current backend events).
|
||
if (this.onPinChangeWithTime) {
|
||
for (let i = 0; i < text.length; i++) {
|
||
this.emitUartTxFrame(text.charCodeAt(i) & 0xff, uart ?? 0);
|
||
}
|
||
}
|
||
// MicroPython REPL injection — 4-stage state machine.
|
||
// Each stage waits for a confirmed string in the serial buffer before
|
||
// advancing, so we never send code before raw REPL mode is verified.
|
||
if (this._pendingMicroPythonCode || this._replState !== 'idle') {
|
||
this._serialBuffer += text;
|
||
|
||
// Stage 1: banner "Type help()" → poke UART with \r to flush ">>> "
|
||
// The >>> prompt has no \n so the backend UART buffer holds it until
|
||
// we send a byte that causes another write.
|
||
if (this._replState === 'idle' && this._serialBuffer.includes('Type "help()"')) {
|
||
this._replState = 'banner_seen';
|
||
console.log('[Esp32Bridge] Stage 1: banner seen → poking UART with \\r');
|
||
setTimeout(() => {
|
||
this._send({ type: 'esp32_serial_input', data: { bytes: [0x0d] } });
|
||
}, 800);
|
||
}
|
||
|
||
// Stage 2: ">>>" → send Ctrl+A to enter raw REPL
|
||
if (this._replState === 'banner_seen' && this._serialBuffer.includes('>>>')) {
|
||
this._replState = 'prompt_seen';
|
||
this._serialBuffer = '';
|
||
console.log('[Esp32Bridge] Stage 2: >>> seen → sending Ctrl+A');
|
||
setTimeout(() => {
|
||
this._send({ type: 'esp32_serial_input', data: { bytes: [0x01] } });
|
||
}, 200);
|
||
}
|
||
|
||
// Stage 3: "raw REPL" confirmation → now safe to send code
|
||
if (this._replState === 'prompt_seen' && this._serialBuffer.includes('raw REPL')) {
|
||
this._replState = 'raw_repl_entered';
|
||
const code = this._pendingMicroPythonCode!;
|
||
this._pendingMicroPythonCode = null;
|
||
this._serialBuffer = '';
|
||
console.log('[Esp32Bridge] Stage 3: raw REPL confirmed → sending code');
|
||
setTimeout(() => this._sendCodeInRawRepl(code), 200);
|
||
}
|
||
|
||
// Keep buffer from growing unboundedly
|
||
if (this._serialBuffer.length > 8192) {
|
||
this._serialBuffer = this._serialBuffer.slice(-1024);
|
||
}
|
||
}
|
||
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);
|
||
// Also feed the scope path so ESP32 digital pin activity shows
|
||
// up on the oscilloscope at parity with AVR / RP2040 boards.
|
||
// Wall-clock timestamp is good enough at 1× sim speed; QEMU
|
||
// virtual time isn't surfaced across the WebSocket today.
|
||
this.onPinChangeWithTime?.(pin, state, performance.now());
|
||
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_duty': {
|
||
this.onLedcDuty?.(msg.data as unknown as LedcDuty);
|
||
break;
|
||
}
|
||
case 'gpio_routing': {
|
||
this.onGpioRouting?.(msg.data as unknown as GpioRouting);
|
||
break;
|
||
}
|
||
case 'gpio_routing_clear': {
|
||
this.onGpioRoutingClear?.(msg.data.gpio as number);
|
||
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 'epaper_update': {
|
||
const componentId = msg.data.component_id as string;
|
||
this.onEpaperUpdate?.(componentId, {
|
||
width: msg.data.width as number,
|
||
height: msg.data.height as number,
|
||
b64: msg.data.frame_b64 as string,
|
||
refreshMs: (msg.data.refresh_ms as number) ?? 50,
|
||
});
|
||
break;
|
||
}
|
||
case 'i2c_event': {
|
||
const addr = msg.data.addr as number;
|
||
const data = msg.data.data as number;
|
||
this.onI2cEvent?.(addr, data);
|
||
break;
|
||
}
|
||
case 'i2c_transaction': {
|
||
const addr = msg.data.addr as number;
|
||
const data = msg.data.data as number[];
|
||
this.onI2cTransaction?.(addr, data);
|
||
break;
|
||
}
|
||
case 'proxy_i2c_complete': {
|
||
// Backend `ProxySlave` saw a full I2C write transaction from
|
||
// the ESP32 firmware and is forwarding the bytes back so the
|
||
// frontend can replay them on the actual peer device. The
|
||
// peer's `I2CDevice.writeByte` handles its own state machine
|
||
// (pointer-byte first, then data) — we just hand off the
|
||
// sequence in order.
|
||
const addr = msg.data.addr as number;
|
||
const data = msg.data.data as number[];
|
||
this.onProxyI2cComplete?.(addr, data);
|
||
break;
|
||
}
|
||
case 'spi_batch': {
|
||
// Worker batches consecutive MOSI bytes from a single SPI
|
||
// transaction into one base64-encoded message. Replays each
|
||
// byte through the same callbacks the per-byte spi_event path
|
||
// uses — parts that subscribed to onSpiByte don't notice. See
|
||
// backend/app/services/esp32_worker.py::_on_spi_event for the
|
||
// batching policy (flush on CS HIGH or buffer cap).
|
||
const b64 = msg.data.b64 as string;
|
||
if (b64) {
|
||
const bin = atob(b64);
|
||
const handler = this.onSpiByte ?? this.onSpiEvent;
|
||
if (handler) {
|
||
for (let i = 0; i < bin.length; i++) {
|
||
const m = bin.charCodeAt(i);
|
||
handler(m);
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case 'spi_event': {
|
||
// Worker emits {bus, event, response}. The 'event' field encodes:
|
||
// event = mosi << 8 (op = event & 0xFF == 0x00) → byte transfer
|
||
// event = ((cs<<1)|level) << 8 | 0x01 (op == 0x01) → CS line change
|
||
// See backend/app/services/esp32_worker.py::_on_spi_event.
|
||
//
|
||
// After the batching change, the byte transfer path goes
|
||
// through 'spi_batch' instead. This branch now only fires for
|
||
// CS-line changes (op == 0x01), but we keep the byte branch
|
||
// for backwards compatibility with older worker builds.
|
||
const event = msg.data.event as number;
|
||
const op = (event ?? 0) & 0xFF;
|
||
if (op === 0x00) {
|
||
const mosi = (event >> 8) & 0xFF;
|
||
this.onSpiEvent?.(mosi);
|
||
this.onSpiByte?.(mosi);
|
||
} else if (op === 0x01) {
|
||
const csIdx = (event >> 9) & 0x3;
|
||
const level = (event >> 8) & 0x1;
|
||
this.onSpiCsChange?.(csIdx, level === 1);
|
||
}
|
||
// Backwards-compat path for callers reading the old `data` field.
|
||
if (msg.data.data !== undefined) {
|
||
this.onSpiEvent?.(msg.data.data as number);
|
||
}
|
||
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.
|
||
*
|
||
* MERGE semantics (upsert by `pin`): pre-existing entries with a different
|
||
* pin are kept, entries with the same pin are replaced. An earlier
|
||
* implementation did `this._pendingSensors = sensors` (full replace) which
|
||
* blew away anything PartSimulationRegistry handlers had already
|
||
* registered via `sendSensorAttach` (e.g. the ePaper SPI slaves on
|
||
* virtual pins) the moment `startBoard` later called `setSensors` with
|
||
* only the wire-resolved sensors it knew about (DHT22, HC-SR04, …).
|
||
* That dropped the ePaper slave registration on every Run click, and the
|
||
* 5.65" UC8159c panel sat unresponsive while its firmware busy-waited.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/** 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 } });
|
||
}
|
||
|
||
/**
|
||
* Push a periodic waveform LUT for an ADC channel. The backend forwards
|
||
* the samples to QEMU, which interpolates them against its virtual clock
|
||
* on every MMIO ADC read — matching the per-read fidelity AVR and RP2040
|
||
* get via `onADCRead` monkey-patching.
|
||
*
|
||
* samples: 12-bit raw values (0-4095) aligned on a uniform time grid
|
||
* periodNs: full period of the LUT in nanoseconds
|
||
*
|
||
* Samples are sent as base64-encoded uint16 little-endian. Clearing the
|
||
* waveform (returning to DC `setAdc` behavior) is done by passing an
|
||
* empty `samples` array.
|
||
*/
|
||
setAdcWaveform(channel: number, samples: Uint16Array, periodNs: number): void {
|
||
// Encode little-endian uint16 → base64 (transport-safe for JSON stdin/WS).
|
||
const bytes = new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
|
||
let binary = '';
|
||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||
const base64 =
|
||
typeof btoa === 'function' ? btoa(binary) : Buffer.from(bytes).toString('base64');
|
||
this._send({
|
||
type: 'esp32_adc_waveform',
|
||
data: { channel, samples_u12_b64: base64, period_ns: periodNs },
|
||
});
|
||
}
|
||
|
||
/** Clear a previously-pushed ADC waveform, reverting to DC `setAdc`. */
|
||
clearAdcWaveform(channel: number): void {
|
||
this._send({
|
||
type: 'esp32_adc_waveform',
|
||
data: { channel, samples_u12_b64: '', period_ns: 0 },
|
||
});
|
||
}
|
||
|
||
/** Configure the byte an I2C device at addr returns */
|
||
setI2cResponse(addr: number, response: number): void {
|
||
this._send({ type: 'esp32_i2c_response', data: { addr, response } });
|
||
}
|
||
|
||
// ── Cross-board I2C proxy ─────────────────────────────────────────────────
|
||
// The backend hosts a `ProxySlave` at each registered address that responds
|
||
// with the register dump pushed by the frontend. Used when an ESP32 is
|
||
// wired to another board's I2C bus and that peer board owns a virtual
|
||
// device — the ESP32 firmware needs to read it synchronously inside QEMU,
|
||
// which a WebSocket round-trip per byte can't deliver. The proxy snapshot
|
||
// is good enough for chip-id reads, calibration constants, and any device
|
||
// whose state changes slowly relative to the ESP32 firmware's poll cadence.
|
||
|
||
/**
|
||
* Install a proxy I2C slave at `addr` initialised with the given register
|
||
* dump (up to 256 bytes). Pushed lazily — buffered until WS opens.
|
||
*/
|
||
registerProxyI2c(addr: number, registers: Uint8Array): void {
|
||
const regs_b64 = btoa(String.fromCharCode(...registers));
|
||
this._send({
|
||
type: 'esp32_proxy_i2c_register',
|
||
data: { addr: addr & 0x7f, regs_b64 },
|
||
});
|
||
}
|
||
|
||
/** Refresh the register state of an existing proxy slave at `addr`. */
|
||
updateProxyI2c(addr: number, registers: Uint8Array): void {
|
||
const regs_b64 = btoa(String.fromCharCode(...registers));
|
||
this._send({
|
||
type: 'esp32_proxy_i2c_update',
|
||
data: { addr: addr & 0x7f, regs_b64 },
|
||
});
|
||
}
|
||
|
||
/** Remove the proxy slave at `addr` (called on bridge teardown). */
|
||
unregisterProxyI2c(addr: number): void {
|
||
this._send({
|
||
type: 'esp32_proxy_i2c_unregister',
|
||
data: { addr: addr & 0x7f },
|
||
});
|
||
}
|
||
|
||
/** 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 {
|
||
// Buffer into _pendingSensors so it is included in start_esp32 if sent
|
||
// before the WebSocket opens (the common case when attachEvents fires
|
||
// before the user clicks Run).
|
||
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);
|
||
}
|
||
// Also send immediately if already connected (re-attach on hot reload)
|
||
if (this._connected) {
|
||
this._send({ type: 'esp32_sensor_attach', data: entry });
|
||
}
|
||
}
|
||
|
||
/** Update sensor properties (temperature, humidity, distance, etc.) */
|
||
sendSensorUpdate(pin: number, properties: Record<string, unknown>): void {
|
||
// Keep _pendingSensors in sync so reconnects get current values
|
||
const idx = this._pendingSensors.findIndex((s) => s['pin'] === pin);
|
||
if (idx >= 0) {
|
||
this._pendingSensors[idx] = { ...this._pendingSensors[idx], ...properties };
|
||
}
|
||
this._send({ type: 'esp32_sensor_update', data: { pin, ...properties } });
|
||
}
|
||
|
||
/** Detach a sensor from a GPIO pin */
|
||
sendSensorDetach(pin: number): void {
|
||
this._pendingSensors = this._pendingSensors.filter((s) => s['pin'] !== pin);
|
||
this._send({ type: 'esp32_sensor_detach', data: { pin } });
|
||
}
|
||
|
||
// ── ESP32-CAM webcam injection ────────────────────────────────────────────
|
||
/** Tell the backend a frame source is connected (call once when the user
|
||
* grants webcam permission). */
|
||
sendCameraAttach(): void {
|
||
this._send({ type: 'esp32_camera_attach', data: { board: 'esp32-cam' } });
|
||
}
|
||
|
||
/** Push one JPEG frame from the browser webcam to the emulator. The
|
||
* backend forwards it via ctypes to the QEMU OV2640+I²S device, which
|
||
* delivers the bytes to the firmware's DMA buffer.
|
||
*
|
||
* Encoding: base64 in JSON. ~10–14 KB per QVGA frame at quality 0.6.
|
||
* At 10 fps that's ~120 KB/s — trivial over local WS. */
|
||
sendCameraFrame(jpegBytes: ArrayBuffer | Uint8Array,
|
||
width = 320, height = 240): void {
|
||
const u8 = jpegBytes instanceof Uint8Array
|
||
? jpegBytes
|
||
: new Uint8Array(jpegBytes);
|
||
// btoa needs a binary string; build one in 32 KB chunks to avoid
|
||
// "argument size limit" issues with very large frames.
|
||
let binary = '';
|
||
const chunkSize = 0x8000;
|
||
for (let i = 0; i < u8.length; i += chunkSize) {
|
||
binary += String.fromCharCode(...u8.subarray(i, i + chunkSize));
|
||
}
|
||
const b64 = btoa(binary);
|
||
this._send({
|
||
type: 'esp32_camera_frame',
|
||
data: { fmt: 'jpeg', w: width, h: height, b64 },
|
||
});
|
||
}
|
||
|
||
/** Drop the queued frame. Call when the user stops the webcam. */
|
||
sendCameraDetach(): void {
|
||
this._send({ type: 'esp32_camera_detach', data: {} });
|
||
}
|
||
|
||
/**
|
||
* Queue user MicroPython code for injection after the REPL boots.
|
||
* The code will be sent via raw-paste protocol once `>>>` is detected.
|
||
*/
|
||
setPendingMicroPythonCode(code: string): void {
|
||
this._pendingMicroPythonCode = code;
|
||
this._serialBuffer = '';
|
||
this._replState = 'idle';
|
||
this.micropythonMode = true;
|
||
}
|
||
|
||
/** Check if this bridge is in MicroPython mode */
|
||
isMicroPythonMode(): boolean {
|
||
return this.micropythonMode;
|
||
}
|
||
|
||
/**
|
||
* Send code bytes to QEMU UART, then Ctrl+D to execute.
|
||
* Called ONLY after "raw REPL; CTRL-B to exit" has been confirmed in the
|
||
* serial buffer (stage 3), so we are guaranteed to be in raw REPL mode.
|
||
*/
|
||
/**
|
||
* Sanitize MicroPython source code before sending to the raw REPL.
|
||
*
|
||
* MicroPython v1.20 on ESP32 uses a byte-oriented tokenizer that doesn't
|
||
* handle non-ASCII bytes in source code. Multi-byte UTF-8 sequences
|
||
* (e.g. Spanish accents: á=\xC3\xA1, ú=\xC3\xBA) in comments confuse the
|
||
* tokenizer and produce SyntaxError at the wrong line.
|
||
*
|
||
* Safe to strip non-ASCII only from comments because:
|
||
* - String literals with non-ASCII would already fail on MicroPython's
|
||
* default build (no wide-unicode support on ESP32).
|
||
* - Identifiers must be ASCII.
|
||
*/
|
||
private static _sanitizeForRepl(code: string): string {
|
||
// 1. Strip UTF-8 BOM if present
|
||
let s = code.startsWith('\uFEFF') ? code.slice(1) : code;
|
||
// 2. Normalize line endings to LF
|
||
s = s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||
// 3. Replace non-ASCII in line-comments with '?' so the line is preserved
|
||
s = s.replace(/^([ \t]*#.*)$/gm, (line) => line.replace(/[^\x00-\x7F]/g, '?'));
|
||
// 4. Replace non-ASCII in inline comments (after code on the same line)
|
||
s = s.replace(/([ \t]+#.*)$/gm, (comment) => comment.replace(/[^\x00-\x7F]/g, '?'));
|
||
return s;
|
||
}
|
||
|
||
private _sendCodeInRawRepl(code: string): void {
|
||
const sanitized = Esp32Bridge._sanitizeForRepl(code);
|
||
console.log(
|
||
`[Esp32Bridge:${this.boardId}] Sending ${sanitized.length} bytes to raw REPL + Ctrl+D`,
|
||
);
|
||
if (sanitized !== code) {
|
||
console.log(
|
||
`[Esp32Bridge:${this.boardId}] Code was sanitized (non-ASCII in comments stripped)`,
|
||
);
|
||
}
|
||
const codeBytes = Array.from(new TextEncoder().encode(sanitized));
|
||
console.log(
|
||
`[Esp32Bridge:${this.boardId}] Sending ${codeBytes.length} bytes in chunks to raw REPL`,
|
||
);
|
||
|
||
// The ESP32 UART RX FIFO is 128 bytes in hardware (and in QEMU's emulation).
|
||
// Sending >128 bytes in one qemu_picsimlab_uart_receive() call overflows the
|
||
// FIFO — the extra bytes are silently dropped, corrupting the injected code
|
||
// (e.g. "time.sleep" becomes "ti" causing NameError).
|
||
// Use ≤64-byte chunks with a 150 ms gap so QEMU drains the FIFO between sends.
|
||
const CHUNK_SIZE = 64;
|
||
const CHUNK_DELAY_MS = 150;
|
||
let offset = 0;
|
||
|
||
const sendChunk = () => {
|
||
if (offset >= codeBytes.length) {
|
||
// All bytes delivered — wait for QEMU to finish processing the last chunk
|
||
setTimeout(() => {
|
||
this.sendSerialBytes([0x04]); // Ctrl+D → compile & execute
|
||
this._replState = 'idle';
|
||
console.log(`[Esp32Bridge:${this.boardId}] Ctrl+D sent — code executing`);
|
||
}, 300);
|
||
return;
|
||
}
|
||
const chunk = codeBytes.slice(offset, offset + CHUNK_SIZE);
|
||
this.sendSerialBytes(chunk);
|
||
offset += CHUNK_SIZE;
|
||
setTimeout(sendChunk, CHUNK_DELAY_MS);
|
||
};
|
||
sendChunk();
|
||
}
|
||
|
||
private _send(payload: unknown): void {
|
||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||
this.socket.send(JSON.stringify(payload));
|
||
}
|
||
}
|
||
}
|