feat(epaper): decode the UC8179/GD7965 7.5" panel + fix its BUSY polarity

The 7.5" 800x480 dashboard (GxEPD2_750_T7) rendered blank: it is a UC8179 /
GD7965 controller, but the panel config claimed controllerFamily 'ssd168x',
so the SSD168x decoder (which only reads 0x24/0x26/0x44/0x45) ignored its
0x10/0x13 DTM stream.

- Add a Uc8179 decoder (worker Uc8179EpaperSlave + browser Uc8179Decoder).
  UC8179 is the same UltraChip command family as the UC8159c (0x10/0x13 DTM,
  0x12 refresh) but mono (1 bit/px). GxEPD2 writes the visible image to 0x13
  (DTM2 "current"; 0x10 is the ignored "previous"), framed by 0x91/0x90
  (partial window, pixel coords MSB-first)/0x13 data/0x92. Data lands at
  absolute pixel coords inside the window, so compose is just the RAM. The
  Frame reuses the SSD168x palette (0=black, 1=white) so paintFrame renders it.
- EPaperPanels.ts: add the 'uc8179' family and point epaper-7in5-bw at it.
  EPaperPart.ts + esp32_worker.py dispatch 'uc8179' to the new decoder.
- Fix the BUSY polarity: UC8179 (like the UC8159c) idles BUSY HIGH, not LOW.
  The worker seeded BUSY LOW for every non-uc8159c panel, so GxEPD2_750_T7's
  _PowerOn()/_InitDisplay() busy-wait timed out (~10 s, "Busy Timeout!") on
  every refresh. Now _PowerOn returns in ~129 us.
- esp32_worker.py: the runtime sensor_attach epaper path still emitted the
  epaper_update payload nested under 'data' (the old double-wrap bug); emit
  it flat like the init path.

The 5.65" ACeP UC8159c example already rendered (it has its own decoder and
got the WS-plumbing fix); verified the 7 colour bars are correct.
This commit is contained in:
David Montero Crespo 2026-06-04 23:45:37 -03:00
parent 3bb6f95a67
commit bfe94a19f5
5 changed files with 319 additions and 30 deletions

View File

