# Contributing to gxepd2 A native ESPHome `display` platform for Good Display / Waveshare SPI e-paper panels. Instead of running GxEPD2's own C++ (or an Arduino-API shim) on the device, it **replays a captured GxEPD2 command sequence** as a non-blocking, `loop()`-stepped state machine. One GxEPD2 driver's exact on-wire behaviour is reproduced byte-for-byte, without its blocking single-call-stack control flow and without any vendored driver code at runtime. This document is the model to work against when adding a panel or changing the engine. If something here no longer matches the code, the code is the source of truth — please fix the doc in the same change. ## Architecture Three parts: - **`EpdPanelDescriptor`** (`components/gxepd2/panel_*.h`) — plain data for one panel: its reset/init/window/refresh command sequences, plane layout, bit depth, palette, and timing. No logic. - **`CycleEngine`** (`cycle_engine.h`) — a `loop()`-stepped state machine that walks a descriptor to drive one full-frame update. - **`display.py`** — ESPHome codegen. `model:` selects the descriptor; the descriptor headers are all `#include`d unconditionally by `gxepd2_display.h`, so any model is selectable with no extra wiring. No GxEPD2 code, vendored or otherwise, runs on the device. ## Bringing up a panel: capture, not transcription A descriptor is **generated by observing a real GxEPD2 driver run on a host**, not written by hand. The tooling lives in `tools/gxepd2_harness/`. ### 1. Rigging the capture `spy/Arduino.h`, `spy/SPI.h` (+ `Adafruit_GFX.h`, `pgmspace.h`) are minimal host stand-ins for the Arduino APIs a GxEPD2 driver expects — just enough to compile and run one driver class (from the vendored source in `third_party/GxEPD2/`) natively. Every `digitalWrite`, SPI byte (command vs data, by the DC line), `delay`, and busy-pin read is recorded instead of touching hardware (`trace.h`). A `run__emit.cpp` is a 4-line `main` that instantiates the vendor driver and hands it to the emitter. ### 2. First pass — the descriptor emitter (`emit_descriptor.h`) The emitter drives a fixed lifecycle (`writeScreenBuffer()` → `writeImage()` → `refresh()`) and turns the recorded event stream into a candidate `panel_*.h`. It phase-segments at the public-API boundary — **the burst of pixel data is the plane write** — and from that recovers: - the **init / window / refresh** command sequences and the **plane-write command(s)**; - **resolution, busy polarity, refresh timeout** (read directly from the driver's `static const` members, not the trace); - **`plane_count`** and, for multi-plane panels, whether the per-plane window sets are identical (shared region → `window_per_plane = false`) or differ (separate physical regions → `window_per_plane = true`). A `delay()` immediately before a busy-wait is the vendor poll loop's, not a command delay, so it folds into `wait_busy_after`. The emitter reproduces every shipped single-plane and shared-region (3-colour) descriptor byte-for-byte. ### 3. What must be checked dynamically Some descriptor facts aren't visible in one straight capture; they're derived by *probing with distinctive inputs and reading the transform back out*: - **Window encoder** (screen coords → window-register bytes). `window_sweep.h` calls `refresh(x,y,w,h)` at several in-bounds rects and fits the per- controller mapping by observation, cross-checked against the full-frame window — catching byte-math errors a command-skeleton trace can't (right command, right byte *count*, one wrong byte *value*). See the encoder table below. - **Pixel packing, palette codes, row order.** Diag harnesses (`run_diag*_harness.h`) replace the uniform fill with positional/value tags and read the exact wire bytes: Pattern A recovers row ordering (`line_order`), Pattern B recovers packing + palette codes (`bits_per_pixel` + `palette`), and a native-path variant handles packed panels whose re-pack doesn't preserve byte identity. - **`plane_invert`** (whether each plane's wire byte is the bit-complement of the engine's buffer byte). The fill-capture probe (in `emit_descriptor.h`, the same shape as the standalone `probe_plane_invert.h`) drives a white/black/red fill through the plane-write path - `writeImage`, not `writeScreenBuffer` - and reconciles each plane's wire byte against the engine's fixed buffer convention (plane 0 bit = white, plane 1 bit = red). It used to drive the fill through `writeScreenBuffer` instead, on the assumption that a driver's screen-buffer clear and its image-plane write share one command; GxEPD2_270 doesn't (clear = `0x13`, image-plane = `0x15`), so the probe searched the clear's trace for a command it never emits, got "not found," and silently misreported a genuinely 1bpp panel as packed. Fixed by probing through the same call that produced the plane command in the first place. - **`bits_per_pixel` (1bpp vs packed).** The same probe: a white fill whose wire byte is all-bits (`0x00`/`0xFF`) is a 1bpp plane model; anything else (e.g. `0x55`) is packed, and depth/palette come from Pattern B *if* the driver exposes a `GxEPD2_4C`/`GxEPD2_7C` colour wrapper to drive. A driver with no colour wrapper at all (still `GxEPD2_BW`) that nonetheless packs internally - GxEPD2_583/UC8159c's `_send8pixel`, a 1bpp buffer expanded to a 2-code wire format - has no named colours for Pattern B to read, so `emit_descriptor.h` falls back to `make_resolver_bw_packed`: drive the plain driver's `writeImage` directly with a uniform all-white/all-black 1bpp buffer, and if both wire bursts are internally uniform, the byte-count ratio against the pixel count gives `bits_per_pixel` and each fill's byte gives that colour's code. Folded into the one-command `capture.py` path, same as 4-colour/7-colour - no source reading needed for this shape anymore. ### 4. Capturing *nothing* is sometimes correct A minimal driver is a valid descriptor, not a failed capture. Controllers that load their configuration from on-chip **OTP** need almost no register setup. `GDEY0213F52` (JD79676) is the example: its whole init is `0xE9` + power-on, it issues **no window command** (OTP defaults to the panel's native frame), and refresh is a bare trigger. The emitter captures exactly that; an empty window is emitted as `window_sequence = {nullptr, nullptr}`, which the engine skips. ### 5. Limits — what capture can *not* derive These are hardware facts no command trace contains: - **Whether the chosen vendor driver is correct for the physical panel.** The harness faithfully reproduces whatever driver it's given; a driver that's wrong for the panel propagates the error. The mitigation is a capture-*input* decision — pick the most authoritative source driver (e.g. a vendor's own reference over a community driver) — plus an on-hardware visual check. No amount of probing substitutes for it. Equally, a bench symptom is not proof the driver is wrong: rule out the physical layer first (supply current, wiring, a marginal unit), since an under-powered panel can mimic a command-sequence bug. - **Busy polarity and post-reset timing.** Busy polarity comes from the driver's `busy_level`; it, and OTP/post-reset busy behaviour, are only confirmable on hardware. The engine always does a busy wait after the reset pulse, because controllers that load from OTP hold BUSY during that load and silently drop any command sent before it clears. - **Paired-edges row order.** The emitter now segments dual-chip (master/slave) panels end to end — the `0xA2`-style region selects in init / window / refresh, per-region `plane_width`, and a candidate `line_order` (it flags `PAIRED_EDGES` when a region's burst is twice its pixel bytes). What a plain capture still can't see is *which* buffer row each wire line carries, so for a `PAIRED_EDGES` panel the exact edges-to-middle order is confirmed from the diag native-path pass or the driver source, not the capture alone. A panel with more than two regions, or asymmetric regions, would still want manual review. ## Execution engine (`cycle_engine.h`) A `loop()`-stepped state machine, not a port of GxEPD2's synchronous `update()`. States: `RESET_LOW` → `RESET_LOW_WAIT` → `RESET_HIGH_WAIT` → `RESET_SETTLE` → `WAIT_BUSY` (post-reset) → `INIT_CMD` → `SET_WINDOW` → `WRITE_PLANE` → `REFRESH_CMD` → `DONE`, with `POST_DELAY`/`WAIT_BUSY` as reusable sub-states and `ABORTED` as a hard bailout on a busy timeout (pressing on past a timed-out wait only obscures which step failed). `step()` runs once per `loop()` tick and always does one small bounded unit of work: one `EpdCommand` per command-table step, at most `BYTE_BUDGET` bytes of a plane burst, and deadline (`millis()`-based) delays and busy-waits — never a blocking call. Nothing here can starve another task or need a watchdog subscription. Multi-plane and multi-region are one mechanism: after writing plane *k*, if more planes remain the engine writes the next (shared window) or re-enters `SET_WINDOW` for `window_sequence[k]` (`window_per_plane`, separate regions, each sized by `plane_width`). `line_order` controls how a plane's wire lines map to buffer rows: `SEQUENTIAL` (line i = row i) or `PAIRED_EDGES`, which emits `2*height` lines (line 2i = row `H-1-i`, line 2i+1 = row i) to fill the 2x-height RAM of a controller that scans from both edges toward the middle - reproducing GxEPD2's own doubled line emission, orientation included. Every command and data byte is its own SPI transaction with CS toggled around it — wasteful versus a burst transfer, but deliberate: matching the vendor `_writeCommand()`/`_writeData()` cycle byte-for-byte is the point. A vendor driver often gates its own reset pulse on `_hibernating || _initial_write` (real RST only on the first cycle or after a hibernate, re-issuing the command sequence with no reset otherwise) - this engine's `RESET_LOW` is unconditional every single cycle instead, a superset of that first-cycle behavior, so a panel descriptor never needs to special-case it. Don't re-derive or re-document this per panel_*.h; it's a property of `request_full_update()`, true for every panel. `power_off_sequence`/`hibernate_sequence` are a separate, standalone entry point (`CycleEngine::request_power_off()`/`request_hibernate()`, exposed as `GxEPD2Display::power_off()`/`hibernate()` for a user's own lambda) - no reset pulse, no init/window/plane-write, just that one command table then `DONE`. Not run automatically by `request_full_update()`: this engine does a full hardware reset every cycle regardless of prior power state, so correctness never depends on calling either - they exist purely for panel longevity (reduced fading between infrequent updates) and, eventually, MCU-side deep-sleep pairing. `request_hibernate()` runs `power_off_sequence` then `hibernate_sequence` (hibernate()'s *tail* only, past whatever it shares with powerOff() - see below); no paired "wake" call exists or is needed, since `request_full_update()`'s `RESET_LOW` is an unconditional hardware reset pulse every cycle, which is exactly how GxEPD2 wakes a hibernating (RST-wakeable) panel. Verifying `power_off()`/`hibernate()` actually took effect needs the panel on a bench, since the real effect is electrical (driving voltage off, or deep sleep) - nothing the MCU can see directly. `hibernate()`'s documented contract (non-responsive until the next RST pulse) is still bench-checkable with no probes, using the screen as the oracle: call `hibernate()`, then `GxEPD2Display::diag_update_no_reset()` (dev builds only - see below) with a pattern distinct from what's on screen; if hibernate took, that pattern shouldn't render (and BUSY may behave oddly). A normal `update()` right after should then wake the panel and render correctly, confirming both the "off" and the "wakes via reset" halves and leaving the panel in a known state. **Confirmed on hardware, all 7 panels currently marked `verified`** (GDEQ0426T82/SSD1677, GDEY075T7/UC8179, GDEY0213F52/JD79676, GDEH0154Z90/SSD1681, GDEQ0583T31/UC8179, GDEY0213Z98/SSD1680, GDEY0579F51/HX8717 - 2026-10-19): `hibernate()` then `diag_update_no_reset()` doesn't just fail to render - it hits a genuine `WAIT_BUSY` timeout (`ABORTED`), i.e. the panel stops acknowledging BUSY at all while hibernating, not just ignoring the image. A subsequent normal `update()` (reset pulse included) recovered and rendered correctly, on every one of the 7. Separately, `power_off()` then `diag_update_no_reset()` rendered fine with no timeout and no reset needed, confirming `power_off()` really doesn't disable the panel the way `hibernate()` does. Same two-halves result across every controller family on hand (SSD16xx, UC8179, JD79676, HX8717) - this looks like GxEPD2's general `hibernate()` contract, not something specific to one chip. `power_off()` alone doesn't make the panel unresponsive (the next real refresh auto-powers back on), so this trick doesn't apply to it - its effect is only visible as reduced fading over long idle periods, or lower current draw if your bench supply shows it live. ### Dev-time diagnostics (`diagnostics: true`) A stash for throwaway bench tests that would otherwise cost every production build flash space for no benefit. Set `diagnostics: true` on the `gxepd2` display in YAML (default `false`) to add `-DUSE_GXEPD2_DIAGNOSTICS`, which compiles in `GxEPD2Display`'s `#ifdef`-gated diagnostic methods - currently just `diag_update_no_reset()` (drives a full update, skipping the reset pulse - see above). Add more here as bench work needs them; this isn't a stable API and nothing here needs to stay once a test has done its job. ## Descriptor format (`epd_command.h`) `EpdPanelDescriptor` fields: `width`/`height`; `busy_active_level` + `busy_timeout_ms`; `init_sequence` / `window_sequence[2]` / `refresh_sequence` (arrays of `EpdCommand{cmd, data, len, delay_before, delay_after, wait_busy}`); `window_per_plane`; `plane_count`, `plane_write_cmd[2]`, `plane_invert[2]`, `plane_width`; `line_order`; and the pixel format — `bits_per_pixel` (1 = 1bpp plane model; 2/4 = packed code-per-pixel) with `palette` + `blank_code` for packed panels. "Is it packed" is `bits_per_pixel > 1` and "which codes" is data, so a 4bpp 7-colour (ACeP) panel is just a palette plus `bits_per_pixel: 4` with no new engine flag (`GXEPD2_PALETTE_7C` is written, awaiting a captured descriptor). `power_off_sequence`/`hibernate_sequence` (both optional, default nullptr/0 - a safe no-op) are captured the same way as the other command tables - see `capture_power_sequence`/`capture_power_off_and_hibernate` in `emit_descriptor.h`. Every `GxEPD2_EPD` subclass implements `powerOff()`/ `hibernate()` (both pure virtual in the base class), so this isn't optional per panel the way a colour family is; what *does* vary is whether `powerOff()` has anything left to do - several SSD16xx-family drivers (154_Z90c, 213_Z98C, 426_GDEQ0426T82) fold power-down into their own full-refresh waveform (`_Update_Full()` sets `_power_is_on = false` itself), so `powerOff()`'s own guard finds nothing to send and `power_off_sequence` captures empty - correct, not a capture failure. `hibernate()` on every driver observed so far is `powerOff()` + exactly one more command (the deep-sleep trigger, gated on `rst >= 0`); `hibernate_sequence` stores only that tail, not a duplicate of `power_off_sequence`'s commands, which is why each needs its own **fresh** driver instance replayed through the normal init/clear/write/refresh setup before the call being captured - capturing both from one live session would have the second call's internal `powerOff()` silently emit nothing (already off from the first). ## Color and bit-depth model Buffers map color literally. For **1bpp** panels `draw_absolute_pixel_internal` sets plane 0 for a bright color (white/blank) and routes a red/accent pixel to plane 1 when the panel has one. For **packed** panels each pixel's wire code is the nearest palette entry to the drawn RGB (exact matches win, so named colors are deterministic). Buffer size is `width*height*bits_per_pixel/8`, heap- allocated via ESPHome's `RAMAllocator` — so anything above 1bpp effectively wants PSRAM. **`invert_mono: false|true`** (1bpp only) handles ESPHome's `COLOR_ON = white` default. With the literal mapping (`false`, native) a default-color `it.print()` is white-on-white and draws nothing; `true` flips the mono sense so bright = ink (the `waveshare_epaper`-compatible convention), easing migration from that component. The red/accent plane is unaffected either way. ## Window encoders by controller Derived by the sweep (screen coords `x,y,w,h`): | Controller | Panels | Window encoding | |---|---|---| | SSD1680 | 213_Z98c, 154_Z90c, 213_B72 (SSD1675A) | `0x11=0x03`; `0x44=[x/8,(x+w-1)/8]` byte-X; `0x45=[y,y+h-1]` 16-bit LE; `0x4E/0x4F` cursors | | SSD1677 | 426_GDEQ0426T82 | `0x11=0x01`; `0x44=[x,x+w-1]` raw-pixel 16-bit LE; `0x45=[H-1-y, H-1-(y+h-1)]` **reversed-Y** 16-bit LE | | UC8179 / UC8176 / UC8151D / UC8159c | 583_GDEQ0583T31; 420 (UC8176); 290_T5D (UC8151D); 583 (UC8159c) | `0x90=[x,x+w-1,y,y+h-1]` raw-pixel 16-bit **big-endian** + flag, bracketed by `0x91`/`0x92` — a reference-driver convention shared across four otherwise-unrelated Good Display controllers. 290_T5D's X fields are single bytes (panel width <256 fits one byte; Y is still 16-bit) where the wider panels use 16-bit X too. 420 omits the `0x91`/`0x92` bracket that 290_T5D/583/the UC8179 panel all use. | | JD79667 | 290c_GDEY029F51 | `0x83=[x,x+w-1,y,y+h-1]` raw-pixel 16-bit BE + mode byte | | JD79676 | 213c_GDEY0213F52 | none — OTP-default frame | | HX8717 dual | 579c_GDEY0579F51 | per-region fixed full-frame `0x83` (sweep shows the rect is ignored) | | unknown (GDEP073E01) | 730c_GDEP073E01 | `0x83=[x,x+w-1,y,y+h]` raw-pixel 16-bit BE + flag — **asymmetric end**: X is inclusive (`x+w-1`) but Y is exclusive (`y+h`), one more than JD79667's otherwise-identical `0x83` layout | | EK79652 (IL91874) | 270 | `0x16=[x,y,w,h]` four raw-pixel 16-bit BE fields, **no window math at all** (literal x,y,w,h, no inclusive-end subtraction, no byte-division) - simplest encoder seen so far. The `(0,0,W,H)` sweep point reads a different, non-literal value because `refresh(0,0,W,H)` takes the driver's full-update path (`_Update_Full`), not the coordinate-driven partial path - expected per window_sweep.h's own cross-check framing, not a math bug. | ## Supported panels The registry is `display.py`'s `PANELS` dict (the single source of truth for which panels ship and their status); `tools/panel_table.py` renders this table from it, and a non-verified panel logs a warning in its boot `dump_config`. | `model:` | Panel | Controller | Format | Status | |---|---|---|---|---| | `GxEPD2_154_Z90c` | GDEH0154Z90 | SSD1681 | 3-color | ✅ verified | | `GxEPD2_213_B72` | GDEH0213B72 | SSD1675A | mono | ⚪ structural | | `GxEPD2_213_Z98C` | GDEY0213Z98 | SSD1680 | 3-color | ✅ verified | | `GxEPD2_213c_GDEY0213F51` | GDEY0213F51 | JD79661 | 4-color | ⚪ structural | | `GxEPD2_213c_GDEY0213F52` | GDEY0213F52 | JD79676 | 4-color | ✅ verified | | `GxEPD2_270` | GDEW027W3 | EK79652 | mono | ⚪ structural | | `GxEPD2_290_T5D` | GDEW029T5D | UC8151D | mono | ⚪ structural | | `GxEPD2_420` | GDEW042T2 | UC8176 | mono | ⚪ structural | | `GxEPD2_426_GDEQ0426T82` | GDEQ0426T82 | SSD1677 | mono | ✅ verified | | `GxEPD2_583` | GDEW0583T7 | UC8159c | mono | ⚪ structural | | `GxEPD2_583_GDEQ0583T31` | GDEQ0583T31 | UC8179 | mono | ✅ verified | | `GxEPD2_730c_GDEP073E01` | GDEP073E01 | unknown | 7-color | ⚪ structural | | `GxEPD2_750_GDEY075T7` | GDEY075T7 | UC8179 | mono | ✅ verified | | `GxEPD2_290c_GDEY029F51` | GDEY029F51 | JD79667 | 4-color | ⚪ structural | | `GxEPD2_579c_GDEY0579F51` | GDEY0579F51 | HX8717 | 4-color | ✅ verified | ✅ rendered correctly on real hardware · ⚪ captured, not yet bench-confirmed · 🔧 diagnostic variant. ## Bench test pattern `GxEPD2Display::test_pattern(font?)` is the canonical visual check; prefer it over a plain fill/text for bring-up. It reads the descriptor, so it is **bit-depth aware**: the color-bar count, ladder cycle, and corner markers scale to the colors the panel can show (2 mono, 3 BW+accent, 4/7 packed). A **dithered grey ramp** gives 1bpp panels a graded sweep rather than two flat blocks, a **corner-to-corner diagonal** catches coordinate-transform bugs (a dashed diagonal is the row-reorder signature that pins down a panel's `line_order`), and the border + corner markers catch clipped/offset origins and rotation/mirror errors. Pass a font to label the panel with its model (the Good Display part number minus its series prefix, e.g. `0583T31`); call `test_pattern()` with no argument for an unlabelled pattern that needs no font in the config. `tests/test_pattern_bar_ladder_diagonal.esp32-idf.yaml` is the reference fixture. ## Footprint Same strategy as ESPHome's own `epaper_spi`: a heap buffer sized by resolution × depth, minimal static footprint. For the same resolution the buffer byte-count is identical, and a full build lands within noise of an `epaper_spi` build in flash and static DRAM (both dominated by the common ESP-IDF/framework floor). The only extra cost is a few hundred `const` bytes of command table per captured panel. ## Target frameworks IDF-first; ESP32-Arduino is supported. ESP8266-Arduino compiles but is resolution-limited at 1bpp and lacks the PSRAM that >1bpp panels need.