Преглед изворни кода

Write the zEPD ESPHome adapter; full compile verified end-to-end

display.py / zepd_display.h: the engine adapter, ported from the legacy
gxepd2_display.h with CycleEngine->Engine, EpdPanelDescriptor->EngineModel,
PANELS dict -> registry.load_model(). Wires codegen.generate_cpp() into
to_code() as a fixed-name generated header (zepd_model_generated.h) at
src/ root, per DESCRIPTOR_REGISTRY_PLAN.md's decided mechanism.

Two real structural fixes found only by actually compiling:

- engine.h/engine_model.h moved out of an engine/ subdirectory: ESPHome's
  external-component loader only auto-copies source files directly in a
  component's top-level package directory (ComponentManifest.resources is
  non-recursive by default) - a plain subdirectory's headers never reach
  the build at all. Flattened into components/zepd/ directly.

- Engine/EngineModel moved from a separate global `zepd` namespace into
  esphome::zepd (the adapter's namespace), not kept apart. ESPHome's
  generated main.cpp references the display class as unqualified
  `zepd::ZepdDisplay`, relying on `using namespace esphome;` to resolve
  it - a second, unrelated top-level `zepd` namespace made that ambiguous
  at compile time. One namespace, no collision.

Added tests/test_smoke_zepd.esp32-arduino.yaml (GxEPD2_102, the one
registry-backed model) and confirmed `esphome compile` succeeds: config
validates, codegen runs, C++ compiles and links to a working firmware
image. Both fixes are corrections to claude/ZEPD_PLAN.md's assumptions,
not yet reflected there - plan update is the next step.
ZiB пре 2 недеља
родитељ
комит
a95be5ac65

+ 6 - 6
esphome-zepd/components/zepd/codegen.py

@@ -10,7 +10,7 @@ from __future__ import annotations
 
 from typing import Any
 
-_LINE_ORDERS = {"SEQUENTIAL": "zepd::LineOrder::SEQUENTIAL", "PAIRED_EDGES": "zepd::LineOrder::PAIRED_EDGES"}
+_LINE_ORDERS = {"SEQUENTIAL": "LineOrder::SEQUENTIAL", "PAIRED_EDGES": "LineOrder::PAIRED_EDGES"}
 
 
 def _hex_bytes(s: str) -> list[int]:
@@ -47,16 +47,16 @@ def _emit_sequence(out: list[str], prefix: str, seq: list[dict]) -> tuple[str, i
         )
     seq_name = f"{prefix}_SEQ"
     if entries:
-        out.append(f"inline const zepd::EpdCommand {seq_name}[] = {{")
+        out.append(f"inline const EpdCommand {seq_name}[] = {{")
         out.extend(entries)
         out.append("};")
     else:
-        out.append(f"inline const zepd::EpdCommand *{seq_name} = nullptr;")
+        out.append(f"inline const EpdCommand *{seq_name} = nullptr;")
     return seq_name, len(entries)
 
 
 def generate_cpp(model: dict[str, Any], var_name: str) -> str:
-    """Generates one `inline const zepd::EngineModel {var_name} = {...};`
+    """Generates one `inline const EngineModel {var_name} = {...};`
     plus its backing command/palette arrays, as a single translation unit's
     worth of text (no #pragma once / includes - the caller wraps this)."""
     out: list[str] = []
@@ -95,7 +95,7 @@ def generate_cpp(model: dict[str, Any], var_name: str) -> str:
     palette_entries = model.get("palette") or []
     if palette_entries:
         palette_name = f"{prefix}_PALETTE"
-        out.append(f"inline const zepd::EpdPaletteEntry {palette_name}[] = {{")
+        out.append(f"inline const EpdPaletteEntry {palette_name}[] = {{")
         for e in palette_entries:
             r, g, b = _hex_bytes(e["rgb"])
             code = _hex_bytes(e["code"])[0]
@@ -103,7 +103,7 @@ def generate_cpp(model: dict[str, Any], var_name: str) -> str:
         out.append("};")
         palette_len = len(palette_entries)
 
-    out.append(f"inline const zepd::EngineModel {var_name} = {{")
+    out.append(f"inline const EngineModel {var_name} = {{")
     out.append(f"    .width = {model['width']},")
     out.append(f"    .height = {model['height']},")
     out.append(f"    .busy_active_level = {'true' if model['busy_active_level'] else 'false'},")

+ 133 - 0
esphome-zepd/components/zepd/display.py

@@ -0,0 +1,133 @@
+"""ESPHome display platform driving a zEPD engine model via the native
+replay engine (see engine/engine.h) instead of GxEPD2's own C++ classes.
+
+Models are selected via `model:`, each a drivers/<model>.json merged over
+drivers/default.json (see registry.py) and codegen'd at compile time to a
+single generated header (see codegen.py) - no per-model panel_*.h, and no
+`PANELS` dict here. See claude/DESCRIPTOR_REGISTRY_PLAN.md.
+"""
+
+import re
+
+import esphome.codegen as cg
+import esphome.config_validation as cv
+from esphome import pins
+from esphome.components import display, spi
+from esphome.const import (
+    CONF_BUSY_PIN,
+    CONF_CS_PIN,
+    CONF_DC_PIN,
+    CONF_ID,
+    CONF_LAMBDA,
+    CONF_MODEL,
+    CONF_RESET_PIN,
+    CONF_SPI_ID,
+    Framework,
+)
+from esphome.core import CORE
+from esphome.helpers import write_file_if_changed
+from esphome.types import ConfigType
+
+from . import codegen as zepd_codegen
+from . import registry as zepd_registry
+
+CODEOWNERS = ["@zib"]
+DEPENDENCIES = ["spi"]
+
+# See the matching comment in the legacy components/gxepd2/display.py - same
+# mono-inversion tradeoff, carried forward unchanged.
+CONF_INVERT_MONO = "invert_mono"
+
+# Compiles in ZepdDisplay's dev-time diagnostic methods (currently
+# diag_update_no_reset()), stashed behind this flag so a production build
+# doesn't pay flash for bench-only tooling. Off by default.
+CONF_DIAGNOSTICS = "diagnostics"
+
+zepd_ns = cg.esphome_ns.namespace("zepd")
+ZepdDisplay = zepd_ns.class_("ZepdDisplay", display.DisplayBuffer)
+
+MODELS = zepd_registry.list_models()
+
+
+# Human-facing label for test_pattern(): the first OEM part number minus its
+# GDEx series prefix (GDEQ0583T31 -> 0583T31), same rule as the legacy
+# _panel_label. Falls back to the registry key itself when a model carries
+# no oem_part_numbers yet.
+def _model_label(model_key: str, model: dict) -> str:
+    parts = model.get("oem_part_numbers") or []
+    part = parts[0] if parts else model_key
+    return re.sub(r"^GDE[A-Z]", "", part)
+
+
+CONFIG_SCHEMA = cv.All(
+    display.FULL_DISPLAY_SCHEMA.extend(
+        {
+            cv.GenerateID(): cv.declare_id(ZepdDisplay),
+            cv.GenerateID(CONF_SPI_ID): cv.use_id(spi.SPIComponent),
+            cv.Required(CONF_MODEL): cv.one_of(*MODELS),
+            cv.Required(CONF_CS_PIN): pins.gpio_output_pin_schema,
+            cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema,
+            cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
+            cv.Optional(CONF_BUSY_PIN): pins.gpio_input_pin_schema,
+            cv.Optional(CONF_INVERT_MONO): cv.boolean,
+            cv.Optional(CONF_DIAGNOSTICS, default=False): cv.boolean,
+        }
+    ),
+    # Same framework support rule as the legacy component - the runtime C++
+    # only uses portable ESPHome abstractions.
+    cv.only_with_framework([Framework.ESP_IDF, Framework.ARDUINO]),
+)
+
+
+async def to_code(config: ConfigType) -> None:
+    var = cg.new_Pvariable(config[CONF_ID])
+
+    model = zepd_registry.load_model(config[CONF_MODEL])
+
+    # Codegen the one selected model to a fixed-name generated header at
+    # src/ root - not under this component's own directory, which
+    # writer.copy_src_tree() would otherwise delete on every build (see
+    # claude/DESCRIPTOR_REGISTRY_PLAN.md, "Where generated C++ lands").
+    cpp_body = zepd_codegen.generate_cpp(model, "ZEPD_MODEL")
+    # Wrapped in esphome::zepd, the same namespace as the adapter
+    # (ZepdDisplay) and the engine core (EngineModel, Engine) both live in -
+    # see engine_model.h/engine.h. Keeping a single namespace avoids the
+    # ambiguity ESPHome's own generated main.cpp would otherwise hit: it
+    # writes unqualified `zepd::Foo` trusting `using namespace esphome;` to
+    # resolve it, which breaks if a second, unrelated top-level `zepd`
+    # namespace also exists.
+    generated = (
+        "#pragma once\n"
+        '#include "esphome/components/zepd/engine_model.h"\n\n'
+        "namespace esphome::zepd {\n\n"
+        f"{cpp_body}\n"
+        "}  // namespace esphome::zepd\n"
+    )
+    write_file_if_changed(CORE.relative_src_path("zepd_model_generated.h"), generated)
+
+    cg.add(var.set_descriptor(cg.RawExpression("&esphome::zepd::ZEPD_MODEL")))
+    cg.add(var.set_cs_pin(await cg.gpio_pin_expression(config[CONF_CS_PIN])))
+    cg.add(var.set_dc_pin(await cg.gpio_pin_expression(config[CONF_DC_PIN])))
+    if reset_pin := config.get(CONF_RESET_PIN):
+        cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin)))
+    if busy_pin := config.get(CONF_BUSY_PIN):
+        cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin)))
+
+    spi_parent = await cg.get_variable(config[CONF_SPI_ID])
+    cg.add(var.set_spi_parent(spi_parent))
+
+    cg.add(var.set_invert_mono(config.get(CONF_INVERT_MONO, False)))
+
+    cg.add(var.set_model_name(_model_label(config[CONF_MODEL], model)))
+    cg.add(var.set_verified(bool(model.get("verified", False))))
+
+    if config[CONF_DIAGNOSTICS]:
+        cg.add_define("USE_ZEPD_DIAGNOSTICS")
+
+    # A top-level `lambda:` must be processed and attached via set_writer()
+    # here - register_display() only wires up `pages:`.
+    if CONF_LAMBDA in config:
+        lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void)
+        cg.add(var.set_writer(lambda_))
+
+    await display.register_display(var, config)

