velxio/frontend/src/simulation/Esp32Bridge.ts

959 lines
40 KiB
TypeScript
Raw Normal View History

/**
* 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: 'gpio_pull', data: { pin: number, pull: 0 | 1 | 2 } } // 0=none 1=up 2=down
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:34 +07:00
* { 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 { getProBoard } from '../lib/proBoardRegistry';
fix(frontend): polyfill crypto.randomUUID for non-secure contexts crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost, 127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is undefined and any code path that calls it throws TypeError. This silently broke ESP32 simulation start for self-hosters: the frontend generates a UUID for the WS client_id when Run is clicked; the throw rejected the promise before reaching the WS connect, so the backend never got the start request — no worker spawned, logs empty, simulation "didn't start" with no visible error. Same root cause would also break the multi-file editor (createFile, createFileGroup) on the same LAN-HTTP self-host setup, just less observably. Add a single generateUUID() helper that: 1. Uses crypto.randomUUID() when available (secure context fast path). 2. Falls back to crypto.getRandomValues() — which IS available in non-secure contexts — to build a v4 UUID by hand. 3. Final fallback to Math.random() if even that is missing (defensive — Web Crypto getRandomValues has been universal for years). Replace all 6 crypto.randomUUID() call sites: - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId) - frontend/src/store/useEditorStore.ts (4 sites — file IDs) Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP. DevTools console showed: TypeError: crypto.randomUUID is not a function at Ph (...) at wh.connect (...) at startBoard (...) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:41:30 +07:00
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' | 'esp32-c6' {
// Overlay-registered boards carry their base chip in the registry.
const proFam = getProBoard(kind)?.esp32Family;
if (proFam) return proFam;
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 => {
// 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';
};
/** 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
fix(frontend): polyfill crypto.randomUUID for non-secure contexts crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost, 127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is undefined and any code path that calls it throws TypeError. This silently broke ESP32 simulation start for self-hosters: the frontend generates a UUID for the WS client_id when Run is clicked; the throw rejected the promise before reaching the WS connect, so the backend never got the start request — no worker spawned, logs empty, simulation "didn't start" with no visible error. Same root cause would also break the multi-file editor (createFile, createFileGroup) on the same LAN-HTTP self-host setup, just less observably. Add a single generateUUID() helper that: 1. Uses crypto.randomUUID() when available (secure context fast path). 2. Falls back to crypto.getRandomValues() — which IS available in non-secure contexts — to build a v4 UUID by hand. 3. Final fallback to Math.random() if even that is missing (defensive — Web Crypto getRandomValues has been universal for years). Replace all 6 crypto.randomUUID() call sites: - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId) - frontend/src/store/useEditorStore.ts (4 sites — file IDs) Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP. DevTools console showed: TypeError: crypto.randomUUID is not a function at Ph (...) at wh.connect (...) at startBoard (...) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:41:30 +07:00
if (typeof sessionStorage === 'undefined') return generateUUID();
const KEY = 'velxio-tab-id';
let id = sessionStorage.getItem(KEY);
if (!id) {
fix(frontend): polyfill crypto.randomUUID for non-secure contexts crypto.randomUUID() is only exposed on secure contexts (HTTPS, localhost, 127.0.0.1, ::1). When Velxio is self-hosted and accessed via a LAN IP over plain HTTP (e.g. http://192.168.31.139:3080/), crypto.randomUUID is undefined and any code path that calls it throws TypeError. This silently broke ESP32 simulation start for self-hosters: the frontend generates a UUID for the WS client_id when Run is clicked; the throw rejected the promise before reaching the WS connect, so the backend never got the start request — no worker spawned, logs empty, simulation "didn't start" with no visible error. Same root cause would also break the multi-file editor (createFile, createFileGroup) on the same LAN-HTTP self-host setup, just less observably. Add a single generateUUID() helper that: 1. Uses crypto.randomUUID() when available (secure context fast path). 2. Falls back to crypto.getRandomValues() — which IS available in non-secure contexts — to build a v4 UUID by hand. 3. Final fallback to Math.random() if even that is missing (defensive — Web Crypto getRandomValues has been universal for years). Replace all 6 crypto.randomUUID() call sites: - frontend/src/simulation/Esp32Bridge.ts (2 sites — getTabSessionId) - frontend/src/store/useEditorStore.ts (4 sites — file IDs) Reported by a self-hoster on OrangePi 5B accessing Velxio via LAN IP. DevTools console showed: TypeError: crypto.randomUUID is not a function at Ph (...) at wh.connect (...) at startBoard (...) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:41:30 +07:00
id = generateUUID();
sessionStorage.setItem(KEY, id);
}
return id;
}
export interface Ws2812Pixel {
r: number;
g: number;
b: number;
}
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:34 +07:00
/** LEDC duty event — channel + duty only. The frontend resolves
* channel→signal_id→pin via its SignalRouter mirror. */
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
export interface LedcDuty {
channel: number;
duty_pct: number;
/** Carrier frequency in Hz, when the engine knows it. The JS engines derive
* it from the LEDC timer registers; without it a speaker on a PWM pin can
* only ever play one fixed note, which is how the M5Stack Core's piano
* spent months stuck on 660 Hz. */
freq_hz?: number;
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
}
/** 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;
/** True when the board's network stack lives in THIS browser tab (in-browser
* JS engine). The IoT gateway must then open in the in-app iframe: a new
* tab backgrounds this one, the emulation gets timer-throttled, and the
* in-chip HTTP server can't answer. Unset = server runs backend-side. */
inBrowser?: boolean;
}
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;
feat(microsd): SD-over-SPI card storage for AVR, RP2040 and ESP32 Add a working microSD card part backed by a FAT16 image, following the Wokwi storage model: the project's own workspace files are auto-copied onto the card (free), and an optional "SD Card" panel uploads extra files (gated as a paid feature by the velxio.dev overlay; OSS default allows it). Frontend (in-browser AVR / RP2040): - ProtocolParts.ts: rewrite the microsd-card part from a handshake stub into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte addressing, single/multi-block read+write, CSD/CID, full CMD set). - utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN). - utils/sdCardFiles.ts: assemble the card image from workspace files plus uploaded files; base64 helpers. - components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI. - DynamicComponent + useSimulatorStore: build and inject the image on run. - lib/proSdCardGate.ts: overlay-installable gate for the upload action. - data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples. Backend (ESP32 via QEMU): - services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of the browser part) with a sparse backing store, idle-state R1 tracking and real CRC16 on data blocks when the host enables CRC (CMD59) -- both required by ESP-IDF's sdspi driver. - esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously) and feed write-only bulk transfers. - esp32_lib_manager.py + routes/simulation.py: forward the FAT image (sd_card.image_b64) from the start config into the worker. Tested: - frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware (real Arduino SD.h on avr8js) -- 86 passing. - backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa (mount, directory listing, read and write-readback).
2026-06-11 08:59:53 +07:00
/**
* Base64 FAT16 image for an on-canvas microSD card, set before connect().
* Undefined when no card is present. Forwarded to the worker, which attaches
* it as a synchronous SD-over-SPI slave (esp32_sd_slave.SdSpiSlave).
*/
sdImageB64: string | undefined = undefined;
/** SD chip-select GPIO for a board with a BUILT-IN SD sharing the SPI bus —
* the worker CS-gates the slave so it doesn't consume the display stream.
* Undefined for a standalone microsd-card component (owns the bus). */
sdCsPin: number | undefined = undefined;
// Callbacks wired up by useSimulatorStore
onSerialData: ((char: string, uart?: number) => void) | null = null;
onPinChange: ((gpioPin: number, state: boolean) => void) | null = null;
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
/**
* 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;
/** Internal pull config the guest programmed into IO_MUX (INPUT_PULLUP /
* INPUT_PULLDOWN). 0 = none, 1 = pull-up, 2 = pull-down. The frontend
* netlist adds the matching weak resistor so idle inputs read the right
* level (real ESP32 internal pulls aren't otherwise visible to SPICE). */
onPinPull: ((gpioPin: number, pull: 0 | 1 | 2) => void) | null = null;
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
/**
* 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;
refactor(esp32): retire ledc_update + broadcastPwm + channelGpioMemo The SignalRouter path has been in prod through Phase 2.5 / Phase 3.3 deploys without regressions, so the temporary fallback shipped in commit 77bf897 can come out. Closes #101. Backend (esp32_worker.py + esp32_lib_manager.py): - Stop emitting `ledc_update` from the 0x5000 LEDC callback and from the polling thread. Only `ledc_duty` (channel + duty_pct) and the GPIO matrix routing events ship now. - Drop the channel→gpio reverse-lookup that fed the legacy event. Frontend: - Delete `PinManager.broadcastPwm` and `PinManager.pwmListenerPinCount`. - Delete `makeLedcUpdateHandler` + its `channelGpioMemo`. - Delete `Esp32Bridge.onLedcUpdate` field + the `case 'ledc_update':` message handler + the `LedcUpdate` type. - Strip `this.onLedcUpdate = null` from 14 test mocks. - Rewrite the `does not call broadcastPwm` guard in esp32-multi-servo-gpio-matrix.test.ts to assert the method itself no longer exists on PinManager (stronger regression guard than the spy version, and doesn't need vi). - Remove the `PinManager.broadcastPwm fallback` describe block from esp32-servo-pot.test.ts — every test in it exercised the deleted fallback path. Docs (ESP32_EMULATION.md): - Replace `ledc_update` rows in the events / implementation tables with the SignalRouter trio (`ledc_duty`, `gpio_routing`, `gpio_routing_clear`). - Update the visual flow diagram + the "why this matters" paragraph to past-tense the broadcastPwm bug. Tests: 1886 frontend tests pass (the previously-failing board-kinds-coverage test that needed the new Pi Zero/1/2 kinds is also green). Backend unit suite: 279 pass, the 11 espidf_real_paths prereq failures are environment-dependent (need arduino-cli libs in the local shell) and unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:05:34 +07:00
/** Wired by the store to `makeLedcDutyHandler` which routes
* channel→pin via the per-board SignalRouter mirror. */
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
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;
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
/**
* 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;
fix(ili9341): add ESP32 SPI byte routing so the LCD renders on ESP32-CAM The ILI9341 part simulation only hooked AVR's SPI peripheral. For ESP32 the simulator is Esp32BridgeShim (no .spi member), so attachEvents bailed early and the LCD stayed black even though the firmware was driving SPI traffic correctly. The QEMU worker already emits per-byte spi_event WS messages (see backend/app/services/esp32_worker.py::_on_spi_event), and the Esp32Bridge already had an onSpiEvent hook — but the bridge was reading msg.data.data (a non-existent field) instead of decoding the worker's {bus, event, response} format. Fixed. Two changes: 1. Esp32Bridge.ts: decode the spi_event payload correctly. The worker encodes byte transfers as `mosi << 8` (op = low byte = 0x00) and CS-line changes as `((cs<<1)|level) << 8 | 0x01` (op == 0x01). Added onSpiByte (per-byte) and onSpiCsChange callbacks alongside the existing onSpiEvent for backwards compat. 2. ComplexParts.ts ili9341Simulation: detect Esp32BridgeShim via `getBridge()` duck-type check. When present, subscribe to bridge.onSpiByte and feed bytes into the same processCommand / processData pipeline used by the AVR path. DC tracking via pinManager.onPinChange already works for ESP32 because the bridge fires triggerPinChange on every gpio_change WS event. Verified end-to-end: ESP32-CAM + ILI9341 example in the gallery now renders the live webcam preview to the simulated TFT (160×120 RGB565 centered in the 320×240 panel) at ~3-4 fps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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;
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;
}
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
/**
* 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-c6':
return 16; // U0TXD default on the C6 (silkscreen TX on the DevKitC-1)
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
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 {
// Force a clean reconnect. The old guard here was
// if (this.socket && readyState !== CLOSED) return;
// which made connect() a SILENT NO-OP whenever a socket lingered in any
// non-CLOSED state (CONNECTING / OPEN / CLOSING). That's exactly the
// "el agente terminó, di Run y no funcionó; recargué y sí" bug: the
// agent's run_simulation left a live/half-dead socket, the backend QEMU
// session had ended, and the user's Run → startBoard → connect() returned
// without doing anything. A page reload worked only because it built a
// fresh bridge. Tearing the zombie socket down and opening a new one to
// the same session key is exactly what that reload does — the backend
// already handles a new WS replacing an existing session (that's why
// reload works), so it's safe to do it without the reload.
if (this.socket) {
try {
this.socket.onopen = null;
this.socket.onmessage = null;
this.socket.onclose = null;
this.socket.onerror = null;
this.socket.close();
} catch {
/* already closing/closed */
}
this.socket = null;
this._connected = false;
}
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,
...(this.sdImageB64
? {
sd_card: {
image_b64: this.sdImageB64,
...(this.sdCsPin !== undefined ? { cs_pin: this.sdCsPin } : {}),
},
}
: {}),
},
});
};
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);
}
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
// 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.
// The poke only fires while the prompt is STILL missing. If the
// backend ever delivers ">>>" on its own within 600 ms, stage 2
// arms Ctrl-A (+200 ms) before this timer (+800 ms) fires, and the
// \r then lands inside the pasted code — MicroPython reads a lone
// \r as a newline, so it silently cuts a source line in half. That
// is exactly what broke the in-browser JS engines, where the prompt
// arrives immediately (see pro microPythonRepl.ts).
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(() => {
if (this._replState !== 'banner_seen') return; // prompt already arrived
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;
// No per-transition logging here: gpio_change fires on every edge
// (e.g. each SPI clock pulse on a display-heavy sketch), so logging
// it floods the console and measurably throttles the main thread and
// simulation throughput. Keep only the functional callbacks below.
this.onPinChange?.(pin, state);
feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard).
2026-05-23 00:58:24 +07:00
// 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 'gpio_pull': {
const pin = msg.data.pin as number;
const pull = msg.data.pull as 0 | 1 | 2;
this.onPinPull?.(pin, pull);
break;
}
feat(esp32): SignalRouter — model the GPIO Matrix as first-class Replaces the per-peripheral ad-hoc `_ledc_gpio_map` cache with a proper signal-routing abstraction that mirrors the ESP32 SoC's IO_MUX + GPIO Matrix exactly. Same idea as real silicon: signal sources (LEDC channels, RMT, MCPWM, ...) → 40-entry routing table → GPIO pins. Motivation (from user bug report in velxio.dev/project/5218f9e3-136d-43b3-bba1-6cebde21e1a4): two ESP32 servos on a solar-tracker visibly oscillated between two positions instead of moving smoothly when the user changed LDR sliders. Commit 77bf897 patched it (per-channel gpio memo + broadcast guard) but the user requested a proper hardware-fidel architecture, not patches. Backend: * `app/services/signal_router.py` — SignalRouter class. Forward index (gpio → signal_id) + reverse index (signal_id → set of gpios). `replace_snapshot()` returns the diff for the polling- fallback path; future C plugin hook becomes a push without touching this code. * `app/services/esp32_signals.py` — Signal id constants from ESP32 TRM (LEDC HS 72-79, LS 80-87) + `ledc_signal_for_channel()` helper. * `app/services/esp32_worker.py` — `_ledc_gpio_map` is gone; `_refresh_ledc_gpio_map` replaced by `_refresh_signal_routing` which emits `gpio_routing {gpio, signal_id}` events on diff. The 0x5000 LEDC callback and the LEDC poll thread now emit `ledc_duty {channel, duty_pct}` (canonical, no gpio) alongside the legacy `ledc_update {channel, duty, gpio}` for back-compat during rollout. Frontend: * `simulation/SignalRouter.ts` — 1-to-1 TS mirror of the Python class. Same forward + reverse index; same `pinsForSignal` / `updateRouting` / `clearRouting` API. * `simulation/esp32-signals.ts` — Signal id constants, mirror of the Python module. * `simulation/Esp32Bridge.ts` — new `onLedcDuty`, `onGpioRouting`, `onGpioRoutingClear` callbacks; handlers for the new event types. * `store/useSimulatorStore.ts` — `makeLedcDutyHandler` looks up pins via `router.pinsForSignal(ledcSignalForChannel(channel))` and dispatches per pin. `makeGpioRoutingHandler` / `makeGpioRoutingClearHandler` keep the mirror in sync. Per-board `signalRouterMap` parallels `pinManagerMap` in lifecycle. `makeLedcUpdateHandler` (and its memo workaround from 77bf897) stays wired for back-compat during rollout; removed in a follow-up commit once prod is verified stable on the new path. Tests: * `test/backend/unit/test_signal_router.py` (20 tests) covers update/clear semantics, idempotency, multi-pin routing, snapshot diff, channel↔signal-id helpers, and the multi-servo regression scenario. * `frontend/src/__tests__/SignalRouter.test.ts` (17 tests) is the mirror — same scenarios on the TS side. * `frontend/src/__tests__/esp32-multi-servo-gpio-matrix.test.ts` (6 tests) drives the end-to-end SignalRouter handler pipeline, asserts that two servos on GPIO 13/12 via LEDC channels 0/1 move independently (no mirroring), that re-routing carries cleanly, and — critically — that `PinManager.broadcastPwm` is never called. Totals: +700 LOC, 1876 frontend tests pass (was 1853), 278 backend unit tests pass (was 259). Docs: ESP32_EMULATION.md §9.2 rewritten with the new architecture diagram + a runbook for adding future peripherals through the SignalRouter. The C plugin hook in qemu-lcgamboa that would push gpio_out_sel writes synchronously (eliminating the polling race window entirely) is the next step — kept as a follow-up because the polling-fallback path here already resolves the routing before each duty event fires, so the bug is fixed end-to-end. The plugin work removes the race condition fundamentally.
2026-05-17 10:00:53 +07:00
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;
}
feat(i2c): cross-board bridging across all velxio boards (AVR/RP2040/ESP32 xtensa+riscv) Closes the remaining gaps in cross-board I2C so any topology of supported boards (Uno↔ESP32, two ESP32s, Uno↔Uno↔Uno, ESP32-C3 connected to anything, etc.) works end-to-end with all I2C components including write-only sinks (SSD1306, PCF8574, LCD-I2C). Implementation (6 phases): 1. **BFS routing in I2CBusManager**: connectToSlave + handleExternalConnect walk the bridge graph with a visited Set so multi-hop chains (A↔B↔C with the device on C) resolve transparently. A new forwarder-device shim is installed at intermediate hops so the existing handleExternalWrite/Read/Stop machinery routes through without per-method visited tracking. 2. **Per-peer proxy ownership in Esp32BridgeShim**: replaces the global _proxiedAddrs Set with _proxiedByPeer Map so concurrent bridges to the same ESP32 (e.g. wired to both Uno and Pico) don't wipe each other's proxies on teardown. Interconnect's per-wire teardown calls clearProxiesForPeer(peerBus) instead of clearAllProxies. 3. **BFS-aware proxy sync**: syncProxyFromPeer now walks the peer bus + its transitive bridges, so an ESP32 sees devices on boards two or more hops away. _peerDeviceLookup keeps a flat addr → device map for write-forwarding and resync. 4. **Periodic resync (250 ms)**: Esp32BridgeShim runs a setInterval while any proxy is live, re-dumping each device with dumpRegisters() and pushing updateProxyI2c only when an XOR- stride hash changes. This keeps RTC time advancing visible to ESP32 firmware without flooding the WS pipe with static calibration dumps. Hash is primed during initial sync so the first tick doesn't push a redundant identical buffer. 5. **Write-forwarding ProxySlave → peer**: backend ProxySlave buffers write bytes during the transaction and emits a `proxy_i2c_complete` event on STOP / repeated-START. Frontend Esp32Bridge dispatches the event to a new onProxyI2cComplete callback; the shim replays the byte sequence on the actual peer I2CDevice via writeByte() + stop(). Makes ESP32 firmware writes to peer SSD1306 actually repaint the OLED, peer PCF8574 latch updates, peer I2CMemoryDevice register mutations propagate. 6. **ESP32-C3 routed as bridge**: Interconnect.isBrowserSim no longer claims c3/xiao-c3/c3-supermini — they were already going through Esp32Bridge per the store's ESP32_RISCV_KINDS routing, but Interconnect was treating them as browser sims which broke proxy install. isEsp32Bridge now correctly includes c3 family + ESP32-S3 + Arduino Nano ESP32. Defensive: addBoard now disposes any existing shim's proxies before overwriting simulatorMap entry so test reruns don't leak timers. Tests: - 4 BFS multi-hop tests (i2c-multi-board-slave-gap.test.ts) - 11 cross-board scenarios + per-peer + write-forward + resync (i2c-esp32-multiboard-bridge.test.ts) - 1 real-firmware E2E for write-forward via QEMU (compile + load + observe proxy_i2c_complete arriving with the byte) - New sketch fixture: esp32_i2c_write_to_peer.ino Result: 90 test files / 1295 tests pass / 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:51:39 +07:00
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;
}
perf(spi): batch SPI bytes per WS message — ~50× faster TFT in emulator User reported the ESP32-CAM + ILI9341 live preview at ~1 frame/min. Profile: 80×60 preview pushes 9600 SPI bytes per drawRGBBitmap, and each byte was emitting a full {type:'spi_event'} JSON message over the worker→backend→WS→frontend pipeline. Per-byte overhead ~150-200µs in Python (json.dumps + sys.stdout.write+flush dominates) plus asyncio + WS dispatch. Net: 1.5-2 sec/frame minimum, much worse with GIL contention. Fix: buffer MOSI bytes in the worker and emit a single base64-encoded `spi_batch` message when CS goes HIGH (transaction ended) or the buffer crosses 4 KiB. ~9600 events/frame collapse to ~3 messages. backend/app/services/esp32_worker.py:_on_spi_event - Add _spi_byte_buf bytearray + threading.Lock - On op==0x00 (byte): append; flush early if buf >= 4096 - On op==0x01 (CS change): flush buffer, then emit the CS event via the legacy spi_event channel (ePaper / custom chips that observe CS still get it). frontend/src/simulation/Esp32Bridge.ts - New 'spi_batch' message handler decodes b64 and replays each byte through the existing onSpiByte callback. Parts that subscribed via simulator.spi.onByte don't notice the protocol change. The 'spi_event' branch still handles CS changes plus legacy single-byte payloads for backwards compat. Now that 38 KB/frame is cheap, restore preview to 160×120 + JPEG quality 0.35 in the gallery example. Real measured speedup: ~50× on the QVGA preview demo. Real hardware was never affected — it runs SPI at 80 MHz and pushes the bitmap in ~4 ms either way. PSRAM emulation is unrelated to this bottleneck and was left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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;
}
case 'spi_event': {
fix(ili9341): add ESP32 SPI byte routing so the LCD renders on ESP32-CAM The ILI9341 part simulation only hooked AVR's SPI peripheral. For ESP32 the simulator is Esp32BridgeShim (no .spi member), so attachEvents bailed early and the LCD stayed black even though the firmware was driving SPI traffic correctly. The QEMU worker already emits per-byte spi_event WS messages (see backend/app/services/esp32_worker.py::_on_spi_event), and the Esp32Bridge already had an onSpiEvent hook — but the bridge was reading msg.data.data (a non-existent field) instead of decoding the worker's {bus, event, response} format. Fixed. Two changes: 1. Esp32Bridge.ts: decode the spi_event payload correctly. The worker encodes byte transfers as `mosi << 8` (op = low byte = 0x00) and CS-line changes as `((cs<<1)|level) << 8 | 0x01` (op == 0x01). Added onSpiByte (per-byte) and onSpiCsChange callbacks alongside the existing onSpiEvent for backwards compat. 2. ComplexParts.ts ili9341Simulation: detect Esp32BridgeShim via `getBridge()` duck-type check. When present, subscribe to bridge.onSpiByte and feed bytes into the same processCommand / processData pipeline used by the AVR path. DC tracking via pinManager.onPinChange already works for ESP32 because the bridge fires triggerPinChange on every gpio_change WS event. Verified end-to-end: ESP32-CAM + ILI9341 example in the gallery now renders the live webcam preview to the simulated TFT (160×120 RGB565 centered in the 320×240 panel) at ~3-4 fps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
perf(spi): batch SPI bytes per WS message — ~50× faster TFT in emulator User reported the ESP32-CAM + ILI9341 live preview at ~1 frame/min. Profile: 80×60 preview pushes 9600 SPI bytes per drawRGBBitmap, and each byte was emitting a full {type:'spi_event'} JSON message over the worker→backend→WS→frontend pipeline. Per-byte overhead ~150-200µs in Python (json.dumps + sys.stdout.write+flush dominates) plus asyncio + WS dispatch. Net: 1.5-2 sec/frame minimum, much worse with GIL contention. Fix: buffer MOSI bytes in the worker and emit a single base64-encoded `spi_batch` message when CS goes HIGH (transaction ended) or the buffer crosses 4 KiB. ~9600 events/frame collapse to ~3 messages. backend/app/services/esp32_worker.py:_on_spi_event - Add _spi_byte_buf bytearray + threading.Lock - On op==0x00 (byte): append; flush early if buf >= 4096 - On op==0x01 (CS change): flush buffer, then emit the CS event via the legacy spi_event channel (ePaper / custom chips that observe CS still get it). frontend/src/simulation/Esp32Bridge.ts - New 'spi_batch' message handler decodes b64 and replays each byte through the existing onSpiByte callback. Parts that subscribed via simulator.spi.onByte don't notice the protocol change. The 'spi_event' branch still handles CS changes plus legacy single-byte payloads for backwards compat. Now that 38 KB/frame is cheap, restore preview to 160×120 + JPEG quality 0.35 in the gallery example. Real measured speedup: ~50× on the QVGA preview demo. Real hardware was never affected — it runs SPI at 80 MHz and pushes the bitmap in ~4 ms either way. PSRAM emulation is unrelated to this bottleneck and was left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
fix(ili9341): add ESP32 SPI byte routing so the LCD renders on ESP32-CAM The ILI9341 part simulation only hooked AVR's SPI peripheral. For ESP32 the simulator is Esp32BridgeShim (no .spi member), so attachEvents bailed early and the LCD stayed black even though the firmware was driving SPI traffic correctly. The QEMU worker already emits per-byte spi_event WS messages (see backend/app/services/esp32_worker.py::_on_spi_event), and the Esp32Bridge already had an onSpiEvent hook — but the bridge was reading msg.data.data (a non-existent field) instead of decoding the worker's {bus, event, response} format. Fixed. Two changes: 1. Esp32Bridge.ts: decode the spi_event payload correctly. The worker encodes byte transfers as `mosi << 8` (op = low byte = 0x00) and CS-line changes as `((cs<<1)|level) << 8 | 0x01` (op == 0x01). Added onSpiByte (per-byte) and onSpiCsChange callbacks alongside the existing onSpiEvent for backwards compat. 2. ComplexParts.ts ili9341Simulation: detect Esp32BridgeShim via `getBridge()` duck-type check. When present, subscribe to bridge.onSpiByte and feed bytes into the same processCommand / processData pipeline used by the AVR path. DC tracking via pinManager.onPinChange already works for ESP32 because the bridge fires triggerPinChange on every gpio_change WS event. Verified end-to-end: ESP32-CAM + ILI9341 example in the gallery now renders the live webcam preview to the simulated TFT (160×120 RGB565 centered in the 320×240 panel) at ~3-4 fps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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);
}
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.
fix(epaper/esp32): preserve PartSimulationRegistry sensors in setSensors + correct BUSY polarity per controller family Two intertwined bugs were leaving every ESP32 ePaper example broken end-to-end. Only the 5.65" UC8159c panel surfaced the failure audibly ("Busy Timeout!" repeating in serial), because its inverted busy polarity caused the firmware to hang inside `_waitBusy()`. The SSD168x ePaper examples APPEARED to run cleanly but never actually rendered anything to the panel — the canvas stayed at the idle paper colour because the same registration path was broken. Root cause #1 — `setSensors` was a full REPLACE, not a merge. `Esp32Bridge.setSensors(sensors)` did `this._pendingSensors = sensors`. At `startBoard()` time the store iterates components, resolves wires for any entry in `SENSOR_COMPONENT_MAP` (DHT22 / HC-SR04 / I²C sensors) and calls `setSensors(...)` with that list. ePaper components live in `PartSimulationRegistry` (not in the sensor map) and are registered via `sendSensorAttach()` AT COMPONENT-MOUNT TIME — well before `startBoard()` runs. Full-replace semantics blew that registration away on every Run click, so the worker never instantiated an `Ssd168xEpaperSlave` / `Uc8159cEpaperSlave`, no SPI bytes were decoded, no frames were latched, and BUSY was never driven. Fix: upsert by `pin` so pre-existing registrations from PartSimulationRegistry handlers are preserved alongside the startBoard-resolved sensors. Confirmed via a WebSocket spy that the `start_esp32` payload now carries the ePaper sensor entry. Root cause #2 — BUSY polarity was hard-coded for SSD168x only. Verified against upstream GxEPD2 source: * SSD168x family — constructor passes `_busy_level = HIGH` → BUSY=HIGH means busy, LOW means ready. * UC8159c family — constructor passes `_busy_level = LOW` → BUSY=LOW means busy, HIGH means ready. The worker only drove BUSY after a frame flush (and at the wrong polarity for UC8159c), so the firmware's first `_waitBusy()` inside `_PowerOn()` / `_InitDisplay()` — which fires BEFORE any frame — blocked for the full 25 s `_busy_timeout`. Fix: read `controller_family` from the registration payload, pick the per-family idle level, and (a) seed the pin to IDLE at registration so the first `_waitBusy()` sees "ready" immediately, (b) use that polarity (idle vs. busy) when pulsing on frame flush. Verified on https://velxio.dev/example/epaper-5in65-7c-esp32-rainbow: the serial timeline now reads `_InitDisplay reset : 1566` / `_PowerOn : 148` / `_PowerOff : 183` / `frame done` (all sub-2 ms busy-waits, no timeouts). Sensor registration confirmed via the `start_esp32` payload carrying the `epaper-ssd168x` entry.
2026-05-23 04:32:33 +07:00
*
* 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 {
fix(epaper/esp32): preserve PartSimulationRegistry sensors in setSensors + correct BUSY polarity per controller family Two intertwined bugs were leaving every ESP32 ePaper example broken end-to-end. Only the 5.65" UC8159c panel surfaced the failure audibly ("Busy Timeout!" repeating in serial), because its inverted busy polarity caused the firmware to hang inside `_waitBusy()`. The SSD168x ePaper examples APPEARED to run cleanly but never actually rendered anything to the panel — the canvas stayed at the idle paper colour because the same registration path was broken. Root cause #1 — `setSensors` was a full REPLACE, not a merge. `Esp32Bridge.setSensors(sensors)` did `this._pendingSensors = sensors`. At `startBoard()` time the store iterates components, resolves wires for any entry in `SENSOR_COMPONENT_MAP` (DHT22 / HC-SR04 / I²C sensors) and calls `setSensors(...)` with that list. ePaper components live in `PartSimulationRegistry` (not in the sensor map) and are registered via `sendSensorAttach()` AT COMPONENT-MOUNT TIME — well before `startBoard()` runs. Full-replace semantics blew that registration away on every Run click, so the worker never instantiated an `Ssd168xEpaperSlave` / `Uc8159cEpaperSlave`, no SPI bytes were decoded, no frames were latched, and BUSY was never driven. Fix: upsert by `pin` so pre-existing registrations from PartSimulationRegistry handlers are preserved alongside the startBoard-resolved sensors. Confirmed via a WebSocket spy that the `start_esp32` payload now carries the ePaper sensor entry. Root cause #2 — BUSY polarity was hard-coded for SSD168x only. Verified against upstream GxEPD2 source: * SSD168x family — constructor passes `_busy_level = HIGH` → BUSY=HIGH means busy, LOW means ready. * UC8159c family — constructor passes `_busy_level = LOW` → BUSY=LOW means busy, HIGH means ready. The worker only drove BUSY after a frame flush (and at the wrong polarity for UC8159c), so the firmware's first `_waitBusy()` inside `_PowerOn()` / `_InitDisplay()` — which fires BEFORE any frame — blocked for the full 25 s `_busy_timeout`. Fix: read `controller_family` from the registration payload, pick the per-family idle level, and (a) seed the pin to IDLE at registration so the first `_waitBusy()` sees "ready" immediately, (b) use that polarity (idle vs. busy) when pulsing on frame flush. Verified on https://velxio.dev/example/epaper-5in65-7c-esp32-rainbow: the serial timeline now reads `_InitDisplay reset : 1566` / `_PowerOn : 148` / `_PowerOff : 183` / `frame done` (all sub-2 ms busy-waits, no timeouts). Sensor registration confirmed via the `start_esp32` payload carrying the `epaper-ssd168x` entry.
2026-05-23 04:32:33 +07:00
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 } });
}
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: {} });
}
/**
* 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();
}
/**
* Push a key press/release for a board's built-in matrix keyboard. `row`/
* `col` are the logical grid position dispatched by the board Web Component;
* the worker's keyboard slave encodes it and pulses its interrupt line.
* No-op for boards without a keyboard peripheral configured.
*/
sendKey(row: number, col: number, pressed: boolean): void {
this._send({ type: 'esp32_keyboard_key', data: { row, col, pressed } });
}
private _send(payload: unknown): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));
}
}
}