mirror of
https://github.com/esphome/esphome.git
synced 2026-09-30 08:20:21 +00:00
[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:
co-authored by
Pierre
Claude Opus 5
clydebarrow
J. Nick Koston
parent
21c9604918
commit
54a9238425
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user