Browse Source

Make update() non-blocking: run the GxEPD2 refresh on its own task

GxEPD2's refresh cycle (draw callbacks, SPI write burst, and above all
the busy-pin wait) is synchronous top to bottom, with no yield points
to turn it into the kind of chunked, resumable loop() state machine
esphome's own epaper_spi uses to avoid ever blocking (its update()
arms a flag and returns; loop() polls busy non-blockingly every main-
loop tick). Rewriting GxEPD2 itself that way is out of reach here.

Instead, run the whole sequence on its own FreeRTOS task: update() now
just wakes that task via a notification and returns immediately, so
the multi-second wait happens off the main loop, and WiFi/API/every
other component stay responsive through a refresh instead of stalling
with it - confirmed blocking the whole app was the actual complaint
(a config-dump held up behind a display refresh, an API log client
that can't even get a response while one is running).

Accepted for now, not solved: a second update() while one is running
is rejected outright (logged, not queued), not serialized or
coalesced; the draw lambda's reads of other components now happen off
the main thread, which most POD state tolerates fine in practice but
is a real departure from esphome's single-threaded assumptions; and
the SPI bus is only actually held for the write bursts (not the busy
wait), which should coexist with a shared spi: bus but hasn't been
stress-tested against one mid-refresh.
ZiB 1 month ago
parent
commit
8102437078
1 changed files with 70 additions and 14 deletions
  1. 70 14
      components/gxepd2/gxepd2_display.h

+ 70 - 14
components/gxepd2/gxepd2_display.h

@@ -1,5 +1,10 @@
 #pragma once
 
+#include <atomic>
+
+#include <freertos/FreeRTOS.h>
+#include <freertos/task.h>
+
 #include "esphome/core/component.h"
 #include "esphome/core/helpers.h"
 #include "esphome/components/display/display_buffer.h"
@@ -62,6 +67,9 @@ class GxEPD2Display : public display::DisplayBuffer {
   }
 
   ~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);
@@ -93,26 +101,51 @@ class GxEPD2Display : public display::DisplayBuffer {
     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 update() does below - 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.
+    // 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));
+
+    // 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.
+    xTaskCreate(&GxEPD2Display::update_task_trampoline_, "gxepd2_update", 4096, this, 1, &this->update_task_);
   }
 
   void update() override {
-    // GxEPD2 blocks synchronously on the panel's busy pin for the whole
-    // call below - this isn't peeking at gxepd2 internals, just timing the
-    // one call from the esphome side, to make that stall visible in the log
-    // instead of it looking like the main loop silently hung.
-    uint32_t start = millis();
-    this->driver_->setFullWindow();
-    this->driver_->firstPage();
-    do {
-      this->do_update_();
-    } while (this->driver_->nextPage());
-    ESP_LOGD(TAG, "Display update took %u ms", (unsigned) (millis() - start));
+    bool expected = false;
+    if (!this->update_running_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
+      ESP_LOGW(TAG, "Skipping update: previous display refresh is still running");
+      return;
+    }
+    xTaskNotifyGive(this->update_task_);
   }
 
   void dump_config() override {
@@ -160,6 +193,29 @@ class GxEPD2Display : public display::DisplayBuffer {
   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_() {
+    while (true) {
+      ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
+      uint32_t start = millis();
+      this->driver_->setFullWindow();
+      this->driver_->firstPage();
+      do {
+        this->do_update_();
+      } 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};
+  std::atomic<bool> update_running_{false};
 };
 
 }  // namespace esphome::gxepd2