+ 1 - 0
esphome-zepd/components/zepd/drivers/GxEPD2_102.json

@@ -0,0 +1 @@
+{}

+ 2 - 2
esphome-zepd/components/zepd/engine/engine.h → esphome-zepd/components/zepd/engine.h

@@ -9,7 +9,7 @@
 #include "esphome/components/spi/spi.h"
 #include "engine_model.h"
 
-namespace zepd {
+namespace esphome::zepd {
 
 // Stock GxEPD2 vendor value (20MHz).
 using EpdSPIDevice = spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
@@ -529,4 +529,4 @@ class Engine {
   bool hibernating_{false};  // POWER_OFF_CMD's done_state: HIBERNATE_CMD if set, else DONE
 };
 
-}  // namespace zepd
+}  // namespace esphome::zepd

+ 2 - 2
esphome-zepd/components/zepd/engine/engine_model.h → esphome-zepd/components/zepd/engine_model.h

@@ -2,7 +2,7 @@
 
 #include <cstdint>
 
-namespace zepd {
+namespace esphome::zepd {
 
 // One command byte plus its (fixed, compile-time) data bytes, as sent by
 // GxEPD2_EPD::_writeCommand()/_writeData(). delay_before_ms/delay_after_ms
@@ -184,4 +184,4 @@ struct EngineModel {
   uint8_t hibernate_sequence_len{0};
 };
 
-}  // namespace zepd
+}  // namespace esphome::zepd

