refactor(spi): unify SPI bus interface across all simulators

Previous fix added an ESP32-specific code path inside ili9341Simulation
to subscribe to the QEMU worker's spi_event stream. That made the LCD
work on ESP32-CAM but left the underlying issue unsolved: every other
SPI part (custom chips, future SD-card emulators, the SSD168x ePaper
already in the codebase) would also need its own per-board branching.

The right shape: every simulator exposes a `.spi` member matching the
SAME SpiBusLike interface, and SPI parts hook .spi.onByte without
caring which board they're attached to. AVRSimulator already had
this — now everything else does too.

  frontend/src/simulation/SpiBus.ts (new)
    Defines the contract — `onByte: (mosi) => void | null` plus
    optional `completeTransfer(miso)`. Documents the single-listener
    semantics that AVR has had since day one.

  frontend/src/store/useSimulatorStore.ts
    Esp32BridgeShim gets a lazy `.spi` getter that wraps
    bridge.onSpiByte (the per-byte WS event from the QEMU worker).
    completeTransfer is a no-op because the worker drives MISO via
    its own _spi_response global. Covers ESP32 (Xtensa), ESP32-S3,
    ESP32-CAM, ESP32-C3 — every kind that routes through Esp32Bridge.

  frontend/src/simulation/RP2040Simulator.ts
    Adds a lazy `.spi` getter that re-routes rp2040.spi[0].onTransmit
    through the adapter. Default loopback (the prior behaviour) is
    preserved when no part has accessed `.spi` yet — only consumers
    that opt in see their handler invoked. Covers Pico and Pico W.

  frontend/src/simulation/parts/ComplexParts.ts
    ili9341Simulation no longer has an ESP32 special case. Single
    code path: `simulator.spi.onByte = handler`. Works on AVR,
    RP2040, all ESP32 variants. Same pattern is now available to
    every future SPI part — ssd1306, sd-card, oled, etc.

The Esp32Bridge.ts spi_event field-name fix from 6afa62e (msg.data.event
instead of the non-existent msg.data.data) stays in place — that's what
makes the per-byte stream actually arrive in the bridge.

