CONTRIBUTING.md 21 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 (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 ✅ verified
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.