Bladeren bron

Copy engine core forward as zepd::Engine/EngineModel

Renamed from the legacy components/gxepd2 CycleEngine/EpdPanelDescriptor:
namespace esphome::gxepd2 -> zepd (no project-name stutter inside its own
namespace), CycleEngine -> Engine, EpdPanelDescriptor -> EngineModel,
GXEPD2_PALETTE_* -> PALETTE_*, log tag/diagnostics define gxepd2 -> zepd.
Still directly uses ESPHome GPIO/SPI/log calls - full adapter decoupling
is future work, not this pass (see claude/ZEPD_PLAN.md).

No behavior change: logic is otherwise untouched from cycle_engine.h /
epd_command.h.
ZiB 2 weken geleden
bovenliggende
commit
5fd8bcca0d

+ 0 - 0
esphome-zepd/components/zepd/engine/.gitkeep


+ 532 - 0
esphome-zepd/components/zepd/engine/engine.h

@@ -0,0 +1,532 @@
+#pragma once
+
+#include <cstdint>
+#include <cstdio>
+
+#include "esphome/core/gpio.h"
+#include "esphome/core/hal.h"
+#include "esphome/core/log.h"
+#include "esphome/components/spi/spi.h"
+#include "engine_model.h"
+
+namespace zepd {
+
+// Stock GxEPD2 vendor value (20MHz).
+using EpdSPIDevice = spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
+                                     spi::DATA_RATE_20MHZ>;
+
+// Replays 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. step() never blocks: every state does a small
+// bounded unit of work and returns, so nothing here can starve another task or
+// need a watchdog subscription.
+//
+// request_power_off()/request_hibernate() are a separate, standalone entry
+// point (no init/window/plane-write) replaying just
+// power_off_sequence/hibernate_sequence - for a user's own lambda, not run
+// automatically by request_full_update(). Called while a refresh is still
+// in flight (e.g. from an adapter's teardown path), they force a reset
+// pulse first to abort it rather than writing over a busy controller - see
+// begin_power_down_().
+class Engine {
+ public:
+  void begin(const EngineModel *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->arm_full_update_(plane0, plane1, this->rst_ != nullptr ? State::RESET_LOW : State::INIT_CMD);
+  }
+
+#ifdef USE_ZEPD_DIAGNOSTICS
+  // Dev-time diagnostic, compiled out unless the adapter's `diagnostics:
+  // true` is set: arms a full update starting at INIT_CMD, skipping the
+  // reset pulse entirely - the same path a panel with no reset pin wired
+  // already takes every cycle. Lets a bench test use the screen as the
+  // oracle for "is the panel actually non-responsive right now" (e.g. right
+  // after hibernate()): if a distinct pattern drawn this way does NOT
+  // appear, the prior power call really did take the panel off-line; a
+  // normal request_full_update() right after (reset pulse included) should
+  // then wake it and render correctly.
+  void diag_request_update_no_reset(const uint8_t *plane0, const uint8_t *plane1) {
+    this->arm_full_update_(plane0, plane1, State::INIT_CMD);
+  }
+#endif
+
+  // Arms a standalone powerOff() replay - no init/window/plane write, just
+  // the captured power_off_sequence (if any) then DONE. Safe to call any
+  // time, including mid-refresh: see begin_power_down_() below for why that
+  // matters. Safe to call repeatedly: GxEPD2's own powerOff() is itself
+  // guarded on "already off," which the capture already baked in.
+  void request_power_off() { this->begin_power_down_(false); }
+
+  // Arms a standalone hibernate() replay: power_off_sequence then
+  // hibernate_sequence (the deep-sleep trigger only - see engine_model.h).
+  // See begin_power_down_() below for the mid-refresh case. No paired "wake"
+  // call exists or is needed: request_full_update()'s RESET_LOW is an
+  // unconditional hardware reset pulse every cycle, which is exactly how
+  // GxEPD2 wakes a hibernating (RST-wakeable) panel.
+  void request_hibernate() { this->begin_power_down_(true); }
+
+  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;
+        // Post-reset busy wait, matching every vendor _InitDisplay()'s
+        // _waitWhileBusy() right after the reset pulse. Some controllers hold
+        // BUSY while they load settings/waveforms from OTP after a hardware
+        // reset (notably JD79676 / GDEY0213F52, whose init is otherwise just
+        // 0xE9 + power-on) - issuing any command before that clears gets it
+        // silently dropped, so the panel never refreshes. Panels that don't
+        // assert BUSY post-reset read "not busy" on the first poll and fall
+        // straight through to INIT_CMD, so this is safe for all of them.
+        // begin_power_down_()'s abort path sets reset_then_wait_busy_ to
+        // false instead - see there for why.
+        if (this->reset_then_wait_busy_) {
+          this->start_busy_wait_(this->reset_done_next_);
+        } else {
+          this->set_state_(this->reset_done_next_);
+        }
+        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:
+        // window_sequence[this->plane_]: index 0 on the first entry
+        // (plane_ reset to 0 in request_full_update()); when
+        // window_per_plane is true, step_write_plane_() re-enters this
+        // state with plane_ already advanced to 1 before the next plane's
+        // byte burst, picking window_sequence[1] instead.
+        this->step_sequence_(this->desc_->window_sequence[this->plane_], this->desc_->window_sequence_len[this->plane_],
+                              State::SET_WINDOW, 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::POWER_OFF_CMD:
+        // request_hibernate() routes through here first (hibernate() is
+        // itself powerOff() + one more command on every GxEPD2 driver) -
+        // done_state branches to HIBERNATE_CMD only then.
+        this->step_sequence_(this->desc_->power_off_sequence, this->desc_->power_off_sequence_len,
+                              State::POWER_OFF_CMD, this->hibernating_ ? State::HIBERNATE_CMD : State::DONE);
+        return;
+
+      case State::HIBERNATE_CMD:
+        this->step_sequence_(this->desc_->hibernate_sequence, this->desc_->hibernate_sequence_len,
+                              State::HIBERNATE_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("zepd", "Display update finished");
+        this->set_state_(State::IDLE);
+        return;
+
+      case State::ABORTED:
+        // A busy timeout means the panel isn't behaving as wired/expected
+        // (unplugged, dead, wrong busy polarity, ...) - pressing ahead
+        // with the rest of the sequence (more command bytes, another
+        // busy-wait) is very unlikely to "just work" and only obscures
+        // which step actually failed. Bail the whole cycle here instead of
+        // continuing to whatever state the timed-out wait was headed to.
+        ESP_LOGE("zepd", "Aborting display update: busy timeout");
+        this->cursor_ = 0;
+        this->plane_ = 0;
+        this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+        this->set_state_(State::IDLE);
+        return;
+    }
+  }
+
+ private:
+  // State/CmdPhase must come before arm_full_update_() below: a member
+  // function's PARAMETER types (unlike its body) are resolved at the point
+  // of declaration, not deferred to complete-class context, so `State` has
+  // to already be a known type by the time arm_full_update_()'s signature is
+  // parsed.
+  enum class State {
+    IDLE,
+    RESET_LOW,
+    RESET_LOW_WAIT,
+    RESET_HIGH_WAIT,
+    RESET_SETTLE,
+    INIT_CMD,
+    SET_WINDOW,
+    WRITE_PLANE,
+    REFRESH_CMD,
+    POWER_OFF_CMD,
+    HIBERNATE_CMD,
+    POST_DELAY,
+    WAIT_BUSY,
+    DONE,
+    ABORTED,
+  };
+  enum class CmdPhase { BEFORE_DELAY, ISSUE };
+
+  // Shared by request_full_update() and (dev builds only) the no-reset
+  // diagnostic path above - identical setup, different starting state.
+  void arm_full_update_(const uint8_t *plane0, const uint8_t *plane1, State start_state) {
+    this->planes_[0] = plane0;
+    this->planes_[1] = plane1;
+    uint32_t w = this->desc_->window_per_plane ? this->desc_->plane_width : this->desc_->width;
+    // width*height*bpp/8: /8 at 1bpp, /4 at 2bpp, /2 at 4bpp.
+    this->plane_bytes_ = (uint32_t) w * this->desc_->height * this->desc_->bits_per_pixel / 8;
+    this->bytes_per_row_ = this->plane_bytes_ / this->desc_->height;
+    // Wire length can exceed the buffer: PAIRED_EDGES sends each row twice
+    // (2x-height RAM). plane_bytes_ stays the real buffer size; wire_bytes_
+    // is what actually goes out on 0x10/0x24/... per plane.
+    uint32_t wire_lines = this->desc_->height;
+    if (this->desc_->line_order == LineOrder::PAIRED_EDGES)
+      wire_lines *= 2;
+    this->wire_bytes_ = wire_lines * this->bytes_per_row_;
+    this->cursor_ = 0;
+    this->plane_ = 0;
+    this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+    // Normal reset path: post-reset busy wait (see RESET_SETTLE below), then
+    // the real init sequence - this->reset_done_next_/reset_then_wait_busy_
+    // are only ever overridden by begin_power_down_()'s abort path.
+    this->reset_done_next_ = State::INIT_CMD;
+    this->reset_then_wait_busy_ = true;
+    this->set_state_(start_state);
+  }
+
+  // Shared by request_power_off()/request_hibernate(). Most controllers
+  // ignore new commands outright while BUSY is asserted, so writing
+  // power_off_sequence/hibernate_sequence straight over a cycle that's still
+  // mid-refresh would silently do nothing - the panel never actually powers
+  // down, it just keeps running the update it was already doing. If a reset
+  // pin is wired, force a hardware reset pulse first (the same one
+  // request_full_update() issues every normal cycle) to abort whatever's in
+  // flight and bring the controller back to a known, command-accepting
+  // state before sending the power-down sequence - skipping the post-reset
+  // busy wait that a real update takes, since this is an emergency path
+  // that can't afford to stall on busy_timeout_ms. Without a reset pin
+  // there's no way to interrupt a busy controller, so this is best-effort
+  // in that case: jamming the state machine straight to POWER_OFF_CMD, same
+  // as calling this while idle.
+  void begin_power_down_(bool hibernating) {
+    this->cursor_ = 0;
+    this->plane_ = 0;
+    this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+    this->hibernating_ = hibernating;
+    if (this->state_ != State::IDLE && this->rst_ != nullptr) {
+      this->reset_done_next_ = State::POWER_OFF_CMD;
+      this->reset_then_wait_busy_ = false;
+      this->set_state_(State::RESET_LOW);
+    } else {
+      this->set_state_(State::POWER_OFF_CMD);
+    }
+  }
+
+  // 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
+  // third_party/GxEPD2/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) {
+#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
+    // Command-level skeleton trace - deliberately left in (not gated to
+    // VERY_VERBOSE): the exact byte-for-byte counterpart to compare against
+    // a captured reference trace on the same panel. Bulk plane data isn't
+    // logged here (see step_write_plane_) - only the command skeleton
+    // matters for that comparison.
+    char buf[32] = {0};
+    int n = 0;
+    for (uint8_t i = 0; i < data_len && n < (int) sizeof(buf) - 3; i++)
+      n += snprintf(buf + n, sizeof(buf) - n, "%02X ", data[i]);
+    ESP_LOGD("zepd", "cmd 0x%02X data[%u]: %s", cmd, data_len, buf);
+#endif
+    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.
+  }
+
+  // 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.
+  //
+  // 16384 allows up to ~25ms of blocking per step; the dominant cost is
+  // write_byte_()'s per-byte SPI enable()/disable() (ESP-IDF transaction
+  // setup/teardown), intentionally NOT batched away: it lets other devices
+  // on a shared spi: bus interleave between our bytes instead of being
+  // locked out for the whole burst.
+  static constexpr uint32_t BYTE_BUDGET = 16384;
+
+  void step_write_plane_() {
+    if (this->cursor_ == 0) {
+      const uint8_t *buf = this->planes_[this->plane_];
+      uint32_t histo[3] = {0, 0, 0};  // [0]=0x00, [1]=0xFF, [2]=other
+      if (buf != nullptr) {
+        for (uint32_t i = 0; i < this->plane_bytes_; i++) {
+          if (buf[i] == 0x00)
+            histo[0]++;
+          else if (buf[i] == 0xFF)
+            histo[1]++;
+          else
+            histo[2]++;
+        }
+      }
+      ESP_LOGD("zepd", "plane %u: cmd 0x%02X, %u wire bytes (buf %u), invert=%d, buf[0]=0x%02X buf[last]=0x%02X, histo(00/FF/other)=%u/%u/%u",
+               this->plane_, this->desc_->plane_write_cmd[this->plane_], (unsigned) this->wire_bytes_,
+               (unsigned) this->plane_bytes_, this->desc_->plane_invert[this->plane_], buf != nullptr ? buf[0] : 0,
+               buf != nullptr ? buf[this->plane_bytes_ - 1] : 0, (unsigned) histo[0], (unsigned) histo[1],
+               (unsigned) histo[2]);
+      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->wire_bytes_)
+      end = this->wire_bytes_;
+    for (; this->cursor_ < end; this->cursor_++) {
+      uint32_t src = this->line_src_index_(this->cursor_);
+      uint8_t b = buf != nullptr ? buf[src] : 0xFF;
+      this->write_byte_(invert ? static_cast<uint8_t>(~b) : b, true);
+    }
+    if (this->cursor_ >= this->wire_bytes_) {
+      this->cursor_ = 0;
+      this->plane_++;
+      this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
+      if (this->plane_ >= this->desc_->plane_count) {
+        this->plane_ = 0;
+        this->set_state_(State::REFRESH_CMD);
+      } else if (this->desc_->window_per_plane) {
+        // Another plane to go and it needs its own window-set first (a
+        // genuinely separate physical RAM region, not just another color
+        // plane sharing the window already set) - re-enter SET_WINDOW,
+        // which will pick window_sequence[this->plane_] now that plane_
+        // has already advanced.
+        this->set_state_(State::SET_WINDOW);
+      }
+      // else: more planes, sharing one window already set - stay in
+      // WRITE_PLANE, next step() call starts the next plane's byte burst.
+    }
+  }
+
+  // Maps a wire-transmission-order byte position (0..wire_bytes_-1) to the
+  // buffer byte it reads. The wire is a sequence of lines, each bytes_per_row_
+  // bytes from one buffer row; line_order picks which buffer row each wire
+  // line carries (and, for PAIRED_EDGES, there are 2x as many wire lines as
+  // buffer rows). SEQUENTIAL is the identity (wire line i -> row i).
+  uint32_t line_src_index_(uint32_t wire_pos) const {
+    uint32_t wire_line = wire_pos / this->bytes_per_row_;
+    uint32_t byte_in_line = wire_pos % this->bytes_per_row_;
+    uint32_t buf_row;
+    switch (this->desc_->line_order) {
+      case LineOrder::PAIRED_EDGES: {
+        // wire lines come in pairs: for i in [0,H), line 2i = row (H-1-i),
+        // line 2i+1 = row i. Matches GxEPD2 writeNative's emission exactly.
+        uint32_t i = wire_line / 2;
+        buf_row = (wire_line % 2 == 0) ? (this->desc_->height - 1 - i) : i;
+        break;
+      }
+      case LineOrder::SEQUENTIAL:
+      default:
+        buf_row = wire_line;
+        break;
+    }
+    return buf_row * this->bytes_per_row_ + byte_in_line;
+  }
+
+  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("zepd", "Busy timeout waiting for panel");
+      this->set_state_(State::ABORTED);
+    }
+  }
+
+  // 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("zepd", "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::POWER_OFF_CMD:
+        return "POWER_OFF_CMD";
+      case State::HIBERNATE_CMD:
+        return "HIBERNATE_CMD";
+      case State::POST_DELAY:
+        return "POST_DELAY";
+      case State::WAIT_BUSY:
+        return "WAIT_BUSY";
+      case State::DONE:
+        return "DONE";
+      case State::ABORTED:
+        return "ABORTED";
+    }
+    return "?";
+  }
+
+  const EngineModel *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};   // real buffer size per plane
+  uint32_t wire_bytes_{0};    // bytes actually sent per plane (>= plane_bytes_)
+  uint32_t bytes_per_row_{0};
+  uint32_t deadline_{0};
+  State reset_done_next_{State::INIT_CMD};  // RESET_SETTLE's continuation - see arm_full_update_()/begin_power_down_()
+  bool reset_then_wait_busy_{true};         // false only for begin_power_down_()'s abort path
+  State delay_return_state_{State::DONE};
+  uint32_t busy_wait_deadline_{0};
+  State busy_wait_next_{State::DONE};
+  bool hibernating_{false};  // POWER_OFF_CMD's done_state: HIBERNATE_CMD if set, else DONE
+};
+
+}  // namespace zepd

