diff --git a/backend/app/services/esp32_spi_slaves.py b/backend/app/services/esp32_spi_slaves.py index 66ea8428..ab6edf85 100644 --- a/backend/app/services/esp32_spi_slaves.py +++ b/backend/app/services/esp32_spi_slaves.py @@ -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: diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index 8b1f3459..1cae0ab1 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -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, diff --git a/frontend/src/__tests__/ssd168x-decoder.test.ts b/frontend/src/__tests__/ssd168x-decoder.test.ts index c8b9e736..c8bf2cb6 100644 --- a/frontend/src/__tests__/ssd168x-decoder.test.ts +++ b/frontend/src/__tests__/ssd168x-decoder.test.ts @@ -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( diff --git a/frontend/src/data/examples-displays-epaper.ts b/frontend/src/data/examples-displays-epaper.ts index 804bb13a..9abaa798 100644 --- a/frontend/src/data/examples-displays-epaper.ts +++ b/frontend/src/data/examples-displays-epaper.ts @@ -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' }, ], }; diff --git a/frontend/src/simulation/displays/SSD168xDecoder.ts b/frontend/src/simulation/displays/SSD168xDecoder.ts index badb60df..088fcb75 100644 --- a/frontend/src/simulation/displays/SSD168xDecoder.ts +++ b/frontend/src/simulation/displays/SSD168xDecoder.ts @@ -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; } diff --git a/frontend/src/simulation/parts/EPaperPart.ts b/frontend/src/simulation/parts/EPaperPart.ts index e543029e..b4c6ff10 100644 --- a/frontend/src/simulation/parts/EPaperPart.ts +++ b/frontend/src/simulation/parts/EPaperPart.ts @@ -242,6 +242,7 @@ const epaperSimulation = { : new SSD168xDecoder({ width: cfg.width, height: cfg.height, + palette: cfg.palette, onFlush: (frame) => { scheduleFlush(frame); pulseBusy(refreshMs); diff --git a/test/test_epaper/ssd168x_decoder.py b/test/test_epaper/ssd168x_decoder.py index 5be73bcc..9445d377 100644 --- a/test/test_epaper/ssd168x_decoder.py +++ b/test/test_epaper/ssd168x_decoder.py @@ -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. diff --git a/test/test_epaper/test_ssd168x_protocol.py b/test/test_epaper/test_ssd168x_protocol.py index 121a36ea..1aa0c6b9 100644 --- a/test/test_epaper/test_ssd168x_protocol.py +++ b/test/test_epaper/test_ssd168x_protocol.py @@ -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)