Verified: ILI9341 + ESP32-CAM gallery example renders the live webcam
preview after a hard refresh. The same simulation code works on Arduino
Uno + ILI9341 (the existing ili9341-test-sketch in example_zip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-02 22:36:29 -03:00
parent 6afa62ea17
commit 8b1433deae
4 changed files with 134 additions and 40 deletions

View File

@ -72,6 +72,38 @@ export class RP2040Simulator {
/** Serial output callback — fires for each byte the Pico sends on UART0 (or USBCDC in MicroPython mode) */
public onSerialData: ((char: string) => void) | null = null;
/**
* Generic SPI bus adapter same shape as AVRSimulator.spi so SPI parts
* (ILI9341, SD cards, custom chips) can hook the bus uniformly across
* boards. Defaults to RP2040 SPI0; firmware that uses SPI1 will need to
* wrap rp2040.spi[1] manually until we add a .spi1 alias.
*
* Lazy-initialised so the rp2040.spi[0].onTransmit is only overridden
* once a part actually accesses .spi (avoiding clobbering the default
* loopback handler if no SPI part is on the canvas).
*/
private _spiAdapter: { onByte: ((mosi: number) => void) | null;
completeTransfer: (miso: number) => void } | null = null;
public get spi(): { onByte: ((mosi: number) => void) | null;
completeTransfer: (miso: number) => void } {
if (!this._spiAdapter) {
const adapter = {
onByte: null as ((mosi: number) => void) | null,
completeTransfer: (miso: number) => {
this.rp2040?.spi[0].completeTransmit(miso & 0xff);
},
};
// Re-route SPI0's onTransmit through our adapter when initMCU /
// initMicroPython runs. Until rp2040 is constructed (mcu=null) the
// setter just stages the handler — we wire it in start().
this._spiAdapter = adapter;
if (this.rp2040) {
this.rp2040.spi[0].onTransmit = (v: number) => adapter.onByte?.(v);
}
}
return this._spiAdapter;
}
/** Fires when the on-board LED on Pico W (driven through the CYW43, not GPIO 25) toggles. */
public onPicoWLed: ((on: boolean) => void) | null = null;
/** Fires whenever the chip emits a Wi-Fi link-up event for the synthetic AP. */
@ -175,8 +207,16 @@ export class RP2040Simulator {
};
this.wireI2C(0);
this.wireI2C(1);
// Default loopback for SPI0 — overridden by the generic .spi adapter
// if a SPI part later accesses simulator.spi. The adapter routes
// onTransmit into adapter.onByte and uses completeTransmit to drive
// MISO when the part calls completeTransfer.
this.rp2040.spi[0].onTransmit = (v: number) => {
this.rp2040!.spi[0].completeTransmit(v);
if (this._spiAdapter && this._spiAdapter.onByte) {
this._spiAdapter.onByte(v);
} else {
this.rp2040!.spi[0].completeTransmit(v);
}
};
this.rp2040.spi[1].onTransmit = (v: number) => {
this.rp2040!.spi[1].completeTransmit(v);

View File

@ -0,0 +1,34 @@
/**
* Generic SPI bus interface, board-agnostic.
*
* Every simulator (AVR, RP2040, ESP32 Xtensa/RISC-V, RiscV bare-metal,
* Pi-Pico-W, ) exposes a `.spi` member matching this shape so SPI-driven
* components ILI9341 displays, custom chips, SD cards, etc. can hook
* the bus without knowing which board they're attached to.
*
* The contract follows the AVRSimulator's existing shape (the original
* implementation): the consumer assigns its handler to `onByte`, which
* receives one MOSI byte per SPI clock cycle. If the consumer is a slave
* that wants to drive the master's MISO line for that cycle, it calls
* `completeTransfer(miso)`. For boards where MISO is driven externally
* (ESP32 the QEMU worker handles the response via _spi_response),
* `completeTransfer` is a no-op.
*
* Each simulator's `.spi` is a SINGLE-LISTENER channel: assigning to
* `onByte` overwrites any previous handler. Components that wrap an
* existing handler must save the old one and chain it in their own
* implementation (the standard pattern; see ili9341Simulation in
* ComplexParts.ts).
*/
export interface SpiBusLike {
/** Settable per-cycle byte handler. Null when no consumer is attached. */
onByte: ((mosi: number) => void) | null;
/**
* Tell the master what to place on MISO for the current cycle.
* Optional boards where MISO is owned by the emulator (ESP32) treat
* this as a no-op. Hardware-faithful boards (AVR, RP2040) must call
* this before the next byte arrives or the master will read 0.
*/
completeTransfer?(miso: number): void;
}

View File

@ -793,17 +793,19 @@ PartSimulationRegistry.register('lcd2002', createLcdSimulation(20, 2));
* DC/RS pin: LOW = command byte, HIGH = data bytes.
*/
const ili9341Simulation = {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper) => {
const el = element as any;
const pinManager = (avrSimulator as any).pinManager;
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;
const pinManager = (simulator as any).pinManager;
// Generic .spi accessor — every simulator (AVR, RP2040, ESP32 family)
// exposes a SpiBusLike object via this name (see frontend/src/simulation/
// SpiBus.ts). Single-listener channel: assign to spi.onByte and
// chain any prior handler in our cleanup.
const spi = (simulator as any).spi as
| { onByte: ((mosi: number) => void) | null;
completeTransfer?: (miso: number) => void }
| undefined;
if (!pinManager) return () => {};
if (!spi && !esp32Bridge) return () => {};
if (!pinManager || !spi) return () => {};
// ── Canvas setup ──────────────────────────────────────────────────
const SCREEN_W = 240;
@ -955,39 +957,25 @@ const ili9341Simulation = {
}
};
// ── Intercept SPI ─────────────────────────────────────────────────
let prevOnByte: ((value: number) => void) | null = null;
let prevSpiByte: ((mosi: number) => void) | null = null;
if (spi) {
// AVR (Arduino) path — hook the simulator's SPI peripheral
prevOnByte = spi.onByte.bind(spi);
spi.onByte = (value: number) => {
if (!dcState) processCommand(value);
else processData(value);
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);
};
}
// ── Intercept SPI (board-agnostic) ────────────────────────────────
// Single hook regardless of board kind: every simulator's `.spi`
// exposes the same shape — settable onByte handler + optional
// completeTransfer to drive MISO. AVR and RP2040 actually use
// completeTransfer; ESP32 ignores it (worker drives MISO via
// its own _spi_response global).
const prevOnByte = spi.onByte;
spi.onByte = (value: number) => {
if (!dcState) processCommand(value);
else processData(value);
// Idle-byte response — the typical ILI9341 driver writes only,
// so any value works. 0xff matches what the prior AVR path
// returned to keep behaviour stable.
spi.completeTransfer?.(0xff);
};
// ── Cleanup ───────────────────────────────────────────────────────
return () => {
if (spi && prevOnByte) spi.onByte = prevOnByte;
if (esp32Bridge) esp32Bridge.onSpiByte = prevSpiByte;
spi.onByte = prevOnByte;
if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener('canvas-ready', onCanvasReady);
unsubscribers.forEach((u) => u());

View File

@ -208,6 +208,38 @@ class Esp32BridgeShim {
getBridge(): Esp32Bridge {
return this.bridge;
}
/**
* Generic SPI bus adapter same shape as AVRSimulator.spi so SPI-driven
* parts (ILI9341, SD cards, custom chips) can hook the bus without
* caring whether they're on AVR, RP2040, or any of the ESP32 variants.
* The MOSI byte arrives via the QEMU worker's spi_event WS message
* (decoded in Esp32Bridge); MISO is driven by the worker's
* `_spi_response` global, so `completeTransfer` is a no-op on ESP32.
*
* Lazy-initialised so the bridge subscription only happens once a part
* actually accesses `.spi`.
*/
private _spiAdapter: { onByte: ((mosi: number) => void) | null;
completeTransfer: (miso: number) => void } | null = null;
get spi(): { onByte: ((mosi: number) => void) | null;
completeTransfer: (miso: number) => void } {
if (!this._spiAdapter) {
const adapter = {
onByte: null as ((mosi: number) => void) | null,
completeTransfer: (_miso: number) => {
/* ESP32 worker drives MISO via _spi_response — no-op here. */
},
};
// Forward every per-byte WS event into whichever handler the part
// installed. Single-listener channel — last writer wins.
this.bridge.onSpiByte = (mosi: number) => {
adapter.onByte?.(mosi);
};
this._spiAdapter = adapter;
}
return this._spiAdapter;
}
updateSensor(pin: number, properties: Record<string, unknown>): void {
this.bridge.sendSensorUpdate(pin, properties);
}