@ -448,3 +448,132 @@ class Uc8159cEpaperSlave:
if self._write_idx < total:
self.ram[self._write_idx] = byte & 0x07
self._write_idx += 1
# ── UC8179 / GD7965 (mono B/W 7.5" 800x480 Waveshare/GoodDisplay) ────────────
#
# UltraChip UC8179 — same command FAMILY as the UC8159c (0x10/0x13 DTM, 0x12
# refresh) but MONO (1 bit/px, 8 px/byte) with a partial window (0x90).
# GxEPD2_750_T7 writes the VISIBLE image to 0x13 (DTM2 "current"); 0x10 is the
# "previous" buffer (ignored). Each image write is framed 0x91 (partial in) /
# 0x90 (window x0/x1/y0/y1 in pixels, MSB-first, byte-aligned x) / 0x13 +
# row-major 1bpp data / 0x92 (partial out). Latches on 0x12. The composed Frame
# uses the SSD168x palette (0=black, 1=white) — `0xFF is white` per the driver —
# so the existing frontend paintFrame renders it. Data lands at ABSOLUTE pixel
# coords inside the window, so compose is just the RAM (no rotation/no union).
UC8179_CMD_POWER_OFF = 0x02
UC8179_CMD_POWER_ON = 0x04
UC8179_CMD_DEEP_SLEEP = 0x07
UC8179_CMD_DTM1 = 0x10 # previous/old buffer (ignored)
UC8179_CMD_DISPLAY_REFRESH = 0x12
UC8179_CMD_DTM2 = 0x13 # current/new image (the visible one)
UC8179_CMD_PARTIAL_WINDOW = 0x90
@dataclass
class Uc8179EpaperSlave:
"""Mono UC8179/GD7965 decoder (7.5" 800x480). Latches on 0x12 DRF and emits
a Frame with 1 byte/pixel (0=black, 1=white)."""
component_id: str
width: int
height: int
on_flush: Optional[Callable[[Frame], None]] = None
ram: bytearray = field(init=False)
_current_cmd: int = -1
_params: List[int] = field(default_factory=list)
_active_visible: bool = False # True while streaming 0x13 (DTM2)
_win_x0: int = 0
_win_x1: int = 0
_win_y0: int = 0
_win_y1: int = 0
_cx: int = 0
_cy: int = 0
refreshed_count: int = 0
unknown_cmds: List[int] = field(default_factory=list)
in_deep_sleep: bool = False
def __post_init__(self) -> None:
self.ram = bytearray([1] * (self.width * self.height)) # white
self._win_x1 = self.width - 1
self._win_y1 = self.height - 1
def feed(self, byte: int, dc_high: bool) -> None:
if not dc_high:
self._begin_command(byte & 0xFF)
else:
self._handle_data(byte & 0xFF)
def reset(self) -> None:
self.ram = bytearray([1] * (self.width * self.height))
self._current_cmd = -1
self._params = []
self._active_visible = False
self._win_x0 = 0
self._win_x1 = self.width - 1
self._win_y0 = 0
self._win_y1 = self.height - 1
self._cx = 0
self._cy = 0
self.in_deep_sleep = False
def compose_frame(self) -> Frame:
return Frame(self.width, self.height, bytes(self.ram))
def compose_frame_b64(self) -> str:
return base64.b64encode(bytes(self.ram)).decode("ascii")
def _begin_command(self, cmd: int) -> None:
self._current_cmd = cmd
self._params = []
if cmd == UC8179_CMD_DTM2:
self._active_visible = True
self._cx, self._cy = self._win_x0, self._win_y0
return
if cmd == UC8179_CMD_DTM1:
self._active_visible = False # old buffer — ignore its data
return
if cmd == UC8179_CMD_DISPLAY_REFRESH:
self.refreshed_count += 1
frame = self.compose_frame()
if self.on_flush:
try:
self.on_flush(frame)
except Exception:
pass
return
# 0x90 + init commands consume their data in _handle_data; others no-op.
def _handle_data(self, byte: int) -> None:
cmd = self._current_cmd
self._params.append(byte)
if cmd == UC8179_CMD_DEEP_SLEEP:
if byte == 0xA5:
self.in_deep_sleep = True
return
if cmd == UC8179_CMD_PARTIAL_WINDOW and len(self._params) == 9:
p = self._params
self._win_x0 = (p[0] << 8) | p[1]
self._win_x1 = (p[2] << 8) | p[3]
self._win_y0 = (p[4] << 8) | p[5]
self._win_y1 = (p[6] << 8) | p[7]
return
if cmd == UC8179_CMD_DTM2 and self._active_visible:
self._write_image_byte(byte)
def _write_image_byte(self, byte: int) -> None:
# 8 px, MSB = leftmost. bit=1 -> white(1), bit=0 -> black(0).
w, h = self.width, self.height
cy = self._cy
if 0 <= cy < h:
base = cy * w
for k in range(8):
x = self._cx + k
if self._win_x0 <= x <= self._win_x1 and 0 <= x < w:
self.ram[base + x] = 1 if (byte & (0x80 >> k)) else 0
self._cx += 8
if self._cx > self._win_x1:
self._cx = self._win_x0
self._cy += 1

View File

