feat(epaper): 4-level greyscale rendering in SSD168xDecoder

Panels that upload a custom waveform LUT (0x32) AND write both RAM planes
(0x24 + 0x26) in a frame drive 4-level greyscale: each pixel is 2 bits (one
per plane) composited by the custom LUT into 4 grey levels. Auto-detect this
from the command stream (custom LUT + red-plane written; never key off a panel
name) and expose an optional Frame.grey (0..3) alongside the existing 1-bit
`pixels` (kept as a level>=2 -> white projection so 1-bit and BWR consumers -
EPaperPart, GxEPD2 - are byte-identical). composeFrame builds a parallel grey
plane and rotates it through the same transpose as the palette plane.

The combo (bw<<1 | red) -> level map is [3,2,1,0] (white/light/dark/black):
bw is the dominant luminance bit (bw=0 -> light pair, bw=1 -> dark pair), red
refines one step within each half; verified against the captured menu's plane
populations. Exported DEFAULT_GREY_COMBO_TO_LEVEL + an optional greyComboToLevel
option (tunes ordering only, never forces greyscale on).

Standard SSD1680 paths (raw badge examples, GxEPD2 full refresh) load the OTP
LUT, never send 0x32, never write 0x26 -> unchanged. 23 unit tests pass
(ssd168x-decoder + epaper-part-integration, incl. new greyscale + no-regression
cases).
This commit is contained in:
David Montero Crespo 2026-08-06 03:56:02 +02:00
parent a77ef68456
commit 6442217893
2 changed files with 198 additions and 31 deletions

View File

