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:
parent
6afa62ea17
commit
8b1433deae
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue