|
|
@@ -0,0 +1,237 @@
|
|
|
+# 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_<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 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.
|