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.
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.
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/.
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.
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:
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.
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_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.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.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.
These are hardware facts no command trace contains:
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.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.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.
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).
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.
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) |
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.
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.
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.
IDF-first; ESP32-Arduino is supported. ESP8266-Arduino compiles but is resolution-limited at 1bpp and lacks the PSRAM that >1bpp panels need.