| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516 |
- #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 "epd_command.h"
- namespace esphome::gxepd2 {
- // Stock GxEPD2 vendor value (20MHz) for this panel. Was dropped to 10MHz
- // (matching epaper_spi) while chasing the solid-black/solid-red symptom,
- // which turned out to be unrelated - display.py never wired the configured
- // lambda to set_writer() at all, so nothing but auto_clear's default fill
- // ever rendered, regardless of SPI clock. Rolled back to test the stock
- // GxEPD2 clock now that the real bug is fixed.
- 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 reset pulse, 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(). See epd_command.h.
- 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->arm_full_update_(plane0, plane1, this->rst_ != nullptr ? State::RESET_LOW : State::INIT_CMD);
- }
- #ifdef USE_GXEPD2_DIAGNOSTICS
- // Dev-time diagnostic, compiled out unless the display's `diagnostics:
- // true` is set (display.py adds -DUSE_GXEPD2_DIAGNOSTICS): 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 reset pulse, no init/window/
- // plane write, just the captured power_off_sequence (if any) then DONE.
- // Undefined to call while !idle(). Safe to call repeatedly: GxEPD2's own
- // powerOff() is itself guarded on "already off," which the capture already
- // baked in (see make_power_sequence_resolvers in emit_descriptor.h).
- void request_power_off() {
- this->cursor_ = 0;
- this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
- this->hibernating_ = false;
- this->set_state_(State::POWER_OFF_CMD);
- }
- // Arms a standalone hibernate() replay: power_off_sequence then
- // hibernate_sequence (the deep-sleep trigger only - see epd_command.h).
- // Undefined to call while !idle(). 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->cursor_ = 0;
- this->cmd_phase_ = CmdPhase::BEFORE_DELAY;
- this->hibernating_ = true;
- this->set_state_(State::POWER_OFF_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;
- // 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.
- this->start_busy_wait_(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:
- // 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 - see
- // panel_579c_gdey0579f51.h for why a panel needs that.
- 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 - see
- // epd_command.h) - 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("gxepd2", "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("gxepd2", "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;
- this->set_state_(start_state);
- }
- // 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) while diagnosing a real-hardware content bug: this is
- // the exact byte-for-byte counterpart to compare against a captured
- // epaper_spi 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("gxepd2", "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, 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.
- //
- // 64 was overly conservative: at 20MHz SPI that's ~25us of actual bus
- // time, so a 426T82 full-plane write (48000 bytes) was bottlenecked
- // almost entirely on loop() scheduling overhead (~17ms/step observed),
- // not SPI - ~12.7s for one plane. Raised to 16384 (up to ~25ms of
- // blocking per step is fine here), cutting that to 3 steps instead of
- // 750 - measured ~1.07s for the plane write on hardware. The remaining
- // time is dominated by write_byte_()'s per-byte SPI enable()/disable()
- // (ESP-IDF transaction setup/teardown, ~21.7us/byte vs ~0.4us raw
- // transfer), which is 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. 1s is well under half this
- // panel's own multi-second refresh time anyway, so it wasn't worth
- // trading that fairness for more throughput.
- 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("gxepd2", "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("gxepd2", "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("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::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 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}; // 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 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 esphome::gxepd2
|