velxio/frontend/src/simulation/displays/SSD168xDecoder.ts

275 lines
9.2 KiB
TypeScript
Raw Normal View History

/**
* SSD168xDecoder TypeScript port of the reference SSD168x SPI decoder.
*
* Source spec / golden file: `test/test_epaper/ssd168x_decoder.py`. This
* port is byte-for-byte algorithmically identical so the cross-decoder
* consistency test (`ssd168x-decoder.test.ts`) can replay the same
* fixtures through both implementations and assert the same framebuffers.
*
* Supports the entire Solomon Systech SSD168x family used by every
* 1.54"7.5" ePaper panel in our Phase-1 catalog (SSD1681, SSD1675A,
* SSD1680, SSD1683). They share ~95 % of the command set; differences
* are RAM size and which driver-output config bytes are accepted, both
* orthogonal to this decoder.
*
* References:
* - SSD1681 datasheet (Adafruit mirror):
* https://cdn-learn.adafruit.com/assets/assets/000/099/573/original/SSD1681.pdf
* - ESP-BSP command header:
* https://github.com/espressif/esp-bsp/blob/master/components/lcd/esp_lcd_ssd1681/esp_lcd_ssd1681_commands.h
*/
// ── Command opcodes (shared across SSD168x family) ───────────────────────────
export const CMD_DRIVER_OUTPUT_CTRL = 0x01;
export const CMD_GATE_DRIVING_VOLTAGE = 0x03;
export const CMD_SOURCE_DRIVING_VOLT = 0x04;
export const CMD_DEEP_SLEEP = 0x10;
export const CMD_DATA_ENTRY_MODE = 0x11;
export const CMD_SW_RESET = 0x12;
export const CMD_TEMP_SENSOR = 0x18;
export const CMD_MASTER_ACTIVATION = 0x20;
export const CMD_DISP_UPDATE_CTRL_1 = 0x21;
export const CMD_DISP_UPDATE_CTRL_2 = 0x22;
export const CMD_WRITE_BLACK_VRAM = 0x24;
export const CMD_WRITE_RED_VRAM = 0x26;
export const CMD_WRITE_VCOM_REG = 0x2c;
export const CMD_WRITE_LUT = 0x32;
export const CMD_BORDER_WAVEFORM = 0x3c;
export const CMD_END_OPTION = 0x3f;
export const CMD_SET_RAMX_RANGE = 0x44;
export const CMD_SET_RAMY_RANGE = 0x45;
export const CMD_SET_RAMX_COUNTER = 0x4e;
export const CMD_SET_RAMY_COUNTER = 0x4f;
// Palette indices used in the composed frame: 0 = black, 1 = white,
// 2 = red (only when the red RAM plane was written).
export type EPaperPalette = 0 | 1 | 2;
export interface Frame {
width: number;
height: number;
/** width*height palette indices. */
pixels: Uint8Array;
}
export interface SSD168xDecoderOptions {
width: number;
height: number;
/** Fired on every 0x20 MASTER_ACTIVATION with the latched composed frame. */
onFlush?: (frame: Frame) => void;
}
/**
* Single-instance state machine. **Not thread-safe** re-use only via the
* (single-threaded) JS event loop.
*/
export class SSD168xDecoder {
readonly width: number;
readonly height: number;
private readonly bytesPerRow: number;
/** B/W RAM plane. 1 bit = 1 px. Bit value 1 = white, 0 = black. */
bwRam: Uint8Array;
/** Red RAM plane. 1 bit = 1 px. Bit value 1 = red, 0 = transparent. */
redRam: Uint8Array;
private currentCmd = -1;
private params: number[] = [];
/** Which RAM plane subsequent data bytes target. */
private ramTarget: 'bw' | 'red' = 'bw';
/** Current X position in bytes (1 byte = 8 px). */
private xByte = 0;
/** Current Y position (scanline). */
private y = 0;
/** Active RAM window in bytes (start, end inclusive). */
private xrange: [number, number] = [0, 0];
/** Active RAM window in scanlines (start, end inclusive). */
private yrange: [number, number] = [0, 0];
/** Data-entry-mode register (0x11). Default = 0x03 (X+, Y+, X-first). */
private entryMode = 0x03;
/** Diagnostics: how many full refresh activations we've seen. */
refreshedCount = 0;
/** Diagnostics: opcodes the host emitted that aren't in our table. */
unknownCmds: number[] = [];
/** True when the chip has been put into deep sleep. */
inDeepSleep = false;
private readonly onFlush?: (frame: Frame) => void;
constructor(opts: SSD168xDecoderOptions) {
this.width = opts.width;
this.height = opts.height;
this.bytesPerRow = (opts.width + 7) >> 3;
this.onFlush = opts.onFlush;
const bwSize = this.bytesPerRow * this.height;
this.bwRam = new Uint8Array(bwSize).fill(0xff); // default white
this.redRam = new Uint8Array(bwSize).fill(0x00); // default no red
this.xrange = [0, this.bytesPerRow - 1];
this.yrange = [0, this.height - 1];
}
// ── Public API ─────────────────────────────────────────────────────
/**
* Process one SPI byte. `dcHigh` mirrors the DC pin (false = LOW = command).
*/
feed(byte: number, dcHigh: boolean): void {
if (!dcHigh) this.beginCommand(byte & 0xff);
else this.handleData(byte & 0xff);
}
/** Clear all state — equivalent to a hardware RST low pulse. */
reset(): void {
this.bwRam.fill(0xff);
this.redRam.fill(0x00);
this.currentCmd = -1;
this.params = [];
this.ramTarget = 'bw';
this.xByte = 0;
this.y = 0;
this.entryMode = 0x03;
this.xrange = [0, this.bytesPerRow - 1];
this.yrange = [0, this.height - 1];
this.inDeepSleep = false;
}
/**
* Build a Frame from the latched RAM planes. Composition rule:
* red plane bit = 1 RED (wins over black)
* bw plane bit = 1 WHITE
* else BLACK
* (Matches every SSD168x-driving Arduino library.)
*/
composeFrame(): Frame {
const out = new Uint8Array(this.width * this.height);
const bpr = this.bytesPerRow;
for (let y = 0; y < this.height; y++) {
for (let xb = 0; xb < bpr; xb++) {
const bByte = this.bwRam[y * bpr + xb];
const rByte = this.redRam[y * bpr + xb];
for (let bit = 0; bit < 8; bit++) {
const x = (xb << 3) + bit;
if (x >= this.width) break;
const mask = 0x80 >> bit;
const isRed = (rByte & mask) !== 0;
const isWhite = (bByte & mask) !== 0;
out[y * this.width + x] = isRed ? 2 : isWhite ? 1 : 0;
}
}
}
return { width: this.width, height: this.height, pixels: out };
}
// ── Internal: command / data dispatch ──────────────────────────────
private beginCommand(cmd: number): void {
this.currentCmd = cmd;
this.params = [];
switch (cmd) {
case CMD_SW_RESET:
this.reset();
return;
case CMD_MASTER_ACTIVATION: {
this.refreshedCount += 1;
const frame = this.composeFrame();
this.onFlush?.(frame);
return;
}
case CMD_WRITE_BLACK_VRAM:
this.ramTarget = 'bw';
return;
case CMD_WRITE_RED_VRAM:
this.ramTarget = 'red';
return;
case CMD_DRIVER_OUTPUT_CTRL:
case CMD_GATE_DRIVING_VOLTAGE:
case CMD_SOURCE_DRIVING_VOLT:
case CMD_DEEP_SLEEP:
case CMD_DATA_ENTRY_MODE:
case CMD_TEMP_SENSOR:
case CMD_DISP_UPDATE_CTRL_1:
case CMD_DISP_UPDATE_CTRL_2:
case CMD_WRITE_VCOM_REG:
case CMD_WRITE_LUT:
case CMD_BORDER_WAVEFORM:
case CMD_END_OPTION:
case CMD_SET_RAMX_RANGE:
case CMD_SET_RAMY_RANGE:
case CMD_SET_RAMX_COUNTER:
case CMD_SET_RAMY_COUNTER:
return;
default:
// Unknown opcode — log so users can report panel quirks, but never
// throw. Real-world firmware sometimes emits vendor-specific bytes.
this.unknownCmds.push(cmd);
}
}
private handleData(byte: number): void {
const cmd = this.currentCmd;
this.params.push(byte);
const params = this.params;
if (cmd === CMD_DEEP_SLEEP && params.length === 1) {
this.inDeepSleep = byte !== 0;
} else if (cmd === CMD_DATA_ENTRY_MODE && params.length === 1) {
this.entryMode = byte;
} else if (cmd === CMD_SET_RAMX_RANGE && params.length === 2) {
this.xrange = [params[0], params[1]];
this.xByte = params[0];
} else if (cmd === CMD_SET_RAMY_RANGE && params.length === 4) {
this.yrange = [
params[0] | (params[1] << 8),
params[2] | (params[3] << 8),
];
this.y = this.yrange[0];
} else if (cmd === CMD_SET_RAMX_COUNTER && params.length === 1) {
this.xByte = byte;
} else if (cmd === CMD_SET_RAMY_COUNTER && params.length === 2) {
this.y = params[0] | (params[1] << 8);
} else if (cmd === CMD_WRITE_BLACK_VRAM) {
this.writeRamByte(this.bwRam, byte);
} else if (cmd === CMD_WRITE_RED_VRAM) {
this.writeRamByte(this.redRam, byte);
}
// Other commands silently buffer their parameters.
}
private writeRamByte(plane: Uint8Array, byte: number): void {
const bpr = this.bytesPerRow;
if (
this.xByte >= 0 &&
this.xByte < bpr &&
this.y >= 0 &&
this.y < this.height
) {
plane[this.y * bpr + this.xByte] = byte;
}
// Auto-increment per data_entry_mode (default 0x03: X+, then Y+ at end of row).
const xInc = (this.entryMode & 0x01) === 0x01;
if (xInc) {
if (this.xByte < this.xrange[1]) {
this.xByte += 1;
} else {
this.xByte = this.xrange[0];
this.y += 1;
}
} else {
if (this.xByte > this.xrange[0]) {
this.xByte -= 1;
} else {
this.xByte = this.xrange[1];
this.y += 1;
}
}
}
}