[epaper_spi] Run the loop continuously between the steps of an update (#20554)

Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
This commit is contained in:
Bonne Eggleston
2026-10-18 10:25:36 +11:00
committed by GitHub
co-authored by clydebarrow
parent 1a28a9220b
commit 818d43f742
5 changed files with 250 additions and 12 deletions
+23 -4
View File
@@ -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<int32_t>(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<int>(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();
}
}
@@ -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<GPIOPin *> 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_{};
+8
View File
@@ -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.
@@ -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<typename T> 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
@@ -0,0 +1,212 @@
#include <gtest/gtest.h>
#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