Pārlūkot izejas kodu

Replace GxEPD2's blocking refresh cycle with a native chunked engine

GxEPD2's own control flow has no yield points, which previously forced
the whole refresh onto a separate FreeRTOS task to keep update() from
blocking the main loop - a task that still intermittently tripped the
ESP-IDF watchdog on real hardware. This drops that task entirely: the
init/write/refresh/busy-wait cycle is now a small state machine stepped
once per loop() tick, mechanically replicating GxEPD2_213_Z98c's own
sequence (hand-derived from the vendor source) but bounded per step so
nothing can ever run long enough to need a watchdog subscription.

Only GxEPD2_213_Z98c is wired up so far - the panel on the bench. Other
models go through the old catalog/template path, which no longer exists,
so their test configs now fail validation until they get their own
descriptors.
ZiB 1 mēnesi atpakaļ
vecāks
revīzija
4fdc4cc523

+ 332 - 0
components/gxepd2/cycle_engine.h

@@ -0,0 +1,332 @@
+#pragma once
+
+#include <cstdint>
+
+#include "esphome/core/gpio.h"
+#include "esphome/core/hal.h"
+#include "esphome/core/log.h"
+#include "esphome/components/spi/spi.h"
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+using EpdSPIDevice = spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
+                                     spi::DATA_RATE_20MHZ>;
+
+// Mechanically replicates one GxEPD2 driver's refresh cycle - reset pulse,
+// init command table, RAM-window set, per-plane byte burst, refresh
+// trigger, busy-pin wait - as a state machine stepped from loop(), instead
+// of GxEPD2's own synchronous top-to-bottom call stack. See the plan this
+// was built from (and gxepd2_task_watchdog_status in project memory) for
+// why: that call stack has no yield points, so making it non-blocking used
+// to require running the whole thing on a separate FreeRTOS task, which
+// intermittently tripped the ESP-IDF watchdog anyway. step() never blocks -
+// every state does a small bounded unit of work and returns - so there is
+// nothing here that can starve another task or need a watchdog subscription
+// at all.
+class CycleEngine {
+ public:
+  void begin(const EpdPanelDescriptor *desc, GPIOPin *cs, GPIOPin *dc, GPIOPin *rst, GPIOPin *busy,
+             EpdSPIDevice *spi) {
+    this->desc_ = desc;
+    this->cs_ = cs;
+    this->dc_ = dc;
+    this->rst_ = rst;
+    this->busy_ = busy;
+    this->spi_ = spi;
+    this->cs_->setup();
+    this->dc_->setup();
+    if (this->rst_ != nullptr)
+      this->rst_->setup();
+    if (this->busy_ != nullptr)
+      this->busy_->setup();
+    this->cs_->digital_write(true);
+    this->dc_->digital_write(true);
+  }
+
+  // Arms the state machine; returns immediately. planes[1] is ignored when
+  // desc->plane_count == 1. Undefined to call while !idle().
+  void request_full_update(const uint8_t *plane0, const uint8_t *plane1) {
+    this->planes_[0] = plane0;
+    this->planes_[1] = plane1;
+    this->plane_bytes_ = (static_cast<uint32_t>(this->desc_->width) * this->desc_->height) / 8;
+    this->cursor_ = 0;
+    this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+    this->set_state_(this->rst_ != nullptr ? State::RESET_LOW : State::INIT_CMD);
+  }
+
+  bool idle() const { return this->state_ == State::IDLE; }
+
+  // Called once per loop() tick. Does one bounded unit of work.
+  void step() {
+    switch (this->state_) {
+      case State::IDLE:
+        return;
+      case State::RESET_LOW:
+        this->rst_->digital_write(true);  // preset high, then pulse low - see GxEPD2_EPD::_reset()
+        this->deadline_ = millis() + 10;
+        this->set_state_(State::RESET_LOW_WAIT);
+        return;
+      case State::RESET_LOW_WAIT:
+        if (millis() < this->deadline_)
+          return;
+        this->rst_->digital_write(false);
+        this->deadline_ = millis() + 10;
+        this->set_state_(State::RESET_HIGH_WAIT);
+        return;
+      case State::RESET_HIGH_WAIT:
+        if (millis() < this->deadline_)
+          return;
+        this->rst_->digital_write(true);
+        this->deadline_ = millis() + 10;
+        this->set_state_(State::RESET_SETTLE);
+        return;
+      case State::RESET_SETTLE:
+        if (millis() < this->deadline_)
+          return;
+        this->cursor_ = 0;
+        this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+        this->set_state_(State::INIT_CMD);
+        return;
+
+      case State::INIT_CMD:
+        this->step_sequence_(this->desc_->init_sequence, this->desc_->init_sequence_len, State::INIT_CMD,
+                              State::SET_WINDOW);
+        return;
+
+      case State::SET_WINDOW:
+        this->set_full_window_();
+        this->cursor_ = 0;
+        this->plane_ = 0;
+        this->set_state_(State::WRITE_PLANE);
+        return;
+
+      case State::WRITE_PLANE:
+        this->step_write_plane_();
+        return;
+
+      case State::REFRESH_CMD:
+        this->step_sequence_(this->desc_->refresh_sequence, this->desc_->refresh_sequence_len, State::REFRESH_CMD,
+                              State::DONE);
+        return;
+
+      case State::POST_DELAY:
+        if (millis() < this->deadline_)
+          return;
+        this->set_state_(this->delay_return_state_);
+        return;
+
+      case State::WAIT_BUSY:
+        this->step_wait_busy_();
+        return;
+
+      case State::DONE:
+        ESP_LOGD("gxepd2", "Display update finished");
+        this->set_state_(State::IDLE);
+        return;
+    }
+  }
+
+ private:
+  enum class State {
+    IDLE,
+    RESET_LOW,
+    RESET_LOW_WAIT,
+    RESET_HIGH_WAIT,
+    RESET_SETTLE,
+    INIT_CMD,
+    SET_WINDOW,
+    WRITE_PLANE,
+    REFRESH_CMD,
+    POST_DELAY,
+    WAIT_BUSY,
+    DONE,
+  };
+  enum class CmdPhase { BEFORE_DELAY, ISSUE };
+
+  // Every command byte and every data byte is its own SPI transaction, CS
+  // toggled around each one - this looks wasteful, but it's mechanically
+  // what GxEPD2_EPD::_writeCommand()/_writeData(uint8_t) do (see
+  // vendor/src/GxEPD2_EPD.cpp), and matching the vendor cycle byte-for-byte
+  // is the point of this engine.
+  void write_byte_(uint8_t b, bool dc_high) {
+    this->dc_->digital_write(dc_high);
+    this->spi_->enable();
+    this->cs_->digital_write(false);
+    this->spi_->write_byte(b);
+    this->cs_->digital_write(true);
+    this->spi_->disable();
+  }
+
+  void write_command_(uint8_t cmd, const uint8_t *data, uint8_t data_len) {
+    this->write_byte_(cmd, false);
+    for (uint8_t i = 0; i < data_len; i++)
+      this->write_byte_(data[i], true);
+  }
+
+  // Steps through one EpdCommand table one entry (and one delay/busy-wait,
+  // if that entry has one) at a time, resuming in self_state after any
+  // wait so the same call re-enters here for the next entry. Shared by
+  // INIT_CMD and REFRESH_CMD - which table is being stepped is entirely
+  // determined by which state called this.
+  void step_sequence_(const EpdCommand *seq, uint8_t len, State self_state, State done_state) {
+    if (this->cursor_ >= len) {
+      this->cursor_ = 0;
+      this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+      this->set_state_(done_state);
+      return;
+    }
+    const EpdCommand &c = seq[this->cursor_];
+    if (this->cmd_phase_ == CmdPhase::BEFORE_DELAY) {
+      this->cmd_phase_ = CmdPhase::ISSUE;
+      if (c.delay_before_ms > 0) {
+        this->start_delay_(c.delay_before_ms, self_state);
+        return;
+      }
+    }
+    this->write_command_(c.cmd, c.data, c.data_len);
+    this->cursor_++;
+    this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+    if (c.wait_busy_after) {
+      this->start_busy_wait_(self_state);
+    } else if (c.delay_after_ms > 0) {
+      this->start_delay_(c.delay_after_ms, self_state);
+    }
+    // else: stays in self_state; the next step() call processes the next entry.
+  }
+
+  // GxEPD2_EPD::_setPartialRamArea(0, 0, width, height) - full frame only,
+  // this slice doesn't do partial windows yet.
+  void set_full_window_() {
+    uint16_t w = this->desc_->width, h = this->desc_->height;
+    uint16_t xe = w - 1, ye = h - 1;
+    const uint8_t x44[] = {0, static_cast<uint8_t>(xe / 8)};
+    this->write_command_(0x44, x44, 2);
+    const uint8_t x45[] = {0, 0, static_cast<uint8_t>(ye % 256), static_cast<uint8_t>(ye / 256)};
+    this->write_command_(0x45, x45, 4);
+    const uint8_t x4e[] = {0};
+    this->write_command_(0x4E, x4e, 1);
+    const uint8_t x4f[] = {0, 0};
+    this->write_command_(0x4F, x4f, 2);
+  }
+
+  // Writes up to BYTE_BUDGET bytes of the current plane per call, then
+  // returns - this bound is what keeps a full-frame write (thousands of
+  // bytes) from ever making one loop() tick run long, replacing the old
+  // per-256-byte esp_task_wdt_reset()+delay(1) throttle with simply not
+  // doing enough work in one call to need it.
+  static constexpr uint32_t BYTE_BUDGET = 64;
+
+  void step_write_plane_() {
+    if (this->cursor_ == 0)
+      this->write_byte_(this->desc_->plane_write_cmd[this->plane_], false);
+    const uint8_t *buf = this->planes_[this->plane_];
+    bool invert = this->desc_->plane_invert[this->plane_];
+    uint32_t end = this->cursor_ + BYTE_BUDGET;
+    if (end > this->plane_bytes_)
+      end = this->plane_bytes_;
+    for (; this->cursor_ < end; this->cursor_++) {
+      uint8_t b = buf != nullptr ? buf[this->cursor_] : 0xFF;
+      this->write_byte_(invert ? static_cast<uint8_t>(~b) : b, true);
+    }
+    if (this->cursor_ >= this->plane_bytes_) {
+      this->cursor_ = 0;
+      this->plane_++;
+      if (this->plane_ >= this->desc_->plane_count) {
+        this->plane_ = 0;
+        this->cursor_ = 0;
+        this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+        this->set_state_(State::REFRESH_CMD);
+      }
+    }
+  }
+
+  void start_delay_(uint16_t delay_ms, State return_to) {
+    this->deadline_ = millis() + delay_ms;
+    this->delay_return_state_ = return_to;
+    this->set_state_(State::POST_DELAY);
+  }
+
+  void start_busy_wait_(State next) {
+    this->busy_wait_next_ = next;
+    this->busy_wait_deadline_ = millis() + this->desc_->busy_timeout_ms;
+    this->set_state_(State::WAIT_BUSY);
+  }
+
+  void step_wait_busy_() {
+    if (this->busy_ == nullptr) {
+      this->set_state_(this->busy_wait_next_);
+      return;
+    }
+    bool busy = this->busy_->digital_read() == this->desc_->busy_active_level;
+    if (!busy) {
+      this->set_state_(this->busy_wait_next_);
+      return;
+    }
+    if (millis() >= this->busy_wait_deadline_) {
+      ESP_LOGW("gxepd2", "Busy timeout waiting for panel");
+      this->set_state_(this->busy_wait_next_);
+    }
+  }
+
+  // Logs every state transition (only when the state actually changes -
+  // WAIT_BUSY and POST_DELAY re-enter their own case on every step() call
+  // while polling, which would otherwise spam this at loop() rate) so a
+  // first hardware run can be checked against the vendor cycle it's meant
+  // to replicate before this gets dialed back to WARN-only.
+  void set_state_(State s) {
+    if (s != this->state_)
+      ESP_LOGD("gxepd2", "state: %s -> %s", state_name_(this->state_), state_name_(s));
+    this->state_ = s;
+  }
+
+  static const char *state_name_(State s) {
+    switch (s) {
+      case State::IDLE:
+        return "IDLE";
+      case State::RESET_LOW:
+        return "RESET_LOW";
+      case State::RESET_LOW_WAIT:
+        return "RESET_LOW_WAIT";
+      case State::RESET_HIGH_WAIT:
+        return "RESET_HIGH_WAIT";
+      case State::RESET_SETTLE:
+        return "RESET_SETTLE";
+      case State::INIT_CMD:
+        return "INIT_CMD";
+      case State::SET_WINDOW:
+        return "SET_WINDOW";
+      case State::WRITE_PLANE:
+        return "WRITE_PLANE";
+      case State::REFRESH_CMD:
+        return "REFRESH_CMD";
+      case State::POST_DELAY:
+        return "POST_DELAY";
+      case State::WAIT_BUSY:
+        return "WAIT_BUSY";
+      case State::DONE:
+        return "DONE";
+    }
+    return "?";
+  }
+
+  const EpdPanelDescriptor *desc_{nullptr};
+  GPIOPin *cs_{nullptr};
+  GPIOPin *dc_{nullptr};
+  GPIOPin *rst_{nullptr};
+  GPIOPin *busy_{nullptr};
+  EpdSPIDevice *spi_{nullptr};
+
+  State state_{State::IDLE};
+  uint32_t cursor_{0};
+  CmdPhase cmd_phase_{CmdPhase::BEFORE_DELAY};
+  uint8_t plane_{0};
+  const uint8_t *planes_[2]{nullptr, nullptr};
+  uint32_t plane_bytes_{0};
+  uint32_t deadline_{0};
+  State delay_return_state_{State::DONE};
+  uint32_t busy_wait_deadline_{0};
+  State busy_wait_next_{State::DONE};
+};
+
+}  // namespace esphome::gxepd2

