Sfoglia il codice sorgente

Add CONTRIBUTING.md; point component docs at it instead of claude/DESIGN.md

The capture/extraction design model lived only in the private claude/
repo (DESIGN.md), so the shipped component's doc references pointed at a
file contributors don't have. Port that model into a self-contained
CONTRIBUTING.md at the repo root and redirect the references:

  - display.py, tools/capture.py, tools/gxepd2_harness/README.md -> the
    new CONTRIBUTING.md
  - gxepd2_display.h, test_pattern fixture -> inline pointers
    (test_pattern() source, "report if it renders correctly")

No behavior change; docs only.
ZiB 3 settimane fa
parent
commit
026fa19eec

+ 237 - 0
CONTRIBUTING.md

@@ -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.

+ 1 - 1
components/gxepd2/display.py

@@ -2,7 +2,7 @@
 engine (see cycle_engine.h) instead of GxEPD2's own C++ classes.
 
 Panels are selected via `model:`, each backed by an EpdPanelDescriptor
-(panel_*.h) generated by the capture emitter - see claude/DESIGN.md.
+(panel_*.h) generated by the capture emitter - see CONTRIBUTING.md.
 """
 
 import re

+ 1 - 1
components/gxepd2/gxepd2_display.h

@@ -218,7 +218,7 @@ class GxEPD2Display : public display::DisplayBuffer {
     ESP_LOGCONFIG(TAG, "  Hardware-verified: %s", YESNO(this->verified_));
     if (!this->verified_)
       ESP_LOGW(TAG, "Panel %s is not yet hardware-verified - the captured descriptor may be wrong; "
-                    "please report what you see (see claude/DESIGN.md)",
+                    "if it renders correctly, please report it so it can be marked verified",
                this->model_name_ != nullptr ? this->model_name_ : "(unknown)");
     LOG_UPDATE_INTERVAL(this);
   }

+ 1 - 2
tests/test_pattern_bar_ladder_diagonal.esp32-idf.yaml

@@ -1,8 +1,7 @@
 # Canonical bench visual-check pattern, not a minimal smoke test like the
 # other fixtures in this directory. Scales to any panel via
 # it.get_width()/it.get_height(); each zone targets a specific bug class -
-# see claude/DESIGN.md's "Bench test pattern" section for the full
-# rationale. This is the actual pattern (previously only ever staged
+# see GxEPD2Display::test_pattern() in gxepd2_display.h for the rationale. This is the actual pattern (previously only ever staged
 # on-device, never committed) whose row ladder + diagonal caught the
 # GDEY0579F51 row-interleave bug and confirmed the fix - see the
 # gxepd2-bench-validation-status memory.

+ 1 - 1
tools/capture.py

@@ -4,7 +4,7 @@
 Resolves a GxEPD2 driver class to its vendored source, builds and runs a host
 harness that *observes* the driver, and prints a candidate EpdPanelDescriptor
 (the body of a `components/gxepd2/panel_<model>.h`). No ESP toolchain needed -
-just a host C++ compiler. See tools/gxepd2_harness/README.md and claude/DESIGN.md.
+just a host C++ compiler. See tools/gxepd2_harness/README.md and CONTRIBUTING.md.
 
     tools/capture.py GxEPD2_154_Z90c             # candidate -> stdout
     tools/capture.py GxEPD2_154_Z90c -o panel.h  # candidate -> file

+ 1 - 1
tools/gxepd2_harness/README.md

@@ -1,7 +1,7 @@
 # gxepd2 capture harness
 
 Host-side tooling that **generates a panel descriptor by observing a real
-GxEPD2 driver run**, instead of hand-writing one. See `claude/DESIGN.md` for
+GxEPD2 driver run**, instead of hand-writing one. See ../../CONTRIBUTING.md for
 the full model; this is the how-to.
 
 The vendored GxEPD2 source it compiles against lives in `third_party/GxEPD2/`.