From 8b1433deae4bf9951d27c9f9ab06d93336a598d9 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 2 May 2026 22:36:29 -0300 Subject: [PATCH] refactor(spi): unify SPI bus interface across all simulators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/simulation/RP2040Simulator.ts | 42 +++++++++++- frontend/src/simulation/SpiBus.ts | 34 ++++++++++ frontend/src/simulation/parts/ComplexParts.ts | 66 ++++++++----------- frontend/src/store/useSimulatorStore.ts | 32 +++++++++ 4 files changed, 134 insertions(+), 40 deletions(-) create mode 100644 frontend/src/simulation/SpiBus.ts diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index e4e3f081..c834aa40 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -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); diff --git a/frontend/src/simulation/SpiBus.ts b/frontend/src/simulation/SpiBus.ts new file mode 100644 index 00000000..51733e9a --- /dev/null +++ b/frontend/src/simulation/SpiBus.ts @@ -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; +} diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts index 12eb7022..2eb9a782 100644 --- a/frontend/src/simulation/parts/ComplexParts.ts +++ b/frontend/src/simulation/parts/ComplexParts.ts @@ -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()); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index b82c01bb..32cb379e 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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): void { this.bridge.sendSensorUpdate(pin, properties); }