+ 12 - 116
components/gxepd2/display.py

@@ -1,153 +1,49 @@
-"""ESPHome display platform that dispatches to GxEPD2's panel driver classes.
+"""ESPHome display platform driving GxEPD2_213_Z98c via a native chunked
+refresh engine (see cycle_engine.h) instead of GxEPD2's own C++ classes.
 
 
-model: is validated against components/gxepd2/catalog.json, generated by
-tools/gxepd2_catalog/generate_catalog.py.
+Only this one panel is wired up so far - this is the first slice of
+replacing the old GxEPD2-template/FreeRTOS-task component; see the plan
+this was implemented from. catalog.json/vendor/arduino_compat stay in the
+tree as reference material for later panels but aren't used by this file.
 """
 """
 
 
-import json
-from pathlib import Path
-
 import esphome.codegen as cg
 import esphome.codegen as cg
 import esphome.config_validation as cv
 import esphome.config_validation as cv
 from esphome import pins
 from esphome import pins
 from esphome.components import display, spi
 from esphome.components import display, spi
-from esphome.components.esp32 import add_idf_component
-from esphome.const import (
-    CONF_BUSY_PIN,
-    CONF_CS_PIN,
-    CONF_DC_PIN,
-    CONF_ID,
-    CONF_MODEL,
-    CONF_RESET_PIN,
-    CONF_SPI_ID,
-    Framework,
-)
-from esphome.core import CORE
+from esphome.const import CONF_BUSY_PIN, CONF_CS_PIN, CONF_DC_PIN, CONF_ID, CONF_RESET_PIN, CONF_SPI_ID, Framework
 from esphome.types import ConfigType
 from esphome.types import ConfigType
 
 
 CODEOWNERS = ["@zib"]
 CODEOWNERS = ["@zib"]
 DEPENDENCIES = ["spi"]
 DEPENDENCIES = ["spi"]
 
 
