diff --git a/backend/app/services/esp32_spi_slaves.py b/backend/app/services/esp32_spi_slaves.py index 3188697a..afb98cbd 100644 --- a/backend/app/services/esp32_spi_slaves.py +++ b/backend/app/services/esp32_spi_slaves.py @@ -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 diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index 1cae0ab1..2a5c95f6 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -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( diff --git a/frontend/src/simulation/displays/EPaperPanels.ts b/frontend/src/simulation/displays/EPaperPanels.ts index 43064f91..4de719b4 100644 --- a/frontend/src/simulation/displays/EPaperPanels.ts +++ b/frontend/src/simulation/displays/EPaperPanels.ts @@ -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 = { bezelPx: 24, fpcStripPx: 36, refreshMs: 100, - controllerFamily: 'ssd168x', + controllerFamily: 'uc8179', controllerIc: 'UC8179 / GD7965', palette: 'bw', }, diff --git a/frontend/src/simulation/displays/Uc8179Decoder.ts b/frontend/src/simulation/displays/Uc8179Decoder.ts new file mode 100644 index 00000000..da2fe86b --- /dev/null +++ b/frontend/src/simulation/displays/Uc8179Decoder.ts @@ -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; + } + } +} diff --git a/frontend/src/simulation/parts/EPaperPart.ts b/frontend/src/simulation/parts/EPaperPart.ts index b4c6ff10..19c77dda 100644 --- a/frontend/src/simulation/parts/EPaperPart.ts +++ b/frontend/src/simulation/parts/EPaperPart.ts @@ -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)