/** * 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; /** * Visible palette. 'bwr' = tri-colour (0x26 is the additive red plane, * red wins on compose). 'bw' (default) = mono; some controllers (e.g. * GDEY029T94) mirror the image into the 0x26 plane, so a B/W panel is * white only where BOTH planes say white. 'acep' is handled elsewhere. */ palette?: 'bw' | 'bwr' | 'acep'; /** 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; /** True for tri-colour B/W/Red panels. */ private readonly isBwr: boolean; /** * RAM geometry — sized to the LONGER side both ways so a rotated native * layout (a 296x128 landscape panel whose controller RAM is 128x296) is * captured without dropping rows. composeFrame() reads back the active * window and rotates to the display orientation. */ private readonly ramBpr: number; private readonly ramRows: 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) — the LAST one set, * used for the write cursor's auto-increment. */ private xrange: [number, number] = [0, 0]; /** Active RAM window in scanlines (start, end inclusive) — last one set. */ private yrange: [number, number] = [0, 0]; /** UNION of every RAM window set since the last flush — paged drivers * (GxEPD2 page height < panel) set one partial window per page, so compose * must use the union (full native area), not just the last page's strip. */ private winX0 = 0; private winX1 = 0; private winY0 = 0; private winY1 = 0; private winXSet = false; private winYSet = false; /** 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.isBwr = opts.palette === 'bwr'; const longSide = Math.max(opts.width, opts.height); this.ramBpr = (longSide + 7) >> 3; this.ramRows = longSide; this.onFlush = opts.onFlush; const size = this.ramBpr * this.ramRows; this.bwRam = new Uint8Array(size).fill(0xff); // default white // B/W panel: 0x26 is a second mono plane → init white. B/W/R panel: // 0x26 is the additive red plane → init "no red" (0x00). this.redRam = new Uint8Array(size).fill(this.isBwr ? 0x00 : 0xff); // Default active window = DISPLAY geometry (the firmware overrides via // 0x44/0x45 before writing). RAM is sized larger, but until a window is // set the panel is treated as un-rotated display-sized. this.xrange = [0, ((opts.width + 7) >> 3) - 1]; this.yrange = [0, opts.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(this.isBwr ? 0x00 : 0xff); this.currentCmd = -1; this.params = []; this.ramTarget = 'bw'; this.xByte = 0; this.y = 0; this.entryMode = 0x03; this.xrange = [0, ((this.width + 7) >> 3) - 1]; this.yrange = [0, this.height - 1]; this.winXSet = false; this.winYSet = false; this.inDeepSleep = false; } /** * Build a Frame from the latched RAM planes. * * Compose in the controller's NATIVE geometry — the active RAM window the * firmware actually wrote (set via 0x44/0x45) — then rotate to the display * orientation. This handles panels driven with setRotation() whose native * RAM (e.g. 128x296) is the transpose of the display (296x128); composing * directly at the display dims would drop half the rows and never rotate. * * Composition: tri-colour → red wins, else B/W plane decides. B/W → white * only if BOTH planes say white (the image may live in 0x24 or 0x26). */ composeFrame(): Frame { // Use the UNION of windows set this frame (paged drivers set one partial // window per page); fall back to the display geometry if none was set. const x0 = this.winXSet ? this.winX0 : 0; const x1 = this.winXSet ? this.winX1 : ((this.width + 7) >> 3) - 1; const y0 = this.winYSet ? this.winY0 : 0; const y1 = this.winYSet ? this.winY1 : this.height - 1; const nwBytes = Math.max(0, x1 - x0 + 1); const nw = nwBytes * 8; // native width (px) const nh = Math.max(0, y1 - y0 + 1); // native height (rows) const native = new Uint8Array(nw * nh); for (let ny = 0; ny < nh; ny++) { const row = (y0 + ny) * this.ramBpr + x0; const outRow = ny * nw; for (let xb = 0; xb < nwBytes; xb++) { const bByte = this.bwRam[row + xb]; const rByte = this.redRam[row + xb]; const base = xb << 3; for (let bit = 0; bit < 8; bit++) { const x = base + bit; if (x >= nw) break; const mask = 0x80 >> bit; const bwWhite = (bByte & mask) !== 0; if (this.isBwr) { native[outRow + x] = (rByte & mask) !== 0 ? 2 : bwWhite ? 1 : 0; } else { native[outRow + x] = bwWhite && (rByte & mask) !== 0 ? 1 : 0; } } } } // Map native -> display. `nw` is byte-padded (nwBytes*8) so it can exceed // the real native width when that isn't a multiple of 8 (e.g. the 2.13" // panel is 122 px wide -> nw=128). Detect orientation by BYTE width and // crop the padding using the true native width. const W = this.width; const H = this.height; const Wb = (W + 7) >> 3; const Hb = (H + 7) >> 3; let pixels: Uint8Array; if (nh === H && nwBytes === Wb) { // Non-transposed (rotation 0): native actual width = W. if (nw === W) { pixels = native; } else { pixels = new Uint8Array(W * H).fill(1); for (let ny = 0; ny < H; ny++) { const s = ny * nw; const d = ny * W; for (let x = 0; x < W; x++) pixels[d + x] = native[s + x]; } } } else if (nh === W && nwBytes === Hb && nh) { // Transposed (rotation 1): native actual width = H. Inverse of // Adafruit_GFX rotation 1: native(x_raw,y_raw) -> display(xd=y_raw, // yd=Wn-1-x_raw), Wn = true native width = H. pixels = new Uint8Array(W * H).fill(1); const Wn = H; for (let ny = 0; ny < nh; ny++) { if (ny >= W) break; const src = ny * nw; for (let x = 0; x < Wn; x++) { pixels[(Wn - 1 - x) * W + ny] = native[src + x]; } } } else { // Unexpected geometry — best-effort top-left copy onto white. pixels = new Uint8Array(W * H).fill(1); for (let ny = 0; ny < Math.min(nh, H); ny++) { const s = ny * nw; const d = ny * W; for (let x = 0; x < Math.min(nw, W); x++) pixels[d + x] = native[s + x]; } } return { width: W, height: H, pixels }; } // ── 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); // Start a fresh window union for the next frame's pages. this.winXSet = false; this.winYSet = false; 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]; if (!this.winXSet) { this.winX0 = params[0]; this.winX1 = params[1]; this.winXSet = true; } else { this.winX0 = Math.min(this.winX0, params[0]); this.winX1 = Math.max(this.winX1, params[1]); } } 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]; if (!this.winYSet) { this.winY0 = this.yrange[0]; this.winY1 = this.yrange[1]; this.winYSet = true; } else { this.winY0 = Math.min(this.winY0, this.yrange[0]); this.winY1 = Math.max(this.winY1, this.yrange[1]); } } 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.ramBpr; if ( this.xByte >= 0 && this.xByte < bpr && this.y >= 0 && this.y < this.ramRows ) { 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; const yInc = (this.entryMode & 0x02) === 0x02; let endOfRow = false; if (xInc) { if (this.xByte < this.xrange[1]) { this.xByte += 1; } else { this.xByte = this.xrange[0]; endOfRow = true; } } else { if (this.xByte > this.xrange[0]) { this.xByte -= 1; } else { this.xByte = this.xrange[1]; endOfRow = true; } } if (endOfRow) { // Advance Y, WRAPPING at the window boundary like the SSD168x RAM address // counter. Some drivers (e.g. GxEPD2_3C) write the 0x24 then the 0x26 // plane without re-seeking the counter, relying on this wrap so the // second plane lands in the window. if (yInc) { this.y = this.y >= this.yrange[1] ? this.yrange[0] : this.y + 1; } else { this.y = this.y <= this.yrange[0] ? this.yrange[1] : this.y - 1; } } } }