2026-03-14 06:35:48 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 } }
|
2026-03-14 22:05:35 +07:00
|
|
|
|
* { type: 'esp32_serial_input', data: { bytes: number[], uart?: number } }
|
2026-03-14 06:35:48 +07:00
|
|
|
|
* { type: 'esp32_gpio_in', data: { pin: number, state: 0 | 1 } }
|
2026-03-14 22:05:35 +07:00
|
|
|
|
* { 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 } }
|
2026-03-23 04:03:17 +07:00
|
|
|
|
* { type: 'esp32_sensor_attach', data: { sensor_type: string, pin: number, ... } }
|
|
|
|
|
|
* { type: 'esp32_sensor_update', data: { pin: number, ... } }
|
|
|
|
|
|
* { type: 'esp32_sensor_detach', data: { pin: number } }
|
2026-03-14 06:35:48 +07:00
|
|
|
|
*
|
|
|
|
|
|
* Backend → Frontend
|
2026-03-14 22:05:35 +07:00
|
|
|
|
* { type: 'serial_output', data: { data: string, uart?: number } }
|
2026-03-14 06:35:48 +07:00
|
|
|
|
* { type: 'gpio_change', data: { pin: number, state: 0 | 1 } }
|
2026-03-14 22:05:35 +07:00
|
|
|
|
* { 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][] } }
|
2026-04-08 01:43:09 +07:00
|
|
|
|
* { type: 'i2c_event', data: { addr: number, data: number } }
|
|
|
|
|
|
* { type: 'i2c_transaction', data: { addr: number, data: number[] } }
|
|
|
|
|
|
* { type: 'spi_event', data: { data: number } }
|
2026-03-14 06:35:48 +07:00
|
|
|
|
* { type: 'system', data: { event: string, ... } }
|
|
|
|
|
|
* { type: 'error', data: { message: string } }
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import type { BoardKind } from '../types/board';
|
2026-05-05 23:41:30 +07:00
|
|
|
|
import { generateUUID } from '../utils/uuid';
|
2026-03-14 06:35:48 +07:00
|
|
|
|
|
2026-03-16 01:39:26 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Map any ESP32-family board kind to the 3 base QEMU machine types understood
|
|
|
|
|
|
* by the backend esp_qemu_manager.
|
|
|
|
|
|
*/
|
2026-03-30 09:12:24 +07:00
|
|
|
|
export function toQemuBoardType(kind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32-c3' {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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';
|
2026-03-16 01:39:26 +07:00
|
|
|
|
return 'esp32'; // esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 06:35:48 +07:00
|
|
|
|
const API_BASE = (): string =>
|
|
|
|
|
|
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'http://localhost:8001/api';
|
|
|
|
|
|
|
2026-03-15 08:55:13 +07:00
|
|
|
|
/** Returns a stable UUID for this browser tab (persists across reloads, resets on new tab). */
|
2026-04-02 08:41:52 +07:00
|
|
|
|
export function getTabSessionId(): string {
|
2026-03-24 00:10:39 +07:00
|
|
|
|
// sessionStorage is not available in Node/test environments
|
2026-05-05 23:41:30 +07:00
|
|
|
|
if (typeof sessionStorage === 'undefined') return generateUUID();
|
2026-03-15 08:55:13 +07:00
|
|
|
|
const KEY = 'velxio-tab-id';
|
|
|
|
|
|
let id = sessionStorage.getItem(KEY);
|
|
|
|
|
|
if (!id) {
|
2026-05-05 23:41:30 +07:00
|
|
|
|
id = generateUUID();
|
2026-03-15 08:55:13 +07:00
|
|
|
|
sessionStorage.setItem(KEY, id);
|
|
|
|
|
|
}
|
|
|
|
|
|
return id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-14 22:05:35 +07:00
|
|
|
|
|
2026-03-14 06:35:48 +07:00
|
|
|
|
export class Esp32Bridge {
|
|
|
|
|
|
readonly boardId: string;
|
|
|
|
|
|
readonly boardKind: BoardKind;
|
|
|
|
|
|
|
2026-04-01 06:53:56 +07:00
|
|
|
|
/** Set to true before connect() to enable WiFi NIC in QEMU. */
|
|
|
|
|
|
wifiEnabled = false;
|
|
|
|
|
|
|
2026-03-14 06:35:48 +07:00
|
|
|
|
// Callbacks wired up by useSimulatorStore
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
onI2cEvent: ((addr: number, data: number) => void) | null = null;
|
|
|
|
|
|
onI2cTransaction: ((addr: number, data: number[]) => void) | null = null;
|
|
|
|
|
|
onSpiEvent: ((data: number) => void) | null = null;
|
2026-05-03 08:31:24 +07:00
|
|
|
|
/** 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;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
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;
|
2026-03-14 06:35:48 +07:00
|
|
|
|
|
|
|
|
|
|
private socket: WebSocket | null = null;
|
|
|
|
|
|
private _connected = false;
|
|
|
|
|
|
private _pendingFirmware: string | null = null;
|
2026-03-23 04:03:17 +07:00
|
|
|
|
private _pendingSensors: Array<Record<string, unknown>> = [];
|
2026-03-14 06:35:48 +07:00
|
|
|
|
|
2026-04-13 09:55:11 +07:00
|
|
|
|
// 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.
|
2026-03-30 09:12:24 +07:00
|
|
|
|
private _pendingMicroPythonCode: string | null = null;
|
|
|
|
|
|
private _serialBuffer = '';
|
2026-04-13 09:55:11 +07:00
|
|
|
|
private _replState: 'idle' | 'banner_seen' | 'prompt_seen' | 'raw_repl_entered' = 'idle';
|
2026-03-30 09:12:24 +07:00
|
|
|
|
micropythonMode = false;
|
|
|
|
|
|
|
2026-03-14 06:35:48 +07:00
|
|
|
|
constructor(boardId: string, boardKind: BoardKind) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.boardId = boardId;
|
2026-03-14 06:35:48 +07:00
|
|
|
|
this.boardKind = boardKind;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get connected(): boolean {
|
|
|
|
|
|
return this._connected;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-02 08:41:52 +07:00
|
|
|
|
get clientId(): string {
|
|
|
|
|
|
return getTabSessionId() + '::' + this.boardId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 06:35:48 +07:00
|
|
|
|
connect(): void {
|
|
|
|
|
|
if (this.socket && this.socket.readyState !== WebSocket.CLOSED) return;
|
|
|
|
|
|
|
|
|
|
|
|
const base = API_BASE();
|
|
|
|
|
|
const wsProtocol = base.startsWith('https') ? 'wss:' : 'ws:';
|
2026-03-15 08:55:13 +07:00
|
|
|
|
const sessionId = getTabSessionId();
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const wsUrl =
|
|
|
|
|
|
base.replace(/^https?:/, wsProtocol) +
|
|
|
|
|
|
`/simulation/ws/${encodeURIComponent(sessionId + '::' + this.boardId)}`;
|
2026-03-14 06:35:48 +07:00
|
|
|
|
|
|
|
|
|
|
const socket = new WebSocket(wsUrl);
|
|
|
|
|
|
this.socket = socket;
|
|
|
|
|
|
|
|
|
|
|
|
socket.onopen = () => {
|
|
|
|
|
|
this._connected = true;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
console.log(
|
|
|
|
|
|
`[Esp32Bridge:${this.boardId}] WebSocket connected → sending start_esp32 (firmware: ${this._pendingFirmware ? `${Math.round((this._pendingFirmware.length * 0.75) / 1024)}KB` : 'none'})`,
|
|
|
|
|
|
);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
this.onConnected?.();
|
|
|
|
|
|
this._send({
|
|
|
|
|
|
type: 'start_esp32',
|
|
|
|
|
|
data: {
|
2026-03-16 01:39:26 +07:00
|
|
|
|
board: toQemuBoardType(this.boardKind),
|
2026-03-14 06:35:48 +07:00
|
|
|
|
...(this._pendingFirmware ? { firmware_b64: this._pendingFirmware } : {}),
|
2026-03-23 04:03:17 +07:00
|
|
|
|
sensors: this._pendingSensors,
|
2026-04-01 06:53:56 +07:00
|
|
|
|
wifi_enabled: this.wifiEnabled,
|
2026-03-14 06:35:48 +07:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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) ?? '';
|
2026-03-14 22:05:35 +07:00
|
|
|
|
const uart = msg.data.uart as number | undefined;
|
2026-03-14 06:35:48 +07:00
|
|
|
|
if (this.onSerialData) {
|
2026-03-14 22:05:35 +07:00
|
|
|
|
for (const ch of text) this.onSerialData(ch, uart);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
}
|
2026-04-13 09:55:11 +07:00
|
|
|
|
// 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') {
|
2026-03-30 09:12:24 +07:00
|
|
|
|
this._serialBuffer += text;
|
2026-04-13 09:55:11 +07:00
|
|
|
|
|
|
|
|
|
|
// 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(() => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this._send({ type: 'esp32_serial_input', data: { bytes: [0x0d] } });
|
2026-04-13 09:55:11 +07:00
|
|
|
|
}, 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!;
|
2026-03-30 09:12:24 +07:00
|
|
|
|
this._pendingMicroPythonCode = null;
|
|
|
|
|
|
this._serialBuffer = '';
|
2026-04-13 09:55:11 +07:00
|
|
|
|
console.log('[Esp32Bridge] Stage 3: raw REPL confirmed → sending code');
|
|
|
|
|
|
setTimeout(() => this._sendCodeInRawRepl(code), 200);
|
2026-03-30 09:12:24 +07:00
|
|
|
|
}
|
2026-04-13 09:55:11 +07:00
|
|
|
|
|
|
|
|
|
|
// Keep buffer from growing unboundedly
|
|
|
|
|
|
if (this._serialBuffer.length > 8192) {
|
|
|
|
|
|
this._serialBuffer = this._serialBuffer.slice(-1024);
|
2026-03-30 09:12:24 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-14 06:35:48 +07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'gpio_change': {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const pin = msg.data.pin as number;
|
2026-03-14 06:35:48 +07:00
|
|
|
|
const state = (msg.data.state as number) === 1;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
console.log(
|
|
|
|
|
|
`[Esp32Bridge:${this.boardId}] gpio_change pin=${pin} state=${state ? 'HIGH' : 'LOW'}`,
|
|
|
|
|
|
);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
this.onPinChange?.(pin, state);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-03-14 22:05:35 +07:00
|
|
|
|
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': {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
console.log(
|
|
|
|
|
|
`[Esp32Bridge:${this.boardId}] ledc_update ch=${msg.data.channel} duty=${msg.data.duty_pct}% gpio=${msg.data.gpio}`,
|
|
|
|
|
|
);
|
2026-03-14 22:05:35 +07:00
|
|
|
|
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);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
break;
|
2026-03-14 22:05:35 +07:00
|
|
|
|
}
|
feat: Add support for SSD168x ePaper panels
- Introduced EPaperPanels.ts to define configurations for various ePaper panels including dimensions, refresh rates, and controller details.
- Implemented SSD168xDecoder.ts to handle the decoding of SPI commands for the SSD168x family of ePaper displays.
- Created EPaperPart.ts to manage the simulation of ePaper panels, integrating with the existing simulator architecture and handling events.
- Added example sketches for 2.13", 2.9", 4.2", and 7.5" ePaper displays, demonstrating basic functionality and text rendering.
- Ensured compatibility with AVR, RP2040, and ESP32 platforms, with appropriate pin configurations for each.
2026-04-30 08:23:00 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-14 22:05:35 +07:00
|
|
|
|
case 'i2c_event': {
|
|
|
|
|
|
const addr = msg.data.addr as number;
|
|
|
|
|
|
const data = msg.data.data as number;
|
|
|
|
|
|
this.onI2cEvent?.(addr, data);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-04-08 01:43:09 +07:00
|
|
|
|
case 'i2c_transaction': {
|
|
|
|
|
|
const addr = msg.data.addr as number;
|
|
|
|
|
|
const data = msg.data.data as number[];
|
|
|
|
|
|
this.onI2cTransaction?.(addr, data);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-05-03 10:25:06 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-14 22:05:35 +07:00
|
|
|
|
case 'spi_event': {
|
2026-05-03 08:31:24 +07:00
|
|
|
|
// 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.
|
2026-05-03 10:25:06 +07:00
|
|
|
|
//
|
|
|
|
|
|
// 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.
|
2026-05-03 08:31:24 +07:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-14 22:05:35 +07:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'system': {
|
|
|
|
|
|
const evt = msg.data.event as string;
|
2026-03-15 02:57:22 +07:00
|
|
|
|
console.log(`[Esp32Bridge:${this.boardId}] system event: ${evt}`, msg.data);
|
2026-03-14 22:05:35 +07:00
|
|
|
|
if (evt === 'crash') {
|
|
|
|
|
|
this.onCrash?.(msg.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.onSystemEvent?.(evt, msg.data);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-04-01 06:53:56 +07:00
|
|
|
|
case 'wifi_status': {
|
|
|
|
|
|
const wifiStatus = msg.data as unknown as WifiStatus;
|
2026-04-22 02:45:45 +07:00
|
|
|
|
console.log(
|
|
|
|
|
|
`[Esp32Bridge:${this.boardId}] wifi_status: ${wifiStatus.status} ssid=${wifiStatus.ssid ?? ''} ip=${wifiStatus.ip ?? ''}`,
|
|
|
|
|
|
);
|
2026-04-01 06:53:56 +07:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-14 06:35:48 +07:00
|
|
|
|
case 'error':
|
2026-03-15 02:57:22 +07:00
|
|
|
|
console.error(`[Esp32Bridge:${this.boardId}] error: ${msg.data.message as string}`);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
this.onError?.(msg.data.message as string);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 02:57:22 +07:00
|
|
|
|
socket.onclose = (ev) => {
|
2026-03-24 00:10:39 +07:00
|
|
|
|
console.log(`[Esp32Bridge:${this.boardId}] WebSocket closed (code=${ev?.code ?? '?'})`);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
this._connected = false;
|
|
|
|
|
|
this.socket = null;
|
|
|
|
|
|
this.onDisconnected?.();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-15 02:57:22 +07:00
|
|
|
|
socket.onerror = (ev) => {
|
|
|
|
|
|
console.error(`[Esp32Bridge:${this.boardId}] WebSocket error`, ev);
|
2026-03-14 06:35:48 +07:00
|
|
|
|
this.onError?.('WebSocket error');
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
disconnect(): void {
|
|
|
|
|
|
if (this.socket) {
|
|
|
|
|
|
this._send({ type: 'stop_esp32' });
|
|
|
|
|
|
this.socket.close();
|
|
|
|
|
|
this.socket = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this._connected = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 23:47:15 +07:00
|
|
|
|
/** Returns true if a firmware has been loaded and is ready to send. */
|
|
|
|
|
|
hasFirmware(): boolean {
|
|
|
|
|
|
return this._pendingFirmware !== null && this._pendingFirmware !== '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 06:35:48 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 } });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
|
/** 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 } });
|
2026-03-14 06:35:48 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Send multiple bytes at once */
|
2026-03-14 22:05:35 +07:00
|
|
|
|
sendSerialBytes(bytes: number[], uart = 0): void {
|
2026-03-14 06:35:48 +07:00
|
|
|
|
if (bytes.length === 0) return;
|
2026-03-14 22:05:35 +07:00
|
|
|
|
this._send({ type: 'esp32_serial_input', data: { bytes, uart } });
|
2026-03-14 06:35:48 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 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 } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
|
/** Set an ADC channel voltage (millivolts, 0–3300) */
|
|
|
|
|
|
setAdc(channel: number, millivolts: number): void {
|
|
|
|
|
|
this._send({ type: 'esp32_adc_set', data: { channel, millivolts } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-21 12:17:30 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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]);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const base64 =
|
|
|
|
|
|
typeof btoa === 'function' ? btoa(binary) : Buffer.from(bytes).toString('base64');
|
2026-04-21 12:17:30 +07:00
|
|
|
|
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 },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 22:05:35 +07:00
|
|
|
|
/** 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 } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
|
// ── 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 {
|
2026-04-11 08:51:56 +07:00
|
|
|
|
// 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 });
|
|
|
|
|
|
}
|
2026-03-23 01:17:44 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
|
/** Update sensor properties (temperature, humidity, distance, etc.) */
|
|
|
|
|
|
sendSensorUpdate(pin: number, properties: Record<string, unknown>): void {
|
2026-04-11 08:51:56 +07:00
|
|
|
|
// 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 };
|
|
|
|
|
|
}
|
2026-03-23 04:03:17 +07:00
|
|
|
|
this._send({ type: 'esp32_sensor_update', data: { pin, ...properties } });
|
2026-03-23 01:17:44 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 04:03:17 +07:00
|
|
|
|
/** Detach a sensor from a GPIO pin */
|
|
|
|
|
|
sendSensorDetach(pin: number): void {
|
2026-04-11 08:51:56 +07:00
|
|
|
|
this._pendingSensors = this._pendingSensors.filter((s) => s['pin'] !== pin);
|
2026-03-23 04:03:17 +07:00
|
|
|
|
this._send({ type: 'esp32_sensor_detach', data: { pin } });
|
2026-03-23 01:17:44 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
feat: ESP32-CAM emulation with webcam frame bridge
First open-source end-to-end emulation of the AI-Thinker ESP32-CAM
in QEMU, paired with a browser webcam → firmware bridge so users can
develop camera sketches without hardware. Status: esp_camera_init()
returns ESP_OK; OV2640 chip-id verifies (PID/VER/MIDH/MIDL exactly
match the datasheet); GPIO 25 VSYNC NEGEDGE interrupt enabled by
the upstream driver. Final piece (cam_task accepting frames) is in
progress — descriptor walker fix landed in this commit.
Backend (Python/FastAPI):
- simulation.py: camera_attach/frame/detach WS handlers
- esp32_worker.py: ctypes binding to velxio_push_camera_frame +
feature-detection fallback for older DLLs
- esp32_lib_manager.py: forward camera commands to the worker stdin
- esp-idf-template/main/CMakeLists.txt: esp32-camera headers added
via add_prebuilt_library + REQUIRES driver (resolves i2c_master_*
symbols). LED_BUILTIN=2 fallback for sketches that hardcode it.
Frontend (React/TS):
- EditorToolbar.tsx: ESP32-CAM (and the rest of the ESP32 family)
added to isQemuBoard list — Run button now starts the QEMU bridge
for these boards instead of falling through to the AVR path
- useWebcamFrames.ts: getUserMedia → OffscreenCanvas →
toBlob('image/jpeg') → base64 → WS at ~10 fps
- CameraToggle.tsx: header button with status colors + frame counter
- SimulatorCanvas.tsx: render CameraToggle for esp32-cam boards
- Esp32Bridge.ts: sendCameraAttach/Frame/Detach + chunked btoa
- useSimulatorStore.ts: diagnostic log on compileBoardProgram
- components-metadata.json: regen including esp32-cam component
Submodule pointer:
- wokwi-libs/qemu-lcgamboa → ff8eee0 (camera devices commit on
davidmonterocrespo24/qemu-lcgamboa branch picsimlab-esp32)
Investigation + tests in test/test-esp32-cam/:
- 13 autosearch markdown docs (overview, SOTA, OV2640 spec, DVP/I2S
spec, build blueprint, blockers resolved, descriptor walker fix)
- 5 sketches (camera_init, sccb_probe, dma_smoke, frame_roundtrip,
webcam_demo) + 8 live + WS regression tests
- README with the user-facing flow
.gitignore:
- libqemu-*.dll.{pre-camera,new,bak} (rollback points, regenerated)
- wokwi-libs/esp32-camera/ (clone consumed by arduino-esp32 path,
not part of this repo)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 05:28:55 +07:00
|
|
|
|
// ── 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: {} });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 09:12:24 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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 = '';
|
2026-04-13 09:55:11 +07:00
|
|
|
|
this._replState = 'idle';
|
2026-03-30 09:12:24 +07:00
|
|
|
|
this.micropythonMode = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Check if this bridge is in MicroPython mode */
|
|
|
|
|
|
isMicroPythonMode(): boolean {
|
|
|
|
|
|
return this.micropythonMode;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-13 09:55:11 +07:00
|
|
|
|
* 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.
|
2026-03-30 09:12:24 +07:00
|
|
|
|
*/
|
2026-04-13 09:55:11 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2026-04-22 02:45:45 +07:00
|
|
|
|
s = s.replace(/^([ \t]*#.*)$/gm, (line) => line.replace(/[^\x00-\x7F]/g, '?'));
|
2026-04-13 09:55:11 +07:00
|
|
|
|
// 4. Replace non-ASCII in inline comments (after code on the same line)
|
2026-04-22 02:45:45 +07:00
|
|
|
|
s = s.replace(/([ \t]+#.*)$/gm, (comment) => comment.replace(/[^\x00-\x7F]/g, '?'));
|
2026-04-13 09:55:11 +07:00
|
|
|
|
return s;
|
|
|
|
|
|
}
|
2026-03-30 09:12:24 +07:00
|
|
|
|
|
2026-04-13 09:55:11 +07:00
|
|
|
|
private _sendCodeInRawRepl(code: string): void {
|
|
|
|
|
|
const sanitized = Esp32Bridge._sanitizeForRepl(code);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
console.log(
|
|
|
|
|
|
`[Esp32Bridge:${this.boardId}] Sending ${sanitized.length} bytes to raw REPL + Ctrl+D`,
|
|
|
|
|
|
);
|
2026-04-13 09:55:11 +07:00
|
|
|
|
if (sanitized !== code) {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
console.log(
|
|
|
|
|
|
`[Esp32Bridge:${this.boardId}] Code was sanitized (non-ASCII in comments stripped)`,
|
|
|
|
|
|
);
|
2026-04-13 09:55:11 +07:00
|
|
|
|
}
|
|
|
|
|
|
const codeBytes = Array.from(new TextEncoder().encode(sanitized));
|
2026-04-22 02:45:45 +07:00
|
|
|
|
console.log(
|
|
|
|
|
|
`[Esp32Bridge:${this.boardId}] Sending ${codeBytes.length} bytes in chunks to raw REPL`,
|
|
|
|
|
|
);
|
2026-04-13 09:55:11 +07:00
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-03-30 09:12:24 +07:00
|
|
|
|
setTimeout(() => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
this.sendSerialBytes([0x04]); // Ctrl+D → compile & execute
|
2026-04-13 09:55:11 +07:00
|
|
|
|
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();
|
2026-03-30 09:12:24 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 06:35:48 +07:00
|
|
|
|
private _send(payload: unknown): void {
|
|
|
|
|
|
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
|
|
|
|
this.socket.send(JSON.stringify(payload));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|