@ -76,6 +76,7 @@ try:
from app.services.esp32_spi_slaves import (
Ssd168xEpaperSlave as _Ssd168xEpaperSlave,
Uc8159cEpaperSlave as _Uc8159cEpaperSlave,
Uc8179EpaperSlave as _Uc8179EpaperSlave,
)
except ImportError:
import importlib.util, pathlib, sys as _sys
@ -87,6 +88,7 @@ except ImportError:
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
_Ssd168xEpaperSlave = _mod.Ssd168xEpaperSlave # type: ignore[assignment]
_Uc8159cEpaperSlave = _mod.Uc8159cEpaperSlave # type: ignore[assignment]
_Uc8179EpaperSlave = _mod.Uc8179EpaperSlave # type: ignore[assignment]
# ─── stdout helpers ──────────────────────────────────────────────────────────
@ -1292,12 +1294,12 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
# polarity, because the two controller families use opposite
# active levels in GxEPD2:
#
# SSD168x family (1.54 / 2.13 / 2.9 / 4.2 / 7.5"):
# SSD168x family (1.54 / 2.13 / 2.9 / 4.2"):
# `_busy_level = HIGH` → BUSY=HIGH means "busy",
# BUSY=LOW means "ready".
#
# UC8159c family (5.65" 7-colour ACeP GDEP0565D90):
# `_busy_level = LOW` → BUSY=LOW means "busy",
# UltraChip family — UC8159c (5.65" ACeP) and UC8179/GD7965
# (7.5" 800x480): `_busy_level = LOW` → BUSY=LOW means "busy",
# BUSY=HIGH means "ready".
#
# Pick the IDLE level per family and (a) seed the pin to IDLE
@ -1313,7 +1315,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
# Read controller_family early; default to ssd168x for
# back-compat with old frontends that didn't send it.
ctl_family_early = str(s.get('controller_family', 'ssd168x'))
busy_idle_level = 1 if ctl_family_early == 'uc8159c' else 0
# UltraChip controllers (uc8159c, uc8179) idle BUSY HIGH; the
# SSD168x family idles BUSY LOW.
busy_idle_level = 1 if ctl_family_early in ('uc8159c', 'uc8179') else 0
busy_busy_level = 1 - busy_idle_level
if busy_pin is not None and busy_pin >= 0:
try:
@ -1376,6 +1380,11 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory(),
)
elif ctl_family == 'uc8179':
slave = _Uc8179EpaperSlave(
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory(),
)
else:
_is_bwr = 'bwr' in str(s.get('panel_kind', '')).lower()
slave = _Ssd168xEpaperSlave(
@ -1741,15 +1750,16 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
frame_b64 = base64.b64encode(frame.pixels).decode('ascii')
except Exception:
return
# Emit FLAT (see the _init_sensors path) — the backend
# re-wraps under 'data', so a nested 'data' here would
# double-wrap and the frontend would never render.
_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:
@ -1772,6 +1782,11 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory_rt(),
)
elif ctl_family == 'uc8179':
slave = _Uc8179EpaperSlave(
component_id=comp_id, width=width, height=height,
on_flush=_flush_factory_rt(),
)
else:
_is_bwr = 'bwr' in str(cmd.get('panel_kind', '')).lower()
slave = _Ssd168xEpaperSlave(

View File

@ -7,7 +7,7 @@
* for sources.
*/
export type EPaperControllerFamily = 'ssd168x' | 'uc8159c';
export type EPaperControllerFamily = 'ssd168x' | 'uc8159c' | 'uc8179';
/**
* Visible palette for a panel.
@ -150,7 +150,7 @@ export const PANEL_CONFIGS: Record<string, EPaperPanelConfig> = {
bezelPx: 24,
fpcStripPx: 36,
refreshMs: 100,
controllerFamily: 'ssd168x',
controllerFamily: 'uc8179',
controllerIc: 'UC8179 / GD7965',
palette: 'bw',
},

View File

@ -0,0 +1,148 @@
/**
* Uc8179Decoder UltraChip UC8179 / GD7965, the MONO controller behind the
* 7.5" 800x480 Waveshare / GoodDisplay panels (GxEPD2_750_T7).
*
* Same command FAMILY as the UC8159c (0x10/0x13 DTM, 0x12 refresh) but 1 bit
* per pixel (8 px/byte) with a partial window (0x90). GxEPD2 writes the VISIBLE
* image to 0x13 (DTM2 "current"); 0x10 is the "previous" buffer (ignored).
*
* Each image write is framed:
* 0x91 partial-in, 0x90 window (x0/x1/y0/y1 px, MSB-first, byte-aligned x),
* 0x13 + row-major 1bpp data, 0x92 partial-out.
* Latches on 0x12 DISPLAY_REFRESH. `0xFF is white` per the driver, so a set bit
* is white. Data lands at ABSOLUTE pixel coords inside the window, so compose
* is just the RAM (no rotation, no window-union). The composed Frame reuses the
* SSD168x palette (0=black, 1=white) so the same paintFrame() renders it.
*/
import type { Frame } from './SSD168xDecoder';
export const UC8179_CMD_POWER_OFF = 0x02;
export const UC8179_CMD_POWER_ON = 0x04;
export const UC8179_CMD_DEEP_SLEEP = 0x07;
export const UC8179_CMD_DTM1 = 0x10; // previous/old buffer (ignored)
export const UC8179_CMD_DISPLAY_REFRESH = 0x12;
export const UC8179_CMD_DTM2 = 0x13; // current/new image (visible)
export const UC8179_CMD_PARTIAL_WINDOW = 0x90;
export interface Uc8179DecoderOptions {
width: number;
height: number;
onFlush?: (frame: Frame) => void;
}
export class Uc8179Decoder {
readonly width: number;
readonly height: number;
/** width*height palette indices (0=black, 1=white), default white. */
ram: Uint8Array;
private currentCmd = -1;
private params: number[] = [];
private activeVisible = false; // true while streaming 0x13 (DTM2)
private winX0 = 0;
private winX1 = 0;
private winY0 = 0;
private winY1 = 0;
private cx = 0;
private cy = 0;
refreshedCount = 0;
unknownCmds: number[] = [];
inDeepSleep = false;
private readonly onFlush?: (frame: Frame) => void;
constructor(opts: Uc8179DecoderOptions) {
this.width = opts.width;
this.height = opts.height;
this.ram = new Uint8Array(opts.width * opts.height).fill(1); // white
this.winX1 = opts.width - 1;
this.winY1 = opts.height - 1;
this.onFlush = opts.onFlush;
}
feed(byte: number, dcHigh: boolean): void {
if (!dcHigh) this.beginCommand(byte & 0xff);
else this.handleData(byte & 0xff);
}
reset(): void {
this.ram.fill(1);
this.currentCmd = -1;
this.params = [];
this.activeVisible = false;
this.winX0 = 0;
this.winX1 = this.width - 1;
this.winY0 = 0;
this.winY1 = this.height - 1;
this.cx = 0;
this.cy = 0;
this.inDeepSleep = false;
}
composeFrame(): Frame {
return { width: this.width, height: this.height, pixels: this.ram.slice() };
}
private beginCommand(cmd: number): void {
this.currentCmd = cmd;
this.params = [];
if (cmd === UC8179_CMD_DTM2) {
this.activeVisible = true;
this.cx = this.winX0;
this.cy = this.winY0;
return;
}
if (cmd === UC8179_CMD_DTM1) {
this.activeVisible = false; // old buffer — ignore its data
return;
}
if (cmd === UC8179_CMD_DISPLAY_REFRESH) {
this.refreshedCount += 1;
this.onFlush?.(this.composeFrame());
return;
}
// 0x90 + init commands consume data in handleData; others are no-ops.
}
private handleData(byte: number): void {
const cmd = this.currentCmd;
this.params.push(byte);
if (cmd === UC8179_CMD_DEEP_SLEEP) {
if (byte === 0xa5) this.inDeepSleep = true;
return;
}
if (cmd === UC8179_CMD_PARTIAL_WINDOW && this.params.length === 9) {
const p = this.params;
this.winX0 = (p[0] << 8) | p[1];
this.winX1 = (p[2] << 8) | p[3];
this.winY0 = (p[4] << 8) | p[5];
this.winY1 = (p[6] << 8) | p[7];
return;
}
if (cmd === UC8179_CMD_DTM2 && this.activeVisible) {
this.writeImageByte(byte);
}
}
private writeImageByte(byte: number): void {
// 8 px, MSB = leftmost. bit=1 -> white(1), bit=0 -> black(0).
const w = this.width;
const cy = this.cy;
if (cy >= 0 && cy < this.height) {
const base = cy * w;
for (let k = 0; k < 8; k++) {
const x = this.cx + k;
if (x >= this.winX0 && x <= this.winX1 && x >= 0 && x < w) {
this.ram[base + x] = byte & (0x80 >> k) ? 1 : 0;
}
}
}
this.cx += 8;
if (this.cx > this.winX1) {
this.cx = this.winX0;
this.cy += 1;
}
}
}

View File

@ -23,6 +23,7 @@ import {
type UC8159cFrame,
ACEP_PALETTE_RGB,
} from '../displays/UC8159cDecoder';
import { Uc8179Decoder } from '../displays/Uc8179Decoder';
import { PANEL_CONFIGS, getPanelConfig, PANEL_IDS } from '../displays/EPaperPanels';
import { RP2040Simulator } from '../RP2040Simulator';
import type { AVRSimulator } from '../AVRSimulator';
@ -229,25 +230,21 @@ const epaperSimulation = {
// Pick the decoder that matches the panel's controller family. Both
// expose .feed(byte, dcHigh) + .reset() so the SPI hook below stays
// family-agnostic.
const onDecoderFlush = (frame: Frame | UC8159cFrame) => {
scheduleFlush(frame);
pulseBusy(refreshMs);
};
const decoder =
cfg.controllerFamily === 'uc8159c'
? new UC8159cDecoder({
width: cfg.width,
height: cfg.height,
onFlush: (frame) => {
scheduleFlush(frame);
pulseBusy(refreshMs);
},
})
: new SSD168xDecoder({
width: cfg.width,
height: cfg.height,
palette: cfg.palette,
onFlush: (frame) => {
scheduleFlush(frame);
pulseBusy(refreshMs);
},
});
? new UC8159cDecoder({ width: cfg.width, height: cfg.height, onFlush: onDecoderFlush })
: cfg.controllerFamily === 'uc8179'
? new Uc8179Decoder({ width: cfg.width, height: cfg.height, onFlush: onDecoderFlush })
: new SSD168xDecoder({
width: cfg.width,
height: cfg.height,
palette: cfg.palette,
onFlush: onDecoderFlush,
});
// CS / DC / RST pin tracking.
let csLow = false; // start with CS de-asserted (idle)