-# Not in esphome/const.py: only the dual-CS "master+slave" panels need it.
-CONF_CS2_PIN = "cs2_pin"
-
-VENDOR_ROOT = Path(__file__).parent / "vendor"
-CATALOG_PATH = Path(__file__).parent / "catalog.json"
-CATALOG = json.loads(CATALOG_PATH.read_text())
-
-# gxepd2::GxEPD2ColorMode enumerator name for each catalog wrapper class.
-COLOR_MODES = {
-    "GxEPD2_BW": "BW",
-    "GxEPD2_3C": "THREE_COLOR",
-    "GxEPD2_4C": "FOUR_COLOR",
-    "GxEPD2_7C": "SEVEN_COLOR",
-}
-# USE_GXEPD2_* define gating which wrapper header gxepd2_display.h includes.
-WRAPPER_DEFINES = {
-    "GxEPD2_BW": "USE_GXEPD2_BW",
-    "GxEPD2_3C": "USE_GXEPD2_3C",
-    "GxEPD2_4C": "USE_GXEPD2_4C",
-    "GxEPD2_7C": "USE_GXEPD2_7C",
-}
-# IT8951 host-controller panels need genuine bidirectional SPI (they read
-# status/data back over MISO); arduino_compat/SPI.h's transfer() is
-# write-only - see the comment there - so these can never work through it.
-MODELS = {
-    model_id: entry
-    for model_id, entry in CATALOG.items()
-    if entry["wrapper"] in COLOR_MODES and not entry["source"].startswith("it8951/")
-}
-
 gxepd2_ns = cg.esphome_ns.namespace("gxepd2")
 gxepd2_ns = cg.esphome_ns.namespace("gxepd2")
 GxEPD2Display = gxepd2_ns.class_("GxEPD2Display", display.DisplayBuffer)
 GxEPD2Display = gxepd2_ns.class_("GxEPD2Display", display.DisplayBuffer)
 
 
