fix(epaper): wrap RAM Y counter at window end (tri-colour red plane)

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.
This commit is contained in:
David Montero Crespo 2026-06-04 22:50:21 -03:00
parent 9ba8687743
commit 3bb6f95a67
3 changed files with 41 additions and 8 deletions

View File

@ -301,18 +301,29 @@ class Ssd168xEpaperSlave:
if 0 <= self._x_byte < self._ram_bpr and 0 <= self._y < self._ram_rows: 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 plane[self._y * self._ram_bpr + self._x_byte] = byte
x_inc = (self._entry_mode & 0x01) == 0x01 x_inc = (self._entry_mode & 0x01) == 0x01
y_inc = (self._entry_mode & 0x02) == 0x02
end_of_row = False
if x_inc: if x_inc:
if self._x_byte < self._xrange[1]: if self._x_byte < self._xrange[1]:
self._x_byte += 1 self._x_byte += 1
else: else:
self._x_byte = self._xrange[0] self._x_byte = self._xrange[0]
self._y += 1 end_of_row = True
else: else:
if self._x_byte > self._xrange[0]: if self._x_byte > self._xrange[0]:
self._x_byte -= 1 self._x_byte -= 1
else: else:
self._x_byte = self._xrange[1] 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) ─────────────────── # ── UC8159c (ACeP 7-colour 5.65" GoodDisplay GDEP0565D90) ───────────────────

View File

@ -374,19 +374,32 @@ export class SSD168xDecoder {
} }
// Auto-increment per data_entry_mode (default 0x03: X+, then Y+ at end of row). // Auto-increment per data_entry_mode (default 0x03: X+, then Y+ at end of row).
const xInc = (this.entryMode & 0x01) === 0x01; const xInc = (this.entryMode & 0x01) === 0x01;
const yInc = (this.entryMode & 0x02) === 0x02;
let endOfRow = false;
if (xInc) { if (xInc) {
if (this.xByte < this.xrange[1]) { if (this.xByte < this.xrange[1]) {
this.xByte += 1; this.xByte += 1;
} else { } else {
this.xByte = this.xrange[0]; this.xByte = this.xrange[0];
this.y += 1; endOfRow = true;
} }
} else { } else {
if (this.xByte > this.xrange[0]) { if (this.xByte > this.xrange[0]) {
this.xByte -= 1; this.xByte -= 1;
} else { } else {
this.xByte = this.xrange[1]; 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;
} }
} }
} }

View File

@ -312,17 +312,26 @@ class SSD168xDecoder:
plane[self._y * self._ram_bpr + self._x_byte] = byte plane[self._y * self._ram_bpr + self._x_byte] = byte
# Auto-increment per data_entry_mode (default x+, then y+ at end of row). # Auto-increment per data_entry_mode (default x+, then y+ at end of row).
x_inc = (self._entry_mode & 0x01) == 0x01 # bit0: 1 = X+ x_inc = (self._entry_mode & 0x01) == 0x01 # bit0: 1 = X+
# entry_mode bit1: Y direction; bit2: which counter advances first. y_inc = (self._entry_mode & 0x02) == 0x02 # bit1: 1 = Y+
# For the default 0x03, X advances; once it hits xrange[1], wrap and Y++. end_of_row = False
if x_inc: if x_inc:
if self._x_byte < self._xrange[1]: if self._x_byte < self._xrange[1]:
self._x_byte += 1 self._x_byte += 1
else: else:
self._x_byte = self._xrange[0] self._x_byte = self._xrange[0]
self._y += 1 end_of_row = True
else: else:
if self._x_byte > self._xrange[0]: if self._x_byte > self._xrange[0]:
self._x_byte -= 1 self._x_byte -= 1
else: else:
self._x_byte = self._xrange[1] 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