CONTRIBUTING.md 13 KB

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 #included 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_<panel>_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.