# 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 1bpp row ordering (`row_interleave`), 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`). `row_interleave` maps each wire byte through an interleaved row order for panels that scan from both edges toward the middle. 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`; `row_interleave`; 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_290c_GDEY029F51` | GDEY029F51 | JD79667 | 4-color | ⚪ structural | | `GxEPD2_579c_GDEY0579F51` | GDEY0579F51 | HX8717 | 4-color | ⚪ structural | | `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 confirmed `row_interleave`), 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.