mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 05:46:12 +00:00
[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:
co-authored by
J. Nick Koston
parent
3f29f1a5f1
commit
e4dd6d452d
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user