From 818d43f742e3fc18c22bb6d2e41f46d1e0debb73 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 18:25:36 -0500 Subject: [PATCH] [epaper_spi] Run the loop continuously between the steps of an update (#20554) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/epaper_spi.cpp | 27 ++- esphome/components/epaper_spi/epaper_spi.h | 4 + tests/components/epaper_spi/common.h | 8 + .../epaper_spi/display/test_busy_timeout.cpp | 11 +- .../epaper_spi/display/test_fast_loop.cpp | 212 ++++++++++++++++++ 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_fast_loop.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 71e87e038b6..fccb5325a7f 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -133,7 +133,9 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { - this->waiting_for_idle_start_ = millis(); + // The wait starts when the step's delay ends, or now for a wait in the middle of a step + const uint32_t now = millis(); + this->waiting_for_idle_start_ = static_cast(this->delay_until_ - now) > 0 ? this->delay_until_ : now; this->waiting_for_idle_ = should_wait; } @@ -147,19 +149,30 @@ void EPaperBase::loop() { auto now = millis(); // using modulus arithmetic to handle wrap-around int diff = now - this->delay_until_; - if (diff < 0) + if (diff < 0) { + // Running the loop continuously cannot shorten a delay longer than one loop interval + if (-diff > static_cast(App.get_loop_interval())) { + this->high_frequency_loop_.stop(); + } else { + this->high_frequency_loop_.start(); + } return; + } if (this->waiting_for_idle_) { if (this->is_idle_()) { this->waiting_for_idle_ = false; + this->high_frequency_loop_.start(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { - if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + const uint32_t busy_for = now - this->waiting_for_idle_start_; + if (busy_for > this->busy_timeout_seconds_ * 1000u) { this->abandon_update_(); return; } + if (busy_for > FAST_BUSY_POLL_MS) + this->high_frequency_loop_.stop(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -170,6 +183,8 @@ void EPaperBase::loop() { } } this->process_state_(); + if (this->is_failed()) + this->high_frequency_loop_.stop(); } // The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full @@ -275,16 +290,20 @@ void EPaperBase::process_state_() { void EPaperBase::set_state_(EPaperState state, uint16_t delay) { ESP_LOGV(TAG, "Exit state %s", this->epaper_state_to_string_()); this->state_ = state; - this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); // allow subclasses to nominate delays if (delay == 0) delay = this->next_delay_; this->next_delay_ = 0; this->delay_until_ = millis() + delay; + this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); ESP_LOGV(TAG, "Enter state %s, delay %u, wait_for_idle=%s", this->epaper_state_to_string_(), delay, TRUEFALSE(this->waiting_for_idle_)); + // Each step of an update otherwise waits for the next loop iteration; loop() slows down again for long waits if (state == EPaperState::IDLE) { + this->high_frequency_loop_.stop(); this->disable_loop(); + } else { + this->high_frequency_loop_.start(); } } diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index d953bf3df81..d6fe4238800 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -5,6 +5,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::epaper_spi { using namespace display; @@ -31,6 +32,8 @@ static constexpr uint8_t SWAP_XY = 4; static constexpr uint32_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run static constexpr size_t MAX_TRANSFER_SIZE = 128; +// Longer busy waits are the waveform or a stuck pin, and are polled at the normal loop pace +static constexpr uint32_t FAST_BUSY_POLL_MS = 50; static constexpr uint8_t DELAY_FLAG = 0xFF; class EPaperBase : public Display, @@ -202,6 +205,7 @@ class EPaperBase : public Display, GPIOPin *reset_pin_{}; std::vector enable_pins_{}; bool waiting_for_idle_{}; + HighFrequencyLoopRequester high_frequency_loop_; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h index f21e674fde2..9631f1e0776 100644 --- a/tests/components/epaper_spi/common.h +++ b/tests/components/epaper_spi/common.h @@ -50,6 +50,14 @@ class RecordingPin : public GPIOPin { bool level{true}; }; +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + /// SPI delegate that records what reaches the bus, filing each payload under the command it /// followed; commands are the bytes written while D/C is low. Optionally burns wall-clock time on /// each data row, so a transfer can be driven past its yield deadline. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp index 7080619b2ff..533c795f272 100644 --- a/tests/components/epaper_spi/display/test_busy_timeout.cpp +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -7,14 +7,8 @@ #include "esphome/components/epaper_spi/epaper_spi_t133a01.h" namespace esphome::epaper_spi::testing { - -/// A busy pin the test can hold at "busy" and release. -class SwitchableBusyPin : public RecordingPin { - public: - bool digital_read() override { return this->busy; } - - bool busy{false}; -}; +// Other test files define fixtures with the same names; keep these to this file +namespace { template void loop_until_idle(T &display) { const uint32_t deadline = millis() + 1000; @@ -142,4 +136,5 @@ TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { << "the panel was not powered off (POF) when the update was abandoned"; } +} // namespace } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_fast_loop.cpp b/tests/components/epaper_spi/display/test_fast_loop.cpp new file mode 100644 index 00000000000..3a2a905281e --- /dev/null +++ b/tests/components/epaper_spi/display/test_fast_loop.cpp @@ -0,0 +1,212 @@ +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { +// Other test files define fixtures with the same names; keep these to this file +namespace { + +constexpr uint8_t MALFORMED_INIT_SEQUENCE[] = {0x01}; + +class TestableSSD1683 : public EPaperSSD1683 { + public: + explicit TestableSSD1683(const uint8_t *init_sequence = nullptr, size_t init_sequence_length = 0) + : EPaperSSD1683("test", 16, 2, init_sequence, init_sequence_length) {} + // The request count is shared by the whole test binary, so never leave it raised + ~TestableSSD1683() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + void draw_nothing() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + /// Runs the loop for the given time, or until the display is idle. + void loop_for(uint32_t ms) { + const uint32_t until = millis() + ms; + while (this->state_ != EPaperState::IDLE && millis() < until) + this->loop(); + } + + EPaperState state() const { return this->state_; } + + RecordingPin dc, reset; + SwitchableBusyPin busy; +}; + +/// With no long waits the loop runs continuously from the start of an update to its end, then goes back to its +/// normal pace. +TEST(EPaperFastLoop, LoopRunsContinuouslyForShortStepsAndStopsAfterward) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.draw_whole_panel(); + display.update(); + bool always_fast = true; + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) { + always_fast &= HighFrequencyLoopRequester::is_high_frequency(); + display.loop(); + } + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_TRUE(always_fast); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()) << "the loop was left running continuously"; +} + +/// An update with nothing drawn ends straight away and does not leave the loop running continuously. +TEST(EPaperFastLoop, UpdateWithNothingDrawnStopsTheFastLoop) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_nothing(); + display.update(); + display.loop(); + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A display marked failed part way through an update is no longer looped, so it must stop the fast loop itself. +TEST(EPaperFastLoop, FailedDisplayStopsTheFastLoop) { + TestableSSD1683 display(MALFORMED_INIT_SEQUENCE, sizeof(MALFORMED_INIT_SEQUENCE)); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (!display.is_failed() && millis() < deadline) + display.loop(); + + ASSERT_TRUE(display.is_failed()); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A long busy wait, such as the waveform or a stuck busy pin, is polled at the normal pace. Once the pin clears, +/// the next steps run continuously again. +TEST(EPaperFastLoop, LongBusyWaitUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.busy.busy = true; + display.draw_whole_panel(); + display.update(); + display.loop_for(FAST_BUSY_POLL_MS + 20); + + ASSERT_NE(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A delay longer than a loop interval, such as a long reset pulse, is waited out at the normal pace. +TEST(EPaperFastLoop, LongDelayUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_reset_pin(&display.reset); + display.set_reset_duration(200); + + display.draw_whole_panel(); + display.update(); + display.loop_for(50); + + ASSERT_EQ(display.state(), EPaperState::RESET_END); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A step that takes several loop passes gets the fast loop back once a long busy wait before it has ended. +TEST(EPaperFastLoop, FastLoopResumesWhenALongBusyWaitEnds) { + TestableSSD1683 display; + // every row of data overruns a transfer slice, so the transfer takes a loop pass per row + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + display.busy.busy = true; + display.loop_for(FAST_BUSY_POLL_MS + 20); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA) << "the transfer should take more than one pass"; + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + ~TestableT133A01() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// A busy wait that starts in the middle of a step is polled continuously for its first moments, however long +/// the step has already run. +TEST(EPaperFastLoop, WaitInTheMiddleOfAStepIsPolledFast) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + const uint32_t until = millis() + FAST_BUSY_POLL_MS + 20; + while (millis() < until) { + } + // the transfer waits for the busy pin after its first command, then sends a row and yields + display.loop(); + display.busy.busy = true; + display.loop(); + + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +} // namespace +} // namespace esphome::epaper_spi::testing