From 2b528bfefce4d737caca3a6902c35187015b5574 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Thu, 4 Jun 2026 02:47:17 -0300 Subject: [PATCH] fix(esp32): render GxEPD2 ePaper panels (WS plumbing + native rotation) ESP32 ePaper examples (e.g. epaper-2in9-esp32-weather) rendered as a blank white panel. Two bugs, both above the SPI layer: 1. The worker's epaper_update event nested its payload under 'data', unlike every other (flat) worker event. The backend qemu_callback re-wraps the post-'type' payload under 'data', so the frontend received msg.data.data.component_id (undefined) and EPaperPart bailed on id !== componentId, so paintFrame/putImageData never ran. Emit it flat. 2. Ssd168xEpaperSlave was sized to the display dims (296x128), but GxEPD2 with setRotation(1) writes the controller's NATIVE RAM (128x296). The _y < height bound dropped rows 128-295 (half the image) and compose never rotated. Size RAM to the longer side, compose in the native active-window geometry (0x44/0x45), then rotate to the display orientation (inverse of Adafruit_GFX rotation 1). Add is_bwr (from panel_kind): B/W panels init the 0x26 plane white and compose white-only-if-both (GDEY029T94 mirrors the image into 0x26); tri-colour panels keep red init 0x00 and red-wins. --- backend/app/services/esp32_spi_slaves.py | 105 ++++++++++++++++++----- backend/app/services/esp32_worker.py | 22 +++-- 2 files changed, 96 insertions(+), 31 deletions(-) diff --git a/backend/app/services/esp32_spi_slaves.py b/backend/app/services/esp32_spi_slaves.py index 23a88dab..66ea8428 100644 --- a/backend/app/services/esp32_spi_slaves.py +++ b/backend/app/services/esp32_spi_slaves.py @@ -62,9 +62,19 @@ class Ssd168xEpaperSlave: 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 bw_ram: bytearray = field(init=False) red_ram: bytearray = field(init=False) + # 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. 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" @@ -78,11 +88,16 @@ class Ssd168xEpaperSlave: 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)) - self.red_ram = bytearray([0x00] * (bytes_per_row * self.height)) - self._xrange = (0, bytes_per_row - 1) - self._yrange = (0, self.height - 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) + self._xrange = (0, self._ram_bpr - 1) + self._yrange = (0, self._ram_rows - 1) # ── Public API ───────────────────────────────────────────────────── @@ -94,9 +109,9 @@ class Ssd168xEpaperSlave: self._handle_data(byte & 0xFF) def reset(self) -> None: - 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" @@ -105,21 +120,66 @@ class Ssd168xEpaperSlave: self.in_deep_sleep = False def compose_frame(self) -> Frame: - bytes_per_row = (self.width + 7) // 8 - pixels = bytearray(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] + # Compose in the controller's NATIVE geometry — the active RAM window + # the firmware actually wrote (0x44/0x45) — then rotate to the display + # 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 + nw_bytes = max(0, x1 - x0 + 1) + nw = nw_bytes * 8 # native width (px) + nh = max(0, y1 - y0 + 1) # native height (rows) + native = bytearray(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, bytes(pixels)) + bw_white = bool(b_byte & mask) + if self.is_bwr: + # Tri-colour: red wins, else the B/W plane decides. + native[out_row + x] = 2 if (r_byte & mask) else (1 if bw_white else 0) + else: + # B/W: the image may live in either plane (0x24 or 0x26), + # 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 + + 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). + pixels = bytearray([1]) * (W * H) + for ny in range(nh): # ny = y_raw (0..nh-1) + 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] + 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 + for x in range(min(nw, W)): + pixels[dst + x] = native[src + x] + return Frame(W, H, bytes(pixels)) def compose_frame_b64(self) -> str: """Convenience for the worker — same as compose_frame() but base64-encoded.""" @@ -187,9 +247,8 @@ class Ssd168xEpaperSlave: self._write_ram_byte(self.red_ram, byte) 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 x_inc = (self._entry_mode & 0x01) == 0x01 if x_inc: if self._x_byte < self._xrange[1]: diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index b6f4e67f..8b1f3459 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -1337,15 +1337,20 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) frame_b64 = base64.b64encode(frame.pixels).decode('ascii') except Exception: return + # NOTE: emit FLAT (fields at top level), like every other + # worker event. The backend's qemu_callback re-wraps the + # post-'type' payload under 'data' (simulation.py), so a + # nested 'data' here would double-wrap and the frontend's + # msg.data.component_id would be undefined (panel never + # renders). This was the long-standing "ESP32 ePaper is + # blank" bug. _emit({ 'type': 'epaper_update', - 'data': { - 'component_id': _comp_id, - 'width': _w, - 'height': _h, - 'frame_b64': frame_b64, - 'refresh_ms': _refresh, - }, + 'component_id': _comp_id, + 'width': _w, + 'height': _h, + 'frame_b64': frame_b64, + 'refresh_ms': _refresh, }) if _busy is not None and _busy >= 0: try: @@ -1372,9 +1377,10 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker) on_flush=_flush_factory(), ) else: + _is_bwr = 'bwr' in str(s.get('panel_kind', '')).lower() slave = _Ssd168xEpaperSlave( component_id=comp_id, width=width, height=height, - on_flush=_flush_factory(), + on_flush=_flush_factory(), is_bwr=_is_bwr, ) state = { 'slave': slave,