@ -24,6 +24,7 @@ import {
CMD_SET_RAMY_COUNTER,
CMD_WRITE_BLACK_VRAM,
CMD_WRITE_RED_VRAM,
CMD_WRITE_LUT,
CMD_MASTER_ACTIVATION,
CMD_DEEP_SLEEP,
} from '../simulation/displays/SSD168xDecoder';
@ -322,6 +323,90 @@ describe('SSD168xDecoder — tri-colour B/W/R pipeline', () => {
});
});
describe('SSD168xDecoder — 4-level greyscale (custom LUT + both planes)', () => {
// A host that uploads its own waveform LUT (0x32) AND writes BOTH RAM planes
// (0x24 + 0x26) is driving a 2-bits-per-pixel 4-level greyscale render. The
// decoder must auto-detect this from the stream (never a panel name) and
// expose a `grey` channel of levels 0..3, with `pixels` a 1-bit projection.
it('composes 4 grey levels from the two bit-planes', () => {
const seen: Frame[] = [];
const d = new SSD168xDecoder({
width: 8,
height: 1,
palette: 'bw',
onFlush: (f) => seen.push(f),
});
feedAll(
d,
cmd(CMD_WRITE_LUT), data(0x00, 0x11, 0x22), // custom waveform LUT
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),
// bwBit sequence 0000 1111 -> 0x0F ; redBit sequence 0011 0011 -> 0x33.
// Per-pixel combo (bwBit<<1|redBit): 0,0,1,1,2,2,3,3.
cmd(CMD_WRITE_BLACK_VRAM), data(0x0f),
cmd(CMD_SET_RAMX_COUNTER), data(0x00),
cmd(CMD_SET_RAMY_COUNTER), data(0x00, 0x00),
cmd(CMD_WRITE_RED_VRAM), data(0x33),
cmd(CMD_MASTER_ACTIVATION),
);
expect(seen.length).toBe(1);
const frame = seen[0];
expect(frame.grey).toBeDefined();
// combo 0->3(white) 1->2(light) 2->1(dark) 3->0(black)
expect(Array.from(frame.grey!)).toEqual([3, 3, 2, 2, 1, 1, 0, 0]);
// 1-bit projection: light levels (>=2) white, dark levels black.
expect(Array.from(frame.pixels)).toEqual([1, 1, 1, 1, 0, 0, 0, 0]);
});
it('does NOT engage greyscale for a standard 1-bit stream (no 0x32, no 0x26)', () => {
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);
// No custom LUT and no red plane -> no greyscale channel, unchanged pixels.
expect(seen[0].grey).toBeUndefined();
expect(seen[0].pixels[0]).toBe(0);
expect(seen[0].pixels[1]).toBe(1);
});
it('a custom LUT alone (no 0x26) stays the legacy 1-bit path (no grey)', () => {
const seen: Frame[] = [];
const d = new SSD168xDecoder({
width: 8,
height: 1,
onFlush: (f) => seen.push(f),
});
feedAll(
d,
cmd(CMD_WRITE_LUT), data(0x01),
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_BLACK_VRAM), data(0xf0),
cmd(CMD_MASTER_ACTIVATION),
);
// customBwLut inverts polarity (bit1=black) but with no red plane written
// it must NOT become greyscale.
expect(seen[0].grey).toBeUndefined();
// bwInvert (custom LUT): white where 0x24 bit==0. 0xF0 (1111 0000) ->
// first 4 px black, last 4 white.
expect(Array.from(seen[0].pixels)).toEqual([0, 0, 0, 0, 1, 1, 1, 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.

View File

@ -49,10 +49,46 @@ export type EPaperPalette = 0 | 1 | 2;
export interface Frame {
width: number;
height: number;
/** width*height palette indices. */
/**
* width*height palette indices (0 = black, 1 = white, 2 = red). Always
* present. For a 4-level greyscale frame this is a 1-bit projection of
* `grey` (level 0/1 -> black, level 2/3 -> white) so 1-bit/BWR consumers
* keep working unchanged.
*/
pixels: Uint8Array;
/**
* OPTIONAL 4-level greyscale channel. Present ONLY when the stream was
* detected as 2-bits-per-pixel greyscale (a custom waveform LUT via 0x32
* AND both RAM planes written): width*height values 0..3 where 0 = black,
* 3 = white, 1/2 = the two intermediate greys. Absent for every 1-bit /
* tri-colour panel, so consumers that only read `pixels` are unaffected.
*/
grey?: Uint8Array;
}
/**
* Maps the 2-bit plane combo -> grey level 0..3 for greyscale mode. The
* combo index is (bwBit << 1) | redBit, where bwBit is the 0x24 (WRITE_RAM_BW)
* plane bit and redBit is the 0x26 (WRITE_RAM_RED) plane bit at that pixel.
* Level 0 = black, 3 = white.
*
* The bwBit (0x24) is the dominant luminance bit — bwBit=0 selects the LIGHT
* pair {white, light-grey}, bwBit=1 the DARK pair {dark-grey, black}; this is
* the same polarity as the custom-waveform 1-bit path (white where 0x24 bit=0),
* so the 1-bit projection of a greyscale frame is byte-identical to the legacy
* render. The redBit (0x26) refines within each half — set = one step darker.
* Verified empirically against a captured SSD1680 2.7" greyscale (picovector,
* antialiased) menu render: combo 0 (both clear) is the white page background,
* and this ordering yields smooth icon greys rather than speckle. Overridable
* via SSD168xDecoderOptions.greyComboToLevel for a LUT that reorders the pair.
*/
export const DEFAULT_GREY_COMBO_TO_LEVEL: Readonly<[number, number, number, number]> = [
3, // combo 0: bw=0 red=0 -> white
2, // combo 1: bw=0 red=1 -> light grey
1, // combo 2: bw=1 red=0 -> dark grey
0, // combo 3: bw=1 red=1 -> black
];
export interface SSD168xDecoderOptions {
width: number;
height: number;
@ -65,6 +101,12 @@ export interface SSD168xDecoderOptions {
palette?: 'bw' | 'bwr' | 'acep';
/** Fired on every 0x20 MASTER_ACTIVATION with the latched composed frame. */
onFlush?: (frame: Frame) => void;
/**
* Optional override of the 2-bit-combo -> grey-level map used in greyscale
* mode (see DEFAULT_GREY_COMBO_TO_LEVEL). Only tunes the level ordering; it
* does NOT force greyscale on — that is auto-detected from the stream.
*/
greyComboToLevel?: Readonly<[number, number, number, number]>;
}
/**
@ -130,6 +172,19 @@ export class SSD168xDecoder {
*/
private customBwLut = false;
/**
* True once the host wrote the 0x26 (WRITE_RAM_RED) plane since the last
* flush/reset. On a standard mono panel 0x26 is never sent; on a tri-colour
* panel it is the additive red plane; combined with customBwLut (a custom
* 0x32 waveform) it signals the SECOND greyscale bit-plane of a 2-bpp
* 4-level greyscale render. Reset per frame so a later 1-bit-only refresh
* (0x24 alone) is not mis-detected as greyscale.
*/
private redWritten = false;
/** combo (bwBit<<1|redBit) -> grey level 0..3, for greyscale compose. */
private readonly greyMap: Readonly<[number, number, number, number]>;
/** Diagnostics: how many full refresh activations we've seen. */
refreshedCount = 0;
/** Diagnostics: opcodes the host emitted that aren't in our table. */
@ -147,6 +202,7 @@ export class SSD168xDecoder {
this.ramBpr = (longSide + 7) >> 3;
this.ramRows = longSide;
this.onFlush = opts.onFlush;
this.greyMap = opts.greyComboToLevel ?? DEFAULT_GREY_COMBO_TO_LEVEL;
const size = this.ramBpr * this.ramRows;
this.bwRam = new Uint8Array(size).fill(0xff); // default white
@ -181,6 +237,7 @@ export class SSD168xDecoder {
this.y = 0;
this.entryMode = 0x03;
this.customBwLut = false;
this.redWritten = false;
this.xrange = [0, ((this.width + 7) >> 3) - 1];
this.yrange = [0, this.height - 1];
this.winXSet = false;
@ -216,10 +273,17 @@ export class SSD168xDecoder {
const yTopIsHigh = (this.entryMode & 0x02) === 0;
// Inverted B/W polarity when the host uploaded a custom waveform LUT (0x32).
const bwInvert = this.customBwLut;
// 4-level greyscale: a custom waveform LUT (0x32) AND both RAM planes
// written this frame means each pixel is 2 bits (0x24 = one bit-plane,
// 0x26 = the other), composited by the custom LUT into 4 grey levels.
// Auto-detected purely from the stream — no panel name involved.
const greyscale = this.customBwLut && this.redWritten;
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);
const native = new Uint8Array(nw * nh); // palette 0/1/2
// Parallel greyscale plane (level 0..3), only allocated in greyscale mode.
const nativeGrey = greyscale ? new Uint8Array(nw * nh) : null;
for (let ny = 0; ny < nh; ny++) {
const srcY = yTopIsHigh ? y1 - ny : y0 + ny;
const row = srcY * this.ramBpr + x0;
@ -232,6 +296,17 @@ export class SSD168xDecoder {
const x = base + bit;
if (x >= nw) break;
const mask = 0x80 >> bit;
if (greyscale) {
// Two raw plane bits -> combo -> grey level via the custom-LUT map.
const bBit = (bByte & mask) !== 0 ? 1 : 0;
const rBit = (rByte & mask) !== 0 ? 1 : 0;
const level = this.greyMap[(bBit << 1) | rBit];
nativeGrey![outRow + x] = level;
// 1-bit projection for consumers that only read `pixels`:
// dark levels (0,1) -> black, light levels (2,3) -> white.
native[outRow + x] = level >= 2 ? 1 : 0;
continue;
}
const bwWhite = ((bByte & mask) !== 0) !== bwInvert;
if (this.isBwr) {
native[outRow + x] = (rByte & mask) !== 0 ? 2 : bwWhite ? 1 : 0;
@ -247,48 +322,51 @@ export class SSD168xDecoder {
}
}
// 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.
// Map a native-geometry array -> display geometry (handles the byte-padded
// native width and the rotation-0 / rotation-1 transpose). Shared by the
// palette plane and the greyscale plane so both rotate identically.
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);
const toDisplay = (src: Uint8Array, fill: number): Uint8Array => {
if (nh === H && nwBytes === Wb) {
// Non-transposed (rotation 0): native actual width = W.
if (nw === W) return src;
const out = new Uint8Array(W * H).fill(fill);
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];
for (let x = 0; x < W; x++) out[d + x] = src[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];
return out;
} 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.
const out = new Uint8Array(W * H).fill(fill);
const Wn = H;
for (let ny = 0; ny < nh; ny++) {
if (ny >= W) break;
const s = ny * nw;
for (let x = 0; x < Wn; x++) out[(Wn - 1 - x) * W + ny] = src[s + x];
}
return out;
}
} else {
// Unexpected geometry — best-effort top-left copy onto white.
pixels = new Uint8Array(W * H).fill(1);
// Unexpected geometry — best-effort top-left copy onto the fill colour.
const out = new Uint8Array(W * H).fill(fill);
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];
for (let x = 0; x < Math.min(nw, W); x++) out[d + x] = src[s + x];
}
return out;
};
const pixels = toDisplay(native, 1); // 1 = white padding
if (nativeGrey) {
const grey = toDisplay(nativeGrey, 3); // 3 = white padding
return { width: W, height: H, pixels, grey };
}
return { width: W, height: H, pixels };
}
@ -307,9 +385,12 @@ export class SSD168xDecoder {
this.refreshedCount += 1;
const frame = this.composeFrame();
this.onFlush?.(frame);
// Start a fresh window union for the next frame's pages.
// Start a fresh window union for the next frame's pages, and a fresh
// red-plane-written flag so a subsequent 1-bit-only refresh (0x24 with
// no 0x26) is not mis-detected as greyscale.
this.winXSet = false;
this.winYSet = false;
this.redWritten = false;
return;
}
// WRITE_BLACK/RED are recognized no-ops here; handleData() routes their
@ -381,6 +462,7 @@ export class SSD168xDecoder {
} else if (cmd === CMD_WRITE_BLACK_VRAM) {
this.writeRamByte(this.bwRam, byte);
} else if (cmd === CMD_WRITE_RED_VRAM) {
this.redWritten = true;
this.writeRamByte(this.redRam, byte);
} else if (cmd === CMD_WRITE_LUT) {
// The host uploaded its own B/W waveform LUT (write_luts()) rather than