From 52c631384a224002a286fa21296ffdfc2a8b39cf Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:28:24 +1100 Subject: [PATCH] [epaper_spi] Add Waveshare 2.13v3 (#13117) --- esphome/components/epaper_spi/display.py | 1 + esphome/components/epaper_spi/epaper_spi.cpp | 53 +--- esphome/components/epaper_spi/epaper_spi.h | 23 +- ...er_spi_ssd1677.cpp => epaper_spi_mono.cpp} | 56 ++-- ...epaper_spi_ssd1677.h => epaper_spi_mono.h} | 13 +- .../epaper_spi/epaper_spi_spectra_e6.cpp | 13 +- .../epaper_spi/epaper_waveshare.cpp | 47 +++ .../components/epaper_spi/epaper_waveshare.h | 30 ++ .../components/epaper_spi/models/__init__.py | 3 + .../components/epaper_spi/models/ssd1677.py | 15 +- .../components/epaper_spi/models/waveshare.py | 88 ++++++ tests/component_tests/epaper_spi/__init__.py | 0 tests/component_tests/epaper_spi/test_init.py | 291 ++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 32 +- 14 files changed, 566 insertions(+), 99 deletions(-) rename esphome/components/epaper_spi/{epaper_spi_ssd1677.cpp => epaper_spi_mono.cpp} (53%) rename esphome/components/epaper_spi/{epaper_spi_ssd1677.h => epaper_spi_mono.h} (57%) create mode 100644 esphome/components/epaper_spi/epaper_waveshare.cpp create mode 100644 esphome/components/epaper_spi/epaper_waveshare.h create mode 100644 esphome/components/epaper_spi/models/waveshare.py create mode 100644 tests/component_tests/epaper_spi/__init__.py create mode 100644 tests/component_tests/epaper_spi/test_init.py diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index b7e71a3cae..a77e291237 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -184,6 +184,7 @@ async def to_code(config): height, init_sequence_id, init_sequence_length, + *model.get_constructor_args(config), ) # Rotation is handled by setting the transform diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 0b600feeae..db803305a5 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -54,20 +54,14 @@ void EPaperBase::setup_pins_() const { float EPaperBase::get_setup_priority() const { return setup_priority::PROCESSOR; } void EPaperBase::command(uint8_t value) { - this->start_command_(); + ESP_LOGV(TAG, "Command: 0x%02X", value); + this->dc_pin_->digital_write(false); + this->enable(); this->write_byte(value); - this->end_command_(); -} - -void EPaperBase::data(uint8_t value) { - this->start_data_(); - this->write_byte(value); - this->end_data_(); + this->disable(); } // write a command followed by zero or more bytes of data. -// The command is the first byte, length is the length of data only in the second byte, followed by the data. -// [COMMAND, LENGTH, DATA...] void EPaperBase::cmd_data(uint8_t command, const uint8_t *ptr, size_t length) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(EPAPER_MAX_CMD_LOG_BYTES)]; @@ -130,14 +124,10 @@ void EPaperBase::wait_for_idle_(bool should_wait) { void EPaperBase::loop() { auto now = millis(); - if (this->delay_until_ != 0) { - // using modulus arithmetic to handle wrap-around - int diff = now - this->delay_until_; - if (diff < 0) { - return; - } - this->delay_until_ = 0; - } + // using modulus arithmetic to handle wrap-around + int diff = now - this->delay_until_; + if (diff < 0) + return; if (this->waiting_for_idle_) { if (this->is_idle_()) { this->waiting_for_idle_ = false; @@ -192,7 +182,7 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::RESET); break; case EPaperState::INITIALISE: - this->initialise_(); + this->initialise(this->update_count_ != 0); this->set_state_(EPaperState::TRANSFER_DATA); break; case EPaperState::TRANSFER_DATA: @@ -230,11 +220,11 @@ 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); - if (delay != 0) { - this->delay_until_ = millis() + delay; - } else { - this->delay_until_ = 0; - } + // allow subclasses to nominate delays + if (delay == 0) + delay = this->next_delay_; + this->next_delay_ = 0; + this->delay_until_ = millis() + delay; ESP_LOGV(TAG, "Enter state %s, delay %u, wait_for_idle=%s", this->epaper_state_to_string_(), delay, TRUEFALSE(this->waiting_for_idle_)); if (state == EPaperState::IDLE) { @@ -242,22 +232,14 @@ void EPaperBase::set_state_(EPaperState state, uint16_t delay) { } } -void EPaperBase::start_command_() { - this->dc_pin_->digital_write(false); - this->enable(); -} - -void EPaperBase::end_command_() { this->disable(); } - void EPaperBase::start_data_() { this->dc_pin_->digital_write(true); this->enable(); } -void EPaperBase::end_data_() { this->disable(); } void EPaperBase::on_safe_shutdown() { this->deep_sleep(); } -void EPaperBase::initialise_() { +void EPaperBase::initialise(bool partial) { size_t index = 0; auto *sequence = this->init_sequence_; @@ -317,9 +299,8 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) { void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { if (!rotate_coordinates_(x, y)) return; - const size_t pixel_position = y * this->width_ + x; - const size_t byte_position = pixel_position / 8; - const uint8_t bit_position = pixel_position % 8; + const size_t byte_position = y * this->row_width_ + x / 8; + const uint8_t bit_position = x % 8; const uint8_t pixel_bit = 0x80 >> bit_position; const auto original = this->buffer_[byte_position]; if ((color_to_bit(color) == 0)) { diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index b587b07e8f..521543f026 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -36,14 +36,16 @@ class EPaperBase : public Display, public spi::SPIDevice { public: - EPaperBase(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) + EPaperBase(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence = nullptr, + size_t init_sequence_length = 0, DisplayType display_type = DISPLAY_TYPE_BINARY) : name_(name), width_(width), height_(height), init_sequence_(init_sequence), init_sequence_length_(init_sequence_length), - display_type_(display_type) {} + display_type_(display_type) { + this->row_width_ = (this->width_ + 7) / 8; // width of a row in bytes + } void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } float get_setup_priority() const override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } @@ -54,9 +56,13 @@ class EPaperBase : public Display, void dump_config() override; void command(uint8_t value); - void data(uint8_t value); void cmd_data(uint8_t command, const uint8_t *ptr, size_t length); + // variant with in-place initializer list + void cmd_data(uint8_t command, std::initializer_list data) { + this->cmd_data(command, data.begin(), data.size()); + } + void update() override; void loop() override; @@ -109,7 +115,7 @@ class EPaperBase : public Display, bool is_idle_() const; void setup_pins_() const; virtual bool reset(); - void initialise_(); + virtual void initialise(bool partial); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); bool rotate_coordinates_(int &x, int &y); @@ -143,14 +149,12 @@ class EPaperBase : public Display, void set_state_(EPaperState state, uint16_t delay = 0); - void start_command_(); - void end_command_(); void start_data_(); - void end_data_(); // properties initialised in the constructor const char *name_; uint16_t width_; + uint16_t row_width_; // width of a row in bytes uint16_t height_; const uint8_t *init_sequence_; size_t init_sequence_length_; @@ -163,7 +167,8 @@ class EPaperBase : public Display, GPIOPin *busy_pin_{}; GPIOPin *reset_pin_{}; bool waiting_for_idle_{}; - uint32_t delay_until_{}; + uint32_t delay_until_{}; // timestamp until which to delay processing + uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; uint8_t update_count_{}; // these values represent the bounds of the updated buffer. Note that x_high and y_high diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp similarity index 53% rename from esphome/components/epaper_spi/epaper_spi_ssd1677.cpp rename to esphome/components/epaper_spi/epaper_spi_mono.cpp index e4f04657ad..d10022c4ac 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -1,25 +1,24 @@ -#include "epaper_spi_ssd1677.h" +#include "epaper_spi_mono.h" #include #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.ssd1677"; +static constexpr const char *const TAG = "epaper_spi.mono"; -void EPaperSSD1677::refresh_screen(bool partial) { +void EPaperMono::refresh_screen(bool partial) { ESP_LOGV(TAG, "Refresh screen"); - this->command(0x22); - this->data(partial ? 0xFF : 0xF7); + this->cmd_data(0x22, {partial ? (uint8_t) 0xFF : (uint8_t) 0xF7}); this->command(0x20); } -void EPaperSSD1677::deep_sleep() { +void EPaperMono::deep_sleep() { ESP_LOGV(TAG, "Deep sleep"); this->command(0x10); } -bool EPaperSSD1677::reset() { +bool EPaperMono::reset() { if (EPaperBase::reset()) { this->command(0x12); return true; @@ -27,29 +26,24 @@ bool EPaperSSD1677::reset() { return false; } -bool HOT EPaperSSD1677::transfer_data() { +void EPaperMono::set_window() { + // round x-coordinates to byte boundaries + this->x_low_ &= ~7; + this->x_high_ += 7; + this->x_high_ &= ~7; + this->cmd_data(0x44, {(uint8_t) this->x_low_, (uint8_t) (this->x_low_ / 256), (uint8_t) (this->x_high_ - 1), + (uint8_t) ((this->x_high_ - 1) / 256)}); + this->cmd_data(0x4E, {(uint8_t) this->x_low_, (uint8_t) (this->x_low_ / 256)}); + this->cmd_data(0x45, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256), (uint8_t) (this->y_high_ - 1), + (uint8_t) ((this->y_high_ - 1) / 256)}); + this->cmd_data(0x4F, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256)}); +} + +bool HOT EPaperMono::transfer_data() { auto start_time = millis(); if (this->current_data_index_ == 0) { - uint8_t data[4]{}; // round to byte boundaries - this->x_low_ &= ~7; - this->y_low_ &= ~7; - this->x_high_ += 7; - this->x_high_ &= ~7; - this->y_high_ += 7; - this->y_high_ &= ~7; - data[0] = this->x_low_; - data[1] = this->x_low_ / 256; - data[2] = this->x_high_ - 1; - data[3] = (this->x_high_ - 1) / 256; - cmd_data(0x4E, data, 2); - cmd_data(0x44, data, sizeof(data)); - data[0] = this->y_low_; - data[1] = this->y_low_ / 256; - data[2] = this->y_high_ - 1; - data[3] = (this->y_high_ - 1) / 256; - cmd_data(0x4F, data, 2); - this->cmd_data(0x45, data, sizeof(data)); + this->set_window(); // for monochrome, we still need to clear the red data buffer at least once to prevent it // causing dirty pixels after partial refresh. this->command(this->send_red_ ? 0x26 : 0x24); @@ -58,10 +52,10 @@ bool HOT EPaperSSD1677::transfer_data() { size_t row_length = (this->x_high_ - this->x_low_) / 8; FixedVector bytes_to_send{}; bytes_to_send.init(row_length); - ESP_LOGV(TAG, "Writing bytes at line %zu at %ums", this->current_data_index_, (unsigned) millis()); + ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis()); this->start_data_(); while (this->current_data_index_ != this->y_high_) { - size_t data_idx = (this->current_data_index_ * this->width_ + this->x_low_) / 8; + size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8; for (size_t i = 0; i != row_length; i++) { bytes_to_send[i] = this->send_red_ ? 0 : this->buffer_[data_idx++]; } @@ -69,12 +63,12 @@ bool HOT EPaperSSD1677::transfer_data() { this->write_array(&bytes_to_send.front(), row_length); // NOLINT if (millis() - start_time > MAX_TRANSFER_TIME) { // Let the main loop run and come back next loop - this->end_data_(); + this->disable(); return false; } } - this->end_data_(); + this->disable(); this->current_data_index_ = 0; if (this->send_red_) { this->send_red_ = false; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_mono.h similarity index 57% rename from esphome/components/epaper_spi/epaper_spi_ssd1677.h rename to esphome/components/epaper_spi/epaper_spi_mono.h index 47584d24c0..f44b59e803 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -3,13 +3,15 @@ #include "epaper_spi.h" namespace esphome::epaper_spi { - -class EPaperSSD1677 : public EPaperBase { +/** + * A class for monochrome epaper displays. + */ +class EPaperMono : public EPaperBase { public: - EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length) + EPaperMono(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { - this->buffer_length_ = width * height / 8; // 8 pixels per byte + this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up } protected: @@ -18,6 +20,7 @@ class EPaperSSD1677 : public EPaperBase { void power_off() override{}; void deep_sleep() override; bool reset() override; + virtual void set_window(); bool transfer_data() override; bool send_red_{true}; }; diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index be243145fc..1ef2dd12c3 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -80,20 +80,17 @@ void EPaperSpectraE6::power_on() { void EPaperSpectraE6::power_off() { ESP_LOGV(TAG, "Power off"); - this->command(0x02); - this->data(0x00); + this->cmd_data(0x02, {0x00}); } void EPaperSpectraE6::refresh_screen(bool partial) { ESP_LOGV(TAG, "Refresh"); - this->command(0x12); - this->data(0x00); + this->cmd_data(0x12, {0x00}); } void EPaperSpectraE6::deep_sleep() { ESP_LOGV(TAG, "Deep sleep"); - this->command(0x07); - this->data(0xA5); + this->cmd_data(0x07, {0xA5}); } void EPaperSpectraE6::fill(Color color) { @@ -143,7 +140,7 @@ bool HOT EPaperSpectraE6::transfer_data() { if (buf_idx == sizeof bytes_to_send) { this->start_data_(); this->write_array(bytes_to_send, buf_idx); - this->end_data_(); + this->disable(); ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis()); buf_idx = 0; @@ -157,7 +154,7 @@ bool HOT EPaperSpectraE6::transfer_data() { if (buf_idx != 0) { this->start_data_(); this->write_array(bytes_to_send, buf_idx); - this->end_data_(); + this->disable(); } this->current_data_index_ = 0; return true; diff --git a/esphome/components/epaper_spi/epaper_waveshare.cpp b/esphome/components/epaper_spi/epaper_waveshare.cpp new file mode 100644 index 0000000000..8d382d86e7 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare.cpp @@ -0,0 +1,47 @@ +#include "epaper_waveshare.h" + +namespace esphome::epaper_spi { + +static const char *const TAG = "epaper_spi.waveshare"; + +void EpaperWaveshare::initialise(bool partial) { + EPaperBase::initialise(partial); + if (partial) { + this->cmd_data(0x32, this->partial_lut_, this->partial_lut_length_); + this->cmd_data(0x3C, {0x80}); + this->cmd_data(0x22, {0xC0}); + this->command(0x20); + this->next_delay_ = 100; + } else { + this->cmd_data(0x32, this->lut_, this->lut_length_); + this->cmd_data(0x3C, {0x05}); + } + this->send_red_ = true; +} + +void EpaperWaveshare::set_window() { + this->x_low_ &= ~7; + this->x_high_ += 7; + this->x_high_ &= ~7; + uint16_t x_start = this->x_low_ / 8; + uint16_t x_end = (this->x_high_ - 1) / 8; + this->cmd_data(0x44, {(uint8_t) x_start, (uint8_t) (x_end)}); + this->cmd_data(0x4E, {(uint8_t) x_start}); + this->cmd_data(0x45, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256), (uint8_t) (this->y_high_ - 1), + (uint8_t) ((this->y_high_ - 1) / 256)}); + this->cmd_data(0x4F, {(uint8_t) this->y_low_, (uint8_t) (this->y_low_ / 256)}); + ESP_LOGV(TAG, "Set window X: %u-%u, Y: %u-%u", this->x_low_, this->x_high_, this->y_low_, this->y_high_); +} + +void EpaperWaveshare::refresh_screen(bool partial) { + if (partial) { + this->cmd_data(0x22, {0x0F}); + } else { + this->cmd_data(0x22, {0xC7}); + } + this->command(0x20); + this->next_delay_ = partial ? 100 : 3000; +} + +void EpaperWaveshare::deep_sleep() { this->cmd_data(0x10, {0x01}); } +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.h b/esphome/components/epaper_spi/epaper_waveshare.h new file mode 100644 index 0000000000..6b894cfd09 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare.h @@ -0,0 +1,30 @@ +#pragma once +#include "epaper_spi.h" +#include "epaper_spi_mono.h" + +namespace esphome::epaper_spi { +/** + * An epaper display that needs LUTs to be sent to it. + */ +class EpaperWaveshare : public EPaperMono { + public: + EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut, + uint16_t partial_lut_length) + : EPaperMono(name, width, height, init_sequence, init_sequence_length), + lut_(lut), + lut_length_(lut_length), + partial_lut_(partial_lut), + partial_lut_length_(partial_lut_length) {} + + protected: + void initialise(bool partial) override; + void set_window() override; + void refresh_screen(bool partial) override; + void deep_sleep() override; + const uint8_t *lut_; + size_t lut_length_; + const uint8_t *partial_lut_; + uint16_t partial_lut_length_; +}; +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 019eb31d18..3fcf3217ec 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -32,6 +32,9 @@ class EpaperModel: return cv.Required(name) return cv.Optional(name, default=self.get_default(name, fallback)) + def get_constructor_args(self, config) -> tuple: + return () + def get_dimensions(self, config) -> tuple[int, int]: if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index 3eb53d650e..f7e012f162 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -4,10 +4,9 @@ from . import EpaperModel class SSD1677(EpaperModel): - def __init__(self, name, class_name="EPaperSSD1677", **kwargs): - if CONF_DATA_RATE not in kwargs: - kwargs[CONF_DATA_RATE] = "20MHz" - super().__init__(name, class_name, **kwargs) + def __init__(self, name, class_name="EPaperMono", data_rate="20MHz", **defaults): + defaults[CONF_DATA_RATE] = data_rate + super().__init__(name, class_name, **defaults) # fmt: off def get_init_sequence(self, config: dict): @@ -23,11 +22,15 @@ class SSD1677(EpaperModel): ssd1677 = SSD1677("ssd1677") -ssd1677.extend( - "seeed-ee04-mono-4.26", +wave_4_26 = ssd1677.extend( + "waveshare-4.26in", width=800, height=480, mirror_x=True, +) + +wave_4_26.extend( + "seeed-ee04-mono-4.26", cs_pin=44, dc_pin=10, reset_pin=38, diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py new file mode 100644 index 0000000000..74a288977d --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -0,0 +1,88 @@ +import esphome.codegen as cg +from esphome.core import ID + +from ..display import CONF_INIT_SEQUENCE_ID +from . import EpaperModel + + +class WaveshareModel(EpaperModel): + def __init__(self, name, lut, lut_partial=None, **defaults): + super().__init__(name, "EpaperWaveshare", **defaults) + self.lut = lut + self.lut_partial = lut_partial + + def get_constructor_args(self, config) -> tuple: + lut = ( + cg.static_const_array( + ID(config[CONF_INIT_SEQUENCE_ID].id + "_lut", type=cg.uint8), self.lut + ), + len(self.lut), + ) + if self.lut_partial is None: + lut_partial = cg.nullptr, 0 + else: + lut_partial = ( + cg.static_const_array( + ID( + config[CONF_INIT_SEQUENCE_ID].id + "_lut_partial", type=cg.uint8 + ), + self.lut_partial, + ), + len(self.lut_partial), + ) + return *lut, *lut_partial + + +# fmt: off +WaveshareModel( + "waveshare-2.13in-v3", + width=122, + height=250, + initsequence=( + (0x01, 0x27, 0x01, 0x00), # driver output control + (0x37, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00), + (0x11, 0x03), # Data entry mode + (0x3F, 0x22), # Undocumented command + (0x2C, 0x36), # write VCOM register + (0x04, 0x41, 0x0C, 0x32), # SRC voltage + (0x03, 0x17), # Gate voltage + (0x21, 0x00, 0x80), # Display update control + (0x18, 0x80), # Select internal temperature sensor + ), + lut=( + 0x80, 0x4A, 0x40, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x40, 0x4A, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x80, 0x4A, 0x40, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x40, 0x4A, 0x80, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0xF, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xF, 0x0, 0x0, + 0xF, 0x0, 0x0, 0x2, 0xF, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x0, 0x0, 0x0, + ), + lut_partial=( + 0x0, 0x40, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x80, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x40, 0x40, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x80, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0xF, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x4, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x0, 0x0, 0x0, + ), +) diff --git a/tests/component_tests/epaper_spi/__init__.py b/tests/component_tests/epaper_spi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py new file mode 100644 index 0000000000..71e66cd043 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_init.py @@ -0,0 +1,291 @@ +"""Tests for epaper_spi configuration validation.""" + +from collections.abc import Callable +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.epaper_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + MODELS, +) +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.const import ( + CONF_BUSY_PIN, + CONF_CS_PIN, + CONF_DC_PIN, + CONF_DIMENSIONS, + CONF_HEIGHT, + CONF_INIT_SEQUENCE, + CONF_RESET_PIN, + CONF_WIDTH, + PlatformFramework, +) +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def run_schema_validation( + config: ConfigType, with_final_validate: bool = False +) -> None: + """Run schema validation on a configuration. + + Args: + config: The configuration to validate + with_final_validate: If True, also run final validation (requires full config setup) + """ + result = CONFIG_SCHEMA(config) + if with_final_validate: + FINAL_VALIDATE_SCHEMA(result) + return result + + +@pytest.mark.parametrize( + ("config", "error_match"), + [ + pytest.param( + "a string", + "expected a dictionary", + id="invalid_string_config", + ), + pytest.param( + {"id": "display_id"}, + r"required key not provided @ data\['model'\]", + id="missing_model", + ), + pytest.param( + { + "id": "display_id", + "model": "ssd1677", + "dimensions": {"width": 200, "height": 200}, + }, + r"required key not provided @ data\['dc_pin'\]", + id="missing_dc_pin", + ), + ], +) +def test_basic_configuration_errors( + config: str | ConfigType, + error_match: str, + set_core_config: SetCoreConfigCallable, +) -> None: + """Test basic configuration validation errors""" + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + with pytest.raises(cv.Invalid, match=error_match): + CONFIG_SCHEMA(config) + + +def test_all_predefined_models( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test all predefined epaper models validate successfully with appropriate defaults.""" + + # Test all models, providing default values where necessary + for name, model in MODELS.items(): + # SEEED models are designed for ESP32-S3 hardware + if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + else: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = {"model": name} + + # Add ID field + config["id"] = "test_display" + + # Add required fields that don't have defaults + # Use safe GPIO pins that work on ESP32 (avoiding flash pins 6-11) + if not model.get_default(CONF_DC_PIN): + config[CONF_DC_PIN] = 21 + + # Add dimensions if not provided by model + if not model.get_default(CONF_WIDTH): + config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320} + + # Add init sequence if model doesn't provide one + if model.initsequence is None: + config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] + + # Add other optional pins that some models might require + if not model.get_default(CONF_BUSY_PIN): + config[CONF_BUSY_PIN] = 22 + + if not model.get_default(CONF_RESET_PIN): + config[CONF_RESET_PIN] = 23 + + if not model.get_default(CONF_CS_PIN): + config[CONF_CS_PIN] = 5 + + run_schema_validation(config) + + +@pytest.mark.parametrize( + "model_name", + [pytest.param(name, id=name.lower()) for name in sorted(MODELS.keys())], +) +def test_individual_models( + model_name: str, + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test each epaper model individually to ensure it validates correctly.""" + # SEEED models are designed for ESP32-S3 hardware + if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"): + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + else: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + model = MODELS[model_name] + config: dict[str, Any] = {"model": model_name, "id": "test_display"} + + # Add required fields based on model defaults + # Use safe GPIO pins that work on ESP32 + if not model.get_default(CONF_DC_PIN): + config[CONF_DC_PIN] = 21 + + if not model.get_default(CONF_WIDTH): + config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320} + + if model.initsequence is None: + config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] + + if not model.get_default(CONF_BUSY_PIN): + config[CONF_BUSY_PIN] = 22 + + if not model.get_default(CONF_RESET_PIN): + config[CONF_RESET_PIN] = 23 + + if not model.get_default(CONF_CS_PIN): + config[CONF_CS_PIN] = 5 + + # This should not raise any exceptions + run_schema_validation(config) + + +def test_model_with_explicit_dimensions( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test model configuration with explicitly provided dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": { + "width": 200, + "height": 200, + }, + } + ) + + +def test_model_with_transform( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test model configuration with transform options.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": { + "width": 200, + "height": 200, + }, + "transform": { + "mirror_x": True, + "mirror_y": False, + }, + } + ) + + +def test_model_with_full_update_every( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Test model configuration with full_update_every option.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + # Configure SPI component which is required by epaper_spi + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": { + "width": 200, + "height": 200, + }, + "full_update_every": 10, + } + ) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index d330b4127d..621a819c3c 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -8,15 +8,39 @@ display: dimensions: width: 800 height: 480 - cs_pin: GPIO5 - dc_pin: GPIO17 - reset_pin: GPIO16 - busy_pin: GPIO4 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 rotation: 0 update_interval: 60s lambda: |- it.circle(64, 64, 50, Color::BLACK); + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-v3 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + - platform: epaper_spi model: seeed-reterminal-e1002 - platform: epaper_spi