355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
/**
|
||
* Vitest port of `test/test_epaper/test_ssd168x_protocol.py`. Ensures the
|
||
* TypeScript SSD168xDecoder produces byte-for-byte identical framebuffers
|
||
* to the Python reference.
|
||
*
|
||
* Adding a new test case here? Mirror it in the Python file too (or vice
|
||
* versa) — the cross-decoder consistency assertion at the bottom hashes a
|
||
* real GxEPD2-emitted byte trace through both and asserts the same Frame.
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
SSD168xDecoder,
|
||
type Frame,
|
||
CMD_SW_RESET,
|
||
CMD_DRIVER_OUTPUT_CTRL,
|
||
CMD_DATA_ENTRY_MODE,
|
||
CMD_SET_RAMX_RANGE,
|
||
CMD_SET_RAMY_RANGE,
|
||
CMD_BORDER_WAVEFORM,
|
||
CMD_DISP_UPDATE_CTRL_1,
|
||
CMD_DISP_UPDATE_CTRL_2,
|
||
CMD_TEMP_SENSOR,
|
||
CMD_SET_RAMX_COUNTER,
|
||
CMD_SET_RAMY_COUNTER,
|
||
CMD_WRITE_BLACK_VRAM,
|
||
CMD_WRITE_RED_VRAM,
|
||
CMD_MASTER_ACTIVATION,
|
||
CMD_DEEP_SLEEP,
|
||
} from '../simulation/displays/SSD168xDecoder';
|
||
|
||
// ── Helpers (mirror the Python ones) ──────────────────────────────────────────
|
||
|
||
const cmd = (c: number): Array<[number, boolean]> => [[c, false]];
|
||
const data = (...bs: number[]): Array<[number, boolean]> => bs.map((b) => [b, true] as [number, boolean]);
|
||
|
||
function feedAll(d: SSD168xDecoder, ...streams: Array<Array<[number, boolean]>>) {
|
||
for (const stream of streams) {
|
||
for (const [byte, dcHigh] of stream) {
|
||
d.feed(byte, dcHigh);
|
||
}
|
||
}
|
||
}
|
||
|
||
function gxepd2Init154(d: SSD168xDecoder) {
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_SW_RESET),
|
||
cmd(CMD_DRIVER_OUTPUT_CTRL),
|
||
data(0xc7, 0x00, 0x00),
|
||
cmd(CMD_DATA_ENTRY_MODE),
|
||
data(0x03),
|
||
cmd(CMD_SET_RAMX_RANGE),
|
||
data(0x00, 0x18),
|
||
cmd(CMD_SET_RAMY_RANGE),
|
||
data(0x00, 0x00, 0xc7, 0x00),
|
||
cmd(CMD_BORDER_WAVEFORM),
|
||
data(0x05),
|
||
cmd(CMD_DISP_UPDATE_CTRL_1),
|
||
data(0x00, 0x80),
|
||
cmd(CMD_TEMP_SENSOR),
|
||
data(0x80),
|
||
cmd(CMD_SET_RAMX_COUNTER),
|
||
data(0x00),
|
||
cmd(CMD_SET_RAMY_COUNTER),
|
||
data(0x00, 0x00),
|
||
);
|
||
}
|
||
|
||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||
|
||
describe('SSD168xDecoder — init sequence', () => {
|
||
it('accepts the GxEPD2 init for the 1.54" panel without unknown opcodes', () => {
|
||
const d = new SSD168xDecoder({ width: 200, height: 200 });
|
||
gxepd2Init154(d);
|
||
expect(d.unknownCmds).toEqual([]);
|
||
expect(d.refreshedCount).toBe(0);
|
||
expect(d.inDeepSleep).toBe(false);
|
||
});
|
||
|
||
it('SW reset clears the BW plane', () => {
|
||
const d = new SSD168xDecoder({ width: 200, height: 200 });
|
||
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0x00, 0x00));
|
||
expect(Array.from(d.bwRam.slice(0, 25)).some((b) => b !== 0xff)).toBe(true);
|
||
feedAll(d, cmd(CMD_SW_RESET));
|
||
expect(Array.from(d.bwRam.slice(0, 25)).every((b) => b === 0xff)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('SSD168xDecoder — RAM windowing', () => {
|
||
it('SET_RAMX_RANGE / SET_RAMY_RANGE set the active window', () => {
|
||
const d = new SSD168xDecoder({ width: 200, height: 200 });
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_SET_RAMX_RANGE),
|
||
data(0x00, 0x18),
|
||
cmd(CMD_SET_RAMY_RANGE),
|
||
data(0x00, 0x00, 0xc7, 0x00),
|
||
);
|
||
// Indirectly via writePixel auto-increment behaviour at the boundary:
|
||
feedAll(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00));
|
||
feedAll(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00));
|
||
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0xaa));
|
||
expect(d.bwRam[0]).toBe(0xaa);
|
||
});
|
||
|
||
it('SET_RAMX_COUNTER / SET_RAMY_COUNTER seek inside the window', () => {
|
||
const d = new SSD168xDecoder({ width: 200, height: 200 });
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_SET_RAMX_COUNTER),
|
||
data(0x05),
|
||
cmd(CMD_SET_RAMY_COUNTER),
|
||
data(0x10, 0x00),
|
||
cmd(CMD_WRITE_BLACK_VRAM),
|
||
data(0xab),
|
||
);
|
||
const bpr = 25; // 200/8
|
||
expect(d.bwRam[0x10 * bpr + 0x05]).toBe(0xab);
|
||
});
|
||
});
|
||
|
||
describe('SSD168xDecoder — pixel writing & wrap', () => {
|
||
it('WRITE_BLACK_VRAM auto-increments X', () => {
|
||
const d = new SSD168xDecoder({ width: 200, height: 200 });
|
||
gxepd2Init154(d);
|
||
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0xff, 0xaa));
|
||
expect(d.bwRam[0]).toBe(0x00);
|
||
expect(d.bwRam[1]).toBe(0xff);
|
||
expect(d.bwRam[2]).toBe(0xaa);
|
||
});
|
||
|
||
it('writes wrap to the next row at xrange[1]', () => {
|
||
const d = new SSD168xDecoder({ width: 200, height: 200 });
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_DATA_ENTRY_MODE),
|
||
data(0x03),
|
||
cmd(CMD_SET_RAMX_RANGE),
|
||
data(0x00, 0x01),
|
||
cmd(CMD_SET_RAMX_COUNTER),
|
||
data(0x00),
|
||
cmd(CMD_SET_RAMY_COUNTER),
|
||
data(0x00, 0x00),
|
||
);
|
||
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0xaa, 0xbb, 0xcc, 0xdd));
|
||
const bpr = 25;
|
||
expect(d.bwRam[0]).toBe(0xaa);
|
||
expect(d.bwRam[1]).toBe(0xbb);
|
||
expect(d.bwRam[bpr + 0]).toBe(0xcc);
|
||
expect(d.bwRam[bpr + 1]).toBe(0xdd);
|
||
});
|
||
});
|
||
|
||
describe('SSD168xDecoder — frame latch & compose', () => {
|
||
it('MASTER_ACTIVATION fires onFlush with the latched frame', () => {
|
||
const seen: Frame[] = [];
|
||
const d = new SSD168xDecoder({
|
||
width: 200,
|
||
height: 200,
|
||
onFlush: (f) => seen.push(f),
|
||
});
|
||
gxepd2Init154(d);
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_DISP_UPDATE_CTRL_2),
|
||
data(0xf7),
|
||
cmd(CMD_MASTER_ACTIVATION),
|
||
);
|
||
expect(seen.length).toBe(1);
|
||
expect(d.refreshedCount).toBe(1);
|
||
const frame = seen[0];
|
||
expect(frame.width).toBe(200);
|
||
expect(frame.height).toBe(200);
|
||
// Default RAM is 0xFF (all bits=1) → all pixels white.
|
||
for (let i = 0; i < frame.pixels.length; i++) expect(frame.pixels[i]).toBe(1);
|
||
});
|
||
|
||
it('red plane wins over black on compose', () => {
|
||
const d = new SSD168xDecoder({ width: 8, height: 2 });
|
||
// Row 0 all-black, Row 1 all-white
|
||
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0xff));
|
||
// Reset cursors then write red plane: row 0 first 4 px red, row 1 nothing
|
||
feedAll(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00));
|
||
feedAll(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00));
|
||
feedAll(d, cmd(CMD_WRITE_RED_VRAM), data(0xf0, 0x00));
|
||
const frame = d.composeFrame();
|
||
expect(Array.from(frame.pixels.slice(0, 4))).toEqual([2, 2, 2, 2]);
|
||
expect(Array.from(frame.pixels.slice(4, 8))).toEqual([0, 0, 0, 0]);
|
||
expect(Array.from(frame.pixels.slice(8, 16))).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
||
});
|
||
});
|
||
|
||
describe('SSD168xDecoder — deep sleep & unknown opcodes', () => {
|
||
it('DEEP_SLEEP with data 0x01 sets the in_deep_sleep flag', () => {
|
||
const d = new SSD168xDecoder({ width: 200, height: 200 });
|
||
feedAll(d, cmd(CMD_DEEP_SLEEP), data(0x01));
|
||
expect(d.inDeepSleep).toBe(true);
|
||
});
|
||
|
||
it('unknown opcodes are logged not raised', () => {
|
||
const d = new SSD168xDecoder({ width: 200, height: 200 });
|
||
feedAll(d, cmd(0xab), data(0x01, 0x02));
|
||
expect(d.unknownCmds).toContain(0xab);
|
||
});
|
||
});
|
||
|
||
describe('SSD168xDecoder — end-to-end hello world', () => {
|
||
it('writes a single black pixel at (0,0) and the frame reflects it', () => {
|
||
const seen: Frame[] = [];
|
||
const d = new SSD168xDecoder({
|
||
width: 200,
|
||
height: 200,
|
||
onFlush: (f) => seen.push(f),
|
||
});
|
||
gxepd2Init154(d);
|
||
feedAll(d, cmd(CMD_SET_RAMX_COUNTER), data(0x00));
|
||
feedAll(d, cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00));
|
||
feedAll(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x7f));
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_DISP_UPDATE_CTRL_2),
|
||
data(0xf7),
|
||
cmd(CMD_MASTER_ACTIVATION),
|
||
);
|
||
expect(seen.length).toBe(1);
|
||
expect(seen[0].pixels[0]).toBe(0); // top-left black
|
||
expect(seen[0].pixels[1]).toBe(1); // (1,0) white
|
||
});
|
||
});
|
||
|
||
describe('SSD168xDecoder — tri-colour B/W/R pipeline', () => {
|
||
// Mirrors how GxEPD2_3C drives a real SSD1680 panel: write the BW plane
|
||
// first (cmd 0x24), reset cursors, then write the red plane (cmd 0x26),
|
||
// then activate. The composed frame must mix both planes correctly.
|
||
|
||
it('three-row pattern composes B / W / R correctly', () => {
|
||
// 16-wide × 3-high panel — easy to pin every pixel by hand.
|
||
const seen: Frame[] = [];
|
||
const d = new SSD168xDecoder({
|
||
width: 16,
|
||
height: 3,
|
||
onFlush: (f) => seen.push(f),
|
||
});
|
||
|
||
// Match the typical GxEPD2 init sub-sequence enough to set window + cursor.
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_DATA_ENTRY_MODE), data(0x03),
|
||
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x01), // 2 bytes wide
|
||
cmd(CMD_SET_RAMY_RANGE), data(0x00, 0x00, 0x02, 0x00), // rows 0..2
|
||
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
|
||
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
|
||
);
|
||
|
||
// BW plane: row 0 all-black, row 1 all-white, row 2 mixed (left half black)
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_WRITE_BLACK_VRAM),
|
||
data(
|
||
0x00, 0x00, // row 0
|
||
0xff, 0xff, // row 1
|
||
0x00, 0xff, // row 2
|
||
),
|
||
);
|
||
|
||
// Reset cursor for the red plane.
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
|
||
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
|
||
);
|
||
|
||
// Red plane: row 0 nothing, row 1 first 4 px red, row 2 nothing.
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_WRITE_RED_VRAM),
|
||
data(
|
||
0x00, 0x00, // row 0 — no red
|
||
0xf0, 0x00, // row 1 — first 4 px red
|
||
0x00, 0x00, // row 2 — no red
|
||
),
|
||
);
|
||
|
||
feedAll(d, cmd(CMD_MASTER_ACTIVATION));
|
||
expect(seen.length).toBe(1);
|
||
const px = seen[0].pixels;
|
||
|
||
// Row 0: all black (0)
|
||
for (let x = 0; x < 16; x++) expect(px[x]).toBe(0);
|
||
|
||
// Row 1: cols 0-3 red (red wins over white), cols 4-15 white
|
||
for (let x = 0; x < 4; x++) expect(px[16 + x]).toBe(2);
|
||
for (let x = 4; x < 16; x++) expect(px[16 + x]).toBe(1);
|
||
|
||
// Row 2: cols 0-7 black, cols 8-15 white
|
||
for (let x = 0; x < 8; x++) expect(px[32 + x]).toBe(0);
|
||
for (let x = 8; x < 16; x++) expect(px[32 + x]).toBe(1);
|
||
});
|
||
|
||
it('writing the red plane alone (BW left default white) shows red on white', () => {
|
||
const seen: Frame[] = [];
|
||
const d = new SSD168xDecoder({
|
||
width: 8,
|
||
height: 1,
|
||
onFlush: (f) => seen.push(f),
|
||
});
|
||
feedAll(
|
||
d,
|
||
cmd(CMD_DATA_ENTRY_MODE), data(0x03),
|
||
cmd(CMD_SET_RAMX_RANGE), data(0x00, 0x00),
|
||
cmd(CMD_SET_RAMY_RANGE), data(0x00, 0x00, 0x00, 0x00),
|
||
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
|
||
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
|
||
cmd(CMD_WRITE_RED_VRAM),
|
||
data(0xaa), // alternating red pixels (1010 1010)
|
||
cmd(CMD_MASTER_ACTIVATION),
|
||
);
|
||
const px = seen[0].pixels;
|
||
expect(Array.from(px)).toEqual([2, 1, 2, 1, 2, 1, 2, 1]);
|
||
});
|
||
});
|
||
|
||
describe('SSD168xDecoder — larger panel sizes', () => {
|
||
// Use Uint8Array.every() (single hot loop, one assertion) rather than per-pixel
|
||
// expect() — at 400×300 = 120k pixels Vitest's per-call overhead dominates.
|
||
|
||
it('initializes a 4.2" 400×300 frame as all white', () => {
|
||
const seen: Frame[] = [];
|
||
const d = new SSD168xDecoder({
|
||
width: 400,
|
||
height: 300,
|
||
onFlush: (f) => seen.push(f),
|
||
});
|
||
feedAll(d, cmd(CMD_MASTER_ACTIVATION));
|
||
expect(seen[0].width).toBe(400);
|
||
expect(seen[0].height).toBe(300);
|
||
expect(seen[0].pixels.length).toBe(400 * 300);
|
||
expect(seen[0].pixels.every((p) => p === 1)).toBe(true);
|
||
});
|
||
|
||
it('initializes a 7.5" 800×480 frame as all white', () => {
|
||
const seen: Frame[] = [];
|
||
const d = new SSD168xDecoder({
|
||
width: 800,
|
||
height: 480,
|
||
onFlush: (f) => seen.push(f),
|
||
});
|
||
feedAll(d, cmd(CMD_MASTER_ACTIVATION));
|
||
expect(seen[0].width).toBe(800);
|
||
expect(seen[0].height).toBe(480);
|
||
expect(seen[0].pixels.length).toBe(800 * 480);
|
||
expect(seen[0].pixels.every((p) => p === 1)).toBe(true);
|
||
});
|
||
});
|