+ 187 - 0
esphome-zepd/components/zepd/engine/engine_model.h

@@ -0,0 +1,187 @@
+#pragma once
+
+#include <cstdint>
+
+namespace zepd {
+
+// 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};
+};
+
+// One palette entry for a packed (bits_per_pixel > 1) panel: a reference
+// RGB color (ESPHome Color space, 0-255 per channel) and the wire code the
+// controller uses for it. Classification at draw time picks the entry
+// nearest the drawn color, with exact matches winning - so named colors map
+// deterministically, the way GxEPD2's own color4()/color7() switch does.
+// Several entries may share a code (GxEPD2 maps green and orange both onto
+// the 4-color yellow code, and blue onto black).
+struct EpdPaletteEntry {
+  uint8_t r, g, b;
+  uint8_t code;
+};
+
+// Standard GxEPD2 4-color (BWRY) palette - codes per GxEPD2_4C.h color4():
+// black=0x00, white=0x01, yellow/green/orange=0x02, red=0x03, blue->black.
+inline const EpdPaletteEntry PALETTE_4C[] = {
+    {0x00, 0x00, 0x00, 0x00},  // black
+    {0xFF, 0xFF, 0xFF, 0x01},  // white
+    {0xFF, 0xFF, 0x00, 0x02},  // yellow
+    {0x00, 0xFF, 0x00, 0x02},  // green  -> yellow code
+    {0xFF, 0xA4, 0x00, 0x02},  // orange -> yellow code
+    {0xFF, 0x00, 0x00, 0x03},  // red
+    {0x00, 0x00, 0xFF, 0x00},  // blue   -> black code
+};
+inline const uint8_t PALETTE_4C_LEN = sizeof(PALETTE_4C) / sizeof(PALETTE_4C[0]);
+
+// Standard ACeP 7-color palette - codes per GxEPD2_7C.h color7(): black=0x00,
+// white=0x01, green=0x02, blue=0x03, red=0x04, yellow=0x05, orange=0x06.
+inline const EpdPaletteEntry PALETTE_7C[] = {
+    {0x00, 0x00, 0x00, 0x00},  // black
+    {0xFF, 0xFF, 0xFF, 0x01},  // white
+    {0x00, 0xFF, 0x00, 0x02},  // green
+    {0x00, 0x00, 0xFF, 0x03},  // blue
+    {0xFF, 0x00, 0x00, 0x04},  // red
+    {0xFF, 0xFF, 0x00, 0x05},  // yellow
+    {0xFF, 0xA4, 0x00, 0x06},  // orange
+};
+inline const uint8_t PALETTE_7C_LEN = sizeof(PALETTE_7C) / sizeof(PALETTE_7C[0]);
+
+// Everything the engine needs to drive one panel - this is the codegen'd
+// "engine model" produced from a model's drivers/<model>.json (see
+// ../../../../claude/DESCRIPTOR_REGISTRY_PLAN.md), hand-derived today from a
+// GxEPD2 vendor driver's .cpp/.h the same way the legacy
+// components/gxepd2/panel_*.h files were. Only what the 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 Engine itself rather
+// than made part of this struct; revisit if/when a controller family turns
+// up that lays it out differently.
+//
+// Order in which the engine emits a plane's wire lines, derived by the diag
+// native-path tool. The buffer always holds `height` rows of real pixels;
+// this controls only which buffer row each wire line carries and how many
+// wire lines there are, so wire length can exceed buffer size.
+//   SEQUENTIAL   - wire line i carries buffer row i; line count == height.
+//                  Every single-region panel captured so far.
+//   PAIRED_EDGES - the controller fills a 2x-height RAM scanned from both
+//                  edges toward the middle: for i in [0,height) it wants
+//                  buffer row (height-1-i) then row i, so line count ==
+//                  2*height and each row is sent twice. GDEY0579F51 / HX8717.
+//                  This is exactly what GxEPD2's writeNative emits for that
+//                  panel (99 bytes/line x 544 lines/region), so a faithful
+//                  replay needs it - nothing panel-specific in the engine.
+enum class LineOrder : uint8_t { SEQUENTIAL, PAIRED_EDGES };
+
+struct EngineModel {
+  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;
+
+  // Sets the (always full-frame, since this engine has no partial-window
+  // support) RAM addressing window. window_sequence[0] runs once after
+  // init_sequence and before the first plane's byte burst; index 1 is only
+  // used when window_per_plane is true (see below). Was hardcoded assuming
+  // every controller shares SSD1680's 0x44/0x45/0x4E/0x4F byte-column
+  // layout - GxEPD2_426_GDEQ0426T82 (SSD1677) turned out to address in raw
+  // pixels with reversed Y and re-issues its entry-mode command here, and
+  // GxEPD2_583_GDEQ0583T31 (UC8179) uses one 0x90 command bracketed by
+  // 0x91/0x92 markers instead. Made per-model data, same as init/refresh,
+  // rather than special-cased in the engine.
+  const EpdCommand *window_sequence[2];
+  uint8_t window_sequence_len[2];
+
+  // true if each plane needs window_sequence[plane index] re-run before
+  // its own byte burst, instead of one window_sequence[0] covering every
+  // plane's burst (the default, e.g. a BW+red panel's shared full-frame
+  // window). This is how a panel with real master/slave regions (two
+  // genuinely separate physical RAM windows, not just two color planes
+  // sharing one) is handled - "plane" here just means "one more buffer
+  // with its own byte burst," which happens to fit a two-physical-region
+  // panel as well as a two-color-plane one.
+  bool window_per_plane{false};
+
+  uint8_t plane_count;          // 1 (BW), 2 (BW + accent color), or 2 physical regions
+  uint8_t plane_write_cmd[2];   // e.g. {0x24, 0x26}
+
+  // The extra "previous RAM bank" write GxEPD2's writeImageForFullRefresh()
+  // makes that plain writeImage() doesn't, on controllers where the two
+  // diverge (SSD1680/SSD1681/SSD1619-family: 0x26 then 0x24, or 0x10 then
+  // 0x13 - same bitmap written twice, to two different commands). 0x00 (the
+  // default) means no divergence was observed: writeImageForFullRefresh
+  // falls back to writeImage for that controller, the common case. NOT read
+  // by Engine today - this engine always does a plain single write per
+  // plane, correct for full-refresh-only operation, since the full-refresh
+  // waveform doesn't depend on the "previous" bank's content. It matters
+  // once partial refresh reads that bank to compute its diff, so it's
+  // captured now rather than needing a second capture pass later.
+  uint8_t plane_write_cmd_for_full_refresh[2]{0, 0};
+
+  bool plane_invert[2];         // true if the byte must be bit-inverted on write
+
+  // When window_per_plane is true, each plane's own buffer is sized for
+  // this width, not the descriptor's overall `width` (which is the full
+  // logical canvas the adapter draws into) - e.g. 396 for GDEY0579F51's
+  // master/slave halves, each 396x272 on its own, not the full 792x272.
+  // Unused otherwise.
+  uint16_t plane_width{0};
+
+  // Wire-line emission order for this panel's plane(s) - see LineOrder
+  // above. Default SEQUENTIAL; PAIRED_EDGES is not assumed for any panel
+  // without the diag native-path tool deriving it.
+  LineOrder line_order{LineOrder::SEQUENTIAL};
+
+  // Pixel bit-depth, which also selects the buffer model:
+  //   1 = one-bit-per-pixel plane model (mono, or BW + accent as two
+  //       separate 1bpp planes). plane_invert applies; palette is unused.
+  //   2 = packed 4-color: 2 bits/pixel, 4 pixels/byte, MSB-first.
+  //   4 = packed 7-color (ACeP): 4 bits/pixel, 2 pixels/byte, MSB-first.
+  // For bits_per_pixel > 1 the buffer is a single packed code-per-pixel
+  // stream (per region when window_per_plane), sized width*height*bpp/8,
+  // and each pixel's wire code comes from `palette` (nearest-color
+  // classification at draw time) rather than the 1bpp plane bitmasks;
+  // plane_invert is unused.
+  uint8_t bits_per_pixel{1};
+
+  // Packed-mode (bits_per_pixel > 1) palette and the wire code the buffer is
+  // cleared to (the panel's white/blank). Unused when bits_per_pixel == 1.
+  // See PALETTE_4C / PALETTE_7C.
+  const EpdPaletteEntry *palette{nullptr};
+  uint8_t palette_len{0};
+  uint8_t blank_code{0};
+
+  const EpdCommand *refresh_sequence;  // e.g. 0x22/0xF7 then trigger 0x20
+  uint8_t refresh_sequence_len;
+
+  // powerOff()/hibernate(), captured the same way as refresh_sequence.
+  // Exposed for a user's own lambda (Engine::request_power_off/
+  // request_hibernate), not run automatically by update(): this engine does
+  // a full hardware reset on every cycle regardless of prior power state,
+  // so correctness never depends on calling either. hibernate_sequence is
+  // the deep-sleep trigger ONLY (every GxEPD2 driver's hibernate() is
+  // powerOff() + one more command when rst is wired) - Engine runs
+  // power_off_sequence first, then hibernate_sequence, to replay the same
+  // two-step effect. Both nullptr/0 (not yet captured) is a safe no-op:
+  // Engine treats a zero-length sequence as already done.
+  const EpdCommand *power_off_sequence{nullptr};
+  uint8_t power_off_sequence_len{0};
+  const EpdCommand *hibernate_sequence{nullptr};  // deep-sleep trigger only, after power_off_sequence
+  uint8_t hibernate_sequence_len{0};
+};
+
+}  // namespace zepd