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>
This commit is contained in:
David Montero Crespo 2026-05-02 22:31:24 -03:00
parent 155b962c21
commit 6afa62ea17
2 changed files with 59 additions and 13 deletions

View File

@ -110,6 +110,12 @@ export class Esp32Bridge {
onI2cEvent: ((addr: number, data: number) => void) | null = null; onI2cEvent: ((addr: number, data: number) => void) | null = null;
onI2cTransaction: ((addr: number, data: number[]) => void) | null = null; onI2cTransaction: ((addr: number, data: number[]) => void) | null = null;
onSpiEvent: ((data: number) => void) | null = null; onSpiEvent: ((data: number) => void) | null = null;
/** Same as onSpiEvent but more explicit (a single MOSI byte). */
onSpiByte: ((mosi: number) => void) | null = null;
/** Fires on every CS line change emitted by the SoC's SPI peripheral.
* `csIdx` is the index of the CS pin within the SPI bus (0-3 typical),
* `low` is true when CS goes LOW (slave selected), false when HIGH. */
onSpiCsChange: ((csIdx: number, low: boolean) => void) | null = null;
onConnected: (() => void) | null = null; onConnected: (() => void) | null = null;
onDisconnected: (() => void) | null = null; onDisconnected: (() => void) | null = null;
onError: ((msg: string) => void) | null = null; onError: ((msg: string) => void) | null = null;
@ -287,8 +293,25 @@ export class Esp32Bridge {
break; break;
} }
case 'spi_event': { case 'spi_event': {
const data = msg.data.data as number; // Worker emits {bus, event, response}. The 'event' field encodes:
this.onSpiEvent?.(data); // 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.
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; break;
} }
case 'system': { case 'system': {

View File

@ -797,8 +797,13 @@ const ili9341Simulation = {
const el = element as any; const el = element as any;
const pinManager = (avrSimulator as any).pinManager; const pinManager = (avrSimulator as any).pinManager;
const spi = (avrSimulator as any).spi; const spi = (avrSimulator as any).spi;
// ESP32 path: simulator is Esp32BridgeShim — no .spi member, but it
// exposes getBridge() to subscribe to the worker's spi_event stream.
const getBridge = (avrSimulator as any).getBridge;
const esp32Bridge = typeof getBridge === 'function' ? getBridge.call(avrSimulator) : null;
if (!pinManager || !spi) return () => {}; if (!pinManager) return () => {};
if (!spi && !esp32Bridge) return () => {};
// ── Canvas setup ────────────────────────────────────────────────── // ── Canvas setup ──────────────────────────────────────────────────
const SCREEN_W = 240; const SCREEN_W = 240;
@ -951,20 +956,38 @@ const ili9341Simulation = {
}; };
// ── Intercept SPI ───────────────────────────────────────────────── // ── Intercept SPI ─────────────────────────────────────────────────
const prevOnByte = spi.onByte.bind(spi); let prevOnByte: ((value: number) => void) | null = null;
let prevSpiByte: ((mosi: number) => void) | null = null;
spi.onByte = (value: number) => { if (spi) {
if (!dcState) { // AVR (Arduino) path — hook the simulator's SPI peripheral
processCommand(value); prevOnByte = spi.onByte.bind(spi);
} else { spi.onByte = (value: number) => {
processData(value); if (!dcState) processCommand(value);
} else processData(value);
spi.completeTransfer(0xff); // Unblock CPU immediately spi.completeTransfer(0xff);
}; };
} else if (esp32Bridge) {
// ESP32 path — subscribe to the QEMU worker's SPI byte stream
// routed through the Esp32Bridge. Each byte arrives via onSpiByte
// (CS gating is left to the user's wiring; with one ILI9341 on the
// bus this works without explicit CS tracking). DC tracking still
// happens via pinManager.onPinChange above — that path is shared
// because the Esp32BridgeShim's pinManager fires on every gpio
// change emitted by the worker.
prevSpiByte = esp32Bridge.onSpiByte;
esp32Bridge.onSpiByte = (mosi: number) => {
if (!dcState) processCommand(mosi);
else processData(mosi);
// Chain to any prior subscriber (defensive — there shouldn't be one)
if (prevSpiByte) prevSpiByte(mosi);
};
}
// ── Cleanup ─────────────────────────────────────────────────────── // ── Cleanup ───────────────────────────────────────────────────────
return () => { return () => {
spi.onByte = prevOnByte; if (spi && prevOnByte) spi.onByte = prevOnByte;
if (esp32Bridge) esp32Bridge.onSpiByte = prevSpiByte;
if (rafId !== null) cancelAnimationFrame(rafId); if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener('canvas-ready', onCanvasReady); el.removeEventListener('canvas-ready', onCanvasReady);
unsubscribers.forEach((u) => u()); unsubscribers.forEach((u) => u());