From 3bb6f95a671522539396fc09d63d0a42dcd7e732 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Thu, 4 Jun 2026 22:50:21 -0300 Subject: [PATCH] fix(epaper): wrap RAM Y counter at window end (tri-colour red plane) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2.9" tri-colour ESP32 alert badge rendered the red ALERT pill as white: the red plane (0x26) was received but landed out of bounds and was dropped. GxEPD2_3C writes the 0x24 (black) plane then the 0x26 (red) plane WITHOUT re-seeking the RAM address counter between them — it relies on the SSD168x counter wrapping back to the window start after the last byte of the window. Our decoder advanced Y past the window end instead of wrapping, so every 0x26 byte hit y >= rows and was discarded (red_ram stayed all-init). Mirror the hardware: when the X cursor wraps at the end of a row, advance Y with a wrap at the active window boundary (yrange), honouring the data-entry Y direction. Applied identically to the worker slave, the browser decoder, and the Python golden reference so the three stay in lockstep. No regression on the mono panels (their counter is re-seeked per plane, so the wrap is a no-op for them); verified the tri-colour pill now renders red and the 2.9" weather / 2.13" clock / 1.54" hello panels are unchanged. --- backend/app/services/esp32_spi_slaves.py | 15 +++++++++++++-- .../src/simulation/displays/SSD168xDecoder.ts | 17 +++++++++++++++-- test/test_epaper/ssd168x_decoder.py | 17 +++++++++++++---- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/backend/app/services/esp32_spi_slaves.py b/backend/app/services/esp32_spi_slaves.py index ab6edf85..3188697a 100644 --- a/backend/app/services/esp32_spi_slaves.py +++ b/backend/app/services/esp32_spi_slaves.py @@ -301,18 +301,29 @@ class Ssd168xEpaperSlave: 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 + y_inc = (self._entry_mode & 0x02) == 0x02 + end_of_row = False if x_inc: if self._x_byte < self._xrange[1]: self._x_byte += 1 else: self._x_byte = self._xrange[0] - self._y += 1 + end_of_row = True else: if self._x_byte > self._xrange[0]: self._x_byte -= 1 else: self._x_byte = self._xrange[1] - self._y += 1 + end_of_row = True + if end_of_row: + # Advance Y, WRAPPING at the window boundary like the SSD168x RAM + # address counter. Some drivers (e.g. GxEPD2_3C) write the 0x24 then + # the 0x26 plane without re-seeking the counter, relying on this + # wrap so the second plane lands in the window. + if y_inc: + self._y = self._yrange[0] if self._y >= self._yrange[1] else self._y + 1 + else: + self._y = self._yrange[1] if self._y <= self._yrange[0] else self._y - 1 # ── UC8159c (ACeP 7-colour 5.65" GoodDisplay GDEP0565D90) ─────────────────── diff --git a/frontend/src/simulation/displays/SSD168xDecoder.ts b/frontend/src/simulation/displays/SSD168xDecoder.ts index 088fcb75..e66178d4 100644 --- a/frontend/src/simulation/displays/SSD168xDecoder.ts +++ b/frontend/src/simulation/displays/SSD168xDecoder.ts @@ -374,19 +374,32 @@ export class SSD168xDecoder { } // Auto-increment per data_entry_mode (default 0x03: X+, then Y+ at end of row). const xInc = (this.entryMode & 0x01) === 0x01; + const yInc = (this.entryMode & 0x02) === 0x02; + let endOfRow = false; if (xInc) { if (this.xByte < this.xrange[1]) { this.xByte += 1; } else { this.xByte = this.xrange[0]; - this.y += 1; + endOfRow = true; } } else { if (this.xByte > this.xrange[0]) { this.xByte -= 1; } else { this.xByte = this.xrange[1]; - this.y += 1; + endOfRow = true; + } + } + if (endOfRow) { + // Advance Y, WRAPPING at the window boundary like the SSD168x RAM address + // counter. Some drivers (e.g. GxEPD2_3C) write the 0x24 then the 0x26 + // plane without re-seeking the counter, relying on this wrap so the + // second plane lands in the window. + if (yInc) { + this.y = this.y >= this.yrange[1] ? this.yrange[0] : this.y + 1; + } else { + this.y = this.y <= this.yrange[0] ? this.yrange[1] : this.y - 1; } } } diff --git a/test/test_epaper/ssd168x_decoder.py b/test/test_epaper/ssd168x_decoder.py index 9445d377..1f0baf90 100644 --- a/test/test_epaper/ssd168x_decoder.py +++ b/test/test_epaper/ssd168x_decoder.py @@ -312,17 +312,26 @@ class SSD168xDecoder: 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. - # For the default 0x03, X advances; once it hits xrange[1], wrap and Y++. + y_inc = (self._entry_mode & 0x02) == 0x02 # bit1: 1 = Y+ + end_of_row = False if x_inc: if self._x_byte < self._xrange[1]: self._x_byte += 1 else: self._x_byte = self._xrange[0] - self._y += 1 + end_of_row = True else: if self._x_byte > self._xrange[0]: self._x_byte -= 1 else: self._x_byte = self._xrange[1] - self._y += 1 + end_of_row = True + if end_of_row: + # Advance Y, WRAPPING at the window boundary like the SSD168x RAM + # address counter. Some drivers (e.g. GxEPD2_3C) write the 0x24 then + # the 0x26 plane without re-seeking the counter, relying on this wrap + # so the second plane lands in the window. + if y_inc: + self._y = self._yrange[0] if self._y >= self._yrange[1] else self._y + 1 + else: + self._y = self._yrange[1] if self._y <= self._yrange[0] else self._y - 1