[epaper_spi] Support Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680) (#21218)

This commit is contained in:
Craig Andrews
2026-10-21 17:09:44 -10:00
committed by GitHub
parent 7a3aac407f
commit c76e9e1d62
6 changed files with 322 additions and 2 deletions
@@ -0,0 +1,12 @@
#include "epaper_goodisplay_3c.h"
namespace esphome::epaper_spi {
void EPaperGoodDisplay3C::power_on() {}
void EPaperGoodDisplay3C::refresh_screen(bool partial) {
EPaperWeAct3C::refresh_screen(partial);
this->next_delay_ = REFRESH_START_MS;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,28 @@
#pragma once
#include "epaper_weact_3c.h"
namespace esphome::epaper_spi {
/**
* Good Display 3-color e-paper displays (SSD1680 controller), e.g. GDEY029Z95.
*
* Driven as EPaperWeAct3C, except that there is no separate power-on: the full refresh (0xF7) powers the panel
* up itself. Busy then only goes high a little after the refresh starts, so the next state waits before
* checking it.
*/
class EPaperGoodDisplay3C : public EPaperWeAct3C {
public:
using EPaperWeAct3C::EPaperWeAct3C;
protected:
// Time from master activation (0x20) until busy can be relied on
static constexpr uint16_t REFRESH_START_MS = 50;
// With EPaperWeAct3C's power-on (0x22 0xF8) first, this panel's refresh ends almost at once and the image
// does not change.
void power_on() override;
void refresh_screen(bool partial) override;
};
} // namespace esphome::epaper_spi
@@ -4,11 +4,17 @@ Supported models:
- weact-2.13in-3c: 122x250 pixels (2.13" display)
- weact-2.9in-3c: 128x296 pixels (2.9" display)
- weact-4.2in-3c: 400x300 pixels (4.2" display)
- goodisplay-gdey029z95-2.9: 128x296 pixels (Good Display GDEY029Z95 2.9" display)
These displays use SSD1680 or SSD1683 controller and require a specific initialization
sequence. The DRV_OUT_CTL command is calculated from the display height.
The Good Display panel is driven as the WeAct ones, except that it must not get the
separate power-on before the refresh, so it has its own driver class, EPaperGoodDisplay3C.
"""
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_RESET_PIN
from . import EpaperModel
@@ -17,8 +23,10 @@ class WeActBWR(EpaperModel):
source_files = ("epaper_weact_3c.cpp",)
def __init__(self, name, **defaults):
super().__init__(name, "EPaperWeAct3C", **defaults)
def __init__(
self, name: str, class_name: str = "EPaperWeAct3C", **defaults: object
) -> None:
super().__init__(name, class_name, **defaults)
def get_init_sequence(self, config):
"""Generate initialization sequence for WeAct BWR displays.
@@ -75,3 +83,27 @@ weact_4p2in3c = WeActBWR(
data_rate="10MHz",
minimum_update_interval="10s",
)
class GoodDisplayBWR(WeActBWR):
"""Good Display Black/White/Red displays using SSD1680 controller."""
source_files = ("epaper_weact_3c.cpp", "epaper_goodisplay_3c.cpp")
def __init__(self, name: str, **defaults: object) -> None:
super().__init__(name, "EPaperGoodDisplay3C", **defaults)
def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required:
# Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy
if name in (CONF_RESET_PIN, CONF_BUSY_PIN):
return cv.Required(name)
return super().option(name, fallback)
# Model: Good Display GDEY029Z95 2.9" BWR - 128x296 pixels, SSD1680 controller
GoodDisplayBWR(
"goodisplay-gdey029z95-2.9",
width=128,
height=296,
minimum_update_interval="10s",
)
@@ -621,6 +621,69 @@ def test_uc8279_width_must_be_a_multiple_of_8(
)
GDEY029Z95 = "goodisplay-gdey029z95-2.9"
def _gdey029z95_config(**overrides: Any) -> ConfigType:
config: ConfigType = {
"id": "test_display",
"model": GDEY029Z95,
CONF_DC_PIN: 21,
CONF_RESET_PIN: 22,
CONF_BUSY_PIN: 23,
}
config.update(overrides)
return config
def test_gdey029z95_uses_the_good_display_driver() -> None:
"""The GDEY029Z95 uses EPaperGoodDisplay3C with the WeAct init sequence."""
model = MODELS[GDEY029Z95.upper()]
assert model.class_name == "EPaperGoodDisplay3C"
assert model.get_init_sequence({}) == (
(0x01, 0x27, 0x01, 0x00),
(0x11, 0x03),
(0x3C, 0x05),
(0x18, 0x80),
(0x21, 0x00, 0x80),
)
@pytest.mark.parametrize("pin", [CONF_RESET_PIN, CONF_BUSY_PIN])
def test_gdey029z95_requires_reset_and_busy_pins(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
pin: str,
) -> None:
"""Only a hardware reset wakes the panel from deep sleep, and refreshes are timed by busy."""
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})
config = _gdey029z95_config()
del config[pin]
with pytest.raises(cv.Invalid, match=pin):
run_schema_validation(config)
def test_gdey029z95_has_no_partial_update(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
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})
run_schema_validation(_gdey029z95_config())
with pytest.raises(cv.Invalid, match="does not support partial update"):
run_schema_validation(_gdey029z95_config(full_update_every=2))
def test_uc8279_x4pro_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -0,0 +1,164 @@
#include <gtest/gtest.h>
#include <utility>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_goodisplay_3c.h"
namespace esphome::epaper_spi::testing {
namespace {
class TestableGoodDisplay3C : public EPaperGoodDisplay3C {
public:
TestableGoodDisplay3C(uint16_t width, uint16_t height) : EPaperGoodDisplay3C("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_));
}
void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); }
/// Both planes of one push; returns how many calls it took.
int run_push() {
int calls = 1;
while (!this->transfer_data())
calls++;
return calls;
}
/// A whole update with the frame already drawn, run through loop() as the main loop would.
void run_update() {
this->set_auto_clear(false);
this->state_ = EPaperState::UPDATE;
const uint32_t deadline = millis() + 2000;
while (this->state_ != EPaperState::IDLE) {
ASSERT_LT(millis(), deadline) << "update did not finish";
this->loop();
}
}
using EPaperGoodDisplay3C::REFRESH_START_MS;
RecordingPin dc;
};
/// Also notes when each command byte was sent.
class TimedCommandDelegate : public RecordingDelegate {
public:
using RecordingDelegate::RecordingDelegate;
void write_array(const uint8_t *ptr, size_t length) override {
if (!this->dc_->level) {
for (size_t i = 0; i != length; i++)
this->sent.emplace_back(ptr[i], millis());
}
RecordingDelegate::write_array(ptr, length);
}
/// When the n-th (from 0) copy of a command was sent.
uint32_t time_of(uint8_t command, int n = 0) const {
for (const auto &[byte, time] : this->sent) {
if (byte == command && n-- == 0)
return time;
}
ADD_FAILURE() << "command " << int(command) << " not sent";
return 0;
}
std::vector<std::pair<uint8_t, uint32_t>> sent;
};
using Bytes = std::vector<uint8_t>;
const Color RED(255, 0, 0);
/// One row of 16 pixels: white, black, red, then white. The red plane goes first as 1=red, then the black/white
/// plane as 1=white with red pixels sent as 0.
TEST(EPaperGoodDisplay3CTransfer, PixelsReachThePanel) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(Color::WHITE);
display.draw(1, 0, Color::BLACK);
display.draw(2, 0, RED);
EXPECT_EQ(display.run_push(), 1);
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24}));
EXPECT_EQ(bus.data[0x44], (Bytes{0x00, 0x01}));
EXPECT_EQ(bus.data[0x45], (Bytes{0x00, 0x00, 0x00, 0x00}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x9F, 0xFF}));
EXPECT_EQ(bus.data[0x26], (Bytes{0x20, 0x00}));
}
/// A push that yields to the main loop after every chunk sends the same commands and bytes as one that runs to
/// completion in one call, and a second push starts again from the window setup.
TEST(EPaperGoodDisplay3CTransfer, ResumedPushMatches) {
// 128x20 pixels is 320 bytes per plane, so each plane takes three chunks of up to MAX_TRANSFER_SIZE.
constexpr int width = 128;
constexpr int height = 20;
const Color colors[] = {Color::WHITE, Color::BLACK, RED};
TestableGoodDisplay3C reference(width, height);
RecordingDelegate reference_bus(&reference.dc);
reference.install(&reference_bus);
TestableGoodDisplay3C display(width, height);
// Every data write takes longer than MAX_TRANSFER_TIME, so the push yields after each chunk.
RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1);
display.install(&bus);
for (int y = 0; y != height; y++) {
for (int x = 0; x != width; x++) {
reference.draw(x, y, colors[(x * 7 + y * 3) % 3]);
display.draw(x, y, colors[(x * 7 + y * 3) % 3]);
}
}
EXPECT_EQ(reference.run_push(), 1);
EXPECT_GT(display.run_push(), 2);
EXPECT_EQ(bus.commands, reference_bus.commands);
EXPECT_EQ(bus.data[0x24].size(), 320u);
EXPECT_EQ(bus.data[0x24], reference_bus.data[0x24]);
EXPECT_EQ(bus.data[0x26], reference_bus.data[0x26]);
bus.clear();
display.run_push();
EXPECT_EQ(bus.commands, reference_bus.commands);
}
/// A whole update sends no power-on (0x22 0xF8) before the refresh (0x22 0xF7): with it, this panel does not
/// refresh. Power off (0x83) and deep sleep follow.
TEST(EPaperGoodDisplay3CTransfer, UpdateHasNoSeparatePowerOn) {
TestableGoodDisplay3C display(16, 1);
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
EXPECT_EQ(bus.commands, (Bytes{0x44, 0x45, 0x4E, 0x4F, 0x26, 0x24, 0x4E, 0x4F, 0x22, 0x20, 0x22, 0x20, 0x10}));
EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF}));
EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00}));
EXPECT_EQ(bus.data[0x22], (Bytes{0xF7, 0x83}));
EXPECT_EQ(bus.data[0x10], (Bytes{0x01}));
}
/// Busy only goes high a little after master activation (0x20), so power off is not sent until REFRESH_START_MS
/// after the refresh starts.
TEST(EPaperGoodDisplay3CTransfer, WaitsForBusyAfterTheRefreshStarts) {
TestableGoodDisplay3C display(16, 1);
TimedCommandDelegate bus(&display.dc);
display.install(&bus);
display.fill(RED);
display.run_update();
// The first 0x20 starts the refresh; the second 0x22 selects power off.
EXPECT_GE(bus.time_of(0x22, 1) - bus.time_of(0x20, 0), TestableGoodDisplay3C::REFRESH_START_MS);
}
} // namespace
} // namespace esphome::epaper_spi::testing
@@ -209,6 +209,27 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Good Display GDEY029Z95 2.9" 3-color e-paper (128x296, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
model: goodisplay-gdey029z95-2.9
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
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# WeAct 4.2" 3-color e-paper (400x300, SSD1683)
- platform: epaper_spi
spi_id: spi_bus