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:
parent
3bb6f95a67
commit
bfe94a19f5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue