diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index 7b7c48c0b0..0240aa5ccc 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -16,6 +16,28 @@ namespace esphome::epaper_spi { /** Delta for when to regard as gray */ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; +/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */ +constexpr uint8_t rec601_luma(Color color) { + return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); +} + +/** Map RGB color to a single monochrome bit + * + * @param color RGB color to convert from + * @return 1 = white, 0 = black + */ +constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } + +/** Map RGB color to one of 4 discrete gray levels (2 bits per pixel) + * + * @param color RGB color to convert from + * @return Gray level: 0 = black, 3 = white + */ +constexpr uint8_t color_to_gray4(Color color) { + const uint8_t level = (uint8_t) ((rec601_luma(color) + 32u) >> 6); // quantize 0..255 to 0..3, rounded + return level > 3 ? 3 : level; +} + /** Map RGB color to discrete BWYR hex 4 color key * * @tparam NATIVE_COLOR Type of native hardware color values @@ -25,7 +47,6 @@ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; * @param hw_yellow Native value for yellow * @param hw_red Native value for red * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_yellow, @@ -38,11 +59,7 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { // It's a shade of gray. Map to BLACK or WHITE. - // We split the luminance at the halfway point (382 = (255*3)/2) - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } // --- Step 2: Check for Primary/Secondary Colors --- @@ -96,7 +113,6 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C * @param hw_green Native value for green * @param hw_blue Native value for blue * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, @@ -105,10 +121,7 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } const bool r_on = (color.r > 128); @@ -158,7 +171,6 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV * @param hw_blue Native value for blue * @param hw_orange Native value for orange * @return Converted native hardware color value - * @internal Constexpr. Does not depend on side effects ("pure"). */ template constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, @@ -167,10 +179,7 @@ constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATI const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { - if ((static_cast(color.r) + color.g + color.b) > 382) { - return hw_white; - } - return hw_black; + return color_to_mono(color) ? hw_white : hw_black; } const bool r_on = (color.r > 128); diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index e9da924de5..3fa504539b 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -1,7 +1,9 @@ +from collections.abc import Callable import importlib import pkgutil +from typing import Any -from esphome import core, pins +from esphome import automation, core, pins import esphome.codegen as cg from esphome.components import display, spi from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation @@ -54,6 +56,12 @@ EPaperBase = epaper_spi_ns.class_( ) Transform = epaper_spi_ns.enum("Transform") +automation.register_apply_action( + "epaper_spi.full_update_next", + automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(EPaperBase)}), + automation.ApplyCall("request_full_update()"), +) + # Import all models dynamically from the models package for module_info in pkgutil.iter_modules(models.__path__): importlib.import_module(f".models.{module_info.name}", package=__package__) @@ -70,6 +78,23 @@ DIMENSION_SCHEMA = cv.Schema( TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} +def _full_update_every_validator( + model: models.EpaperModel, +) -> Callable[[Any], int]: + if model.get_default("partial_update"): + return cv.int_range(1, 255) + + def validate(value: Any) -> int: + value = cv.int_range(1, 255)(value) + if value != 1: + raise cv.Invalid( + f"{model.name} does not support partial update; full_update_every must be 1" + ) + return value + + return validate + + def model_schema(config): model = MODELS[config[CONF_MODEL]] class_name = epaper_spi_ns.class_(model.class_name, EPaperBase) @@ -96,7 +121,9 @@ def model_schema(config): cv.Required(CONF_MIRROR_Y): cv.boolean, } ), - cv.Optional(CONF_FULL_UPDATE_EVERY, default=1): cv.int_range(1, 255), + cv.Optional( + CONF_FULL_UPDATE_EVERY, default=1 + ): _full_update_every_validator(model), model.option(CONF_BUSY_PIN): pins.gpio_input_pin_schema, model.option(CONF_CS_PIN): pins.gpio_output_pin_schema, model.option(CONF_DC_PIN, fallback=None): pins.gpio_output_pin_schema, @@ -132,8 +159,15 @@ def customise_schema(config): extra=cv.ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] + model.check_requirements() config = model_schema(config)(config) + config = model.validate_config(config) width, height = model.get_dimensions(config) + if width % (width_multiple := model.get_default("width_multiple", 1)): + raise cv.Invalid( + f"{model.name} requires a width that is a multiple of {width_multiple}", + path=[CONF_DIMENSIONS], + ) display.add_metadata( config[CONF_ID], width, diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 3b3418d911..1fab038c28 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -196,6 +196,15 @@ void EPaperBase::process_state_() { break; case EPaperState::UPDATE: this->do_update_(); // Calls ESPHome (current page) lambda + if (this->full_update_requested_) { + // Refresh the whole panel even if nothing was drawn + this->full_update_requested_ = false; + this->update_count_ = 0; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) { this->set_state_(EPaperState::IDLE); return; @@ -327,9 +336,9 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { return; 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 uint8_t pixel_bit = 0x80u >> bit_position; const auto original = this->buffer_[byte_position]; - if ((color_to_bit(color) == 0)) { + if (color_to_mono(color) == 0) { this->buffer_[byte_position] = original & ~pixel_bit; } else { this->buffer_[byte_position] = original | pixel_bit; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 8e2fd78e62..0040c867b2 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -1,5 +1,6 @@ #pragma once +#include "colorconv.h" #include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" @@ -80,15 +81,7 @@ class EPaperBase : public Display, DisplayType get_display_type() override { return this->display_type_; }; - // Default implementations for monochrome displays - static uint8_t color_to_bit(Color color) { - // It's always a shade of gray. Map to BLACK or WHITE. - // We split the luminance at a suitable point - if ((color.r + color.g + color.b) >= 382) { - return 1; - } - return 0; - } + // Default implementation for monochrome displays void fill(Color color) override { // If clipping is active, fall back to base implementation if (this->get_clipping().is_set()) { @@ -96,7 +89,7 @@ class EPaperBase : public Display, return; } - auto pixel_color = color_to_bit(color) ? 0xFF : 0x00; + auto pixel_color = color_to_mono(color) ? 0xFF : 0x00; // We store 8 pixels per byte this->buffer_.fill(pixel_color); @@ -114,6 +107,8 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; + // Make the next update a full one. Applied when that update starts, so one in progress is not affected. + void request_full_update() { this->full_update_requested_ = true; } protected: int get_height_internal() override { return this->height_; }; @@ -185,6 +180,7 @@ class EPaperBase : public Display, uint8_t transform_{}; uint8_t effective_transform_{}; uint8_t update_count_{}; + bool full_update_requested_{}; // 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_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index f44b59e803..d0740595fc 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -9,8 +9,8 @@ namespace esphome::epaper_spi { class EPaperMono : public EPaperBase { public: 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) { + size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type) { this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up } diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp new file mode 100644 index 0000000000..e845a5d585 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -0,0 +1,101 @@ +#include "epaper_spi_ssd1677.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.ssd1677"; + +void EPaperSSD1677::setup() { + EPaperMono::setup(); + if (!this->is_failed()) + this->init_comparison_frame_(); +} + +void EPaperSSD1677::init_comparison_frame_() { + if (!this->is_using_partial_update_()) + return; + if (!this->sent_.init(this->plane_row_length_() * this->height_)) { + ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas"); + } +} + +void EPaperSSD1677::plane_row(size_t y, uint8_t *out) { + const size_t row_length = this->plane_row_length_(); + const size_t data_idx = y * row_length; + for (size_t i = 0; i != row_length; i++) + out[i] = this->buffer_[data_idx + i]; +} + +// Nothing a partial update needs is kept in controller RAM any more, so skip the reset for those. +bool EPaperSSD1677::reset() { + if (this->update_count_ != 0 && this->sent_.is_valid()) + return true; + return EPaperMono::reset(); +} + +// The window always covers the whole panel, so each plane is the frame's bytes in order. Where +// those bytes are already stored as the plane needs them (the comparison frame, and a 1-bit +// buffer) they are written straight from the buffer, as many at a time as the time slice allows; +// otherwise they are built a row at a time by plane_row(). +bool HOT EPaperSSD1677::transfer_data() { + if (!this->sent_.is_valid()) + return EPaperMono::transfer_data(); + + const auto start_time = millis(); + if (this->current_data_index_ == 0) { + if (this->plane_ == 0) { + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + } + this->set_window(); + this->command(this->plane_ == 0 ? 0x26 : 0x24); + } + // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after + // boot): send the new frame to both planes. + const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; + const bool direct = send_copy || this->buffer_is_plane(); + const auto &source = send_copy ? this->sent_ : this->buffer_; + const size_t row_length = this->plane_row_length_(); + const size_t plane_length = row_length * this->height_; + // Roughly what the bus moves in one time slice, so a slice is not overrun by much + const size_t max_chunk = std::max(this->data_rate_ / 8000 * MAX_TRANSFER_TIME, MAX_TRANSFER_SIZE); + SmallBufferWithHeapFallback<128> row_alloc(direct ? 0 : row_length); + this->start_data_(); + while (this->current_data_index_ != plane_length) { + size_t length; + const uint8_t *data; + if (direct) { + data = source.get_span(this->current_data_index_, length); + length = std::min(length, max_chunk); + } else { + // Always at the start of a row here, since this path sends whole rows only + this->plane_row(this->current_data_index_ / row_length, row_alloc.get()); + data = row_alloc.get(); + length = row_length; + } + this->write_array(data, length); + if (this->plane_ == 1) + this->sent_.write(this->current_data_index_, data, length); + this->current_data_index_ += length; + if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; + } + } + this->disable(); + this->current_data_index_ = 0; + if (this->plane_ == 0) { + this->plane_ = 1; + return false; + } + this->plane_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h new file mode 100644 index 0000000000..ddf3d4e089 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -0,0 +1,49 @@ +#pragma once + +#include "epaper_spi_mono.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome SSD1677 with partial refreshes that leave unchanged pixels alone. + * + * A partial refresh drives each pixel from the pair (RAM 0x26 = the image on the panel, + * RAM 0x24 = the new image) across the whole panel; the RAM window only scopes a write. + * EPaperMono writes 0x26 once and afterwards only the changed window of 0x24, which relies on + * the controller's RAM being unchanged from one update to the next. On this controller it is not: + * the hardware reset at the start of each update loses it, and even without resets, keeping 0x26 + * in step one window at a time left unchanged areas alternating between older frames. Either way + * unchanged pixels get driven on every partial and wash out. + * + * So before every refresh this class writes both planes over the whole panel: 0x26 from a copy of + * the frame last sent, 0x24 from the buffer. GxEPD2 likewise rewrites both planes after each + * partial on this controller. The copy is taken as the data goes out, not from the buffer, which + * may already hold the next frame by the time the refresh completes. + */ +class EPaperSSD1677 : public EPaperMono { + public: + EPaperSSD1677(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) + : EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type) {} + + void setup() override; + + protected: + // Allocates the comparison frame when partial updates are enabled. Separate from setup() so it + // can run without a bus. + void init_comparison_frame_(); + // Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds. + size_t plane_row_length_() const { return (this->width_ + 7) / 8; } + // Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is. + // A subclass with a deeper buffer returns false and overrides plane_row(). + virtual bool buffer_is_plane() const { return true; } + // Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white. + virtual void plane_row(size_t y, uint8_t *out); + bool reset() override; + bool transfer_data() override; + + split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows + uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24 +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp new file mode 100644 index 0000000000..18fbb016f3 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.cpp @@ -0,0 +1,129 @@ +#include "epaper_spi_ssd1677_gray4.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.ssd1677_gray4"; + +// Combine two source bytes (4 pixels each, 2 bits per pixel, most significant pixel first) into +// one plane byte covering the same 8 pixels (1 bit per pixel), choosing high or low bit +static uint8_t plane_byte(uint8_t first, uint8_t second, bool high_bit) { + uint8_t out = 0; + for (const uint8_t src : {first, second}) { + for (uint8_t shift = 6;; shift -= 2) { + const uint8_t level = (src >> shift) & 0x03; + out = (uint8_t) ((out << 1) | (high_bit ? (level >> 1) : (level & 1))); + if (shift == 0) + break; + } + } + return out; +} + +void EPaperSSD1677Gray4::fill(Color color) { + if (this->get_clipping().is_set()) { + // Falls back to the generic per-pixel implementation for the clipped rectangle. + EPaperBase::fill(color); + return; + } + const uint8_t level = color_to_gray4(color); + this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6))); + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + const uint8_t level = color_to_gray4(color); + const size_t byte_position = (size_t) y * this->row_width_ + x / 4; + const uint8_t shift = (uint8_t) (6 - 2 * (x % 4)); // most significant pixel first + const uint8_t original = this->buffer_[byte_position]; + this->buffer_[byte_position] = (uint8_t) ((original & ~(0x03 << shift)) | (level << shift)); +} + +// A partial update reduces each pixel to its high bit: levels 2 and 3 are light, 0 and 1 dark. +void EPaperSSD1677Gray4::plane_row(size_t y, uint8_t *out) { + const size_t src_row = y * this->row_width_; + for (size_t i = 0; i != this->plane_row_length_(); i++) + out[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], true); +} + +// the high bit of every pixel's level goes to the new (bw) plane +// (0x24), the low bit to the old (red) plane (0x26) +bool HOT EPaperSSD1677Gray4::transfer_data() { + if (this->is_partial_push_()) + return EPaperSSD1677::transfer_data(); + + auto start_time = millis(); + const bool first_pass = this->send_red_; + if (this->current_data_index_ == 0) { + if (first_pass) { + // With partial updates enabled the window follows the changed area, but the four-level + // refresh drives every pixel from both planes, and the reset before it does not keep RAM. + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + this->set_window(); + } + this->command(first_pass ? 0x24 : 0x26); + this->current_data_index_ = this->y_low_; + } + const size_t plane_row_length = (this->x_high_ - this->x_low_) / 8; + // Stack-backed for every panel width in practice; only a custom `dimensions:` far wider than any + // supported panel would fall back to the heap. + SmallBufferWithHeapFallback<128> bytes_to_send_alloc(plane_row_length); + uint8_t *bytes_to_send = bytes_to_send_alloc.get(); + ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", plane_row_length, this->current_data_index_, + (unsigned) millis()); + this->start_data_(); + while (this->current_data_index_ != this->y_high_) { + const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4; + for (size_t i = 0; i != plane_row_length; i++) { + const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass); + // The OTP grayscale waveform treats data as inverted relative to monochrome + bytes_to_send[i] = (uint8_t) ~plane; + // What the next partial update compares against: the high bits, as a black-and-white frame. + if (first_pass && this->sent_.is_valid()) + this->sent_[this->current_data_index_ * plane_row_length + i] = plane; + } + ++this->current_data_index_; + this->write_array(bytes_to_send, plane_row_length); + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; + } + } + + this->disable(); + this->current_data_index_ = 0; + if (first_pass) { + this->send_red_ = false; + return false; + } + this->send_red_ = true; + return true; +} + +void EPaperSSD1677Gray4::refresh_screen(bool partial) { + if (this->is_partial_push_()) { + ESP_LOGV(TAG, "Black-and-white partial refresh"); + // The border follows the LUT selected in 0x3C. The model's setting (sent with the init + // sequence) picks the LUT that is white under the four-level waveform's inverted data; under + // the black-and-white waveform that LUT drives black and the border darkens, so use LUT1. + this->cmd_data(0x3C, {0x01}); + EPaperSSD1677::refresh_screen(true); + return; + } + ESP_LOGV(TAG, "Four-level refresh"); + this->cmd_data(0x1A, {0x67, 0x00}); // force temperature by OTP + this->cmd_data(0x22, {0xD7}); // four-level update sequence, panel's OTP waveform + this->command(0x20); // master activation +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h new file mode 100644 index 0000000000..47436d2db1 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -0,0 +1,49 @@ +#pragma once + +#include "colorconv.h" +#include "epaper_spi_ssd1677.h" + +namespace esphome::epaper_spi { + +/** + * Four-level grayscale for SSD1677 panels. + * + * The SSD1677 has two independent 1-bit RAM planes, normally used for a + * black/white and a red plane. This class writes image data to both, + * splitting each pixel's 2-bit gray level across them, and triggers the + * panel's own OTP grayscale waveform instead of the normal monochrome update + * sequence. No custom LUT upload needed for any currently supported panel, + * the OTP waveform is used instead. + * + * The framebuffer therefore packs 2 bits per pixel (4 per byte, most + * significant pixel first) instead of EPaperMono's 1 bit. + * + * The grayscale waveform has no partial form: master activation redraws the whole panel + * regardless of the RAM window. So a full update is a four-level refresh, and when partial updates + * are enabled (full_update_every > 1, which the model only allows on explicit request) a partial + * update is EPaperSSD1677's black-and-white one, each pixel reduced to light or dark. The partial + * waveform also drives unchanged pixels towards black or white, so from the first partial update + * the whole panel loses its gray levels until the next full update. + */ +class EPaperSSD1677Gray4 : public EPaperSSD1677 { + public: + EPaperSSD1677Gray4(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE) { + this->row_width_ = (width + 3) / 4; // 4 pixels per byte + this->buffer_length_ = (size_t) this->row_width_ * height; + } + + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + // A partial update if partial updates are enabled and this is not a full one. + bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } + bool buffer_is_plane() const override { return false; } + void plane_row(size_t y, uint8_t *out) override; + void refresh_screen(bool partial) override; + bool transfer_data() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 34e65061f3..fbedd6371d 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -2,7 +2,9 @@ from typing import Any, Self import esphome.config_validation as cv from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.core import CORE from esphome.cpp_generator import MockObj +from esphome.types import ConfigType class EpaperModel: @@ -48,6 +50,16 @@ class EpaperModel: """ return {} + def validate_config(self, config: ConfigType) -> ConfigType: + """ + Validate the configuration as a whole, once the schema has been applied. + The base implementation accepts it unchanged; specific models override this for + rules that span several options. + :param config: The validated configuration + :return: The configuration, possibly updated + """ + return config + async def to_code(self, var: MockObj, config: dict) -> dict: """ Generate model-specific code for the options added by add_options(). @@ -89,3 +101,26 @@ class EpaperModel: defaults = self.defaults.copy() defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) + + def check_requirements(self) -> None: + """ + Raise a friendly error if any component this model requires is not configured. + + This runs during schema validation (before ID references are resolved) so that a + model whose default pins live on a pin expander reports the missing expander clearly + instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + """ + if requirements := self.get_default("requires", set()): + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a0457c5812..ca824dccd4 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -10,7 +10,9 @@ from . import EpaperModel class JD79660(EpaperModel): def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs): - super().__init__(name, class_name, **kwargs) + # Only a fast_update sequence lets the driver do anything but a full refresh + kwargs.setdefault("partial_update", fast_update is not None) + super().__init__(name, class_name=class_name, **kwargs) self.fast_update = fast_update def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required: diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index 13f1035045..2249529611 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -1,12 +1,56 @@ -from esphome.const import CONF_DATA_RATE +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DATA_RATE, CONF_FULL_UPDATE_EVERY +from esphome.types import ConfigType from . import EpaperModel +CONF_BORDER_WAVEFORM = "border_waveform" +CONF_MONOCHROME_PARTIAL_UPDATES = "monochrome_partial_updates" + +# partial_update value for models whose partial updates are black and white only +MONOCHROME = "monochrome" + class SSD1677(EpaperModel): - def __init__(self, name, class_name="EPaperMono", data_rate="20MHz", **defaults): + def __init__( + self, + name: str, + class_name: str = "EPaperSSD1677", + data_rate: str = "20MHz", + border_waveform: int = 0x01, + **defaults: Any, + ) -> None: defaults[CONF_DATA_RATE] = data_rate - super().__init__(name, class_name, **defaults) + defaults[CONF_BORDER_WAVEFORM] = border_waveform + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) + + def get_config_options(self) -> dict: + options = { + self.option(CONF_BORDER_WAVEFORM): cv.hex_uint8_t, + } + if self.get_default("partial_update") == MONOCHROME: + options[cv.Optional(CONF_MONOCHROME_PARTIAL_UPDATES, default=False)] = ( + cv.boolean + ) + return options + + def validate_config(self, config: ConfigType) -> ConfigType: + if ( + self.get_default("partial_update") == MONOCHROME + and config[CONF_FULL_UPDATE_EVERY] > 1 + and not config[CONF_MONOCHROME_PARTIAL_UPDATES] + ): + raise cv.Invalid( + f"{self.name} can only update partially in black and white, and a partial " + "update reduces the whole panel to black and white until the next full " + f"update. Set '{CONF_MONOCHROME_PARTIAL_UPDATES}: true' to accept this, " + "or leave full_update_every at 1", + path=[CONF_FULL_UPDATE_EVERY], + ) + return config # fmt: off def get_init_sequence(self, config: dict): @@ -15,13 +59,14 @@ class SSD1677(EpaperModel): (0x18, 0x80), # Select internal Temp sensor (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 (0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1) - (0x3C, 0x01), # Set border waveform + (0x3C, config[CONF_BORDER_WAVEFORM]), # Set border waveform (0x11, 3), # Set transform ) ssd1677 = SSD1677("ssd1677") + wave_4_26 = ssd1677.extend( "waveshare-4.26in", width=800, @@ -52,7 +97,8 @@ ssd1677.extend( mirror_x=True, ) -ssd1677.extend( +# Sticky - monochrome version +seeed_sticky = ssd1677.extend( "seeed-reterminal-sticky", width=800, height=480, @@ -63,4 +109,15 @@ ssd1677.extend( reset_pin=17, busy_pin=18, data_rate="10MHz", + requires={"psram"}, +) + +# Sticky - 4 level grayscale; partial updates only in black and white, on request +seeed_sticky.extend( + "seeed-reterminal-sticky-gray4", + class_name="EPaperSSD1677Gray4", + border_waveform=0x00, + partial_update=MONOCHROME, + # each plane byte is built from two whole buffer bytes + width_multiple=8, ) diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py index 983f5bb382..b43168a336 100644 --- a/esphome/components/epaper_spi/models/ssd1683.py +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -6,7 +6,8 @@ from . import EpaperModel class SSD1683(EpaperModel): def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults): defaults[CONF_DATA_RATE] = data_rate - super().__init__(name, class_name, **defaults) + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) # fmt: off def get_init_sequence(self, config: dict): diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index bea133c328..91c649808a 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -32,7 +32,8 @@ class UC8179(EpaperModel): **defaults: Any, ) -> None: defaults.setdefault(CONF_DATA_RATE, data_rate) - super().__init__(name, class_name, **defaults) + defaults.setdefault("partial_update", True) + super().__init__(name, class_name=class_name, **defaults) def get_init_sequence(self, config: dict) -> tuple: """Generate the initialization sequence for UC8179 mono displays. diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py index 74a288977d..aecda72364 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -6,8 +6,12 @@ from . import EpaperModel class WaveshareModel(EpaperModel): - def __init__(self, name, lut, lut_partial=None, **defaults): - super().__init__(name, "EpaperWaveshare", **defaults) + def __init__( + self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults + ): + # A partial LUT is what lets EpaperWaveshare do partial refresh + defaults.setdefault("partial_update", lut_partial is not None) + super().__init__(name, class_name=class_name, **defaults) self.lut = lut self.lut_partial = lut_partial diff --git a/tests/component_tests/epaper_spi/config/full_update_next_test.yaml b/tests/component_tests/epaper_spi/config/full_update_next_test.yaml new file mode 100644 index 0000000000..de5e678a70 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/full_update_next_test.yaml @@ -0,0 +1,29 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + full_update_every: 5 + dimensions: + width: 200 + height: 200 + +binary_sensor: + - platform: gpio + pin: GPIO27 + name: Trigger + on_press: + - epaper_spi.full_update_next: epaper_display diff --git a/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml b/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml new file mode 100644 index 0000000000..3d15ab8f90 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/ssd1677_border_waveform_test.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + dimensions: + width: 200 + height: 200 + border_waveform: 0x1A diff --git a/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml b/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml new file mode 100644 index 0000000000..d107807298 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/ssd1677_gray4_test.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +psram: + mode: octal + speed: 80MHz + +spi: + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + id: epaper_display + model: seeed-reterminal-sticky-gray4 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 5e2e7d6013..9d6ebe831a 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -312,6 +312,66 @@ def test_model_with_full_update_every( ) +def test_update_interval_below_model_minimum_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """update_interval faster than the model's minimum_update_interval is rejected.""" + 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="at least"): + 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, + }, + "update_interval": "500ms", + } + ) + + +def test_reset_duration_over_max_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """reset_duration over the 500ms cap is rejected.""" + 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="at most"): + 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, + }, + "reset_duration": "600ms", + } + ) + + def test_busy_pin_input_mode_ssd1677( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], @@ -481,6 +541,16 @@ def test_enable_pin_code_generation( assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp +def test_full_update_next_action_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The epaper_spi.full_update_next action targets the configured display.""" + main_cpp = generate_main(component_config_path("full_update_next_test.yaml")) + + assert "epaper_display->request_full_update();" in main_cpp + + def test_model_with_no_default_init_sequence_generates( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/component_tests/epaper_spi/test_model_requirements.py b/tests/component_tests/epaper_spi/test_model_requirements.py new file mode 100644 index 0000000000..26c50a133a --- /dev/null +++ b/tests/component_tests/epaper_spi/test_model_requirements.py @@ -0,0 +1,376 @@ +"""Tests for the SSD1677 border_waveform option and EpaperModel.check_requirements().""" + +from collections.abc import Callable, Generator +from pathlib import Path +import re +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.epaper_spi.display import CONFIG_SCHEMA, MODELS +from esphome.components.epaper_spi.models import EpaperModel +from esphome.components.epaper_spi.models.ssd1677 import CONF_BORDER_WAVEFORM +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _ssd1677_config(**overrides: Any) -> ConfigType: + config: ConfigType = { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "busy_pin": 22, + "reset_pin": 23, + "cs_pin": 5, + "dimensions": {"width": 200, "height": 200}, + } + config.update(overrides) + return config + + +def _setup_esp32( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + variant: str = VARIANT_ESP32, + board: str = "esp32dev", +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: board, KEY_VARIANT: variant}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + +@pytest.fixture +def temp_model() -> Generator[Callable[..., EpaperModel]]: + """Register a throwaway EpaperModel for a test and remove it from the shared registry after.""" + created: list[EpaperModel] = [] + + def _make(name: str, **defaults: Any) -> EpaperModel: + model = EpaperModel(name, class_name="EPaperMono", **defaults) + created.append(model) + return model + + yield _make + for model in created: + MODELS.pop(model.name, None) + + +# --- border_waveform --------------------------------------------------------- + + +def test_border_waveform_default_mono( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """ssd1677 defaults border_waveform to 0x01.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config()) + + assert result[CONF_BORDER_WAVEFORM] == 0x01 + + +def test_border_waveform_default_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The 4-level grayscale variant defaults border_waveform to 0x00, independently of mono.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + {"id": "test_display", "model": "seeed-reterminal-sticky-gray4"} + ) + + assert result[CONF_BORDER_WAVEFORM] == 0x00 + + +def test_border_waveform_override( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """An explicit border_waveform overrides the model default.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(border_waveform=0x1A)) + + assert result[CONF_BORDER_WAVEFORM] == 0x1A + + +def test_border_waveform_accepts_hex_string( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """border_waveform accepts a hex string like the YAML author would write.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(border_waveform="0x1A")) + + assert result[CONF_BORDER_WAVEFORM] == 0x1A + + +def test_border_waveform_out_of_range( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """border_waveform rejects values that don't fit in a byte.""" + _setup_esp32(set_core_config, set_component_config) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_ssd1677_config(border_waveform=0x100)) + + +def test_border_waveform_in_generated_init_sequence( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The configured border_waveform byte reaches the generated init sequence. + + Command 0x3C (60) is followed by a length of 1 and the waveform byte. + """ + main_cpp = generate_main(component_config_path("ssd1677_border_waveform_test.yaml")) + + assert re.search(r"60,\s*1,\s*0x1A,", main_cpp) + + +# --- full_update_every / supports_partial_update ------------------------------ + + +def test_full_update_every_rejected_for_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The gray4 driver's partial updates are black and white and flatten the whole + panel, so full_update_every > 1 is refused unless that is explicitly accepted.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + with pytest.raises(cv.Invalid, match="monochrome_partial_updates: true"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "full_update_every": 5, + } + ) + + +def test_full_update_every_accepted_for_gray4_with_monochrome_partials( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With the trade-off accepted, the gray4 driver takes full_update_every > 1.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "full_update_every": 5, + "monochrome_partial_updates": True, + } + ) + + assert result["full_update_every"] == 5 + + +def test_monochrome_partial_updates_not_offered_for_mono_sticky( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The option only exists where partial updates lose something.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + with pytest.raises(cv.Invalid, match="monochrome_partial_updates"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky", + "monochrome_partial_updates": True, + } + ) + + +def test_full_update_every_default_accepted_for_gray4( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Leaving full_update_every at its default of 1 is fine for the gray4 driver.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + {"id": "test_display", "model": "seeed-reterminal-sticky-gray4"} + ) + + assert result["full_update_every"] == 1 + + +def test_full_update_every_accepted_for_mono_sticky( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The mono sibling model supports partial update, unaffected by the gray4 restriction.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky", + "full_update_every": 5, + } + ) + + assert result["full_update_every"] == 5 + + +# --- check_requirements ------------------------------------------------------- + + +def test_requirement_missing_raises( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """seeed-reterminal-sticky requires psram; without it, config validation fails.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {} + + with pytest.raises(cv.Invalid, match="requires component 'psram'"): + CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + +def test_requirement_satisfied_does_not_raise( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With psram present at the top level, seeed-reterminal-sticky validates.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + CORE.raw_config = {"psram": {}} + + result = CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + assert result["model"] == "SEEED-RETERMINAL-STICKY" + + +def test_requirement_check_skipped_without_raw_config( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """With no raw_config (e.g. a schema invoked directly, as in these tests), the + requirement check is a no-op rather than a false failure.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + assert CORE.raw_config is None + + # Should not raise even though "psram" is required and nothing was configured. + CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-sticky"}) + + +def test_requirement_missing_multiple_pluralised( + temp_model: Callable[..., EpaperModel], +) -> None: + """The error message pluralises "component(s)" and lists every missing one.""" + model = temp_model("test-multi-requirement", requires={"aaa", "bbb"}) + CORE.raw_config = {} + + with pytest.raises(cv.Invalid, match="requires components 'aaa', 'bbb'"): + model.check_requirements() + + +# --- width_multiple ----------------------------------------------------------- + + +@pytest.mark.parametrize("width", [804, 801]) +def test_gray4_width_not_multiple_of_8_rejected( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + width: int, +) -> None: + """The gray4 plane split reads two whole buffer bytes per plane byte, so width must be a multiple of 8.""" + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + + with pytest.raises(cv.Invalid, match="multiple of 8"): + CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "dimensions": {"width": width, "height": 480}, + } + ) + + +def test_gray4_width_multiple_of_8_accepted( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + _setup_esp32( + set_core_config, set_component_config, VARIANT_ESP32S3, "esp32-s3-devkitc-1" + ) + + result = CONFIG_SCHEMA( + { + "id": "test_display", + "model": "seeed-reterminal-sticky-gray4", + "dimensions": {"width": 808, "height": 480}, + } + ) + + assert result["dimensions"]["width"] == 808 + + +def test_mono_ssd1677_accepts_any_width( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The width restriction applies only to the gray4 model.""" + _setup_esp32(set_core_config, set_component_config) + + result = CONFIG_SCHEMA(_ssd1677_config(dimensions={"width": 204, "height": 200})) + + assert result["dimensions"]["width"] == 204 + + +# --- extend(class_name=...) -------------------------------------------------- + + +def test_gray4_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """seeed-reterminal-sticky-gray4 generates the EPaperSSD1677Gray4 driver, not EPaperMono.""" + main_cpp = generate_main(component_config_path("ssd1677_gray4_test.yaml")) + + assert "epaper_spi::EPaperSSD1677Gray4" in main_cpp + assert "epaper_spi::EPaperMono" not in main_cpp diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h index 5ac12afa6a..f21e674fde 100644 --- a/tests/components/epaper_spi/common.h +++ b/tests/components/epaper_spi/common.h @@ -2,6 +2,9 @@ #include +#include +#include + #include "esphome/components/spi/spi.h" #include "esphome/core/hal.h" @@ -47,4 +50,51 @@ class RecordingPin : public GPIOPin { bool level{true}; }; +/// 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. +class RecordingDelegate : public spi::SPIDelegate { + public: + explicit RecordingDelegate(const RecordingPin *dc, uint32_t row_transfer_ms = 0) + : dc_(dc), row_transfer_ms_(row_transfer_ms) {} + + uint8_t transfer(uint8_t data) override { + this->record_(&data, 1); + return 0; + } + void write_array(const uint8_t *ptr, size_t length) override { + this->record_(ptr, length); + if (this->dc_->level && this->row_transfer_ms_ != 0) { + const uint32_t until = millis() + this->row_transfer_ms_; + while (millis() < until) { + } + } + } + + void clear() { + this->commands.clear(); + this->data.clear(); + } + + std::vector commands; + std::map> data; + + protected: + void record_(const uint8_t *ptr, size_t length) { + if (!this->dc_->level) { + for (size_t i = 0; i != length; i++) { + this->commands.push_back(ptr[i]); + this->last_command_ = ptr[i]; + } + return; + } + auto &payload = this->data[this->last_command_]; + payload.insert(payload.end(), ptr, ptr + length); + } + + const RecordingPin *dc_; + uint32_t row_transfer_ms_; + uint8_t last_command_{0}; +}; + } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp new file mode 100644 index 0000000000..1a66efe983 --- /dev/null +++ b/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp @@ -0,0 +1,298 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" + +namespace esphome::epaper_spi::testing { + +class TestableSSD1677Gray4 : public EPaperSSD1677Gray4 { + public: + TestableSSD1677Gray4(uint16_t width, uint16_t height) : EPaperSSD1677Gray4("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + /// As configured with monochrome_partial_updates: full_update_every > 1. + void install_with_partials(spi::SPIDelegate *delegate) { + this->install(delegate); + this->set_full_update_every(5); + this->init_comparison_frame_(); + ASSERT_TRUE(this->sent_.is_valid()); + } + + /// What the base class would decide; 0 means the next push is a full one. + void set_update_count(uint8_t count) { this->update_count_ = count; } + + /// Pretend only this rectangle changed. + void set_dirty(uint16_t x_low, uint16_t y_low, uint16_t x_high, uint16_t y_high) { + this->x_low_ = x_low; + this->y_low_ = y_low; + this->x_high_ = x_high; + this->y_high_ = y_high; + } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + using EPaperSSD1677Gray4::refresh_screen; + using EPaperSSD1677Gray4::transfer_data; + + RecordingPin dc; +}; + +using Bytes = std::vector; + +namespace { + +/// A gray that lands squarely on each of the four levels. +Color color_for_level(uint8_t level) { + static const uint8_t GRAYS[4] = {0, 64, 128, 255}; + const uint8_t v = GRAYS[level]; + return Color(v, v, v); +} + +void draw_row(TestableSSD1677Gray4 &display, int y, const std::vector &levels) { + for (size_t x = 0; x != levels.size(); x++) + display.draw_pixel_at((int) x, y, color_for_level(levels[x])); +} + +} // namespace + +/// Each pixel's 2-bit level is split across the RAM planes: the high bit to 0x24, the low bit to +/// 0x26, both inverted because the four-level waveform reads 1 as white. +TEST(EPaperSSD1677Gray4, SplitsEachLevelAcrossBothPlanes) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3}); + display.run_push(); + + // levels 0 1 2 3 0 1 2 3 + // high bit 0 0 1 1 0 0 1 1 = 0x33, inverted 0xCC + // low bit 0 1 0 1 0 1 0 1 = 0x55, inverted 0xAA + EXPECT_EQ(bus.data[0x24], (Bytes{0xCC})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xAA})); +} + +/// Two buffer bytes (4 pixels each) make one plane byte (8 pixels), leftmost pixel in the most +/// significant bit. An asymmetric row catches a swapped pair or reversed bit order. +TEST(EPaperSSD1677Gray4, PacksPixelsLeftmostFirstAcrossSourceBytes) { + TestableSSD1677Gray4 display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + draw_row(display, 0, {3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}); + display.run_push(); + + // high bits: pixel 0 (3) and pixel 15 (2) -> 0x80 0x01, inverted 0x7F 0xFE + // low bits: pixel 0 (3) only -> 0x80 0x00, inverted 0x7F 0xFF + EXPECT_EQ(bus.data[0x24], (Bytes{0x7F, 0xFE})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x7F, 0xFF})); +} + +/// The high-bit plane goes out first, each plane exactly once per push. +TEST(EPaperSSD1677Gray4, WritesTheHighBitPlaneBeforeTheLowBitPlane) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.run_push(); + + const auto &cmds = bus.commands; + ASSERT_EQ(std::count(cmds.begin(), cmds.end(), 0x24), 1); + ASSERT_EQ(std::count(cmds.begin(), cmds.end(), 0x26), 1); + EXPECT_LT(std::find(cmds.begin(), cmds.end(), 0x24) - cmds.begin(), + std::find(cmds.begin(), cmds.end(), 0x26) - cmds.begin()); +} + +/// A push that yields partway through must resume the right plane at the right row. +TEST(EPaperSSD1677Gray4, ResumesBothPlanesAfterYielding) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install(&bus); + + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + draw_row(display, 1, {1, 1, 1, 1, 1, 1, 1, 1}); + draw_row(display, 2, {2, 2, 2, 2, 2, 2, 2, 2}); + draw_row(display, 3, {3, 3, 3, 3, 3, 3, 3, 3}); + const int calls = display.run_push(); + + EXPECT_GT(calls, 2) << "the transfer never yielded, so this test proves nothing"; + // rows at levels 0..3: high bits 0 0 1 1, low bits 0 1 0 1, each inverted across the row + EXPECT_EQ(bus.data[0x24], (Bytes{0xFF, 0xFF, 0x00, 0x00})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0x00, 0xFF, 0x00})); +} + +/// Without partial updates enabled (the default) every refresh is the four-level sequence, even if +/// the update count says otherwise. +TEST(EPaperSSD1677Gray4, WithoutPartialUpdatesEveryRefreshIsFourLevel) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.set_update_count(1); + display.refresh_screen(true); + + EXPECT_EQ(bus.commands, (Bytes{0x1A, 0x22, 0x20})); + EXPECT_EQ(bus.data[0x1A], (Bytes{0x67, 0x00})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xD7})); +} + +// --- With monochrome partial updates ------------------------------------------------------------ + +/// A full update is still four-level. It also records, as the frame the next partial update +/// compares against, what the panel shows in black-and-white terms: the high bit of each level. +TEST(EPaperSSD1677Gray4, FullPushRecordsTheHighBitsForTheNextPartial) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3}); + display.set_update_count(0); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0xCC})) << "full update is no longer the four-level split"; + EXPECT_EQ(bus.data[0x26], (Bytes{0xAA})); + bus.clear(); + + // Nothing changed: old and new planes must match, or the partial drives every pixel. + display.set_update_count(1); + display.run_push(); + EXPECT_EQ(bus.data[0x26], (Bytes{0x33})) << "comparison frame is not the high bits"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x33})); +} + +/// A partial update sends the comparison frame to 0x26 and the new frame's high bits to 0x24, +/// not inverted (it runs the black-and-white waveform), over the whole panel. +TEST(EPaperSSD1677Gray4, PartialPushSendsTheHighBitsInBlackAndWhite) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3}); + draw_row(display, 1, {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + draw_row(display, 1, {3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}); + display.set_dirty(0, 1, 8, 2); // only the start of the second row changed + display.set_update_count(1); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x33, 0xFF, 0x00, 0x00})) << "old plane is not the frame on the panel"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x33, 0xFF, 0xF0, 0x00})) << "new plane is not the whole frame's high bits"; +} + +/// The new plane of a partial update is built a row at a time; a push that yields partway through +/// must resume at the right row. +TEST(EPaperSSD1677Gray4, PartialPushResumesAfterYielding) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install_with_partials(&bus); + + display.set_update_count(0); + display.run_push(); + bus.clear(); + + // The buffer starts white; darken all of row 0 and the right half of row 2 + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + draw_row(display, 2, {3, 3, 3, 3, 0, 0, 0, 0}); + display.set_update_count(1); + const int calls = display.run_push(); + + EXPECT_GT(calls, 2) << "the transfer never yielded, so this test proves nothing"; + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0xFF, 0xF0, 0xFF})); +} + +/// Regression test: a full update requested while a partial one is being sent must not switch the +/// push to the four-level transfer halfway, which misread the partial's progress and never finished. +TEST(EPaperSSD1677Gray4, FullUpdateRequestDuringAPartialPushWaitsForTheNextUpdate) { + TestableSSD1677Gray4 display(8, 4); + RecordingDelegate bus(&display.dc, 6); // two rows exceed MAX_TRANSFER_TIME + display.install_with_partials(&bus); + + display.set_update_count(0); + display.run_push(); + bus.clear(); + + draw_row(display, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + display.set_update_count(1); + ASSERT_FALSE(display.transfer_data()); + display.request_full_update(); + int calls = 1; + while (!display.transfer_data()) + ASSERT_LT(++calls, 20) << "partial push never finished"; + + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0xFF, 0xFF, 0xFF})); + bus.clear(); + display.refresh_screen(true); + EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})) << "refresh does not match the partial data sent"; +} + +/// The four-level refresh follows a reset, which loses controller RAM, so it must send the whole +/// panel even when partial updates are enabled but the comparison frame could not be allocated. +TEST(EPaperSSD1677Gray4, FourLevelPushCoversTheWholePanelWithoutAComparisonFrame) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_full_update_every(5); // partial updates on, but no comparison frame + + display.set_dirty(8, 1, 16, 2); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x24].size(), 4u) << "four-level update did not send the whole new plane"; + EXPECT_EQ(bus.data[0x26].size(), 4u) << "four-level update did not send the whole old plane"; +} + +/// A full update resets the controller, which does not keep RAM, so even when only part of the +/// frame changed it must send the whole panel. +TEST(EPaperSSD1677Gray4, FullPushWithPartialsEnabledCoversTheWholePanel) { + TestableSSD1677Gray4 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + display.set_dirty(8, 1, 16, 2); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x24].size(), 4u) << "full update did not send the whole new plane"; + EXPECT_EQ(bus.data[0x26].size(), 4u) << "full update did not send the whole old plane"; +} + +/// The refresh matches what was sent: black-and-white for a partial update, four-level for a full. +TEST(EPaperSSD1677Gray4, PartialRefreshIsBlackAndWhiteAndFullIsFourLevel) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install_with_partials(&bus); + + display.set_update_count(1); + display.refresh_screen(true); + EXPECT_EQ(bus.commands, (Bytes{0x3C, 0x22, 0x20})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})) << "partial update did not use the black-and-white waveform"; + // The model's border setting is right for the four-level waveform only; under this one it + // would drive the border black on every partial. + EXPECT_EQ(bus.data[0x3C], (Bytes{0x01})) << "partial update did not switch the border to LUT1"; + bus.clear(); + + display.set_update_count(0); + display.refresh_screen(false); + EXPECT_EQ(bus.data[0x22], (Bytes{0xD7})) << "full update did not use the four-level waveform"; + EXPECT_EQ(bus.data.count(0x3C), 0u) << "full update overrode the model's border setting"; +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp new file mode 100644 index 0000000000..cad853b197 --- /dev/null +++ b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp @@ -0,0 +1,233 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677.h" + +namespace esphome::epaper_spi::testing { + +class TestableSSD1677 : public EPaperSSD1677 { + public: + TestableSSD1677(uint16_t width, uint16_t height) : EPaperSSD1677("test", width, height, nullptr, 0) {} + + 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); + this->init_comparison_frame_(); + } + + bool has_comparison_frame() const { return this->sent_.is_valid(); } + + void set_frame(std::initializer_list bytes) { + size_t i = 0; + for (const uint8_t byte : bytes) + this->buffer_[i++] = byte; + } + + /// Fill the frame with a byte pattern that differs per seed; returns it. + std::vector set_pattern(uint8_t seed) { + std::vector frame; + for (size_t i = 0; i != this->buffer_length_; i++) { + frame.push_back((uint8_t) (seed + i * 7)); + this->buffer_[i] = frame.back(); + } + return frame; + } + + /// What the base class would decide; 0 means the next push is a full one. + void set_update_count(uint8_t count) { this->update_count_ = count; } + + /// Pretend only this rectangle changed. + void set_dirty(uint16_t x_low, uint16_t y_low, uint16_t x_high, uint16_t y_high) { + this->x_low_ = x_low; + this->y_low_ = y_low; + this->x_high_ = x_high; + this->y_high_ = y_high; + } + + /// One call into the transfer; false while there is more to send. + bool step() { return this->transfer_data(); } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + /// Run the UPDATE state, with nothing drawn, and report whether a push follows. + bool run_update_state() { + this->set_auto_clear(false); + this->set_dirty(this->width_, this->height_, 0, 0); + this->state_ = EPaperState::UPDATE; + this->process_state_(); + return this->state_ == EPaperState::RESET; + } + uint8_t update_count() const { return this->update_count_; } + + bool reset_in(EPaperState state) { + this->state_ = state; + return this->reset(); + } + + RecordingPin dc; + RecordingPin reset_pin; +}; + +using Bytes = std::vector; + +/// A full push ignores the old-image plane, and on the first push after boot the comparison frame +/// holds nothing real yet, so the new frame goes to both planes. +TEST(EPaperSSD1677, FullPushSendsTheNewFrameToBothPlanes) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); +} + +/// Regression test. +/// +/// A partial refresh drives every pixel from the pair (0x26 = the image on the panel, 0x24 = the +/// new image), across the whole panel whatever RAM window was written. The controller does not +/// keep its RAM intact between updates, so sending only the changed window of 0x24 - and 0x26 once +/// - leaves the pair wrong outside that window: unchanged pixels get driven on every partial and +/// wash out. Both planes must go out whole, 0x26 holding the frame actually on the panel. +TEST(EPaperSSD1677, PartialPushComparesAgainstTheFrameOnThePanel) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.set_dirty(8, 1, 16, 2); // only the last byte changed + display.set_update_count(1); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})) << "old plane is not the frame on the panel"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00})) << "new plane is not the whole new frame"; + // The RAM window, set once per plane, must span the panel too, not the changed rectangle. + EXPECT_EQ(bus.data[0x44], (Bytes{0, 0, 15, 0, 0, 0, 15, 0})) << "x window is not the whole panel"; + EXPECT_EQ(bus.data[0x45], (Bytes{0, 0, 1, 0, 0, 0, 1, 0})) << "y window is not the whole panel"; +} + +/// The comparison frame must record the bytes that went to 0x24, not whatever the buffer holds +/// later: LVGL can draw into the buffer while a push is in progress. Here the buffer changes +/// between the two planes of a push; the next push must compare against what was actually sent. +TEST(EPaperSSD1677, ComparisonFrameIsWhatWasSentNotTheBuffer) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x11, 0x11, 0x11, 0x11}); + display.set_update_count(0); + display.run_push(); + + display.set_frame({0x22, 0x22, 0x22, 0x22}); + display.set_update_count(1); + ASSERT_FALSE(display.step()) << "expected the old plane to go out on its own first"; + display.set_frame({0x33, 0x33, 0x33, 0x33}); // drawn mid-push, before the new plane + while (!display.step()) { + } + ASSERT_EQ(bus.data[0x24].size(), 8u); + EXPECT_EQ(Bytes(bus.data[0x24].begin() + 4, bus.data[0x24].end()), (Bytes{0x33, 0x33, 0x33, 0x33})); + bus.clear(); + + display.set_frame({0x44, 0x44, 0x44, 0x44}); + display.set_update_count(2); + display.run_push(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x33, 0x33, 0x33, 0x33})) << "old plane is not what was last sent"; +} + +/// Two full planes can take several loop iterations to send; each resumed call must continue the +/// right plane at the right byte. Planes go out in runs sized to the time slice, not row by row. +TEST(EPaperSSD1677, ResumesTheRightPlaneAfterYielding) { + // 400x100 is 5000 bytes per plane: two runs at the default 2 MHz bus + TestableSSD1677 display(400, 100); + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); // every run overruns the time slice + display.install(&bus, 5); + + const auto old_frame = display.set_pattern(1); + display.set_update_count(0); + display.run_push(); + bus.clear(); + + const auto new_frame = display.set_pattern(2); + display.set_update_count(1); + const int calls = display.run_push(); + + EXPECT_EQ(calls, 4) << "expected two runs per plane, one per call"; + EXPECT_EQ(bus.data[0x26], old_frame); + EXPECT_EQ(bus.data[0x24], new_frame); +} + +/// A requested full update takes effect when the next update starts, and pushes the whole panel +/// even if nothing was drawn. +TEST(EPaperSSD1677, RequestedFullUpdateAppliesWhenTheNextUpdateStarts) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_update_count(3); + EXPECT_FALSE(display.run_update_state()) << "an update with nothing drawn should not push"; + + display.request_full_update(); + EXPECT_EQ(display.update_count(), 3) << "request changed the update in progress"; + EXPECT_TRUE(display.run_update_state()) << "requested full update did not push"; + EXPECT_EQ(display.update_count(), 0) << "requested update is not a full one"; + + display.set_update_count(3); + EXPECT_FALSE(display.run_update_state()) << "request was applied more than once"; +} + +/// Nothing a partial needs lives in controller RAM any more, so a partial push skips the reset +/// altogether; a full one still gets the hardware pulse and the software reset. +TEST(EPaperSSD1677, PartialPushSkipsTheResetAndAFullPushKeepsIt) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_update_count(1); + EXPECT_TRUE(display.reset_in(EPaperState::RESET)) << "partial push waited on a reset"; + EXPECT_TRUE(display.reset_pin.level) << "partial push pulsed the reset pin"; + EXPECT_TRUE(bus.commands.empty()) << "partial push sent a software reset"; + + display.set_update_count(0); + EXPECT_FALSE(display.reset_in(EPaperState::RESET)); + EXPECT_FALSE(display.reset_pin.level) << "full push did not pulse the reset pin"; + EXPECT_TRUE(display.reset_in(EPaperState::RESET_END)); + EXPECT_TRUE(display.reset_pin.level); + EXPECT_EQ(bus.commands, (Bytes{0x12})) << "full push did not send a software reset"; +} + +/// With every update a full one nothing is ever compared against 0x26, so no comparison frame is +/// allocated and the transfer is EPaperMono's. +TEST(EPaperSSD1677, NoComparisonFrameWhenEveryUpdateIsFull) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + + EXPECT_FALSE(display.has_comparison_frame()); + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.set_update_count(0); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); +} + +} // 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 602aeb8d0e..1678f46331 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -1,6 +1,14 @@ packages: spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml +psram: + mode: octal + +esphome: + on_boot: + then: + - epaper_spi.full_update_next: epaper_partial + display: - platform: epaper_spi spi_id: spi_bus @@ -85,12 +93,24 @@ display: busy_pin: 37 enable_pin: 39 - platform: epaper_spi + id: epaper_partial model: seeed-ee04-mono-4.26 full_update_every: 10 # Override pins to avoid conflict with other display configs busy_pin: 43 dc_pin: 42 + # Seeed reTerminal Sticky, four-level grayscale (800x480, SSD1677) + # dc_pin/reset_pin overridden to avoid conflict with other display configs + # full_update_every is not supported by this model, so left at its default of 1 + - platform: epaper_spi + model: seeed-reterminal-sticky-gray4 + dc_pin: 45 + reset_pin: 9 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color(170, 170, 170)); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + # WeAct 2.13" 3-color e-paper (122x250, SSD1680) - platform: epaper_spi spi_id: spi_bus