-# GxEPD2's plain int16_t pin ids (PinRegistry keys), assigned at codegen time.
-_PIN_ID_KEY = "gxepd2_next_pin_id"
-
-
-def _allocate_pin_id() -> int:
-    pin_id = CORE.data.get(_PIN_ID_KEY, 0)
-    CORE.data[_PIN_ID_KEY] = pin_id + 1
-    return pin_id
-
-
-# .cpp files this config needs, accumulated across every gxepd2 instance.
-_SOURCES_KEY = "gxepd2_needed_sources"
-_SHARED_SOURCE = "GxEPD2_EPD.cpp"
-
-
-def _write_sources_manifest(source: str) -> None:
-    needed: set[str] = CORE.data.setdefault(_SOURCES_KEY, {_SHARED_SOURCE})
-    needed.add(source)
-    manifest = CORE.relative_src_path("gxepd2_sources.cmake")
-    manifest.parent.mkdir(parents=True, exist_ok=True)
-    lines = ["set(GXEPD2_SOURCES"]
-    lines += [f'    "${{CMAKE_CURRENT_SOURCE_DIR}}/src/{path}"' for path in sorted(needed)]
-    lines.append(")")
-    manifest.write_text("\n".join(lines) + "\n")
-
-
-def validate_cs2_pin_required(config: ConfigType) -> ConfigType:
-    needs_cs2 = MODELS[config[CONF_MODEL]]["ctor_args"] == 5
-    has_cs2 = CONF_CS2_PIN in config
-    if needs_cs2 and not has_cs2:
-        raise cv.Invalid(f"'{CONF_CS2_PIN}' is required for model {config[CONF_MODEL]} (a dual-CS panel)")
-    if has_cs2 and not needs_cs2:
-        raise cv.Invalid(f"'{CONF_CS2_PIN}' is not used by model {config[CONF_MODEL]}")
-    return config
-
-
 CONFIG_SCHEMA = cv.All(
 CONFIG_SCHEMA = cv.All(
     display.FULL_DISPLAY_SCHEMA.extend(
     display.FULL_DISPLAY_SCHEMA.extend(
         {
         {
             cv.GenerateID(): cv.declare_id(GxEPD2Display),
             cv.GenerateID(): cv.declare_id(GxEPD2Display),
             cv.GenerateID(CONF_SPI_ID): cv.use_id(spi.SPIComponent),
             cv.GenerateID(CONF_SPI_ID): cv.use_id(spi.SPIComponent),
-            cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=False),
             cv.Required(CONF_CS_PIN): pins.gpio_output_pin_schema,
             cv.Required(CONF_CS_PIN): pins.gpio_output_pin_schema,
             cv.Required(CONF_DC_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_RESET_PIN): pins.gpio_output_pin_schema,
             cv.Optional(CONF_BUSY_PIN): pins.gpio_input_pin_schema,
             cv.Optional(CONF_BUSY_PIN): pins.gpio_input_pin_schema,
-            cv.Optional(CONF_CS2_PIN): pins.gpio_output_pin_schema,
         }
         }
     ),
     ),
     cv.only_with_framework(Framework.ESP_IDF),
     cv.only_with_framework(Framework.ESP_IDF),
-    validate_cs2_pin_required,
 )
 )
 
 
 
 
 async def to_code(config: ConfigType) -> None:
 async def to_code(config: ConfigType) -> None:
-    entry = MODELS[config[CONF_MODEL]]
-    driver_t = cg.global_ns.class_(entry["driver"])
-    page_height = f"{entry['driver']}::HEIGHT / {entry['height_divisor']}"
-    display_t = cg.RawExpression(f"{entry['wrapper']}<{entry['driver']}, {page_height}>")
-    color_mode = cg.RawExpression(f"gxepd2::GxEPD2ColorMode::{COLOR_MODES[entry['wrapper']]}")
-    dual_cs = entry["ctor_args"] == 5
-    specific_type = GxEPD2Display.template(driver_t, display_t, color_mode, dual_cs)
-
-    cg.add_define(WRAPPER_DEFINES[entry["wrapper"]])
-
-    add_idf_component(name=VENDOR_ROOT.name, path=str(VENDOR_ROOT))
-    _write_sources_manifest(entry["source"])
-
-    cs_id, dc_id = _allocate_pin_id(), _allocate_pin_id()
-    reset_id = _allocate_pin_id() if CONF_RESET_PIN in config else -1
-    busy_id = _allocate_pin_id() if CONF_BUSY_PIN in config else -1
-    ctor_args = [cs_id, dc_id, reset_id, busy_id]
-    if entry["ctor_args"] == 5:
-        cs2_id = _allocate_pin_id()
-        ctor_args.append(cs2_id)
-
-    rhs = specific_type.new(*ctor_args)
-    var = cg.Pvariable(config[CONF_ID], rhs, specific_type)
+    var = cg.new_Pvariable(config[CONF_ID])
 
 
-    cg.add(var.set_pin(cs_id, await cg.gpio_pin_expression(config[CONF_CS_PIN])))
-    cg.add(var.set_pin(dc_id, await cg.gpio_pin_expression(config[CONF_DC_PIN])))
+    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):
     if reset_pin := config.get(CONF_RESET_PIN):
-        cg.add(var.set_pin(reset_id, await cg.gpio_pin_expression(reset_pin)))
+        cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin)))
     if busy_pin := config.get(CONF_BUSY_PIN):
     if busy_pin := config.get(CONF_BUSY_PIN):
-        cg.add(var.set_pin(busy_id, await cg.gpio_pin_expression(busy_pin)))
-    if entry["ctor_args"] == 5:
-        cg.add(var.set_pin(cs2_id, await cg.gpio_pin_expression(config[CONF_CS2_PIN])))
+        cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin)))
 
 
     spi_parent = await cg.get_variable(config[CONF_SPI_ID])
     spi_parent = await cg.get_variable(config[CONF_SPI_ID])
     cg.add(var.set_spi_parent(spi_parent))
     cg.add(var.set_spi_parent(spi_parent))

+ 48 - 0
components/gxepd2/epd_command.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <cstdint>
+
+namespace esphome::gxepd2 {
+
+// One command byte plus its (fixed, compile-time) data bytes, as sent by
+// GxEPD2_EPD::_writeCommand()/_writeData(). delay_before_ms/delay_after_ms
+// and wait_busy_after mirror the fixed delay()s and _waitWhileBusy() calls
+// bracketing specific commands in the vendor source (which command gets
+// which varies per panel - e.g. GxEPD2_213_Z98c delays 10ms on both sides
+// of its SWRESET, while GxEPD2_154_Z90c busy-waits after its SWRESET
+// instead). At most one of delay_after_ms/wait_busy_after is expected to be
+// set on any one command.
+struct EpdCommand {
+  uint8_t cmd;
+  const uint8_t *data;  // nullptr if the command takes no data bytes
+  uint8_t data_len;
+  uint16_t delay_before_ms{0};
+  uint16_t delay_after_ms{0};
+  bool wait_busy_after{false};
+};
+
+// Everything CycleEngine needs to drive one panel, hand-derived from a
+// GxEPD2 vendor driver's .cpp/.h (see panel_154_z90c.h). Only what this
+// first slice's engine actually uses - the RAM-window-set command layout
+// (0x44/0x45/0x4E/0x4F) is common to every panel inspected so far and is
+// hardcoded in CycleEngine itself rather than made part of this struct;
+// revisit if/when the extraction-tooling phase finds a controller family
+// that lays it out differently.
+struct EpdPanelDescriptor {
+  uint16_t width;
+  uint16_t height;
+  bool busy_active_level;    // digitalRead() level meaning "still busy"
+  uint32_t busy_timeout_ms;  // ceiling for every busy wait (init and refresh alike)
+
+  const EpdCommand *init_sequence;  // run once, after the reset pulse
+  uint8_t init_sequence_len;
+
+  uint8_t plane_count;          // 1 (BW) or 2 (BW + one accent color)
+  uint8_t plane_write_cmd[2];   // e.g. {0x24, 0x26}
+  bool plane_invert[2];         // true if the byte must be bit-inverted on write
+
+  const EpdCommand *refresh_sequence;  // e.g. 0x22/0xF7 then trigger 0x20
+  uint8_t refresh_sequence_len;
+};
+
+}  // namespace esphome::gxepd2

+ 60 - 258
components/gxepd2/gxepd2_display.h

@@ -1,302 +1,104 @@
 #pragma once
 #pragma once
 
 
-#include <atomic>
-
-#include <esp_task_wdt.h>
-#include <freertos/FreeRTOS.h>
-#include <freertos/semphr.h>
-#include <freertos/task.h>
-
 #include "esphome/core/component.h"
 #include "esphome/core/component.h"
 #include "esphome/core/helpers.h"
 #include "esphome/core/helpers.h"
 #include "esphome/components/display/display_buffer.h"
 #include "esphome/components/display/display_buffer.h"
 #include "esphome/components/spi/spi.h"
 #include "esphome/components/spi/spi.h"
-#include "pin_registry.h"
-
-// Only the wrapper the configured model needs gets included - see
-// display.py's USE_GXEPD2_* defines. Including all four unconditionally
-// fails to parse under ESP-IDF's toolchain: the un-instantiated template
-// bodies of the unused ones hit the same bare WIDTH/HEIGHT names, and
-// GCC's two-phase lookup only resolves those once instantiated.
-#ifdef USE_GXEPD2_BW
-#include <GxEPD2_BW.h>
-#endif
-#ifdef USE_GXEPD2_3C
-#include <GxEPD2_3C.h>
-#endif
-#ifdef USE_GXEPD2_4C
-#include <GxEPD2_4C.h>
-#endif
-#ifdef USE_GXEPD2_7C
-#include <GxEPD2_7C.h>
-#endif
+#include "cycle_engine.h"
+#include "panel_213_z98c.h"
 
 
 namespace esphome::gxepd2 {
 namespace esphome::gxepd2 {
 
 
 static const char *const TAG = "gxepd2";
 static const char *const TAG = "gxepd2";
 
 
-// Selected by codegen from catalog.json's "wrapper" field.
-enum class GxEPD2ColorMode {
-  BW,
-  THREE_COLOR,  // black/white plus one accent ink (red or yellow panels alike)
-  FOUR_COLOR,   // black/white/red/yellow(-ish) 4-ink panels
-  SEVEN_COLOR,  // full ACeP-style palette
-};
-
-// DriverT: a GxEPD2 panel driver class (e.g. GxEPD2_290_T94).
-// DisplayT: that driver wrapped in its GxEPD2 color-depth template (e.g.
-// GxEPD2_BW<GxEPD2_290_T94, GxEPD2_290_T94::HEIGHT>).
-// DualCS: whether DriverT takes 5 constructor args (cs, dc, rst, busy, cs2)
-// instead of 4. All four are chosen by codegen from catalog.json.
-//
-// cs_pin/dc_pin/reset_pin/busy_pin(/cs2_pin) are synthetic PinRegistry ids,
-// not real esphome::GPIOPin; digitalWrite()/digitalRead()/pinMode() in
-// arduino_compat/Arduino.h resolve them back to the real pin.
-//
-// display::DisplayBuffer already derives PollingComponent via Display - do
-// not add PollingComponent again here, it creates an ambiguous base.
-template<typename DriverT, typename DisplayT, GxEPD2ColorMode ColorMode, bool DualCS>
+// Native chunked-engine replacement for the old GxEPD2-template/FreeRTOS-task
+// component - see the plan this was built from (and project memory entry
+// gxepd2_task_watchdog_status) for why. Only GxEPD2_213_Z98c is wired up so
+// far; more panels arrive as hand-written (later, extracted) descriptors,
+// not by templating this class over GxEPD2's own driver classes.
 class GxEPD2Display : public display::DisplayBuffer {
 class GxEPD2Display : public display::DisplayBuffer {
  public:
  public:
-  GxEPD2Display(int16_t cs_pin, int16_t dc_pin, int16_t reset_pin, int16_t busy_pin)
-      : cs_(cs_pin), dc_(dc_pin), rst_(reset_pin), busy_(busy_pin) {
-    static_assert(!DualCS, "use the 5-pin constructor for a dual-CS model");
-  }
-
-  GxEPD2Display(int16_t cs_pin, int16_t dc_pin, int16_t reset_pin, int16_t busy_pin, int16_t cs2_pin)
-      : cs_(cs_pin), dc_(dc_pin), rst_(reset_pin), busy_(busy_pin), cs2_(cs2_pin) {
-    static_assert(DualCS, "the 5-pin constructor is only for a dual-CS model");
-  }
-
-  ~GxEPD2Display() {
-    if (this->update_task_ != nullptr) {
-      vTaskDelete(this->update_task_);
-    }
-    if (this->driver_ != nullptr) {
-      this->driver_->~DisplayT();
-      RAMAllocator<uint8_t>().deallocate(reinterpret_cast<uint8_t *>(this->driver_), 0);
-    }
-  }
-
-  void set_pin(int16_t id, GPIOPin *pin) { PinRegistry::set(id, pin); }
+  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_spi_parent(spi::SPIComponent *parent) { this->spi_.set_spi_parent(parent); }
 
 
-  // DisplayT (holds the page buffer, by far the biggest thing in this
-  // component) is allocated here via RAMAllocator, not as a plain member -
-  // ESPHome's Pvariable placement-news components into static storage, so
-  // this is the one deliberate, explicitly-sized dynamic allocation that
-  // lets the buffer prefer PSRAM when available, made once at boot and
-  // never freed.
   void setup() override {
   void setup() override {
+    const auto *desc = &PANEL_213_Z98C;
+    this->plane_bytes_ = (static_cast<uint32_t>(desc->width) * desc->height) / 8;
     RAMAllocator<uint8_t> allocator;
     RAMAllocator<uint8_t> allocator;
-    void *mem = allocator.allocate(sizeof(DisplayT));
-    if (mem == nullptr) {
-      ESP_LOGE(TAG, "Failed to allocate %u bytes for display buffer", (unsigned) sizeof(DisplayT));
+    this->plane0_ = allocator.allocate(this->plane_bytes_);
+    this->plane1_ = desc->plane_count > 1 ? allocator.allocate(this->plane_bytes_) : nullptr;
+    if (this->plane0_ == nullptr || (desc->plane_count > 1 && this->plane1_ == nullptr)) {
+      ESP_LOGE(TAG, "Failed to allocate %u-byte plane buffer(s)", (unsigned) this->plane_bytes_);
       this->mark_failed();
       this->mark_failed();
       return;
       return;
     }
     }
-    if constexpr (DualCS) {
-      this->driver_ = new (mem) DisplayT(DriverT(this->cs_, this->dc_, this->rst_, this->busy_, this->cs2_));
-    } else {
-      this->driver_ = new (mem) DisplayT(DriverT(this->cs_, this->dc_, this->rst_, this->busy_));
-    }
-    this->spi_.spi_setup();
-    this->driver_->epd2.selectSPI(this->spi_, SPISettings());
-    // GxEPD2's init() does a real panel clear/refresh, blocking on busy just
-    // like a full update does - it runs during ESPHome's synchronous setup()
-    // phase, before any component's dump_config() is called, which is why
-    // the whole boot log can appear to stall here.
-    uint32_t init_start = millis();
-    this->driver_->init(0);
-    ESP_LOGD(TAG, "GxEPD2 init took %u ms", (unsigned) (millis() - init_start));
+    memset(this->plane0_, 0xFF, this->plane_bytes_);
+    if (this->plane1_ != nullptr)
+      memset(this->plane1_, 0xFF, this->plane_bytes_);
 
 
-    // GxEPD2's own refresh cycle - draw callbacks, the SPI write burst, and
-    // above all the busy-pin wait - is synchronous top to bottom, with no
-    // yield points we can hook to turn it into the kind of chunked,
-    // resumable loop() state machine ESPHome components are expected to be
-    // (that's how esphome's own epaper_spi avoids ever blocking: its
-    // update() just arms a flag and returns, and loop() polls busy
-    // non-blockingly on every main-loop tick). Rewriting GxEPD2 itself to
-    // work that way is out of reach here, so instead the whole sequence
-    // runs on its own FreeRTOS task: update() just wakes this task and
-    // returns immediately, so the real several-seconds-long wait happens
-    // off the main loop entirely, and WiFi/API/every other component stay
-    // responsive through a refresh instead of stalling with it.
-    //
-    // Caveats accepted for now, matching this project's actual constraint
-    // (an update() that blocks the whole app is a non-starter, full stop):
-    //  - The draw lambda (and anything it reads - sensors, globals, other
-    //    components) now runs off the main loop's thread. ESPHome code is
-    //    generally written assuming single-threaded access; simple POD
-    //    reads (numbers, bools) are practically fine, but this is a real
-    //    departure from the norm worth keeping in mind for anything richer.
-    //  - A second update() call while one is still running is rejected
-    //    outright (logged, not queued) via update_running_ below - no
-    //    attempt to serialize or coalesce them.
-    //  - The SPI bus is only actually held (bus acquired, CS asserted) for
-    //    the write bursts, not for the busy-pin wait in between, so a
-    //    shared spi: bus should mostly be fine - but that's about the
-    //    write phase, not this task boundary, and hasn't been stress-tested
-    //    against another device on the same bus mid-refresh.
-    //
-    // KNOWN ISSUE, unresolved as of this comment: on real hardware,
-    // "gxepd2_update" itself still sometimes trips the ESP-IDF task
-    // watchdog (confirmed via the panic naming it directly), despite: (1)
-    // being explicitly subscribed to it (see update_task_loop_), and (2)
-    // every long-running spot we've found - the busy-wait in
-    // arduino_compat/Arduino.h, the per-256-byte throttle in
-    // arduino_compat/SPI.h - calling esp_task_wdt_reset() directly rather
-    // than through esphome's globally-rate-limited App.feed_wdt(). Not
-    // chased further this session; likely another GxEPD2 call path that
-    // runs long without hitting either of those two spots.
-    //
-    // Bigger-picture option worth weighing before sinking more time into
-    // patching this shape further: the actual value GxEPD2 gives us is its
-    // per-panel database (dimensions, init sequences, LUTs, quirks like
-    // 579c's master/slave split) and a reference implementation to check
-    // against - not really its control flow, which is exactly what's
-    // fighting us here (synchronous, no yield points, not built for
-    // esphome's cooperative loop() model). A future pass could extract
-    // just the per-device data/sequences and reimplement the actual
-    // drawing/refresh loop natively in esphome's chunked loop() style
-    // (like epaper_spi), dropping this task entirely along with whatever
-    // watchdog and thread-safety fragility comes with it. That's a real
-    // rewrite, not a patch - worth a dedicated pass, not a continuation of
-    // this one.
-    this->lambda_done_sem_ = xSemaphoreCreateBinary();
-    xTaskCreate(&GxEPD2Display::update_task_trampoline_, "gxepd2_update", 4096, this, 1, &this->update_task_);
+    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 {
   void update() override {
-    bool expected = false;
-    if (!this->update_running_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
+    if (!this->engine_.idle()) {
       ESP_LOGW(TAG, "Skipping update: previous display refresh is still running");
       ESP_LOGW(TAG, "Skipping update: previous display refresh is still running");
       return;
       return;
     }
     }
-    // update_task_loop_() suspends this exact task around every later
-    // page's draw lambda too (see there) - it needs to know who to
-    // suspend, since after this call returns nothing keeps it waiting for
-    // update_task_ to signal it.
-    this->caller_task_ = xTaskGetCurrentTaskHandle();
-    xTaskNotifyGive(this->update_task_);
-    // Block only through this frame's draw lambda - not through the SPI
-    // write / physical refresh that follows, which continues on
-    // update_task_ after we return. For a paged (height_divisor > 1) model
-    // this covers only the first page's lambda call synchronously; later
-    // pages' lambda calls happen after we've returned, protected instead
-    // by briefly suspending this task around each one (below).
-    xSemaphoreTake(this->lambda_done_sem_, portMAX_DELAY);
+    this->do_update_();
+    this->engine_.request_full_update(this->plane0_, this->plane1_);
   }
   }
 
 
+  void loop() override { this->engine_.step(); }
+
   void dump_config() override {
   void dump_config() override {
     ESP_LOGCONFIG(TAG, "GxEPD2 Display");
     ESP_LOGCONFIG(TAG, "GxEPD2 Display");
     LOG_UPDATE_INTERVAL(this);
     LOG_UPDATE_INTERVAL(this);
   }
   }
 
 
-  display::DisplayType get_display_type() override {
-    if constexpr (ColorMode == GxEPD2ColorMode::BW) {
-      return display::DisplayType::DISPLAY_TYPE_BINARY;
-    } else {
-      return display::DisplayType::DISPLAY_TYPE_COLOR;
-    }
-  }
+  display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
 
 
  protected:
  protected:
   void draw_absolute_pixel_internal(int x, int y, Color color) override {
   void draw_absolute_pixel_internal(int x, int y, Color color) override {
-    if constexpr (ColorMode == GxEPD2ColorMode::BW) {
-      this->driver_->drawPixel(x, y, color.is_on() ? GxEPD_BLACK : GxEPD_WHITE);
-    } else if constexpr (ColorMode == GxEPD2ColorMode::THREE_COLOR) {
-      if (color.red > color.green && color.red > color.blue && color.red > 0x7f) {
-        this->driver_->drawPixel(x, y, GxEPD_RED);
+    if (x < 0 || y < 0 || x >= PANEL_213_Z98C.width || y >= PANEL_213_Z98C.height)
+      return;
+    uint32_t idx = (static_cast<uint32_t>(y) * PANEL_213_Z98C.width + x) / 8;
+    uint8_t mask = 0x80 >> (x % 8);
+    // Same classification the old task-based component used: red/accent
+    // pixels go to the color plane, 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.
+    bool red = color.red > color.green && color.red > color.blue && color.red > 0x7f;
+    bool bright = color.red > 0x7f || color.green > 0x7f || color.blue > 0x7f;
+    if (red) {
+      this->plane1_[idx] &= ~mask;  // 0 bit = colored, per GxEPD2_213_Z98c::writeImage's color plane
+    } else {
+      this->plane1_[idx] |= mask;
+      if (bright) {
+        this->plane0_[idx] |= mask;
       } else {
       } else {
-        // get_display_type() reports DISPLAY_TYPE_COLOR for this mode, so
-        // callers draw with literal RGB colors (Color(255,255,255,...) for
-        // white, Color(0,0,0,...) for black) - not the on/off convention
-        // is_on() (raw_32 != 0) assumes, which is only correct for the BW
-        // path above (DISPLAY_TYPE_BINARY). Using is_on() here inverted
-        // black and white for any lambda drawing literal colors.
-        bool bright = color.red > 0x7f || color.green > 0x7f || color.blue > 0x7f;
-        this->driver_->drawPixel(x, y, bright ? GxEPD_WHITE : GxEPD_BLACK);
+        this->plane0_[idx] &= ~mask;
       }
       }
-    } else {
-      uint16_t rgb565 = ((color.red & 0xF8) << 8) | ((color.green & 0xFC) << 3) | (color.blue >> 3);
-      this->driver_->drawPixel(x, y, rgb565);
     }
     }
   }
   }
-  int get_width_internal() override { return this->driver_->width(); }
-  int get_height_internal() override { return this->driver_->height(); }
-
-  int16_t cs_;
-  int16_t dc_;
-  int16_t rst_;
-  int16_t busy_;
-  int16_t cs2_{-1};
-  DisplayT *driver_{nullptr};
-  SPIClass spi_;
-
- private:
-  static void update_task_trampoline_(void *param) { static_cast<GxEPD2Display *>(param)->update_task_loop_(); }
-
-  // Blocks (cooperatively - ulTaskNotifyTake sleeps this task, not the CPU)
-  // until update() wakes it, runs one full refresh, then goes back to
-  // sleep. Never returns; this is the task's entire body for its lifetime.
-  [[noreturn]] void update_task_loop_() {
-    // Arduino.h's delay()/yield() and SPI.h's transfer() call
-    // esphome::App.feed_wdt() to keep this task's own long busy-waits and
-    // SPI write bursts from starving another task's watchdog - but
-    // feed_wdt() only resets the *calling* task's own subscription, and
-    // this task was never subscribed (only the main loop task is, by
-    // esphome's own setup). Every one of those calls was silently failing
-    // (esp_task_wdt_reset() logging "task not found"), which is at best
-    // useless and, going by a crash inside the same watchdog subsystem's
-    // idle-task hook right after a burst of that error, plausibly worse
-    // than useless. Subscribe for real so those resets land.
-    esp_task_wdt_add(nullptr);
-    while (true) {
-      ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
-      uint32_t start = millis();
-      this->driver_->setFullWindow();
-      this->driver_->firstPage();
-      bool first_page = true;
-      do {
-        if (!first_page) {
-          // update() already returned after the first page, so the caller
-          // task is off running its own thing again - nothing keeps this
-          // page's draw lambda from racing whatever it's doing (reading a
-          // sensor, updating a global, ...). Freeze it just long enough to
-          // draw this page too, so every page's lambda gets the same
-          // "nothing else is running" guarantee the first one had for
-          // free, not just the first. Resumed right after, before the
-          // (slow) hardware write below, so the caller stays responsive
-          // through the part that actually takes a while.
-          vTaskSuspend(this->caller_task_);
-        }
-        this->do_update_();
-        if (first_page) {
-          // update() is waiting on exactly this: the draw lambda for the
-          // first (often only) page has finished, so the caller can
-          // proceed while the SPI write and physical refresh below run
-          // without it.
-          xSemaphoreGive(this->lambda_done_sem_);
-          first_page = false;
-        } else {
-          vTaskResume(this->caller_task_);
-        }
-      } while (this->driver_->nextPage());
-      ESP_LOGD(TAG, "Display update took %u ms", (unsigned) (millis() - start));
-      this->update_running_.store(false, std::memory_order_release);
-    }
-  }
-
-  TaskHandle_t update_task_{nullptr};
-  // Set fresh by update() every call, before update_task_ can possibly read
-  // it (it's blocked on lambda_done_sem_ until the first page finishes) -
-  // safe without extra synchronization since update_running_ already
-  // guarantees only one update cycle, and thus only one writer/reader pair
-  // for this field, is ever in flight at a time.
-  TaskHandle_t caller_task_{nullptr};
-  SemaphoreHandle_t lambda_done_sem_{nullptr};
-  std::atomic<bool> update_running_{false};
+  int get_width_internal() override { return PANEL_213_Z98C.width; }
+  int get_height_internal() override { return PANEL_213_Z98C.height; }
+
+  GPIOPin *cs_{nullptr};
+  GPIOPin *dc_{nullptr};
+  GPIOPin *rst_{nullptr};
+  GPIOPin *busy_{nullptr};
+  EpdSPIDevice spi_;
+  CycleEngine engine_;
+  uint8_t *plane0_{nullptr};
+  uint8_t *plane1_{nullptr};
+  uint32_t plane_bytes_{0};
 };
 };
 
 
 }  // namespace esphome::gxepd2
 }  // namespace esphome::gxepd2

+ 64 - 0
components/gxepd2/panel_213_z98c.h

@@ -0,0 +1,64 @@
+#pragma once
+
+// Hand-derived from vendor/src/epd3c/GxEPD2_213_Z98c.{h,cpp} (GDEY0213Z98,
+// SSD1680 controller, 128x250 RAM window (122 columns visible), black +
+// red). This is the panel actually on the bench for this first slice of the
+// native chunked engine; see the plan this was implemented from (and
+// gxepd2_task_watchdog_status in project memory) for why GxEPD2's own
+// control flow is being dropped in favor of just its per-panel data.
+
+#include "epd_command.h"
+
+namespace esphome::gxepd2 {
+
+// GxEPD2_213_Z98c::_InitDisplay(). Unlike GxEPD2_154_Z90c (the panel this
+// slice originally targeted), this controller's SWRESET is bracketed by
+// fixed 10ms delays rather than a busy-pin wait, and it has an extra
+// "Display update control" command (0x21) 154_Z90c doesn't. Minus the
+// trailing _setPartialRamArea() - CycleEngine's SET_WINDOW state (used for
+// every write, not just init) covers that.
+inline const uint8_t PANEL_213_Z98C_DRIVER_OUTPUT_CONTROL[] = {0xF9, 0x00, 0x00};
+inline const uint8_t PANEL_213_Z98C_DATA_ENTRY_MODE[] = {0x03};
+inline const uint8_t PANEL_213_Z98C_BORDER_WAVEFORM[] = {0x05};
+inline const uint8_t PANEL_213_Z98C_TEMP_SENSOR[] = {0x80};
+inline const uint8_t PANEL_213_Z98C_DISPLAY_UPDATE_CONTROL[] = {0x00, 0x80};
+
+inline const EpdCommand PANEL_213_Z98C_INIT[] = {
+    {0x12, nullptr, 0, /*delay_before_ms=*/10, /*delay_after_ms=*/10, /*wait_busy_after=*/false},  // SWRESET
+    {0x01, PANEL_213_Z98C_DRIVER_OUTPUT_CONTROL, 3},
+    {0x11, PANEL_213_Z98C_DATA_ENTRY_MODE, 1},
+    {0x3C, PANEL_213_Z98C_BORDER_WAVEFORM, 1},
+    {0x18, PANEL_213_Z98C_TEMP_SENSOR, 1},
+    {0x21, PANEL_213_Z98C_DISPLAY_UPDATE_CONTROL, 2},
+};
+
+// GxEPD2_213_Z98c::_Update_Full()/_Update_Part() are identical - both just
+// write 0xF7 to the display-update-control register (0x22) and trigger
+// (0x20). This panel has no separate fast-partial mode.
+inline const uint8_t PANEL_213_Z98C_UPDATE_MODE[] = {0xF7};
+
+inline const EpdCommand PANEL_213_Z98C_REFRESH[] = {
+    {0x22, PANEL_213_Z98C_UPDATE_MODE, 1},
+    {0x20, nullptr, 0, /*delay_before_ms=*/0, /*delay_after_ms=*/0, /*wait_busy_after=*/true},
+};
+
+// full_refresh_time/partial_refresh_time from the vendor header are both
+// 15000ms; used as the ceiling for every busy wait in this slice, not just
+// the refresh one it was measured for - simpler than carrying a separate
+// timeout per wait site, and erring long only delays reporting a genuine
+// timeout, it doesn't cause one.
+inline const EpdPanelDescriptor PANEL_213_Z98C = {
+    .width = 128,   // RAM window width - _setPartialRamArea works in this, not WIDTH_VISIBLE (122)
+    .height = 250,
+    .busy_active_level = true,  // GxEPD2_EPD(..., /*busy_level=*/HIGH, ...)
+    .busy_timeout_ms = 16000,
+    .init_sequence = PANEL_213_Z98C_INIT,
+    .init_sequence_len = sizeof(PANEL_213_Z98C_INIT) / sizeof(PANEL_213_Z98C_INIT[0]),
+    .plane_count = 2,
+    .plane_write_cmd = {0x24, 0x26},
+    .plane_invert = {false, true},  // color plane written as ~data - see writeImage()
+    .refresh_sequence = PANEL_213_Z98C_REFRESH,
+    .refresh_sequence_len = sizeof(PANEL_213_Z98C_REFRESH) / sizeof(PANEL_213_Z98C_REFRESH[0]),
+};
+
+}  // namespace esphome::gxepd2

+ 0 - 36
components/gxepd2/pin_registry.h

@@ -1,36 +0,0 @@
-#pragma once
-
-#include <vector>
-
-#include "esphome/core/gpio.h"
-
-namespace esphome::gxepd2 {
-
-// Maps GxEPD2's plain int16_t pin numbers back to the real esphome::GPIOPin
-// each one stands for. IDs are assigned in Python at codegen time, not via
-// any runtime allocation call, so there's no construction-order hazard.
-class PinRegistry {
- public:
-  static void set(int16_t id, GPIOPin *pin) {
-    if (id < 0 || pin == nullptr)
-      return;
-    if (static_cast<size_t>(id) >= pins().size())
-      pins().resize(id + 1, nullptr);
-    pins()[id] = pin;
-    pin->setup();
-  }
-
-  static GPIOPin *get(int16_t id) {
-    if (id < 0 || static_cast<size_t>(id) >= pins().size())
-      return nullptr;
-    return pins()[id];
-  }
-
- protected:
-  static std::vector<GPIOPin *> &pins() {
-    static std::vector<GPIOPin *> pins;
-    return pins;
-  }
-};
-
-}  // namespace esphome::gxepd2

+ 0 - 1
tests/test_gxepd2_3c.esp32-idf.yaml

@@ -15,7 +15,6 @@ spi:
 logger:
 logger:
 display:
 display:
   - platform: gxepd2
   - platform: gxepd2
-    model: GxEPD2_154_Z90c
     cs_pin: 5
     cs_pin: 5
     dc_pin: 17
     dc_pin: 17
     reset_pin: 16
     reset_pin: 16