393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
/**
|
|
* 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 `<canvas>`
|
|
* 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<string, unknown>) => void;
|
|
sendPinEvent: (gpio: number, state: boolean) => void;
|
|
};
|
|
registerSensor?: (type: string, pin: number, properties: Record<string, unknown>) => 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<typeof setTimeout> | 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 };
|