+ 455 - 0
esphome-zepd/components/zepd/zepd_display.h

@@ -0,0 +1,455 @@
+#pragma once
+
+#include "esphome/core/component.h"
+#include "esphome/core/helpers.h"
+#include "esphome/components/display/display_buffer.h"
+#include "esphome/components/spi/spi.h"
+#include "engine.h"
+#include "engine_model.h"
+#include "zepd_model_generated.h"
+
+namespace esphome::zepd {
+
+static const char *const TAG = "zepd";
+
+// ESPHome display component (the engine adapter) for a zEPD model. The
+// model arrives as one codegen'd zepd::EngineModel, selected at ESPHome
+// compile time by display.py from the drivers/*.json registry (see
+// claude/DESCRIPTOR_REGISTRY_PLAN.md) and written to
+// zepd_model_generated.h - not templated over per-panel C++ classes the
+// way GxEPD2 itself is. Driven by zepd::Engine, the generic replay state
+// machine; this class is the ESPHome-side half of the engine adapter.
+class ZepdDisplay : public display::DisplayBuffer {
+ public:
+  void set_cs_pin(GPIOPin *pin) { this->cs_ = pin; }
+  void set_dc_pin(GPIOPin *pin) { this->dc_ = pin; }
+  void set_reset_pin(GPIOPin *pin) { this->rst_ = pin; }
+  void set_busy_pin(GPIOPin *pin) { this->busy_ = pin; }
+  void set_spi_parent(spi::SPIComponent *parent) { this->spi_.set_spi_parent(parent); }
+  void set_descriptor(const EngineModel *desc) { this->desc_ = desc; }
+  void set_invert_mono(bool invert) { this->invert_mono_ = invert; }
+  // The short model label test_pattern() can print, derived from the
+  // registry's oem_part_numbers[0] (see display.py).
+  void set_model_name(const char *name) { this->model_name_ = name; }
+  // Whether this model's data is confirmed on real hardware (vs captured
+  // but not yet bench-verified) - surfaced in dump_config(). From the
+  // registry's `verified` field, via display.py.
+  void set_verified(bool v) { this->verified_ = v; }
+
+  // Bench visual-check pattern, bit-depth aware. Everything scales to what
+  // THIS panel can actually show (see test_colors_): the color-bar count,
+  // the ladder color cycle and the corner markers all follow the panel's
+  // real color set instead of a hardcoded 4, so the drawn pattern doubles as
+  // a statement of what the engine model expects of the panel. Adds a
+  // dithered grey ramp so a 1bpp panel shows a graded sweep, not just two
+  // flat blocks.
+  //
+  // Pass a font to label the panel with its model (and the panel can spare the
+  // room); pass nothing to skip the label - handy when you just want a quick
+  // pattern without defining a font. From the display lambda:
+  //   id(<display_id>).test_pattern();                 // no label
+  //   id(<display_id>).test_pattern(id(<font_id>));    // labelled
+  void test_pattern(display::BaseFont *font = nullptr) {
+    const int w = this->get_width();
+    const int h = this->get_height();
+    Color colors[8];
+    const int n = this->test_colors_(colors, 8);
+
+    this->fill(Color(0xFF, 0xFF, 0xFF));
+
+    const int smin = w < h ? w : h;
+    const int border = smin / 100 > 1 ? smin / 100 : 1;
+    const int x0 = border;
+    int y0 = border;
+    const int iw = w - 2 * border;
+    int ih = h - 2 * border;
+
+    // Name band, above every test zone. Drawn only when a font was passed and
+    // the panel is tall enough to give it a fifth of the interior without
+    // crowding the zones below.
+    if (font != nullptr && this->model_name_ != nullptr && this->model_name_[0] != '\0') {
+      int tw, xoff, baseline, th;
+      font->measure(this->model_name_, &tw, &xoff, &baseline, &th);
+      const int band = th + 4;
+      if (band <= ih / 5) {
+        this->print(w / 2, y0 + band / 2, font, Color(0x00, 0x00, 0x00), display::TextAlign::CENTER, this->model_name_);
+        y0 += band;
+        ih -= band;
+      }
+    }
+
+    // Vertical budget for the four zones below any name band.
+    const int bars_h = ih * 34 / 100;
+    int dither_h = ih * 12 / 100;
+    if (dither_h < 8) dither_h = 8;
+    const int row_h = ih * 18 / 100 > 2 ? ih * 18 / 100 : 2;
+    const int dy = y0 + bars_h;
+    const int ry = dy + dither_h;
+    const int col_y = ry + row_h;
+    const int col_h = (y0 + ih) - col_y;
+
+    // Color bars: one solid block per available color, in palette/wire-code
+    // order (black, white, then the accents). A wrong or missing block
+    // points straight at the color - or the wire code - it stands for.
+    const int bar_w = iw / n;
+    for (int i = 0; i < n; i++) {
+      const int bx = x0 + i * bar_w;
+      const int bw = (i == n - 1) ? (x0 + iw - bx) : bar_w;
+      this->filled_rectangle(bx, y0, bw, bars_h, colors[i]);
+    }
+
+    // Dithered grey ramp: brightness 255 (left) -> 0 (right) via an ordered
+    // 4x4 Bayer threshold. On a 1bpp panel this is the only zone that shows
+    // a graded sweep; on deeper panels it's a quick mono/uniformity check.
+    static const uint8_t BAYER4[4][4] = {
+        {0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
+    for (int yy = 0; yy < dither_h; yy++) {
+      for (int xx = 0; xx < iw; xx++) {
+        const int bright = (iw <= 1) ? 255 : 255 - xx * 255 / (iw - 1);
+        const int thr = (BAYER4[yy & 3][xx & 3] * 2 + 1) * 255 / 32;
+        if (bright <= thr)
+          this->draw_pixel_at(x0 + xx, dy + yy, Color(0x00, 0x00, 0x00));
+      }
+    }
+
+    // Row ladder: 1px rows cycling the full color set - a dropped, doubled
+    // or reordered row breaks the N-row cadence.
+    for (int yy = 0; yy < row_h; yy++)
+      this->horizontal_line(x0, ry + yy, iw, colors[yy % n]);
+
+    // Column ladder: the same rotated 90 degrees, catching nibble/column
+    // order bugs the row ladder can't (packed formats put several pixels per
+    // byte, so a column-order fault can leave every row looking fine).
+    for (int xx = 0; xx < iw; xx++)
+      this->vertical_line(x0 + xx, col_y, col_h, colors[xx % n]);
+
+    // Corner markers for orientation, using the panel's ink colors (every
+    // color but white). BL is always black; TL/TR take the next inks when
+    // the panel has them; BR is left blank as the distinct "no marker"
+    // state. A 90/180 rotation or mirror can't survive this even though the
+    // bars and ladders are otherwise symmetric.
+    Color ink[8];
+    int ni = 0;
+    for (int i = 0; i < n; i++) {
+      if (colors[i].red == 0xFF && colors[i].green == 0xFF && colors[i].blue == 0xFF)
+        continue;  // skip white - a white marker is invisible
+      ink[ni++] = colors[i];
+    }
+    const int mk = smin / 12 > 3 ? smin / 12 : 3;
+    if (ni > 0) this->filled_rectangle(x0, y0 + ih - mk, mk, mk, ink[0]);        // BL
+    if (ni > 1) this->filled_rectangle(x0, y0, mk, mk, ink[1]);                  // TL
+    if (ni > 2) this->filled_rectangle(x0 + iw - mk, y0, mk, mk, ink[2]);        // TR
+
+    // Corner-to-corner diagonals over everything: the cheapest coordinate-
+    // transform check - a clean line means no row/column reordering.
+    this->line(0, 0, w - 1, h - 1, Color(0x00, 0x00, 0x00));
+    this->line(w - 1, 0, 0, h - 1, Color(0x00, 0x00, 0x00));
+
+    // Border frame last, so it stays an unbroken rectangle on all four edges
+    // even where a zone fills to the edge - catches a clipped/offset origin.
+    this->rectangle(0, 0, w, h, Color(0x00, 0x00, 0x00));
+  }
+
+  void setup() override {
+    const auto *desc = this->desc_;
+    // window_per_plane panels (e.g. a master+slave dual-region panel) size
+    // each plane's own buffer from plane_width (each region's own local
+    // width), not the descriptor's overall width (the full logical canvas
+    // the adapter draws into) - both regions are the same size here, so
+    // one plane_bytes_ value covers both buffers.
+    uint32_t w = desc->window_per_plane ? desc->plane_width : desc->width;
+    // width*height*bpp/8: /8 at 1bpp, /4 at 2bpp (4-color), /2 at 4bpp (7-color).
+    this->plane_bytes_ = (uint32_t) w * desc->height * desc->bits_per_pixel / 8;
+    RAMAllocator<uint8_t> allocator;
+    this->plane0_ = allocator.allocate(this->plane_bytes_);
+    bool need_plane1 = desc->plane_count > 1;
+    this->plane1_ = need_plane1 ? allocator.allocate(this->plane_bytes_) : nullptr;
+    if (this->plane0_ == nullptr || (need_plane1 && this->plane1_ == nullptr)) {
+      ESP_LOGE(TAG, "Failed to allocate %u-byte plane buffer(s)", (unsigned) this->plane_bytes_);
+      this->mark_failed();
+      return;
+    }
+    if (desc->bits_per_pixel > 1) {
+      // Packed modes: clear every pixel to the panel's white/blank code,
+      // replicated across the byte - e.g. 4-color white=0x01 -> 0x55, 7-color
+      // white=0x01 -> 0x11, matching GxEPD2's own writeScreenBuffer() fill
+      // (color4()*0x55 / color7()|<<4). Both regions of a window_per_plane
+      // panel (master/slave) get the same blank fill.
+      uint8_t fill = replicate_code_(desc->blank_code, desc->bits_per_pixel);
+      memset(this->plane0_, fill, this->plane_bytes_);
+      if (this->plane1_ != nullptr)
+        memset(this->plane1_, fill, this->plane_bytes_);
+    } else {
+      // 1bpp plane model: plane0 white = all bits set (0xFF); plane1 (the
+      // accent/red plane, when present) = 0 = not-red.
+      memset(this->plane0_, 0xFF, this->plane_bytes_);
+      if (this->plane1_ != nullptr)
+        memset(this->plane1_, 0x00, this->plane_bytes_);
+    }
+
+    this->spi_.spi_setup();
+    this->engine_.begin(desc, this->cs_, this->dc_, this->rst_, this->busy_, &this->spi_);
+  }
+
+  // Arms the state machine and returns immediately - the real several-
+  // seconds-long write/refresh/busy-wait cycle happens across many
+  // subsequent loop() ticks, not before this call returns.
+  void update() override {
+    if (!this->engine_.idle()) {
+      ESP_LOGW(TAG, "Skipping update: previous display refresh is still running");
+      return;
+    }
+    this->do_update_();
+    this->engine_.request_full_update(this->plane0_, this->plane1_);
+  }
+
+  void loop() override { this->engine_.step(); }
+
+  // True once the engine has returned to idle after the last update()/
+  // power_off()/hibernate() call. Lets a more complex application (a lambda
+  // chaining several updates, a deep_sleep automation deciding whether to
+  // wait) check "is the panel still mid-refresh" instead of guessing from
+  // timing - the same thing power_off()/hibernate()'s own guards below check
+  // internally.
+  bool idle() const { return this->engine_.idle(); }
+
+  // Turns off the panel's driving-voltage generation (reduces fading over
+  // time between refreshes). Call from a lambda, e.g.
+  // id(<display_id>).power_off(); - not called automatically by update(),
+  // since this engine does a full hardware reset every cycle regardless of
+  // prior power state, so correctness never depends on it. (It IS called
+  // automatically from on_powerdown() below.) Safe to call mid-refresh: see
+  // zepd::Engine::request_power_off().
+  void power_off() {
+    if (!this->engine_.idle())
+      ESP_LOGW(TAG, "power_off() called mid-refresh; aborting the in-progress update");
+    this->engine_.request_power_off();
+  }
+
+  // power_off() + deep sleep, wakeable only by a hardware reset pulse - which
+  // the next update() always issues (request_full_update()'s RESET_LOW), so
+  // no paired "wake" call is needed; just call update() as usual. Safe to
+  // call mid-refresh: see zepd::Engine::request_hibernate().
+  void hibernate() {
+    if (!this->engine_.idle())
+      ESP_LOGW(TAG, "hibernate() called mid-refresh; aborting the in-progress update");
+    this->engine_.request_hibernate();
+  }
+
+  // ESPHome calls this on every component right before deep_sleep actually
+  // suspends the MCU, and on safe_reboot() - see
+  // Application::run_powerdown_hooks() (fires after teardown_components()
+  // completes or times out, deep_sleep's should_teardown_() is unconditional
+  // on every supported platform). Powers the panel down with no YAML/lambda
+  // needed for the common case: hibernate() when a reset pin is wired (the
+  // next update()'s unconditional RESET_LOW wakes it - see hibernate()
+  // above), else power_off() (needs no reset pulse to recover, so it's safe
+  // with no rst pin at all).
+  //
+  // Deliberately NOT paired with a teardown() override to wait out a
+  // mid-flight refresh: ESPHome's teardown budget (1s on reboot, 5s on deep
+  // sleep) is far shorter than this engine's slower busy_timeout_ms classes
+  // (up to 45-60s for 4bpp/7-color panels), so stalling teardown here would
+  // just lose that race every time and get force-advanced anyway - the
+  // budget is sized for a network flush (see api_server::teardown()), not a
+  // hardware operation. If a refresh is still running when this fires,
+  // power_off()/hibernate() abort it with a reset pulse first rather than
+  // writing a power-down command over a controller that's still asserting
+  // BUSY and would just ignore it (see zepd::Engine::request_power_off()). A
+  // caller who cares about that race can check idle() (above) and hold off
+  // triggering deep sleep themselves.
+  void on_powerdown() override {
+    if (this->rst_ != nullptr) {
+      this->hibernate();
+    } else {
+      this->power_off();
+    }
+  }
+
+#ifdef USE_ZEPD_DIAGNOSTICS
+  // Dev-time diagnostics - compiled out entirely unless this display's YAML
+  // sets `diagnostics: true` (display.py then adds -DUSE_ZEPD_DIAGNOSTICS),
+  // so a production build pays nothing for them. A stash for throwaway bench
+  // tests, not a stable API - add more here as needed, don't feel obliged to
+  // keep any of it around once a test has done its job.
+  //
+  // Draws the current lambda/pages content and pushes it WITHOUT the reset
+  // pulse update() normally does first - see
+  // zepd::Engine::diag_request_update_no_reset(). Call from a lambda, e.g. to
+  // check whether power_off()/hibernate() actually took: call one of those,
+  // then this with a pattern distinct from what's already on screen - no
+  // change (or odd BUSY behavior) means the panel really is off-line; follow
+  // with a normal update() to confirm the next real reset wakes it and to
+  // leave the panel in a known state either way.
+  void diag_update_no_reset() {
+    if (!this->engine_.idle()) {
+      ESP_LOGW(TAG, "Skipping diag_update_no_reset: display refresh still in progress");
+      return;
+    }
+    this->do_update_();
+    this->engine_.diag_request_update_no_reset(this->plane0_, this->plane1_);
+  }
+#endif
+
+  void dump_config() override {
+    ESP_LOGCONFIG(TAG, "zEPD Display:");
+    ESP_LOGCONFIG(TAG, "  Model: %s", this->model_name_ != nullptr ? this->model_name_ : "(unknown)");
+    ESP_LOGCONFIG(TAG, "  Dimensions: %ux%u", (unsigned) this->desc_->width, (unsigned) this->desc_->height);
+    ESP_LOGCONFIG(TAG, "  Bits/pixel: %u (%s)", (unsigned) this->desc_->bits_per_pixel,
+                  this->desc_->bits_per_pixel == 1 ? "mono/plane" : "packed palette");
+    ESP_LOGCONFIG(TAG, "  Planes/regions: %u", (unsigned) this->desc_->plane_count);
+    ESP_LOGCONFIG(TAG, "  Busy active level: %s", this->desc_->busy_active_level ? "HIGH" : "LOW");
+    if (this->desc_->bits_per_pixel == 1)
+      ESP_LOGCONFIG(TAG, "  invert_mono: %s", YESNO(this->invert_mono_));
+    ESP_LOGCONFIG(TAG, "  Hardware-verified: %s", YESNO(this->verified_));
+    if (!this->verified_)
+      ESP_LOGW(TAG, "Model %s is not yet hardware-verified - the captured data may be wrong; "
+                    "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);
+  }
+
+  display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
+
+ protected:
+  // Replicates a packed wire code across a whole byte (one code per pixel
+  // slot), e.g. 2bpp code 0x01 -> 0x55, 4bpp code 0x01 -> 0x11.
+  static uint8_t replicate_code_(uint8_t code, uint8_t bpp) {
+    uint8_t mask = static_cast<uint8_t>((1u << bpp) - 1);
+    uint8_t out = 0;
+    for (uint8_t i = 0; i < 8 / bpp; i++)
+      out = static_cast<uint8_t>((out << bpp) | (code & mask));
+    return out;
+  }
+
+  // Maps a drawn RGB color to its panel wire code via the descriptor's
+  // palette: exact matches win (so named colors are deterministic, like
+  // GxEPD2's color4()/color7() switch), otherwise nearest in RGB space.
+  static uint8_t classify_(Color c, const EpdPaletteEntry *pal, uint8_t len) {
+    if (pal == nullptr || len == 0)
+      return 0;
+    uint8_t best = 0;
+    long best_d = -1;
+    for (uint8_t i = 0; i < len; i++) {
+      long dr = static_cast<long>(c.red) - pal[i].r;
+      long dg = static_cast<long>(c.green) - pal[i].g;
+      long db = static_cast<long>(c.blue) - pal[i].b;
+      long d = dr * dr + dg * dg + db * db;
+      if (d == 0)
+        return pal[i].code;
+      if (best_d < 0 || d < best_d) {
+        best_d = d;
+        best = i;
+      }
+    }
+    return pal[best].code;
+  }
+
+  // Packed code-per-pixel model (bits_per_pixel > 1): N bits/pixel, MSB
+  // first, matching GxEPD2's own out_data packing in writeImage (4-color
+  // 4px/byte, 7-color 2px/byte). Routes to the correct region for a
+  // window_per_plane (master/slave) panel the same way the mono path does.
+  void draw_packed_(int x, int y, Color color) {
+    const uint8_t bpp = this->desc_->bits_per_pixel;
+    uint8_t code = classify_(color, this->desc_->palette, this->desc_->palette_len);
+    uint16_t stride = this->desc_->width;
+    uint8_t *buf = this->plane0_;
+    int local_x = x;
+    if (this->desc_->window_per_plane && this->desc_->plane_count > 1) {
+      stride = this->desc_->plane_width;
+      if (x >= stride) {
+        buf = this->plane1_;
+        local_x = x - stride;
+      }
+    }
+    uint32_t px = static_cast<uint32_t>(y) * stride + local_x;
+    const uint8_t ppb = 8 / bpp;
+    uint32_t idx = px / ppb;
+    uint8_t shift = static_cast<uint8_t>((ppb - 1 - (px % ppb)) * bpp);
+    uint8_t mask = static_cast<uint8_t>((1u << bpp) - 1);
+    buf[idx] = static_cast<uint8_t>((buf[idx] & ~(mask << shift)) | ((code & mask) << shift));
+  }
+
+  void draw_absolute_pixel_internal(int x, int y, Color color) override {
+    if (x < 0 || y < 0 || x >= this->desc_->width || y >= this->desc_->height)
+      return;
+    if (this->desc_->bits_per_pixel > 1) {
+      this->draw_packed_(x, y, color);
+      return;
+    }
+    bool red = color.red > color.green && color.red > color.blue && color.red > 0x7f;
+    bool bright = color.red > 0x7f || color.green > 0x7f || color.blue > 0x7f;
+    uint32_t idx = (static_cast<uint32_t>(y) * this->desc_->width + x) / 8;
+    uint8_t mask = 0x80 >> (x % 8);
+    // Red/accent pixels go to the color plane (when the panel has one),
+    // everything else is black/white on the black plane. get_display_type()
+    // reports DISPLAY_TYPE_COLOR, so callers draw literal RGB colors, not
+    // the on/off is_on() convention, even on a plane_count==1 (BW-only)
+    // panel - there just is no color plane for a "red" classification to
+    // land on.
+    //
+    // Buffer convention here is 1=red/0=not-red, transformed to the wire
+    // value by plane_invert[1].
+    if (red && this->plane1_ != nullptr) {
+      this->plane1_[idx] |= mask;
+    } else {
+      if (this->plane1_ != nullptr)
+        this->plane1_[idx] &= ~mask;
+      // plane0 bit set = white/blank. invert_mono_ flips the mono sense so a
+      // bright color (e.g. ESPHome's default COLOR_ON) becomes ink, not blank
+      // - see display.py's CONF_INVERT_MONO. Red classification is unaffected.
+      if (bright ^ this->invert_mono_) {
+        this->plane0_[idx] |= mask;
+      } else {
+        this->plane0_[idx] &= ~mask;
+      }
+    }
+  }
+  int get_width_internal() override { return this->desc_->width; }
+  int get_height_internal() override { return this->desc_->height; }
+
+  // The colors this panel can actually show, each as a representative RGB, in
+  // a stable order (black, white, then accents). Packed panels (>1bpp): one
+  // entry per distinct palette wire code, first RGB seen for each. 1bpp:
+  // black + white, plus red when a second plane exists for the accent (only
+  // red-classified pixels reach it - see draw_absolute_pixel_internal). This
+  // is what makes test_pattern() bit-depth aware. Returns the count.
+  int test_colors_(Color *out, int max) {
+    int n = 0;
+    if (this->desc_->bits_per_pixel > 1 && this->desc_->palette != nullptr) {
+      uint8_t seen[16];
+      int seen_n = 0;
+      for (int i = 0; i < this->desc_->palette_len && n < max; i++) {
+        const uint8_t code = this->desc_->palette[i].code;
+        bool dup = false;
+        for (int j = 0; j < seen_n; j++)
+          if (seen[j] == code) { dup = true; break; }
+        if (dup) continue;
+        if (seen_n < (int) sizeof(seen)) seen[seen_n++] = code;
+        const auto &e = this->desc_->palette[i];
+        out[n++] = Color(e.r, e.g, e.b);
+      }
+    } else {
+      if (n < max) out[n++] = Color(0x00, 0x00, 0x00);  // black
+      if (n < max) out[n++] = Color(0xFF, 0xFF, 0xFF);  // white
+      if (this->desc_->plane_count >= 2 && n < max) out[n++] = Color(0xFF, 0x00, 0x00);  // accent
+    }
+    return n;
+  }
+
+  const EngineModel *desc_{nullptr};
+  const char *model_name_{nullptr};
+  bool verified_{false};
+  GPIOPin *cs_{nullptr};
+  GPIOPin *dc_{nullptr};
+  GPIOPin *rst_{nullptr};
+  GPIOPin *busy_{nullptr};
+  EpdSPIDevice spi_;
+  Engine engine_;
+  uint8_t *plane0_{nullptr};
+  uint8_t *plane1_{nullptr};
+  uint32_t plane_bytes_{0};
+  bool invert_mono_{false};
+};
+
+}  // namespace esphome::zepd

+ 27 - 0
tests/test_smoke_zepd.esp32-arduino.yaml

@@ -0,0 +1,27 @@
+# zEPD smoke test for the esphome-zepd tree (new registry-based component,
+# not the legacy gxepd2 one - see claude/ZEPD_PLAN.md). Mirrors
+# test_smoke.esp32-arduino.yaml's shape: 1bpp model (no PSRAM needed),
+# Arduino framework.
+esphome:
+  name: zepd-smoke-arduino
+esp32:
+  board: esp32dev
+  framework:
+    type: arduino
+external_components:
+  - source:
+      type: local
+      path: ../esphome-zepd/components
+    components: [zepd]
+spi:
+  clk_pin: 18
+  mosi_pin: 23
+logger:
+display:
+  - platform: zepd
+    model: GxEPD2_102
+    cs_pin: 5
+    dc_pin: 17
+    reset_pin: 16
+    busy_pin: 4
+    update_interval: 60s