velxio/frontend/src/__tests__/ssd168x-decoder.test.ts

357 lines
12 KiB
TypeScript
Raw Normal View History

/**
* 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', () => {
fix(epaper): correct orientation across all boards + Pico VCC wire ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13" Pico clock came out sideways and clipped). The ESP32 worker decoder was just taught to compose in the controller's native RAM geometry and rotate to the display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040) still composed at display dims with no rotation, so the two diverged. - SSD168xDecoder.ts: port the worker's native-window compose + rotation. * Size RAM to the longer side both ways so a rotated native layout (128x296 behind a 296x128 panel) isn't truncated. * Compose in the active RAM window, then rotate via the inverse of Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled. * Track the UNION of windows per frame: paged drivers (GxEPD2 page height < panel) set one partial window per page, so compose must use the full native area, not just the last page's strip. Fixes the all-white render on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32). * Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white only if both planes white), tri-colour panels keep red-wins. * Default the active window to display geometry; the firmware overrides it. - EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder. - esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation + window-union in the worker, and derive is_bwr from panel_kind on the runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge). - test_epaper/ssd168x_decoder.py: re-port the golden reference to match (keeps the 3-way TS/Python/worker identity invariant). Tests updated to construct tri-colour cases with is_bwr/palette='bwr'. - examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)', which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the wire snapped to the board corner. Use '3V3'.
2026-06-04 13:32:02 +07:00
const d = new SSD168xDecoder({ width: 8, height: 2, palette: 'bwr' });
// 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,
fix(epaper): correct orientation across all boards + Pico VCC wire ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13" Pico clock came out sideways and clipped). The ESP32 worker decoder was just taught to compose in the controller's native RAM geometry and rotate to the display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040) still composed at display dims with no rotation, so the two diverged. - SSD168xDecoder.ts: port the worker's native-window compose + rotation. * Size RAM to the longer side both ways so a rotated native layout (128x296 behind a 296x128 panel) isn't truncated. * Compose in the active RAM window, then rotate via the inverse of Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled. * Track the UNION of windows per frame: paged drivers (GxEPD2 page height < panel) set one partial window per page, so compose must use the full native area, not just the last page's strip. Fixes the all-white render on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32). * Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white only if both planes white), tri-colour panels keep red-wins. * Default the active window to display geometry; the firmware overrides it. - EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder. - esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation + window-union in the worker, and derive is_bwr from panel_kind on the runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge). - test_epaper/ssd168x_decoder.py: re-port the golden reference to match (keeps the 3-way TS/Python/worker identity invariant). Tests updated to construct tri-colour cases with is_bwr/palette='bwr'. - examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)', which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the wire snapped to the board corner. Use '3V3'.
2026-06-04 13:32:02 +07:00
palette: 'bwr',
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,
fix(epaper): correct orientation across all boards + Pico VCC wire ePaper panels rendered rotated/misaligned on AVR and RP2040 (e.g. the 2.13" Pico clock came out sideways and clipped). The ESP32 worker decoder was just taught to compose in the controller's native RAM geometry and rotate to the display orientation, but the browser-side SSD168xDecoder (used by AVR/RP2040) still composed at display dims with no rotation, so the two diverged. - SSD168xDecoder.ts: port the worker's native-window compose + rotation. * Size RAM to the longer side both ways so a rotated native layout (128x296 behind a 296x128 panel) isn't truncated. * Compose in the active RAM window, then rotate via the inverse of Adafruit_GFX setRotation(1). Detect orientation by BYTE width so a non-multiple-of-8 native width (the 2.13" panel is 122 px) is handled. * Track the UNION of windows per frame: paged drivers (GxEPD2 page height < panel) set one partial window per page, so compose must use the full native area, not just the last page's strip. Fixes the all-white render on paged panels (1.54" Uno, 4.2" Pico, 7.5" ESP32). * Add an isBwr option: B/W panels treat 0x26 as a 2nd mono plane (white only if both planes white), tri-colour panels keep red-wins. * Default the active window to display geometry; the firmware overrides it. - EPaperPart.ts: pass isBwr = cfg.palette === 'bwr' to the decoder. - esp32_spi_slaves.py / esp32_worker.py: mirror the byte-aware rotation + window-union in the worker, and derive is_bwr from panel_kind on the runtime sensor_attach path too (fixes the tri-colour ESP32 alert badge). - test_epaper/ssd168x_decoder.py: re-port the golden reference to match (keeps the 3-way TS/Python/worker identity invariant). Tests updated to construct tri-colour cases with is_bwr/palette='bwr'. - examples-displays-epaper.ts: the Pico VCC wire referenced '3V3(OUT)', which the velxio-pi-pico-w element doesn't expose (it has '3V3'), so the wire snapped to the board corner. Use '3V3'.
2026-06-04 13:32:02 +07:00
palette: 'bwr',
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);
});
});