[epaper_spi] reTerminal Sticky: Four-level grayscale for SSD1677 panels, and a full-refresh action (#19213)

Co-authored-by: Pierre <pierre@mysweethome.ch>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
FuNK3Y
2026-09-29 19:38:01 +10:00
committed by GitHub
co-authored by Pierre Claude Opus 5 clydebarrow J. Nick Koston
parent 21c9604918
commit 54a9238425
24 changed files with 1635 additions and 42 deletions
+25 -16
View File
@@ -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<typename NATIVE_COLOR>
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<int>(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<typename NATIVE_COLOR>
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<int>(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<typename NATIVE_COLOR>
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<int>(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);
+36 -2
View File
@@ -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,
+11 -2
View File
@@ -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;
+6 -10
View File
@@ -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_{};
@@ -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
}
@@ -0,0 +1,101 @@
#include "epaper_spi_ssd1677.h"
#include <algorithm>
#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<size_t>(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
@@ -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
@@ -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
@@ -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
@@ -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"
)
@@ -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:
@@ -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,
)
@@ -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):
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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],
@@ -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
+50
View File
@@ -2,6 +2,9 @@
#include <gtest/gtest.h>
#include <map>
#include <vector>
#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<uint8_t> commands;
std::map<uint8_t, std::vector<uint8_t>> 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
@@ -0,0 +1,298 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <vector>
#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<uint8_t>;
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<uint8_t> &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
@@ -0,0 +1,233 @@
#include <gtest/gtest.h>
#include <initializer_list>
#include <vector>
#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<uint8_t> 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<uint8_t> set_pattern(uint8_t seed) {
std::vector<uint8_t> 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<uint8_t>;
/// 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
@@ -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