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 (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.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 / 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.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.
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 (GDEQ0426T82/SSD1677, 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. 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.
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.
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.
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).
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, 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. |
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 | ⚪ structural |
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.