# 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 (`probe_plane_invert.h`, also used inside the emitter) drives white/black/red `writeScreenBuffer` fills and reconciles each plane's wire byte against the engine's fixed buffer convention (plane 0 bit = white, plane 1 bit = red). - **`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. ### 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. - **True dual-region command structure.** Dual-chip panels (master/slave) carry `0xA2`-style region selects in init and refresh that the emitter's single/shared-region segmentation doesn't yet model; those descriptors still take manual assembly for the region layer. ## 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. ## 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). ## 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 | `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 | 583_GDEQ0583T31 | `0x90=[x,x+w-1,y,y+h-1]` raw-pixel 16-bit **big-endian** + flag, bracketed by `0x91`/`0x92` | | 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) | ## 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_Z98C` | GDEY0213Z98 | SSD1680 | 3-color | ✅ verified | | `GxEPD2_213c_GDEY0213F51` | GDEY0213F51 | JD79661 | 4-color | ⚪ structural | | `GxEPD2_213c_GDEY0213F52` | GDEY0213F52 | JD79676 | 4-color | ✅ verified | | `GxEPD2_426_GDEQ0426T82` | GDEQ0426T82 | SSD1677 | mono | ✅ verified | | `GxEPD2_583_GDEQ0583T31` | GDEQ0583T31 | UC8179 | mono | ✅ verified | | `GxEPD2_750_GDEY075T7` | GDEY075T7 | UC8179 | mono | ✅ verified | | `GxEPD2_290c_GDEY029F51` | GDEY029F51 | JD79667 | 4-color | ⚪ structural | | `GxEPD2_579c_GDEY0579F51` | GDEY0579F51 | HX8717 | 4-color | ✅ verified | | `GxEPD2_579c_GDEY0579F51_MASTER_ONLY` | GDEY0579F51 | HX8717 | 4-color | 🔧 diagnostic | ✅ 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.