diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index d04ffd33be8..6a391811112 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_MIRROR_X, CONF_MIRROR_Y, CONF_MODEL, + CONF_OFFSET_HEIGHT, CONF_PAGES, CONF_RESET_DURATION, CONF_RESET_PIN, @@ -73,12 +74,17 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = models.EpaperModel.models -DIMENSION_SCHEMA = cv.Schema( - { + +def dimension_schema(model: models.EpaperModel) -> cv.Schema: + schema = { cv.Required(CONF_WIDTH): cv.int_, cv.Required(CONF_HEIGHT): cv.int_, } -) + # Only models with an offset_height default use it, and the default must survive explicit dimensions + if (offset_height := model.get_default(CONF_OFFSET_HEIGHT, None)) is not None: + schema[cv.Optional(CONF_OFFSET_HEIGHT, default=offset_height)] = cv.positive_int + return cv.Schema(schema) + TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} @@ -135,7 +141,7 @@ def model_schema(config): model.option(CONF_RESET_PIN): pins.gpio_output_pin_schema, cv.GenerateID(): cv.declare_id(class_name), cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8), - cv_dimensions(CONF_DIMENSIONS): DIMENSION_SCHEMA, + cv_dimensions(CONF_DIMENSIONS): dimension_schema(model), model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema), model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list( map_sequence diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index e95d2fd2869..71e87e038b6 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -227,10 +227,12 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::IDLE); return; } + // A full update resets update_count_ to zero, so a non-zero count marks a partial one + this->partial_ = this->update_count_ != 0; this->set_state_(EPaperState::RESET); break; case EPaperState::INITIALISE: - if (!this->initialise(this->update_count_ != 0)) { + if (!this->initialise(this->partial_)) { return; // Not done yet, come back next loop } this->set_state_(EPaperState::TRANSFER_DATA); @@ -250,11 +252,14 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::REFRESH_SCREEN); break; case EPaperState::REFRESH_SCREEN: - this->refresh_screen(this->update_count_ != 0); + this->refresh_screen(this->partial_); this->update_count_ = (this->update_count_ + 1) % this->full_update_every_; this->set_state_(EPaperState::POWER_OFF); break; case EPaperState::POWER_OFF: + if (!this->after_refresh()) { + return; // Not done yet, come back next loop + } this->power_off(); this->set_state_(EPaperState::DEEP_SLEEP); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 046df97cccd..d953bf3df81 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -167,6 +167,13 @@ class EPaperBase : public Display, */ virtual void power_off() = 0; + /** + * Called after the refresh, before power off, repeatedly until it returns true. + * Lets a driver do extra work spread over several loops, such as rewriting the previous image. + * @return true when done, false to be called again next loop + */ + virtual bool after_refresh() { return true; } + /** * Place the display into deep sleep */ @@ -201,6 +208,7 @@ class EPaperBase : public Display, uint8_t effective_transform_{}; uint8_t update_count_{}; bool full_update_requested_{}; + bool partial_{}; // the update in progress is a partial one; set when UPDATE moves to RESET // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp index 328cd833299..60cc80e91bd 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -10,12 +10,11 @@ ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8179"); bool EPaperUC8179::initialise(bool partial) { EPaperBase::initialise(partial); // send the model init sequence - this->partial_ = partial; ESP_LOGV(TAG, "Power on"); // POWER ON must precede the waveform/mode registers and the data transfer // (the original driver powers on and busy-waits before writing them). // The state machine busy-waits before entering TRANSFER_DATA. - this->command(0x04); + this->command(CMD_POWER_ON); // Give the busy line time to assert before the state machine polls it this->next_delay_ = 100; return true; @@ -27,21 +26,17 @@ void EPaperUC8179::set_refresh_mode_() { return; // plain full refresh uses the mode set by the init sequence } // Fast and partial refresh use flipped data polarity and a floating border - this->cmd_data(0x50, {0xA9, 0x07}); + this->cmd_data(CMD_CDI, {0xA9, 0x07}); // Force the waveform via the temperature registers: 0x5A selects the fast // full-refresh waveform, 0x6E the partial-refresh waveform - this->cmd_data(0xE0, {0x02}); + this->cmd_data(CMD_CCSET, {0x02}); if (this->partial_) { - this->cmd_data(0xE5, {0x6E}); - this->command(0x91); // enter partial mode - // Set the partial window to the full screen - const uint16_t x_end = this->width_ - 1; - const uint16_t y_end = this->height_ - 1; - this->cmd_data(0x90, {0x00, 0x00, static_cast(x_end >> 8), static_cast(x_end & 0xFF), 0x00, 0x00, - static_cast(y_end >> 8), static_cast(y_end & 0xFF), 0x01}); + this->cmd_data(CMD_TSSET, {0x6E}); + // Partial mode, with the window set to the full screen + this->set_partial_window_(0, this->width_ - 1, 0, this->height_ - 1); } else { - this->cmd_data(0xE5, {0x5A}); - this->command(0x92); // exit partial mode + this->cmd_data(CMD_TSSET, {0x5A}); + this->command(CMD_PARTIAL_OUT); // exit partial mode } } @@ -63,7 +58,7 @@ bool HOT EPaperUC8179::transfer_data() { // Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image if (two_pass && this->current_data_index_ < buffer_length) { if (this->current_data_index_ == 0) { - this->command(0x10); // DATA START TRANSMISSION 1 (previous image) + this->command(CMD_DTM1); // DATA START TRANSMISSION 1 (previous image) } this->start_data_(); while (this->current_data_index_ < buffer_length) { @@ -86,7 +81,7 @@ bool HOT EPaperUC8179::transfer_data() { const size_t total = offset + buffer_length; if (this->current_data_index_ < total) { if (this->current_data_index_ == offset) { - this->command(0x13); // DATA START TRANSMISSION 2 (new image) + this->command(CMD_DTM2); // DATA START TRANSMISSION 2 (new image) } this->start_data_(); while (this->current_data_index_ < total) { @@ -117,7 +112,7 @@ void EPaperUC8179::power_on() { void EPaperUC8179::refresh_screen(bool /*partial*/) { ESP_LOGV(TAG, "Refresh"); - this->command(0x12); // DISPLAY REFRESH + this->command(CMD_REFRESH); // DISPLAY REFRESH // Delay the next busy poll: the busy line takes a short time to assert after // the refresh command, and polling too early would read it as already idle this->next_delay_ = 100; @@ -125,15 +120,7 @@ void EPaperUC8179::refresh_screen(bool /*partial*/) { void EPaperUC8179::power_off() { ESP_LOGV(TAG, "Power off"); - this->command(0x02); // POWER OFF -} - -void EPaperUC8179::deep_sleep() { - // Deep sleep loses the previous-image RAM that partial refresh compares against - if (!this->is_using_partial_update_()) { - ESP_LOGV(TAG, "Deep sleep"); - this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code - } + this->command(CMD_POWER_OFF); // POWER OFF } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index fda4297802b..5d85c6bd04a 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -1,6 +1,6 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_ultrachip.h" namespace esphome::epaper_spi { @@ -28,11 +28,11 @@ namespace esphome::epaper_spi { * waveform is forced, partial mode is entered with a full-screen window and * only DTM2 is sent; the controller compares against its previous-image RAM. */ -class EPaperUC8179 final : public EPaperBase { +class EPaperUC8179 final : public EPaperUltraChip { public: EPaperUC8179(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) {} + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} protected: bool initialise(bool partial) override; @@ -40,11 +40,7 @@ class EPaperUC8179 final : public EPaperBase { void refresh_screen(bool partial) override; void power_on() override; void power_off() override; - void deep_sleep() override; void set_refresh_mode_(); - - // Set by initialise() so transfer_data() knows which planes to send - bool partial_{}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.cpp b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp new file mode 100644 index 00000000000..096b2e54ae7 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp @@ -0,0 +1,144 @@ +#include "epaper_spi_uc8279.h" + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8279"); + +static constexpr uint8_t CMD_PSR = 0x00; +static constexpr uint8_t CMD_PFS = 0x03; +static constexpr uint8_t CMD_GATE_SCAN = 0xE1; + +static constexpr uint8_t PSR0_OTP_LUT = 0x17; +static constexpr uint8_t PSR1 = 0x4D; +static constexpr uint8_t PFS = 0x20; +static constexpr uint8_t GATE_SCAN = 0x02; +static constexpr uint8_t CCSET = 0x02; +static constexpr uint8_t TSSET_FULL = 0x1E; +static constexpr uint8_t TSSET_PARTIAL = 0x5A; +static constexpr uint8_t CDI_FULL = 0x97; +static constexpr uint8_t CDI_PARTIAL = 0xD7; + +static constexpr uint16_t RESET_SETTLE_MS = 50; +static constexpr uint16_t BUSY_ASSERT_MS = 10; +static constexpr uint16_t REFRESH_START_MS = 50; + +bool EPaperUC8279::reset() { + // A partial update follows one that left the controller configured, in standby, with DTM1 holding the image + if (this->partial_) + return true; + if (!EPaperBase::reset()) + return false; + this->next_delay_ = RESET_SETTLE_MS; + return true; +} + +bool EPaperUC8279::initialise(bool partial) { + if (partial) + return true; + return EPaperBase::initialise(partial); +} + +void EPaperUC8279::write_gate_(size_t gate, bool white) { + const size_t row_width = this->row_width_; + if (white || gate < this->offset_height_) { + uint8_t white_bytes[MAX_TRANSFER_SIZE]; + memset(white_bytes, 0xFF, sizeof(white_bytes)); + for (size_t written = 0; written != row_width;) { + const size_t length = std::min(row_width - written, MAX_TRANSFER_SIZE); + this->write_array(white_bytes, length); + written += length; + } + return; + } + size_t index = (gate - this->offset_height_) * row_width; + for (size_t remaining = row_width; remaining != 0;) { + size_t length; + const uint8_t *data = this->buffer_.get_span(index, length); + length = std::min(length, remaining); + this->write_array(data, length); + index += length; + remaining -= length; + } +} + +// One plane of one gate's row per step, time-sliced; `base` is the value of current_data_index_ at its first gate +bool HOT EPaperUC8279::write_plane_(uint8_t command, bool white, size_t base) { + const uint32_t start_time = millis(); + const size_t gates = this->gate_count_(); + if (this->current_data_index_ == base) + this->command(command); + this->start_data_(); + while (this->current_data_index_ - base != gates) { + this->write_gate_(this->current_data_index_ - base, white); + ++this->current_data_index_; + if (this->current_data_index_ - base != gates && millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + return true; +} + +// DTM2 gets the new image; a full refresh also writes a white DTM1, so every pixel transitions +bool HOT EPaperUC8279::transfer_data() { + const size_t gates = this->gate_count_(); + if (this->current_data_index_ < gates) { + if (!this->write_plane_(CMD_DTM2, false, 0)) + return false; + // Start DTM1 on the next pass, so this one stays within its time slice + if (!this->partial_) + return false; + } + if (!this->partial_ && !this->write_plane_(CMD_DTM1, true, gates)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_on() { + ESP_LOGV(TAG, "Power on"); + this->cmd_data(CMD_CDI, {this->partial_ ? CDI_PARTIAL : CDI_FULL}); + this->cmd_data(CMD_CCSET, {CCSET}); + this->cmd_data(CMD_TSSET, {this->partial_ ? TSSET_PARTIAL : TSSET_FULL}); + if (this->partial_) { + this->cmd_data(CMD_PFS, {PFS}); + this->cmd_data(CMD_GATE_SCAN, {GATE_SCAN}); + } + this->command(CMD_POWER_ON); + this->next_delay_ = BUSY_ASSERT_MS; +} + +void EPaperUC8279::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen (%s)", partial ? LOG_STR_LITERAL("partial") : LOG_STR_LITERAL("full")); + if (partial) + this->set_partial_window_(0, this->width_ - 1, this->offset_height_, this->gate_count_() - 1); + // Power-on reloads the panel's stored settings, so the panel setting only takes when written after it + this->cmd_data(CMD_PSR, {PSR0_OTP_LUT, PSR1}); + this->command(CMD_REFRESH); + this->next_delay_ = REFRESH_START_MS; +} + +bool HOT EPaperUC8279::after_refresh() { + // While partial updates are in use, DTM1 holds the image on the panel for the next partial comparison + if (!this->is_using_partial_update_()) + return true; + if (this->current_data_index_ == 0 && this->partial_) + this->command(CMD_PARTIAL_OUT); + if (!this->write_plane_(CMD_DTM1, false, 0)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_off() { + this->command(CMD_POWER_OFF); + this->next_delay_ = BUSY_ASSERT_MS; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.h b/esphome/components/epaper_spi/epaper_spi_uc8279.h new file mode 100644 index 00000000000..fb430855516 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.h @@ -0,0 +1,46 @@ +#pragma once + +#include "epaper_spi_ultrachip.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome e-paper displays using the UltraChip UC8279 controller, such as the Xteink X4 Pro. + * + * Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default). + * + * The controller scans offset_height + height gates; the panel is bonded to the last height of them, + * so every image plane is written for all gates, white outside the panel. + * + * A full refresh writes the new image to DTM2 and white to DTM1. A partial refresh writes only DTM2 + * and drives the pixels that differ from DTM1, which therefore has to hold the image on the panel: + * while partial updates are in use it is rewritten after every refresh, in time slices like the + * image transfer. The panel loses both planes in its own deep sleep, so it then waits in standby + * between updates instead. + */ +class EPaperUC8279 : public EPaperUltraChip { + public: + EPaperUC8279(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, uint16_t offset_height) + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY), + offset_height_(offset_height) {} + + protected: + bool reset() override; + bool initialise(bool partial) override; + bool transfer_data() override; + void power_on() override; + void refresh_screen(bool partial) override; + void power_off() override; + bool after_refresh() override; + + uint16_t gate_count_() const { return this->offset_height_ + this->height_; } + // Writes one gate's row of the image, or white for a gate outside the panel or when white is true + void write_gate_(size_t gate, bool white); + // Writes one plane in time slices, sending `command` at its first gate; true once all gates are written + bool write_plane_(uint8_t command, bool white, size_t base); + + uint16_t offset_height_; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp new file mode 100644 index 00000000000..d34e022380e --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp @@ -0,0 +1,33 @@ +#include "epaper_spi_ultrachip.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +ESPHOME_LOG_TAG(TAG, "epaper_spi.ultrachip"); + +static constexpr uint8_t hi(uint16_t v) { return static_cast(v >> 8); } +static constexpr uint8_t lo(uint16_t v) { return static_cast(v & 0xFF); } + +void EPaperUltraChip::deep_sleep() { + // Partial updates compare against the previous image, which deep sleep would lose; standby keeps it + if (this->is_using_partial_update_()) + return; + this->send_deep_sleep_(); +} + +void EPaperUltraChip::send_deep_sleep_() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(CMD_DEEP_SLEEP, {DEEP_SLEEP_CHECK}); +} + +void EPaperUltraChip::on_safe_shutdown() { this->send_deep_sleep_(); } + +void EPaperUltraChip::set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end) { + this->command(CMD_PARTIAL_IN); + this->cmd_data(CMD_PARTIAL_WINDOW, + {hi(x_start), static_cast(lo(x_start) & 0xF8), hi(x_end), + static_cast(lo(x_end) | 0x07), hi(y_start), lo(y_start), hi(y_end), lo(y_end), 0x01}); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.h b/esphome/components/epaper_spi/epaper_spi_ultrachip.h new file mode 100644 index 00000000000..4dfd732fa24 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.h @@ -0,0 +1,43 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Shared base for the UltraChip UC81xx mono controllers (UC8179, UC8279). + * + * They use the same command set and the same deep-sleep and partial-window handling; the + * concrete drivers differ only in how each refresh is configured and which planes are sent. + */ +class EPaperUltraChip : public EPaperBase { + public: + using EPaperBase::EPaperBase; + + // Nothing carries the image across a restart or the controller's deep sleep, so sleep the panel rather than + // leaving it waiting in standby + void on_safe_shutdown() override; + + protected: + static constexpr uint8_t CMD_POWER_OFF = 0x02; + static constexpr uint8_t CMD_POWER_ON = 0x04; + static constexpr uint8_t CMD_DEEP_SLEEP = 0x07; + static constexpr uint8_t CMD_DTM1 = 0x10; + static constexpr uint8_t CMD_REFRESH = 0x12; + static constexpr uint8_t CMD_DTM2 = 0x13; + static constexpr uint8_t CMD_CDI = 0x50; + static constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; + static constexpr uint8_t CMD_PARTIAL_IN = 0x91; + static constexpr uint8_t CMD_PARTIAL_OUT = 0x92; + static constexpr uint8_t CMD_CCSET = 0xE0; + static constexpr uint8_t CMD_TSSET = 0xE5; + static constexpr uint8_t DEEP_SLEEP_CHECK = 0xA5; + + void deep_sleep() override; + // Sends the deep-sleep command unconditionally; deep_sleep() skips it while partial updates are in use + void send_deep_sleep_(); + // Enters partial mode and sets the refresh window; x is rounded out to whole bytes + void set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index fbedd6371d1..ba8ebb6c873 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -1,12 +1,17 @@ from typing import Any, Self import esphome.config_validation as cv -from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +def ultrachip_resolution(width: int, gates: int) -> tuple: + """The UC81xx resolution command (0x61): source (width) and gate counts, each big-endian.""" + return (0x61, width >> 8, width & 0xFF, gates >> 8, gates & 0xFF) + + class EpaperModel: models: dict[str, Self] = {} @@ -86,6 +91,13 @@ class EpaperModel: height = self.get_default(CONF_HEIGHT) return width, height + def get_offset_height(self, config) -> int: + # The panel's first row within the controller's addressable area; from explicit dimensions or the model default + dimensions = config.get(CONF_DIMENSIONS, {}) + return dimensions.get( + CONF_OFFSET_HEIGHT, self.get_default(CONF_OFFSET_HEIGHT, 0) + ) + def extend(self, name, **kwargs) -> "EpaperModel": """ Extend the current model with additional parameters or a modified init sequence. diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index 91c649808a8..84b736081fb 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -18,7 +18,7 @@ from typing import Any from esphome.const import CONF_DATA_RATE -from . import EpaperModel +from . import EpaperModel, ultrachip_resolution class UC8179(EpaperModel): @@ -51,13 +51,7 @@ class UC8179(EpaperModel): # PANEL SETTING (black/white mode, LUT from OTP) (0x00, 0x1F), # RESOLUTION SETTING (width x height) - ( - 0x61, - (width >> 8) & 0xFF, - width & 0xFF, - (height >> 8) & 0xFF, - height & 0xFF, - ), + ultrachip_resolution(width, height), # DUAL SPI MODE (disabled) (0x15, 0x00), # VCOM AND DATA INTERVAL SETTING diff --git a/esphome/components/epaper_spi/models/uc8279.py b/esphome/components/epaper_spi/models/uc8279.py new file mode 100644 index 00000000000..deb487883de --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8279.py @@ -0,0 +1,74 @@ +"""Monochrome e-paper displays using the UltraChip UC8279 controller. + +Supported models: +- uc8279: the controller on its own; dimensions and pins must be configured +- xteink-x4pro-uc8279: the Xteink X4 Pro e-reader (ESP32-S3) with its 800x480 UC8279 panel; + the X4 Pro also ships with other panel controllers + +The controller scans offset_height + height gates, and the panel is bonded to the last +height of them; offset_height (under dimensions) is a property of the panel. +""" + +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DATA_RATE, CONF_DIMENSIONS, CONF_OFFSET_HEIGHT +from esphome.types import ConfigType + +from . import EpaperModel, ultrachip_resolution + + +class UC8279(EpaperModel): + def __init__( + self, + name: str, + class_name: str = "EPaperUC8279", + data_rate: str = "10MHz", + **defaults: Any, + ) -> None: + defaults.setdefault(CONF_DATA_RATE, data_rate) + defaults.setdefault("partial_update", True) + defaults.setdefault(CONF_OFFSET_HEIGHT, 0) + defaults.setdefault("width_multiple", 8) + super().__init__(name, class_name=class_name, **defaults) + + def validate_config(self, config: ConfigType) -> ConfigType: + _width, height = self.get_dimensions(config) + if self.get_offset_height(config) + height > 0xFFFF: + raise cv.Invalid( + f"{CONF_OFFSET_HEIGHT} plus the panel height must not exceed {0xFFFF} gates", + path=[CONF_DIMENSIONS, CONF_OFFSET_HEIGHT], + ) + return config + + def get_constructor_args(self, config) -> tuple: + return (self.get_offset_height(config),) + + # fmt: off + def get_init_sequence(self, config: dict) -> tuple: + width, height = self.get_dimensions(config) + gates = self.get_offset_height(config) + height + return ( + (0x00, 0x37, 0x4D), # panel setting; the driver switches it to the OTP LUT before each refresh + ultrachip_resolution(width, gates), # resolution: sources, gates + (0x65, 0x00, 0x00, 0x00, 0x00), # gate and source start + (0x03, 0x20), # power off sequence + (0x30, 0x0E), # PLL + (0xE1, 0x02), # gate scan + ) + # fmt: on + + +uc8279 = UC8279("uc8279") + +uc8279.extend( + "xteink-x4pro-uc8279", + width=800, + height=480, + offset_height=120, + cs_pin=13, + dc_pin=18, + reset_pin=14, + busy_pin={"number": 6, "inverted": True}, + reset_duration="50ms", +) diff --git a/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml new file mode 100644 index 00000000000..34b55ab4456 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +spi: + clk_pin: GPIO12 + mosi_pin: GPIO11 + +display: + - platform: epaper_spi + id: epaper_display + model: xteink-x4pro-uc8279 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 81e197abf81..644a505bf82 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -13,7 +13,12 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, @@ -516,6 +521,140 @@ def test_uc8179_e1001_code_generation( assert "97, 4, 3, 32, 1, 224" in main_cpp +def test_uc8279_width_must_be_a_multiple_of_8( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The UC8279 takes whole bytes of sources, so the width is a multiple of 8.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="multiple of 8"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 804, "height": 480}, + } + ) + + +def test_uc8279_x4pro_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Xteink X4 Pro generates the UC8279 driver with the panel's gate offset and gate count.""" + main_cpp = generate_main(component_config_path("uc8279_x4pro_test.yaml")) + + assert "epaper_spi::EPaperUC8279" in main_cpp + # 800 sources, 480 rows, and the panel on the last 480 of 600 gates + assert re.search( + r'"XTEINK-X4PRO-UC8279",\s*800,\s*480,.*?,\s*120\)', main_cpp, re.DOTALL + ) + resolution = next( + cmd + for cmd in MODELS["XTEINK-X4PRO-UC8279"].get_init_sequence({}) + if cmd[0] == 0x61 + ) + assert resolution == (0x61, 0x03, 0x20, 0x02, 0x58) + + +def test_uc8279_offset_height_configurable( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The generic uc8279 model takes offset_height under dimensions, reaching the constructor and gate count.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 8}, + } + ) + model = MODELS["UC8279"] + assert model.get_constructor_args(config) == (8,) + resolution = next(cmd for cmd in model.get_init_sequence(config) if cmd[0] == 0x61) + # 16 sources, and 8 + 2 = 10 gates + assert resolution == (0x61, 0x00, 0x10, 0x00, 0x0A) + + +def test_uc8279_offset_height_plus_height_must_fit_16_bits( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """offset_height plus the panel height is the gate count, which the controller holds in 16 bits.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="must not exceed"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 0xFFFF}, + } + ) + + +def test_uc8279_x4pro_explicit_dimensions_keep_the_model_offset( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Writing out the X4 Pro's dimensions without offset_height keeps the panel's offset of 120.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 12, "mosi_pin": 11}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "xteink-x4pro-uc8279", + "dimensions": {"width": 800, "height": 480}, + } + ) + assert MODELS["XTEINK-X4PRO-UC8279"].get_constructor_args(config) == (120,) + + +def test_offset_height_rejected_by_models_without_it( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model that does not use offset_height rejects it rather than ignoring it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="offset_height"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "dimensions": {"width": 200, "height": 200, "offset_height": 8}, + } + ) + + def test_enable_pin_code_generation( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/epaper_spi/display/test_uc8279_transfer.cpp b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp new file mode 100644 index 00000000000..a0a1e1258c5 --- /dev/null +++ b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp @@ -0,0 +1,237 @@ +#include + +#include +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_uc8279.h" + +namespace esphome::epaper_spi::testing { + +/// A reset pin that counts how often it is driven low. +class CountingPin : public RecordingPin { + public: + void digital_write(bool value) override { + if (!value) + this->pulses++; + RecordingPin::digital_write(value); + } + int pulses{0}; +}; + +/// A 16x2 panel bonded to gates 1 and 2 of three, so gate 0 is outside the panel. +class TestableUC8279 : public EPaperUC8279 { + public: + TestableUC8279() : EPaperUC8279("test", 16, 2, nullptr, 0, 1) {} + + void install(spi::SPIDelegate *delegate, uint8_t full_update_every) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_reset_pin(&this->reset_pin); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->set_full_update_every(full_update_every); + } + + void set_frame(std::initializer_list bytes) { + size_t i = 0; + for (const uint8_t byte : bytes) + this->buffer_[i++] = byte; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + /// A whole update with the frame already drawn, run through loop() as the main loop would. + void run_update() { + this->set_auto_clear(false); + this->state_ = EPaperState::UPDATE; + const uint32_t deadline = millis() + 2000; + while (this->state_ != EPaperState::IDLE) { + ASSERT_LT(millis(), deadline) << "update did not finish"; + this->loop(); + } + } + + bool transfer() { return this->transfer_data(); } + + using EPaperUC8279::on_safe_shutdown; + + RecordingPin dc; + CountingPin reset_pin; +}; + +using Bytes = std::vector; + +static size_t count(const Bytes &commands, uint8_t command) { + return std::count(commands.begin(), commands.end(), command); +} + +/// A full refresh writes the new image to DTM2 and white to DTM1, for every gate the controller scans: +/// white on the gate outside the panel, then the panel's rows. +TEST(EPaperUC8279, FullRefreshWritesTheImageAndAWhitePreviousImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_TRUE(display.reset_pin.level) << "reset pin left low"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x10].size(), 12u) << "DTM1 is not the white previous image plus the image written after"; + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "a full refresh entered partial mode"; + EXPECT_EQ(bus.data[0x00], (Bytes{0x17, 0x4D})) << "the OTP LUT was not selected before the refresh"; +} + +/// While partial updates are in use, DTM1 is rewritten with the image after every refresh, so the next +/// partial one compares against the image on the panel. The panel then waits in standby, since its own +/// deep sleep loses both planes. +TEST(EPaperUC8279, PreviousImageIsRewrittenAfterEveryRefresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02) << "the panel was not powered off after DTM1 was written"; + EXPECT_EQ(count(bus.commands, 0x07), 0u) << "the panel was put into deep sleep"; +} + +/// A partial refresh writes only DTM2, without a reset or the configuration, inside a partial window +/// covering the panel's gates. +TEST(EPaperUC8279, PartialRefreshWritesOnlyTheNewImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + + EXPECT_EQ(display.reset_pin.pulses, pulses) << "the partial refresh reset the panel"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})) + << "DTM1 was written before the refresh, or not rewritten after it"; + EXPECT_EQ(bus.data[0x90], (Bytes{0x00, 0x00, 0x00, 0x0F, 0x00, 0x01, 0x00, 0x02, 0x01})) + << "the partial window does not cover the panel's gates"; + const auto refresh = std::find(bus.commands.begin(), bus.commands.end(), 0x12); + const auto dtm1 = std::find(bus.commands.begin(), bus.commands.end(), 0x10); + EXPECT_LT(refresh, dtm1) << "DTM1 was written before the refresh"; +} + +/// Without partial updates the panel goes into its deep sleep after every update, and the next update +/// resets it and writes both planes again. +TEST(EPaperUC8279, EveryUpdateFullSleepsAndResetsEachTime) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))) << "DTM1 was rewritten without partial updates"; + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u); +} + +/// The rewrite of DTM1 is spread over loop iterations like the image transfer, and the state machine +/// waits for it: the panel is powered off and the update finishes only once all of it is written. +TEST(EPaperUC8279, PreviousImageRewriteIsTimeSliced) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); // every row overruns the time slice + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02); +} + +/// In a full update the white DTM1 plane starts on the pass after DTM2 ends, so one pass never writes both planes. +TEST(EPaperUC8279, FullTransferWritesOnePlanePerPass) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + + EXPECT_FALSE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(count(bus.commands, 0x10), 0u) << "DTM1 started in the same pass as DTM2"; + + EXPECT_TRUE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); +} + +/// The full refresh that comes round after partial ones resets the controller, configures it again and writes a white +/// DTM1 over the image it held, as the first full refresh does. +TEST(EPaperUC8279, FullRefreshAfterPartialOnesStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 2); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x00, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1) << "the full refresh did not reset the controller"; + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "the full refresh entered partial mode"; + ASSERT_EQ(bus.data[0x10].size(), 12u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x00, 0x00})); +} + +/// A requested full update, between partial ones, is a full refresh from scratch too. +TEST(EPaperUC8279, RequestedFullUpdateStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.request_full_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1); + EXPECT_EQ(count(bus.commands, 0x91), 0u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); +} + +/// Before a restart or the controller's deep sleep the panel goes into its own deep sleep, even while partial updates +/// would otherwise leave it in standby. +TEST(EPaperUC8279, ShutdownPutsThePanelToSleep) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + display.on_safe_shutdown(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6a487064cbc..144c60dd2ff 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -435,3 +435,27 @@ display: allow_other_uses: true number: GPIO4 inverted: true + + # Xteink X4 Pro (800x480, UC8279) + # Pins overridden to avoid conflicts with the other display configs + # full_update_every > 1 exercises the partial refresh path + - platform: epaper_spi + spi_id: spi_bus + model: xteink-x4pro-uc8279 + full_update_every: 10 + 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 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK);