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); }