[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
@@ -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