/** * EPaperPart — simulation hook for the SSD168x ePaper family. * * Registers all five Phase-1 panel kinds against a single `attachEvents` * factory. Internally the factory: * * - Decodes SPI bytes via `SSD168xDecoder` (browser-side AVR / RP2040). * - Subscribes to `bridge.onEpaperUpdate` (ESP32 backend renders). * - Tracks DC + CS + RST pins via `pinManager.onPinChange`. * - On flush: paints the latched framebuffer to the element's `` * via `putImageData()` (RAF-batched), drives BUSY HIGH for `refreshMs`, * then back LOW so firmware busy-waits see realistic timing. * * Per the plan in `C:\Users\David\.claude\plans\ahora-integrarlo-en-el-greedy-stearns.md`, * this is the only file that touches the simulator-specific surface — the * decoder is pure data and the Web Component is pure presentation. */ import { PartSimulationRegistry, type AnySimulator } from './PartSimulationRegistry'; import { SSD168xDecoder, type Frame } from '../displays/SSD168xDecoder'; import { UC8159cDecoder, type UC8159cFrame, ACEP_PALETTE_RGB, } from '../displays/UC8159cDecoder'; import { Uc8179Decoder } from '../displays/Uc8179Decoder'; import { PANEL_CONFIGS, getPanelConfig, PANEL_IDS } from '../displays/EPaperPanels'; import { RP2040Simulator } from '../RP2040Simulator'; import type { AVRSimulator } from '../AVRSimulator'; // ── Types ──────────────────────────────────────────────────────────────────── interface AvrLikeSimulator { spi?: { onByte: (value: number) => void; completeTransfer: (resp: number) => void }; pinManager?: { onPinChange(pin: number, cb: (p: number, state: boolean) => void): () => void }; } interface Esp32LikeSimulator { pinManager: { onPinChange(pin: number, cb: (p: number, state: boolean) => void): () => void }; // The shim exposes the underlying bridge so we can subscribe to backend frames. getBridge?: () => { onEpaperUpdate: | (( componentId: string, frame: { width: number; height: number; b64: string; refreshMs: number }, ) => void) | null; sendSensorAttach: (type: string, pin: number, properties: Record) => void; sendPinEvent: (gpio: number, state: boolean) => void; }; registerSensor?: (type: string, pin: number, properties: Record) => boolean; unregisterSensor?: (pin: number) => void; } // Pin name → panel-side label. The Web Component exposes them as // 'GND', 'VCC', 'SCK', 'SDI', 'CS', 'DC', 'RST', 'BUSY'. const PIN_DC = 'DC'; const PIN_CS = 'CS'; const PIN_RST = 'RST'; const PIN_BUSY = 'BUSY'; // ── Helpers ────────────────────────────────────────────────────────────────── function isRP2040(sim: AnySimulator): sim is RP2040Simulator { return sim instanceof RP2040Simulator; } function isAvr(sim: AnySimulator): sim is AVRSimulator { // AVR exposes an `spi` member with `onByte`/`completeTransfer`. const s = sim as AvrLikeSimulator; return !!s.spi && typeof s.spi.onByte === 'function'; } function isEsp32Shim(sim: AnySimulator): sim is Esp32LikeSimulator { const s = sim as Esp32LikeSimulator; return typeof s.getBridge === 'function' && typeof s.registerSensor === 'function'; } /** Decode a base64 string to a Uint8Array. Used for ESP32 backend frames. */ function b64ToBytes(b64: string): Uint8Array { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } /** * Render an SSD168x palette frame (0=black, 1=white, 2=red) into RGBA on canvas. */ function paintFrame(ctx: CanvasRenderingContext2D, frame: Frame): void { const { width, height, pixels } = frame; const id = ctx.createImageData(width, height); for (let i = 0; i < pixels.length; i++) { const v = pixels[i]; const o = i * 4; if (v === 0) { id.data[o] = 0x20; id.data[o + 1] = 0x20; id.data[o + 2] = 0x20; } else if (v === 2) { id.data[o] = 0xc0; id.data[o + 1] = 0x10; id.data[o + 2] = 0x10; } else { id.data[o] = 0xf4; id.data[o + 1] = 0xf1; id.data[o + 2] = 0xe8; } id.data[o + 3] = 0xff; } ctx.putImageData(id, 0, 0); } /** * Render a UC8159c ACeP 7-colour frame using the palette table. * Palette indices 7+ render as white (clean state). */ function paintAcePFrame(ctx: CanvasRenderingContext2D, frame: UC8159cFrame): void { const { width, height, pixels } = frame; const id = ctx.createImageData(width, height); for (let i = 0; i < pixels.length; i++) { const idx = pixels[i]; const rgb = ACEP_PALETTE_RGB[idx] ?? ACEP_PALETTE_RGB[1]; const o = i * 4; id.data[o] = rgb[0]; id.data[o + 1] = rgb[1]; id.data[o + 2] = rgb[2]; id.data[o + 3] = 0xff; } ctx.putImageData(id, 0, 0); } // ── Hook factory ───────────────────────────────────────────────────────────── const epaperSimulation = { attachEvents: ( element: HTMLElement, simulator: AnySimulator, getArduinoPinHelper: (componentPinName: string) => number | null, componentId: string, ) => { const cleanups: Array<() => void> = []; // ── Resolve panel config from the Web Component's panel-kind attr ───── const panelKind = (element.getAttribute && element.getAttribute('panel-kind')) ?? 'epaper-1in54-bw'; const cfg = getPanelConfig(panelKind); const explicitRefreshMs = parseFloat(element.getAttribute('refresh-ms') ?? ''); const refreshMs = !isNaN(explicitRefreshMs) && explicitRefreshMs > 0 ? explicitRefreshMs : cfg.refreshMs; // ── Canvas plumbing ────────────────────────────────────────────────── const initCanvas = (): CanvasRenderingContext2D | null => { const cv = (element as any).canvas as HTMLCanvasElement | null; if (!cv) return null; // Paint the idle "paper" colour so a freshly-mounted panel doesn't // show as a transparent rectangle before the first refresh. const ctx = cv.getContext('2d'); if (ctx) { ctx.fillStyle = '#f4f1e8'; ctx.fillRect(0, 0, cfg.width, cfg.height); } return ctx; }; let ctx = initCanvas(); const onCanvasReady = () => { ctx = initCanvas(); }; element.addEventListener('canvas-ready', onCanvasReady); cleanups.push(() => element.removeEventListener('canvas-ready', onCanvasReady)); // ── BUSY pulse plumbing ────────────────────────────────────────────── let busyTimer: ReturnType | null = null; const setBusy = (state: boolean) => { (element as any).busy = state; // Drive BUSY pin low (LOW = ready) or high (HIGH = refreshing). const busyPin = getArduinoPinHelper(PIN_BUSY); if (busyPin === null) return; if (isAvr(simulator)) { (simulator as any).setPinState?.(busyPin, state); } else if (isRP2040(simulator)) { (simulator as any).setPinState?.(busyPin, state); } else if (isEsp32Shim(simulator)) { // ESP32 BUSY is driven by the backend on the same pin via // qemu_picsimlab_set_pin; the shim's setPinState delegates to it. (simulator as any).setPinState?.(busyPin, state); } }; const pulseBusy = (ms: number) => { setBusy(true); if (busyTimer) clearTimeout(busyTimer); busyTimer = setTimeout(() => { busyTimer = null; setBusy(false); }, ms); }; cleanups.push(() => { if (busyTimer) clearTimeout(busyTimer); }); // ── RAF-batched flush ──────────────────────────────────────────────── // Both decoder families produce {width, height, pixels: Uint8Array}. // The palette interpretation differs (B/W/R vs ACeP 7-colour), so we // dispatch on `cfg.palette` rather than the structural type. type AnyFrame = Frame | UC8159cFrame; let pendingFrame: AnyFrame | null = null; let rafId: number | null = null; const scheduleFlush = (frame: AnyFrame) => { pendingFrame = frame; if (rafId !== null) return; rafId = requestAnimationFrame(() => { rafId = null; if (!pendingFrame) return; if (!ctx) ctx = initCanvas(); if (ctx) { if (cfg.palette === 'acep') paintAcePFrame(ctx, pendingFrame as UC8159cFrame); else paintFrame(ctx, pendingFrame as Frame); } pendingFrame = null; }); }; cleanups.push(() => { if (rafId !== null) cancelAnimationFrame(rafId); }); // ── Browser-side decoder + SPI hook (AVR / RP2040) ─────────────────── const installBrowserPath = () => { // Pick the decoder that matches the panel's controller family. Both // expose .feed(byte, dcHigh) + .reset() so the SPI hook below stays // family-agnostic. const onDecoderFlush = (frame: Frame | UC8159cFrame) => { scheduleFlush(frame); pulseBusy(refreshMs); }; const decoder = cfg.controllerFamily === 'uc8159c' ? new UC8159cDecoder({ width: cfg.width, height: cfg.height, onFlush: onDecoderFlush }) : cfg.controllerFamily === 'uc8179' ? new Uc8179Decoder({ width: cfg.width, height: cfg.height, onFlush: onDecoderFlush }) : new SSD168xDecoder({ width: cfg.width, height: cfg.height, palette: cfg.palette, onFlush: onDecoderFlush, }); // CS / DC / RST pin tracking. let csLow = false; // start with CS de-asserted (idle) let dcHigh = false; const dcPin = getArduinoPinHelper(PIN_DC); const csPin = getArduinoPinHelper(PIN_CS); const rstPin = getArduinoPinHelper(PIN_RST); const pm = (simulator as AvrLikeSimulator).pinManager ?? ((simulator as unknown as { pinManager: any }).pinManager as any); if (pm) { if (dcPin !== null) { cleanups.push( pm.onPinChange(dcPin, (_p: number, s: boolean) => { dcHigh = s; }), ); } if (csPin !== null) { cleanups.push( pm.onPinChange(csPin, (_p: number, s: boolean) => { csLow = !s; }), ); } if (rstPin !== null) { cleanups.push( pm.onPinChange(rstPin, (_p: number, s: boolean) => { // RST is active LOW: a falling edge resets the controller. if (!s) decoder.reset(); }), ); } } // SPI byte source: AVR or RP2040. if (isRP2040(simulator)) { const sim = simulator as RP2040Simulator; const rp = (sim as any).rp2040 as | { spi: Array<{ onTransmit: (v: number) => void; completeTransmit: (v: number) => void }> } | undefined; if (rp?.spi?.length) { // SPI0 covers the GxEPD2 default pinmap (GP18=SCK, GP19=MOSI). // Hook both buses; whichever the user wired will do the work. for (let bus = 0; bus < rp.spi.length; bus++) { const spi = rp.spi[bus]; const prev = spi.onTransmit; spi.onTransmit = (value: number) => { if (csLow || csPin === null) decoder.feed(value, dcHigh); spi.completeTransmit(0xff); }; cleanups.push(() => { spi.onTransmit = prev; }); } } } else if (isAvr(simulator)) { const spi = (simulator as AvrLikeSimulator).spi!; const prev = spi.onByte.bind(spi); spi.onByte = (value: number) => { if (csLow || csPin === null) decoder.feed(value, dcHigh); spi.completeTransfer(0xff); }; cleanups.push(() => { spi.onByte = prev; }); } }; // ── ESP32 backend path (decoded frames arrive over WS) ─────────────── const installEsp32Path = () => { if (!isEsp32Shim(simulator)) return; const bridge = simulator.getBridge!(); // Tell the backend to spin up an SSD168x slave for this component. const dcPin = getArduinoPinHelper(PIN_DC) ?? -1; const csPin = getArduinoPinHelper(PIN_CS) ?? -1; const rstPin = getArduinoPinHelper(PIN_RST) ?? -1; const busyPin = getArduinoPinHelper(PIN_BUSY) ?? -1; // Use a virtual-pin slot so the existing sensor wiring fits. The // backend matches by component_id, so the pin is just a transport // key; we use the DC pin number when valid, else 0xFF. const virtualPin = dcPin >= 0 ? dcPin : 0xff; simulator.registerSensor!('epaper-ssd168x', virtualPin, { component_id: componentId, panel_kind: panelKind, controller_family: cfg.controllerFamily, width: cfg.width, height: cfg.height, dc_pin: dcPin, cs_pin: csPin, rst_pin: rstPin, busy_pin: busyPin, refresh_ms: refreshMs, }); const prev = bridge.onEpaperUpdate; bridge.onEpaperUpdate = (id, frame) => { prev?.(id, frame); if (id !== componentId) return; const palette = b64ToBytes(frame.b64); scheduleFlush({ width: frame.width, height: frame.height, pixels: palette }); pulseBusy(frame.refreshMs); }; cleanups.push(() => { // Restore the previous handler. bridge.onEpaperUpdate = prev; simulator.unregisterSensor?.(virtualPin); }); }; // ── Pick the path ──────────────────────────────────────────────────── if (isEsp32Shim(simulator)) { installEsp32Path(); } else if (isAvr(simulator) || isRP2040(simulator)) { installBrowserPath(); } // Other simulators (RiscV, Esp32C3, …) silently no-op for now; the // canvas stays in its idle paper colour. See plan §"Out of scope". // ── Cleanup ────────────────────────────────────────────────────────── return () => { while (cleanups.length) { try { cleanups.pop()?.(); } catch { /* ignore individual handler errors */ } } }; }, }; // ── Register all five panel variants under the same factory ────────────────── for (const id of PANEL_IDS) { PartSimulationRegistry.register(id, epaperSimulation); } // Re-export so callers can introspect the supported set. export const EPAPER_PANEL_IDS = PANEL_IDS; export { PANEL_CONFIGS };