From b7bb2763430286f73da3687e73ae475cf4cc990c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:09:01 +1200 Subject: [PATCH] [epaper_spi] Add UC8179 mono driver and Seeed reTerminal E1001 model --- .../epaper_spi/epaper_spi_uc8179.cpp | 138 ++++++++++++++++++ .../components/epaper_spi/epaper_spi_uc8179.h | 52 +++++++ .../components/epaper_spi/models/uc8179.py | 92 ++++++++++++ .../epaper_spi/config/uc8179_e1001_test.yaml | 15 ++ tests/component_tests/epaper_spi/test_init.py | 17 +++ .../epaper_spi/test.esp32-s3-idf.yaml | 42 ++++++ 6 files changed, 356 insertions(+) create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8179.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8179.h create mode 100644 esphome/components/epaper_spi/models/uc8179.py create mode 100644 tests/component_tests/epaper_spi/config/uc8179_e1001_test.yaml diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp new file mode 100644 index 0000000000..177d4b8763 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -0,0 +1,138 @@ +#include "epaper_spi_uc8179.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.uc8179"; + +bool EPaperUC8179::initialise(bool partial) { + EPaperBase::initialise(partial); // send the model init sequence + this->partial_ = partial; + ESP_LOGV(TAG, "Power on"); + // POWER ON must precede the waveform/mode registers and the data transfer + // (the original driver powers on and busy-waits before writing them). + // The state machine busy-waits before entering TRANSFER_DATA. + this->command(0x04); + // Give the busy line time to assert before the state machine polls it + this->next_delay_ = 100; + return true; +} + +// Set up the refresh mode. Must be called after power-on has completed. +void EPaperUC8179::set_refresh_mode_() { + if (!this->is_using_partial_update_()) { + return; // plain full refresh uses the mode set by the init sequence + } + // Fast and partial refresh use flipped data polarity and a floating border + this->cmd_data(0x50, {0xA9, 0x07}); + // Force the waveform via the temperature registers: 0x5A selects the fast + // full-refresh waveform, 0x6E the partial-refresh waveform + this->cmd_data(0xE0, {0x02}); + if (this->partial_) { + this->cmd_data(0xE5, {0x6E}); + this->command(0x91); // enter partial mode + // Set the partial window to the full screen + const uint16_t x_end = this->width_ - 1; + const uint16_t y_end = this->height_ - 1; + this->cmd_data(0x90, {0x00, 0x00, static_cast(x_end >> 8), static_cast(x_end & 0xFF), 0x00, 0x00, + static_cast(y_end >> 8), static_cast(y_end & 0xFF), 0x01}); + } else { + this->cmd_data(0xE5, {0x5A}); + this->command(0x92); // exit partial mode + } +} + +bool HOT EPaperUC8179::transfer_data() { + const uint32_t start_time = millis(); + const size_t buffer_length = this->buffer_length_; + if (this->current_data_index_ == 0) { + this->set_refresh_mode_(); + } + // Fast full refresh sends the previous-image plane as well, so that every pixel transitions + const bool two_pass = this->is_using_partial_update_() && !this->partial_; + // Plain full refresh sends inverted data (buffer is 1=white, the wire wants 0=white); + // in fast/partial mode the data polarity is flipped via the VCOM/data-interval + // register instead, so the new-image plane is sent unmodified + const bool invert_new_data = !this->is_using_partial_update_(); + + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + + // Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image + if (two_pass && this->current_data_index_ < buffer_length) { + if (this->current_data_index_ == 0) { + this->command(0x10); // DATA START TRANSMISSION 1 (previous image) + } + this->start_data_(); + while (this->current_data_index_ < buffer_length) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + // Phase 2: new image via 0x13 (DTM2) + const size_t offset = two_pass ? buffer_length : 0; + const size_t total = offset + buffer_length; + if (this->current_data_index_ < total) { + if (this->current_data_index_ == offset) { + this->command(0x13); // DATA START TRANSMISSION 2 (new image) + } + this->start_data_(); + while (this->current_data_index_ < total) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, total - this->current_data_index_); + const size_t data_idx = this->current_data_index_ - offset; + for (size_t i = 0; i < bytes_to_copy; i++) { + const uint8_t byte = this->buffer_[data_idx + i]; + bytes_to_send[i] = invert_new_data ? ~byte : byte; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8179::power_on() { + // Power-on is sent at the end of initialise() instead, because the + // waveform/mode registers and the data transfer must follow it +} + +void EPaperUC8179::refresh_screen(bool /*partial*/) { + ESP_LOGV(TAG, "Refresh"); + this->command(0x12); // DISPLAY REFRESH + // The busy line needs at least 200us to assert after the refresh command + this->next_delay_ = 100; +} + +void EPaperUC8179::power_off() { + ESP_LOGV(TAG, "Power off"); + this->command(0x02); // POWER OFF +} + +void EPaperUC8179::deep_sleep() { + // Deep sleep loses the previous-image RAM that partial refresh compares against + if (!this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code + } +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h new file mode 100644 index 0000000000..85c0eb623e --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -0,0 +1,52 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome e-paper displays using the UC8179 controller. + * Supports: 7.5" V2 (EPD_7in5_V2), 800x480 pixels, as used by the + * Waveshare 7.5" V2 HAT and the Seeed reTerminal E1001. + * + * Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default). + * + * The INITIALISE state sends the panel configuration followed by power-on + * (0x04); the state machine busy-waits for power-on to complete before + * TRANSFER_DATA, which first writes the waveform/mode registers (these are + * only accepted while powered) and then the image data. The state machine + * busy-waits again before triggering REFRESH_SCREEN (0x12). + * + * Three refresh modes are used, following the Waveshare EPD_7in5_V2 examples: + * - full_update_every == 1: plain full refresh. The new image is sent + * inverted to DTM2 (0x13) and the controller uses its normal waveform. + * - full_update_every > 1, full update: fast full refresh. The data polarity + * is flipped via the VCOM/data-interval register, a fast waveform is forced + * via the temperature registers, and the image is sent to both DTM1 (0x10, + * inverted) and DTM2 (0x13) so that every pixel transitions. + * - full_update_every > 1, partial update: partial refresh. A partial-update + * waveform is forced, partial mode is entered with a full-screen window and + * only DTM2 is sent; the controller compares against its previous-image RAM. + */ +class EPaperUC8179 final : public EPaperBase { + public: + EPaperUC8179(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) { + this->buffer_length_ = this->row_width_ * height; + } + + protected: + bool initialise(bool partial) override; + bool transfer_data() override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + void set_refresh_mode_(); + + // Set by initialise() so transfer_data() knows which planes to send + bool partial_{}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py new file mode 100644 index 0000000000..9d6e4173dd --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -0,0 +1,92 @@ +"""Monochrome e-paper displays using the UC8179 controller. + +Supported models: +- waveshare-7.5in-v2: 7.5" mono display, 800x480 pixels (EPD_7in5_V2) +- seeed-reterminal-e1001: Seeed reTerminal E1001, which uses the same + 7.5" 800x480 panel on an integrated ESP32-S3 board + +Panel configuration is sent during the INITIALISE state. Power-on is handled +in the POWER_ON state, after data transfer, so the state machine's built-in +busy wait covers the power-on delay. + +These displays support fast full and partial refresh: set ``full_update_every`` +greater than 1 to enable it. Every ``full_update_every`` updates a fast full +refresh is performed, with partial refreshes in between. +""" + +from typing import Any + +from esphome.const import CONF_DATA_RATE + +from . import EpaperModel + + +class UC8179(EpaperModel): + """EpaperModel class for monochrome displays using the UC8179 controller.""" + + def __init__( + self, + name: str, + class_name: str = "EPaperUC8179", + data_rate: str = "10MHz", + **defaults: Any, + ) -> None: + defaults.setdefault(CONF_DATA_RATE, data_rate) + super().__init__(name, class_name, **defaults) + + def get_init_sequence(self, config: dict) -> tuple: + """Generate the initialization sequence for UC8179 mono displays. + + Panel configuration only — power-on is handled separately in power_on() + after data transfer, with the state machine busy-waiting before refresh. + """ + width, height = self.get_dimensions(config) + return ( + # POWER SETTING + (0x01, 0x07, 0x07, 0x3F, 0x3F), + # BOOSTER SOFT START + (0x06, 0x17, 0x17, 0x28, 0x17), + # PANEL SETTING (black/white mode, LUT from OTP) + (0x00, 0x1F), + # RESOLUTION SETTING (width x height) + ( + 0x61, + (width >> 8) & 0xFF, + width & 0xFF, + (height >> 8) & 0xFF, + height & 0xFF, + ), + # DUAL SPI MODE (disabled) + (0x15, 0x00), + # VCOM AND DATA INTERVAL SETTING + (0x50, 0x10, 0x07), + # TCON SETTING + (0x60, 0x22), + ) + + +uc8179 = UC8179("uc8179") + +# Waveshare 7.5" V2 mono (EPD_7in5_V2) — 800x480, UC8179 controller +waveshare_7_5_v2 = uc8179.extend( + "waveshare-7.5in-v2", + width=800, + height=480, +) + +# Seeed reTerminal E1001 — 7.5" mono e-paper (800x480), same panel as the +# Waveshare 7.5" V2, driven by an integrated ESP32-S3 board +waveshare_7_5_v2.extend( + "seeed-reterminal-e1001", + cs_pin=10, + dc_pin=11, + reset_pin=12, + busy_pin={ + "number": 13, + "inverted": True, + "mode": { + "input": True, + "pullup": True, + }, + }, +) diff --git a/tests/component_tests/epaper_spi/config/uc8179_e1001_test.yaml b/tests/component_tests/epaper_spi/config/uc8179_e1001_test.yaml new file mode 100644 index 0000000000..73f956c8ee --- /dev/null +++ b/tests/component_tests/epaper_spi/config/uc8179_e1001_test.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +spi: + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + id: epaper_display + model: seeed-reterminal-e1001 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 1396c18e3b..55481895dd 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -439,6 +439,23 @@ def test_enable_pin_multiple( assert all(pin["mode"]["output"] is True for pin in enable_pins) +def test_uc8179_e1001_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test that the reTerminal E1001 model generates the UC8179 driver and init sequence.""" + main_cpp = generate_main(component_config_path("uc8179_e1001_test.yaml")) + + # The model must instantiate the UC8179 driver class with the panel dimensions + assert "epaper_spi::EPaperUC8179" in main_cpp + assert '"SEEED-RETERMINAL-E1001", 800, 480' in main_cpp + + # The generated init sequence must contain the UC8179 resolution setting + # for 800x480: command 0x61, 4 data bytes 0x03 0x20 0x01 0xE0 + # (rendered as decimal in the generated array) + assert "97, 4, 3, 32, 1, 224" in main_cpp + + def test_enable_pin_code_generation( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index fb43b06567..1699d16e70 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -232,3 +232,45 @@ display: it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0)); + + # Waveshare 7.5" V2 mono (800x480, UC8179 controller, EPD_7in5_V2) + # full_update_every > 1 exercises the fast/partial refresh paths + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-7.5in-v2 + full_update_every: 4 + 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 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + + # Seeed reTerminal E1001 - 7.5" mono e-paper (800x480, UC8179) + # Pins overridden to avoid conflicts with the E1002 defaults above + - platform: epaper_spi + spi_id: spi_bus + model: seeed-reterminal-e1001 + 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 + inverted: true