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'.
This commit is contained in:
parent
2b528bfefc
commit
9ba8687743
|
|
@ -82,6 +82,15 @@ class Ssd168xEpaperSlave:
|
|||
_y: int = 0
|
||||
_xrange: tuple = (0, 0)
|
||||
_yrange: tuple = (0, 0)
|
||||
# UNION of every RAM window set since the last flush — paged drivers set one
|
||||
# partial window per page, so compose must use the union (full native area),
|
||||
# not just the last page's strip.
|
||||
_win_x0: int = 0
|
||||
_win_x1: int = 0
|
||||
_win_y0: int = 0
|
||||
_win_y1: int = 0
|
||||
_win_x_set: bool = False
|
||||
_win_y_set: bool = False
|
||||
_entry_mode: int = 0x03
|
||||
refreshed_count: int = 0
|
||||
unknown_cmds: List[int] = field(default_factory=list)
|
||||
|
|
@ -96,8 +105,11 @@ class Ssd168xEpaperSlave:
|
|||
# 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).
|
||||
self.red_ram = bytearray([0x00 if self.is_bwr else 0xFF] * n)
|
||||
self._xrange = (0, self._ram_bpr - 1)
|
||||
self._yrange = (0, self._ram_rows - 1)
|
||||
# Default active window = DISPLAY geometry (the firmware overrides via
|
||||
# 0x44/0x45 before writing). RAM is sized larger; until a window is set
|
||||
# the panel is treated as un-rotated display-sized.
|
||||
self._xrange = (0, (self.width + 7) // 8 - 1)
|
||||
self._yrange = (0, self.height - 1)
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -117,6 +129,11 @@ class Ssd168xEpaperSlave:
|
|||
self._ram_target = "bw"
|
||||
self._x_byte = 0
|
||||
self._y = 0
|
||||
self._entry_mode = 0x03
|
||||
self._xrange = (0, (self.width + 7) // 8 - 1)
|
||||
self._yrange = (0, self.height - 1)
|
||||
self._win_x_set = False
|
||||
self._win_y_set = False
|
||||
self.in_deep_sleep = False
|
||||
|
||||
def compose_frame(self) -> Frame:
|
||||
|
|
@ -125,8 +142,16 @@ class Ssd168xEpaperSlave:
|
|||
# orientation. Handles panels driven with setRotation() whose native
|
||||
# RAM (e.g. 128x296) is the transpose of the display (296x128); the old
|
||||
# code assumed display==native and dropped half the rows.
|
||||
x0, x1 = self._xrange
|
||||
y0, y1 = self._yrange
|
||||
# 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.
|
||||
if self._win_x_set:
|
||||
x0, x1 = self._win_x0, self._win_x1
|
||||
else:
|
||||
x0, x1 = 0, (self.width + 7) // 8 - 1
|
||||
if self._win_y_set:
|
||||
y0, y1 = self._win_y0, self._win_y1
|
||||
else:
|
||||
y0, y1 = 0, self.height - 1
|
||||
nw_bytes = max(0, x1 - x0 + 1)
|
||||
nw = nw_bytes * 8 # native width (px)
|
||||
nh = max(0, y1 - y0 + 1) # native height (rows)
|
||||
|
|
@ -152,33 +177,44 @@ class Ssd168xEpaperSlave:
|
|||
# so a pixel is white only if BOTH planes say white.
|
||||
native[out_row + x] = 1 if (bw_white and (r_byte & mask)) else 0
|
||||
|
||||
# Map native -> display. `nw` is byte-padded (nw_bytes*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.
|
||||
W, H = self.width, self.height
|
||||
if (nw, nh) == (W, H):
|
||||
pixels = native
|
||||
elif (nw, nh) == (H, W) and nw and nh:
|
||||
# 90° rotation (firmware used setRotation(1): native RAM is the
|
||||
# rotated buffer). Inverse of Adafruit_GFX rotation 1:
|
||||
# logical(x,y) -> native(x_raw=Wn-1-y, y_raw=x)
|
||||
# so native(x_raw, y_raw) -> display(xd=y_raw, yd=Wn-1-x_raw),
|
||||
# where Wn = native width = nw. Verified visually (text upright).
|
||||
Wb = (W + 7) // 8
|
||||
Hb = (H + 7) // 8
|
||||
if nh == H and nw_bytes == Wb:
|
||||
# Non-transposed (rotation 0): native actual width = W.
|
||||
if nw == W:
|
||||
pixels = native
|
||||
else:
|
||||
pixels = bytearray([1]) * (W * H)
|
||||
for ny in range(H):
|
||||
s = ny * nw
|
||||
d = ny * W
|
||||
for x in range(W):
|
||||
pixels[d + x] = native[s + x]
|
||||
elif nh == W and nw_bytes == Hb and 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 = bytearray([1]) * (W * H)
|
||||
for ny in range(nh): # ny = y_raw (0..nh-1)
|
||||
wn = H
|
||||
for ny in range(nh): # ny = y_raw (0..W-1)
|
||||
if ny >= W:
|
||||
break
|
||||
src = ny * nw
|
||||
xd = ny
|
||||
if not (0 <= xd < W):
|
||||
continue
|
||||
for x in range(nw): # x = x_raw (0..nw-1)
|
||||
yd = (nw - 1) - x
|
||||
if 0 <= yd < H:
|
||||
pixels[yd * W + xd] = native[src + x]
|
||||
for x in range(wn): # x = x_raw (0..H-1, true native width)
|
||||
pixels[(wn - 1 - x) * W + ny] = native[src + x]
|
||||
else:
|
||||
# Unexpected geometry — best-effort top-left copy onto white.
|
||||
pixels = bytearray([1]) * (W * H)
|
||||
for ny in range(min(nh, H)):
|
||||
src = ny * nw
|
||||
dst = ny * W
|
||||
s = ny * nw
|
||||
d = ny * W
|
||||
for x in range(min(nw, W)):
|
||||
pixels[dst + x] = native[src + x]
|
||||
pixels[d + x] = native[s + x]
|
||||
return Frame(W, H, bytes(pixels))
|
||||
|
||||
def compose_frame_b64(self) -> str:
|
||||
|
|
@ -197,6 +233,9 @@ class Ssd168xEpaperSlave:
|
|||
if cmd == CMD_MASTER_ACTIVATION:
|
||||
self.refreshed_count += 1
|
||||
frame = self.compose_frame()
|
||||
# Start a fresh window union for the next frame's pages.
|
||||
self._win_x_set = False
|
||||
self._win_y_set = False
|
||||
if self.on_flush:
|
||||
try:
|
||||
self.on_flush(frame)
|
||||
|
|
@ -233,10 +272,22 @@ class Ssd168xEpaperSlave:
|
|||
elif cmd == CMD_SET_RAMX_RANGE and len(params) == 2:
|
||||
self._xrange = (params[0], params[1])
|
||||
self._x_byte = params[0]
|
||||
if not self._win_x_set:
|
||||
self._win_x0, self._win_x1 = params[0], params[1]
|
||||
self._win_x_set = True
|
||||
else:
|
||||
self._win_x0 = min(self._win_x0, params[0])
|
||||
self._win_x1 = max(self._win_x1, params[1])
|
||||
elif cmd == CMD_SET_RAMY_RANGE and len(params) == 4:
|
||||
self._yrange = (params[0] | (params[1] << 8),
|
||||
params[2] | (params[3] << 8))
|
||||
self._y = self._yrange[0]
|
||||
if not self._win_y_set:
|
||||
self._win_y0, self._win_y1 = self._yrange
|
||||
self._win_y_set = True
|
||||
else:
|
||||
self._win_y0 = min(self._win_y0, self._yrange[0])
|
||||
self._win_y1 = max(self._win_y1, self._yrange[1])
|
||||
elif cmd == CMD_SET_RAMX_COUNTER and len(params) == 1:
|
||||
self._x_byte = byte
|
||||
elif cmd == CMD_SET_RAMY_COUNTER and len(params) == 2:
|
||||
|
|
|
|||
|
|
@ -1773,9 +1773,10 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
|
|||
on_flush=_flush_factory_rt(),
|
||||
)
|
||||
else:
|
||||
_is_bwr = 'bwr' in str(cmd.get('panel_kind', '')).lower()
|
||||
slave = _Ssd168xEpaperSlave(
|
||||
component_id=comp_id, width=width, height=height,
|
||||
on_flush=_flush_factory_rt(),
|
||||
on_flush=_flush_factory_rt(), is_bwr=_is_bwr,
|
||||
)
|
||||
state = {
|
||||
'slave': slave,
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ describe('SSD168xDecoder — frame latch & compose', () => {
|
|||
});
|
||||
|
||||
it('red plane wins over black on compose', () => {
|
||||
const d = new SSD168xDecoder({ width: 8, height: 2 });
|
||||
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
|
||||
|
|
@ -239,6 +239,7 @@ describe('SSD168xDecoder — tri-colour B/W/R pipeline', () => {
|
|||
const d = new SSD168xDecoder({
|
||||
width: 16,
|
||||
height: 3,
|
||||
palette: 'bwr',
|
||||
onFlush: (f) => seen.push(f),
|
||||
});
|
||||
|
||||
|
|
@ -302,6 +303,7 @@ describe('SSD168xDecoder — tri-colour B/W/R pipeline', () => {
|
|||
const d = new SSD168xDecoder({
|
||||
width: 8,
|
||||
height: 1,
|
||||
palette: 'bwr',
|
||||
onFlush: (f) => seen.push(f),
|
||||
});
|
||||
feedAll(
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ void loop() {
|
|||
{ id: 'w-busy', start: { componentId: 'raspberry-pi-pico', pinName: 'GP13' }, end: { componentId: 'epd-213', pinName: 'BUSY' }, color: '#22cc22' },
|
||||
{ id: 'w-mosi', start: { componentId: 'raspberry-pi-pico', pinName: 'GP19' }, end: { componentId: 'epd-213', pinName: 'SDI' }, color: '#22aaff' },
|
||||
{ id: 'w-sck', start: { componentId: 'raspberry-pi-pico', pinName: 'GP18' }, end: { componentId: 'epd-213', pinName: 'SCK' }, color: '#ffdd33' },
|
||||
{ id: 'w-vcc', start: { componentId: 'raspberry-pi-pico', pinName: '3V3(OUT)' }, end: { componentId: 'epd-213', pinName: 'VCC' }, color: '#ff4444' },
|
||||
{ id: 'w-vcc', start: { componentId: 'raspberry-pi-pico', pinName: '3V3' }, end: { componentId: 'epd-213', pinName: 'VCC' }, color: '#ff4444' },
|
||||
{ id: 'w-gnd', start: { componentId: 'raspberry-pi-pico', pinName: 'GND.1' }, end: { componentId: 'epd-213', pinName: 'GND' }, color: '#000000' },
|
||||
],
|
||||
};
|
||||
|
|
@ -281,7 +281,7 @@ void loop() {}
|
|||
{ id: 'w-busy', start: { componentId: 'raspberry-pi-pico', pinName: 'GP13' }, end: { componentId: 'epd-420', pinName: 'BUSY' }, color: '#22cc22' },
|
||||
{ id: 'w-mosi', start: { componentId: 'raspberry-pi-pico', pinName: 'GP19' }, end: { componentId: 'epd-420', pinName: 'SDI' }, color: '#22aaff' },
|
||||
{ id: 'w-sck', start: { componentId: 'raspberry-pi-pico', pinName: 'GP18' }, end: { componentId: 'epd-420', pinName: 'SCK' }, color: '#ffdd33' },
|
||||
{ id: 'w-vcc', start: { componentId: 'raspberry-pi-pico', pinName: '3V3(OUT)' }, end: { componentId: 'epd-420', pinName: 'VCC' }, color: '#ff4444' },
|
||||
{ id: 'w-vcc', start: { componentId: 'raspberry-pi-pico', pinName: '3V3' }, end: { componentId: 'epd-420', pinName: 'VCC' }, color: '#ff4444' },
|
||||
{ id: 'w-gnd', start: { componentId: 'raspberry-pi-pico', pinName: 'GND.1' }, end: { componentId: 'epd-420', pinName: 'GND' }, color: '#000000' },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -56,6 +56,13 @@ export interface Frame {
|
|||
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;
|
||||
}
|
||||
|
|
@ -67,7 +74,16 @@ export interface SSD168xDecoderOptions {
|
|||
export class SSD168xDecoder {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
private readonly bytesPerRow: 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;
|
||||
|
|
@ -84,11 +100,22 @@ export class SSD168xDecoder {
|
|||
/** Current Y position (scanline). */
|
||||
private y = 0;
|
||||
|
||||
/** Active RAM window in bytes (start, end inclusive). */
|
||||
/** 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). */
|
||||
/** 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;
|
||||
|
||||
|
|
@ -104,15 +131,23 @@ export class SSD168xDecoder {
|
|||
constructor(opts: SSD168xDecoderOptions) {
|
||||
this.width = opts.width;
|
||||
this.height = opts.height;
|
||||
this.bytesPerRow = (opts.width + 7) >> 3;
|
||||
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 bwSize = this.bytesPerRow * this.height;
|
||||
this.bwRam = new Uint8Array(bwSize).fill(0xff); // default white
|
||||
this.redRam = new Uint8Array(bwSize).fill(0x00); // default no red
|
||||
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);
|
||||
|
||||
this.xrange = [0, this.bytesPerRow - 1];
|
||||
this.yrange = [0, this.height - 1];
|
||||
// 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 ─────────────────────────────────────────────────────
|
||||
|
|
@ -128,43 +163,108 @@ export class SSD168xDecoder {
|
|||
/** Clear all state — equivalent to a hardware RST low pulse. */
|
||||
reset(): void {
|
||||
this.bwRam.fill(0xff);
|
||||
this.redRam.fill(0x00);
|
||||
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.bytesPerRow - 1];
|
||||
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. Composition rule:
|
||||
* red plane bit = 1 → RED (wins over black)
|
||||
* bw plane bit = 1 → WHITE
|
||||
* else → BLACK
|
||||
* (Matches every SSD168x-driving Arduino library.)
|
||||
* 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 {
|
||||
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];
|
||||
// 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 = (xb << 3) + bit;
|
||||
if (x >= this.width) break;
|
||||
const x = base + bit;
|
||||
if (x >= nw) 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { width: this.width, height: this.height, pixels: out };
|
||||
|
||||
// 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 ──────────────────────────────
|
||||
|
|
@ -181,6 +281,9 @@ export class SSD168xDecoder {
|
|||
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:
|
||||
|
|
@ -225,12 +328,28 @@ export class SSD168xDecoder {
|
|||
} 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) {
|
||||
|
|
@ -244,12 +363,12 @@ export class SSD168xDecoder {
|
|||
}
|
||||
|
||||
private writeRamByte(plane: Uint8Array, byte: number): void {
|
||||
const bpr = this.bytesPerRow;
|
||||
const bpr = this.ramBpr;
|
||||
if (
|
||||
this.xByte >= 0 &&
|
||||
this.xByte < bpr &&
|
||||
this.y >= 0 &&
|
||||
this.y < this.height
|
||||
this.y < this.ramRows
|
||||
) {
|
||||
plane[this.y * bpr + this.xByte] = byte;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ const epaperSimulation = {
|
|||
: new SSD168xDecoder({
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
palette: cfg.palette,
|
||||
onFlush: (frame) => {
|
||||
scheduleFlush(frame);
|
||||
pulseBusy(refreshMs);
|
||||
|
|
|
|||
|
|
@ -81,27 +81,52 @@ class SSD168xDecoder:
|
|||
width: int
|
||||
height: int
|
||||
on_flush: Optional[Callable[[Frame], None]] = None
|
||||
# True for tri-colour B/W/Red panels (0x26 = additive red plane). False for
|
||||
# plain B/W panels, where some controllers (e.g. GDEY029T94) put the image
|
||||
# into 0x26 as a second mono plane.
|
||||
is_bwr: bool = False
|
||||
|
||||
# Internal state
|
||||
bw_ram: bytearray = field(init=False)
|
||||
red_ram: bytearray = field(init=False)
|
||||
# RAM 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. compose_frame() reads back the active window and
|
||||
# rotates to the display orientation.
|
||||
_ram_bpr: int = field(init=False, default=0)
|
||||
_ram_rows: int = field(init=False, default=0)
|
||||
_current_cmd: int = -1
|
||||
_params: List[int] = field(default_factory=list)
|
||||
_ram_target: str = "bw" # 'bw' or 'red' — which plane we're writing
|
||||
_x_byte: int = 0 # current X position (in bytes — 8 px/byte)
|
||||
_y: int = 0 # current Y position (scanline)
|
||||
_xrange: tuple = (0, 0) # (start_byte, end_byte)
|
||||
_yrange: tuple = (0, 0) # (start_y, end_y)
|
||||
_xrange: tuple = (0, 0) # (start_byte, end_byte) — last window set
|
||||
_yrange: tuple = (0, 0) # (start_y, end_y) — last window set
|
||||
# UNION of every RAM window set since the last flush — paged drivers set one
|
||||
# partial window per page, so compose uses the union (full native area).
|
||||
_win_x0: int = 0
|
||||
_win_x1: int = 0
|
||||
_win_y0: int = 0
|
||||
_win_y1: int = 0
|
||||
_win_x_set: bool = False
|
||||
_win_y_set: bool = False
|
||||
_entry_mode: int = 0x03 # x+ y+ x-first (default for most drivers)
|
||||
refreshed_count: int = 0 # how many MASTER_ACTIVATIONs we've seen
|
||||
unknown_cmds: List[int] = field(default_factory=list)
|
||||
in_deep_sleep: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
bytes_per_row = (self.width + 7) // 8
|
||||
self.bw_ram = bytearray([0xFF] * (bytes_per_row * self.height)) # white
|
||||
self.red_ram = bytearray([0x00] * (bytes_per_row * self.height)) # no red
|
||||
self._xrange = (0, bytes_per_row - 1)
|
||||
long_side = max(self.width, self.height)
|
||||
self._ram_bpr = (long_side + 7) // 8
|
||||
self._ram_rows = long_side
|
||||
n = self._ram_bpr * self._ram_rows
|
||||
self.bw_ram = bytearray([0xFF] * n)
|
||||
# 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).
|
||||
self.red_ram = bytearray([0x00 if self.is_bwr else 0xFF] * n)
|
||||
# Default active window = DISPLAY geometry (firmware overrides via
|
||||
# 0x44/0x45 before writing).
|
||||
self._xrange = (0, (self.width + 7) // 8 - 1)
|
||||
self._yrange = (0, self.height - 1)
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
|
|
@ -115,33 +140,97 @@ class SSD168xDecoder:
|
|||
|
||||
def reset(self) -> None:
|
||||
"""Clear all state — equivalent to a hardware RST low pulse."""
|
||||
bytes_per_row = (self.width + 7) // 8
|
||||
self.bw_ram = bytearray([0xFF] * (bytes_per_row * self.height))
|
||||
self.red_ram = bytearray([0x00] * (bytes_per_row * self.height))
|
||||
n = self._ram_bpr * self._ram_rows
|
||||
self.bw_ram = bytearray([0xFF] * n)
|
||||
self.red_ram = bytearray([0x00 if self.is_bwr else 0xFF] * n)
|
||||
self._current_cmd = -1
|
||||
self._params = []
|
||||
self._ram_target = "bw"
|
||||
self._x_byte = 0
|
||||
self._y = 0
|
||||
self._entry_mode = 0x03
|
||||
self._xrange = (0, (self.width + 7) // 8 - 1)
|
||||
self._yrange = (0, self.height - 1)
|
||||
self._win_x_set = False
|
||||
self._win_y_set = False
|
||||
self.in_deep_sleep = False
|
||||
|
||||
def compose_frame(self) -> Frame:
|
||||
"""Build a Frame from the latched RAM planes (red wins over black)."""
|
||||
bytes_per_row = (self.width + 7) // 8
|
||||
pixels: List[int] = [0] * (self.width * self.height)
|
||||
for y in range(self.height):
|
||||
for xb in range(bytes_per_row):
|
||||
b_byte = self.bw_ram[y * bytes_per_row + xb]
|
||||
r_byte = self.red_ram[y * bytes_per_row + xb]
|
||||
"""Build a Frame from the latched RAM planes.
|
||||
|
||||
Compose in the controller's NATIVE geometry — the active RAM window the
|
||||
firmware wrote (0x44/0x45) — then rotate to the display orientation, so
|
||||
panels driven with setRotation() (native RAM = transpose of the display)
|
||||
render upright. Tri-colour: red wins. B/W: white only if BOTH planes say
|
||||
white (the image may live in 0x24 or 0x26).
|
||||
"""
|
||||
# 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.
|
||||
if self._win_x_set:
|
||||
x0, x1 = self._win_x0, self._win_x1
|
||||
else:
|
||||
x0, x1 = 0, (self.width + 7) // 8 - 1
|
||||
if self._win_y_set:
|
||||
y0, y1 = self._win_y0, self._win_y1
|
||||
else:
|
||||
y0, y1 = 0, self.height - 1
|
||||
nw_bytes = max(0, x1 - x0 + 1)
|
||||
nw = nw_bytes * 8 # native width (px)
|
||||
nh = max(0, y1 - y0 + 1) # native height (rows)
|
||||
native = [0] * (nw * nh)
|
||||
for ny in range(nh):
|
||||
row = (y0 + ny) * self._ram_bpr + x0
|
||||
out_row = ny * nw
|
||||
for xb in range(nw_bytes):
|
||||
b_byte = self.bw_ram[row + xb]
|
||||
r_byte = self.red_ram[row + xb]
|
||||
base = xb << 3
|
||||
for bit in range(8):
|
||||
x = xb * 8 + bit
|
||||
if x >= self.width:
|
||||
x = base + bit
|
||||
if x >= nw:
|
||||
break
|
||||
mask = 0x80 >> bit
|
||||
is_red = bool(r_byte & mask)
|
||||
is_white = bool(b_byte & mask)
|
||||
pixels[y * self.width + x] = 2 if is_red else (1 if is_white else 0)
|
||||
return Frame(self.width, self.height, pixels)
|
||||
bw_white = bool(b_byte & mask)
|
||||
if self.is_bwr:
|
||||
native[out_row + x] = 2 if (r_byte & mask) else (1 if bw_white else 0)
|
||||
else:
|
||||
native[out_row + x] = 1 if (bw_white and (r_byte & mask)) else 0
|
||||
|
||||
# Map native -> display (nw is byte-padded; detect orientation by byte
|
||||
# width and crop padding with the true native width).
|
||||
W, H = self.width, self.height
|
||||
Wb = (W + 7) // 8
|
||||
Hb = (H + 7) // 8
|
||||
if nh == H and nw_bytes == Wb:
|
||||
if nw == W:
|
||||
pixels = native
|
||||
else:
|
||||
pixels = [1] * (W * H)
|
||||
for ny in range(H):
|
||||
s = ny * nw
|
||||
d = ny * W
|
||||
for x in range(W):
|
||||
pixels[d + x] = native[s + x]
|
||||
elif nh == W and nw_bytes == Hb and 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 = [1] * (W * H)
|
||||
wn = H
|
||||
for ny in range(nh):
|
||||
if ny >= W:
|
||||
break
|
||||
src = ny * nw
|
||||
for x in range(wn):
|
||||
pixels[(wn - 1 - x) * W + ny] = native[src + x]
|
||||
else:
|
||||
pixels = [1] * (W * H)
|
||||
for ny in range(min(nh, H)):
|
||||
s = ny * nw
|
||||
d = ny * W
|
||||
for x in range(min(nw, W)):
|
||||
pixels[d + x] = native[s + x]
|
||||
return Frame(W, H, pixels)
|
||||
|
||||
# ── Internal: command / data dispatch ──────────────────────────────
|
||||
|
||||
|
|
@ -155,6 +244,9 @@ class SSD168xDecoder:
|
|||
if cmd == CMD_MASTER_ACTIVATION:
|
||||
self.refreshed_count += 1
|
||||
frame = self.compose_frame()
|
||||
# Start a fresh window union for the next frame's pages.
|
||||
self._win_x_set = False
|
||||
self._win_y_set = False
|
||||
if self.on_flush:
|
||||
self.on_flush(frame)
|
||||
return
|
||||
|
|
@ -189,10 +281,22 @@ class SSD168xDecoder:
|
|||
elif cmd == CMD_SET_RAMX_RANGE and len(params) == 2:
|
||||
self._xrange = (params[0], params[1])
|
||||
self._x_byte = params[0]
|
||||
if not self._win_x_set:
|
||||
self._win_x0, self._win_x1 = params[0], params[1]
|
||||
self._win_x_set = True
|
||||
else:
|
||||
self._win_x0 = min(self._win_x0, params[0])
|
||||
self._win_x1 = max(self._win_x1, params[1])
|
||||
elif cmd == CMD_SET_RAMY_RANGE and len(params) == 4:
|
||||
self._yrange = (params[0] | (params[1] << 8),
|
||||
params[2] | (params[3] << 8))
|
||||
self._y = self._yrange[0]
|
||||
if not self._win_y_set:
|
||||
self._win_y0, self._win_y1 = self._yrange
|
||||
self._win_y_set = True
|
||||
else:
|
||||
self._win_y0 = min(self._win_y0, self._yrange[0])
|
||||
self._win_y1 = max(self._win_y1, self._yrange[1])
|
||||
elif cmd == CMD_SET_RAMX_COUNTER and len(params) == 1:
|
||||
self._x_byte = byte
|
||||
elif cmd == CMD_SET_RAMY_COUNTER and len(params) == 2:
|
||||
|
|
@ -204,9 +308,8 @@ class SSD168xDecoder:
|
|||
# Other commands silently buffer their parameters.
|
||||
|
||||
def _write_ram_byte(self, plane: bytearray, byte: int) -> None:
|
||||
bytes_per_row = (self.width + 7) // 8
|
||||
if 0 <= self._x_byte < bytes_per_row and 0 <= self._y < self.height:
|
||||
plane[self._y * bytes_per_row + self._x_byte] = byte
|
||||
if 0 <= self._x_byte < self._ram_bpr and 0 <= self._y < self._ram_rows:
|
||||
plane[self._y * self._ram_bpr + self._x_byte] = byte
|
||||
# Auto-increment per data_entry_mode (default x+, then y+ at end of row).
|
||||
x_inc = (self._entry_mode & 0x01) == 0x01 # bit0: 1 = X+
|
||||
# entry_mode bit1: Y direction; bit2: which counter advances first.
|
||||
|
|
|
|||
|
|
@ -182,7 +182,8 @@ class TestFrameLatchAndCompose:
|
|||
)
|
||||
|
||||
def test_red_plane_wins_over_black(self):
|
||||
d = SSD168xDecoder(width=8, height=2) # tiny 1-byte-wide panel
|
||||
# Tri-colour panel: 0x26 is the additive red plane (red wins on compose).
|
||||
d = SSD168xDecoder(width=8, height=2, is_bwr=True) # tiny 1-byte-wide panel
|
||||
# Black plane: row 0 all-black (0x00), row 1 all-white (0xFF)
|
||||
feed_all(d, cmd(CMD_WRITE_BLACK_VRAM), data(0x00, 0xFF))
|
||||
# Red plane: row 0 first 4 px red (0xF0), row 1 nothing (0x00)
|
||||
|
|
|
|||
Loading…
Reference in New Issue