[epaper_spi] Add SSD1681 model with differential partial refresh (#20332)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Bonne Eggleston
2026-10-21 08:18:32 -04:00
committed by GitHub
co-authored by J. Nick Koston
parent 3f29f1a5f1
commit e4dd6d452d
9 changed files with 330 additions and 16 deletions
@@ -22,11 +22,11 @@ void EPaperMono::deep_sleep() {
}
bool EPaperMono::reset() {
if (EPaperBase::reset()) {
if (!EPaperBase::reset())
return false;
if (this->software_reset_)
this->command(0x12);
return true;
}
return false;
return true;
}
void EPaperMono::set_window() {
@@ -21,6 +21,7 @@ class EPaperMono : public EPaperBase {
virtual void set_window();
bool transfer_data() override;
bool send_red_{true};
bool software_reset_{true}; // send 0x12 after the hardware reset
// When set, the red RAM pass writes the image rather than blank data.
bool send_red_as_image_{false};
};
@@ -0,0 +1,35 @@
#include "epaper_spi_ssd1681.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1681");
bool HOT EPaperSSD1681::transfer_data() {
if (this->current_data_index_ == 0 && this->send_red_) {
if (!this->partial_) {
// A refresh drives the whole panel from RAM, so a full one makes both RAM banks whole again
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
} else {
// 0x26 already holds the image on the panel: the controller copies 0x24 into it after each refresh
this->set_window();
this->send_red_ = false;
}
}
return EPaperSSD1683::transfer_data();
}
void EPaperSSD1681::refresh_screen(bool partial) {
ESP_LOGV(TAG, "Refresh screen");
this->cmd_data(0x3C, {partial ? (uint8_t) 0x80 : (uint8_t) 0x01});
// 0x26 is compared as it is on a partial refresh and ignored on a full one
this->cmd_data(0x21, {partial ? (uint8_t) 0x00 : (uint8_t) 0x40, (uint8_t) 0x00});
// Both end with analog and OSC off before the panel goes to sleep
this->cmd_data(0x22, {partial ? (uint8_t) 0xFF : (uint8_t) 0xF7});
this->command(0x20);
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,23 @@
#pragma once
#include "epaper_spi_ssd1683.h"
namespace esphome::epaper_spi {
// Partial refreshes compare RAM 0x24 (new image) with RAM 0x26 (image on the panel) and drive only changed pixels.
class EPaperSSD1681 : public EPaperSSD1683 {
public:
EPaperSSD1681(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperSSD1683(name, width, height, init_sequence, init_sequence_length) {
// 0x12 puts the display option register back to its default, which turns off the RAM ping-pong the panel's
// OTP enables; without it the controller no longer carries 0x24 over to 0x26 after a partial refresh
this->software_reset_ = false;
}
protected:
bool transfer_data() override;
void refresh_screen(bool partial) override;
};
} // namespace esphome::epaper_spi
@@ -0,0 +1,36 @@
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_DIMENSIONS, CONF_RESET_PIN
from esphome.types import ConfigType
from .ssd1683 import SSD1683
MAX_WIDTH = 200
MAX_HEIGHT = 200
class SSD1681(SSD1683):
def __init__(
self, name: str, class_name: str = "EPaperSSD1681", **defaults: Any
) -> None:
super().__init__(name, class_name=class_name, **defaults)
def validate_config(self, config: ConfigType) -> ConfigType:
config = super().validate_config(config)
width, height = self.get_dimensions(config)
if width > MAX_WIDTH or height > MAX_HEIGHT:
raise cv.Invalid(
f"{self.name} supports at most {MAX_WIDTH}x{MAX_HEIGHT} pixels",
path=[CONF_DIMENSIONS],
)
if CONF_RESET_PIN not in config:
# The panel sleeps after every update, and only the hardware reset wakes it
raise cv.Invalid(f"{self.name} needs a {CONF_RESET_PIN}")
return config
ssd1681 = SSD1681("ssd1681", width=200, height=200)
ssd1681.extend("goodisplay-gdey0154d67-1.54")
ssd1681.extend("waveshare-1.54in-v2")
@@ -31,15 +31,3 @@ goodisplay_gdey042t81 = ssd1683.extend(
width=400,
height=300,
)
# The SSD1681 shares the SSD1683 command set for everything this driver uses:
# the same 0x01/0x11/0x18 initialisation, the same 0x44/0x45 window and
# 0x4E/0x4F cursor registers with a byte-addressed X and a 16-bit Y, and the
# same 0x22/0x20 update. It only differs in the panel sizes it drives.
ssd1681 = SSD1683("ssd1681")
waveshare_1_54in_v2 = ssd1681.extend(
"waveshare-1.54in-v2",
width=200,
height=200,
)
@@ -377,6 +377,83 @@ def test_reset_duration_over_max_rejected(
)
def test_ssd1681_defaults_and_driver_class(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The SSD1681 models default to 200x200 and all use the SSD1681 driver class."""
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})
for name in ("ssd1681", "goodisplay-gdey0154d67-1.54", "waveshare-1.54in-v2"):
model = MODELS[name.upper()]
assert model.class_name == "EPaperSSD1681"
config = run_schema_validation(
{
"id": "test_display",
"model": name,
"dc_pin": 21,
"reset_pin": 23,
"full_update_every": 20,
}
)
assert model.get_dimensions(config) == (200, 200)
def test_ssd1681_needs_reset_pin(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The SSD1681 sleeps after every update and only a hardware reset wakes it, so reset_pin is required."""
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})
for full_update_every in (1, 20):
with pytest.raises(cv.Invalid, match="reset_pin"):
run_schema_validation(
{
"id": "test_display",
"model": "ssd1681",
"dc_pin": 21,
"full_update_every": full_update_every,
}
)
def test_ssd1681_dimensions_over_controller_limit_rejected(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Dimensions larger than the SSD1681 can drive are 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 200x200"):
run_schema_validation(
{
"id": "test_display",
"model": "ssd1681",
"dc_pin": 21,
"dimensions": {
"width": 400,
"height": 300,
},
}
)
def test_busy_pin_input_mode_ssd1677(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
@@ -0,0 +1,137 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <initializer_list>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_spi_ssd1681.h"
namespace esphome::epaper_spi::testing {
class TestableSSD1681 : public EPaperSSD1681 {
public:
TestableSSD1681(uint16_t width, uint16_t height) : EPaperSSD1681("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);
}
void set_frame(std::initializer_list<uint8_t> bytes) {
size_t i = 0;
for (const uint8_t byte : bytes)
this->buffer_[i++] = byte;
}
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;
}
/// Run an update from the point where the frame has been drawn through to idle.
void run_update() {
// What the UPDATE state decides before it hands over to RESET
this->partial_ = this->update_count_ != 0;
this->state_ = EPaperState::RESET;
while (this->state_ != EPaperState::IDLE)
this->process_state_();
}
RecordingPin dc;
RecordingPin reset_pin;
};
using Bytes = std::vector<uint8_t>;
/// A full refresh ignores 0x26, but both RAM banks must end up holding the whole frame so that
/// later partial refreshes compare against a known image, whatever area was drawn.
TEST(EPaperSSD1681, FullRefreshSendsTheWholeFrameToBothBanks) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.set_dirty(8, 1, 16, 2);
display.run_update();
EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0xC3}));
EXPECT_EQ(bus.data[0x21], (Bytes{0x40, 0x00}));
EXPECT_EQ(bus.data[0x22], (Bytes{0xF7}));
}
/// Regression test for speckled white areas after a partial refresh.
///
/// With the new frame in both banks and 0x26 inverted, the controller treats every pixel as
/// changed and drives it with the short partial waveform, which does not fully restore white on
/// this panel. Only the changed window of 0x24 is written before a partial refresh, with 0x26
/// compared as it is; the controller copies 0x24 into 0x26 after each refresh by itself.
TEST(EPaperSSD1681, PartialRefreshWritesOnlyTheChangedWindowOfTheNewImageBank) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.run_update();
bus.clear();
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.set_dirty(8, 1, 16, 2); // only the last byte changed
display.run_update();
EXPECT_EQ(bus.data[0x24], (Bytes{0x00}));
EXPECT_EQ(bus.data.count(0x26), 0u) << "0x26 was written";
EXPECT_EQ(bus.data[0x44], (Bytes{1, 1}));
EXPECT_EQ(bus.data[0x45], (Bytes{1, 0, 1, 0}));
EXPECT_EQ(bus.data[0x21], (Bytes{0x00, 0x00})) << "0x26 is not compared as it is";
EXPECT_EQ(bus.data[0x22], (Bytes{0xFF}));
}
/// The software reset 0x12 turns off the RAM ping-pong the panel's OTP enables, after which the
/// controller no longer keeps 0x26 in step and the next partial refresh compares against a stale
/// image. So every update starts with the hardware reset only, and ends in deep sleep mode 1, which
/// keeps the RAM and is left by that reset.
TEST(EPaperSSD1681, UpdatesResetInHardwareOnlyAndSleepInBetween) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.run_update();
EXPECT_EQ(bus.commands.front(), 0x44) << "full refresh sent a software reset";
EXPECT_EQ(bus.data[0x10], (Bytes{0x01})) << "panel was not put into deep sleep mode 1";
bus.clear();
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.set_dirty(8, 1, 16, 2);
display.run_update();
EXPECT_EQ(std::count(bus.commands.begin(), bus.commands.end(), 0x12), 0) << "partial refresh sent a software reset";
EXPECT_EQ(bus.data[0x10], (Bytes{0x01})) << "panel was not put into deep sleep mode 1";
}
/// With every update a full one the controller loses its RAM in deep sleep mode 2, as before.
TEST(EPaperSSD1681, EveryUpdateFullSleepsWithoutKeepingRAM) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 1);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.run_update();
bus.clear();
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.set_dirty(8, 1, 16, 2);
display.run_update();
EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0x00}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00}));
EXPECT_EQ(bus.data[0x21], (Bytes{0x40, 0x00}));
EXPECT_EQ(bus.data[0x10], (Bytes{0x03}));
}
} // namespace esphome::epaper_spi::testing
@@ -458,6 +458,23 @@ display:
number: GPIO4
inverted: true
# Good Display GDEY0154D67 1.54" mono e-paper (200x200, SSD1681)
- platform: epaper_spi
spi_id: spi_bus
model: goodisplay-gdey0154d67-1.54
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
# Xteink X4 Pro (800x480, UC8279)
# Pins overridden to avoid conflicts with the other display configs
# full_update_every > 1 exercises the partial refresh path