Browse Source

Abort mid-refresh with a reset pulse before power_off()/hibernate()

Most controllers ignore new commands outright while BUSY is asserted, so
writing power_off_sequence/hibernate_sequence over a still-running refresh
silently did nothing. When a reset pin is wired, force the pulse first to
abort the cycle and bring the controller back to a command-accepting state;
without one, fall back to the previous best-effort behavior.
ZiB 2 weeks ago
parent
commit
09e2910950
2 changed files with 73 additions and 40 deletions
  1. 60 24
      components/gxepd2/cycle_engine.h
  2. 13 16
      components/gxepd2/gxepd2_display.h

+ 60 - 24
components/gxepd2/cycle_engine.h

@@ -28,9 +28,12 @@ using EpdSPIDevice = spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARIT
 // 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
+// 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(). See epd_command.h.
+// automatically by request_full_update(). Called while a refresh is still
+// in flight (e.g. from on_powerdown()'s teardown path), they force a reset
+// pulse first to abort it rather than writing over a busy controller - see
+// begin_power_down_(). See epd_command.h.
 class CycleEngine {
  public:
   void begin(const EpdPanelDescriptor *desc, GPIOPin *cs, GPIOPin *dc, GPIOPin *rst, GPIOPin *busy,
@@ -72,30 +75,21 @@ class CycleEngine {
   }
 #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 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 (see
+  // make_power_sequence_resolvers in emit_descriptor.h).
+  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 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);
-  }
+  // 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; }
 
@@ -136,7 +130,13 @@ class CycleEngine {
         // 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);
+        // 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:
@@ -252,9 +252,43 @@ class CycleEngine {
     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
+  // (e.g. on_powerdown()'s tight teardown budget) 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
@@ -507,6 +541,8 @@ class CycleEngine {
   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};

+ 13 - 16
components/gxepd2/gxepd2_display.h

@@ -271,25 +271,21 @@ class GxEPD2Display : public display::DisplayBuffer {
   // id(<display_id>).power_off(); - not called automatically by update(),
   // since this engine does a full hardware reset every cycle regardless of
   // prior power state, so correctness never depends on it. (It IS called
-  // automatically from on_powerdown() below.) A no-op (with a warning) while
-  // a refresh is still in progress.
+  // automatically from on_powerdown() below.) Safe to call mid-refresh: see
+  // CycleEngine::request_power_off().
   void power_off() {
-    if (!this->engine_.idle()) {
-      ESP_LOGW(TAG, "Skipping power_off: display refresh still in progress");
-      return;
-    }
+    if (!this->engine_.idle())
+      ESP_LOGW(TAG, "power_off() called mid-refresh; aborting the in-progress update");
     this->engine_.request_power_off();
   }
 
   // power_off() + deep sleep, wakeable only by a hardware reset pulse - which
   // the next update() always issues (request_full_update()'s RESET_LOW), so
-  // no paired "wake" call is needed; just call update() as usual. A no-op
-  // (with a warning) while a refresh is still in progress.
+  // no paired "wake" call is needed; just call update() as usual. Safe to
+  // call mid-refresh: see CycleEngine::request_hibernate().
   void hibernate() {
-    if (!this->engine_.idle()) {
-      ESP_LOGW(TAG, "Skipping hibernate: display refresh still in progress");
-      return;
-    }
+    if (!this->engine_.idle())
+      ESP_LOGW(TAG, "hibernate() called mid-refresh; aborting the in-progress update");
     this->engine_.request_hibernate();
   }
 
@@ -310,10 +306,11 @@ class GxEPD2Display : public display::DisplayBuffer {
   // just lose that race every time and get force-advanced anyway - the
   // budget is sized for a network flush (see api_server::teardown()), not a
   // hardware operation. If a refresh is still running when this fires,
-  // power_off()/hibernate()'s own guard above skips it with a warning rather
-  // than fighting the panel for the bus mid-write. A caller who cares about
-  // that race can check idle() (above) and hold off triggering deep sleep
-  // themselves.
+  // power_off()/hibernate() abort it with a reset pulse first rather than
+  // writing a power-down command over a controller that's still asserting
+  // BUSY and would just ignore it (see CycleEngine::request_power_off()). A
+  // caller who cares about that race can check idle() (above) and hold off
+  // triggering deep sleep themselves.
   void on_powerdown() override {
     if (this->rst_ != nullptr) {
       this->hibernate();