From 2d0e3faa3d1282d1ec6928d4e76b62db3d37434c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:57:05 +1100 Subject: [PATCH] [st7305] Add reflective LCD display driver (#21099) --- CODEOWNERS | 1 + esphome/components/st7305/__init__.py | 2 + esphome/components/st7305/display.py | 224 +++++++++++++++ esphome/components/st7305/models/__init__.py | 0 esphome/components/st7305/models/waveshare.py | 60 ++++ esphome/components/st7305/st7305.cpp | 258 ++++++++++++++++++ esphome/components/st7305/st7305.h | 127 +++++++++ tests/component_tests/st7305/test_st7305.py | 74 +++++ tests/components/st7305/common.h | 77 ++++++ tests/components/st7305/common.yaml | 6 + .../components/st7305/display/test_st7305.cpp | 255 +++++++++++++++++ .../components/st7305/test.esp32-s3-idf.yaml | 8 + .../validate-custom-landscape.esp32-idf.yaml | 21 ++ .../st7305/validate-custom.esp32-idf.yaml | 22 ++ 14 files changed, 1135 insertions(+) create mode 100644 esphome/components/st7305/__init__.py create mode 100644 esphome/components/st7305/display.py create mode 100644 esphome/components/st7305/models/__init__.py create mode 100644 esphome/components/st7305/models/waveshare.py create mode 100644 esphome/components/st7305/st7305.cpp create mode 100644 esphome/components/st7305/st7305.h create mode 100644 tests/component_tests/st7305/test_st7305.py create mode 100644 tests/components/st7305/common.h create mode 100644 tests/components/st7305/common.yaml create mode 100644 tests/components/st7305/display/test_st7305.cpp create mode 100644 tests/components/st7305/test.esp32-s3-idf.yaml create mode 100644 tests/components/st7305/validate-custom-landscape.esp32-idf.yaml create mode 100644 tests/components/st7305/validate-custom.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 3bb01931381..9f9956930d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -602,6 +602,7 @@ esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_spi/* @kbx81 esphome/components/st7123/* @miniskipper +esphome/components/st7305/* @clydebarrow esphome/components/st7567_base/* @latonita esphome/components/st7567_i2c/* @latonita esphome/components/st7567_spi/* @latonita diff --git a/esphome/components/st7305/__init__.py b/esphome/components/st7305/__init__.py new file mode 100644 index 00000000000..6b48f6a9504 --- /dev/null +++ b/esphome/components/st7305/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@clydebarrow"] +DOMAIN = "st7305" diff --git a/esphome/components/st7305/display.py b/esphome/components/st7305/display.py new file mode 100644 index 00000000000..09ca8cdf7c0 --- /dev/null +++ b/esphome/components/st7305/display.py @@ -0,0 +1,224 @@ +import importlib +import pkgutil + +from esphome import pins +import esphome.codegen as cg +from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.mipi import ( + DriverChip, + flatten_sequence, + map_sequence, + model_schema_extractor, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, + CONF_CS_PIN, + CONF_DATA_RATE, + CONF_DC_PIN, + CONF_HEIGHT, + CONF_ID, + CONF_INIT_SEQUENCE, + CONF_LAMBDA, + CONF_MODEL, + CONF_PAGES, + CONF_RESET_PIN, + CONF_ROTATION, + CONF_WIDTH, +) +from esphome.types import ConfigType + +from . import models + +DEPENDENCIES = ["spi"] + +CONF_COLUMN_START = "column_start" +CONF_COLUMN_END = "column_end" +CONF_PORTRAIT = "portrait" +CONF_ROW_START = "row_start" +CONF_ROW_END = "row_end" + +st7305_ns = cg.esphome_ns.namespace("st7305") +ST7305 = st7305_ns.class_("ST7305", display.DisplayBuffer, spi.SPIDevice) + +Orientation = st7305_ns.enum("Orientation") + +# CUSTOM must be registered before the model modules are imported. +DriverChip("CUSTOM") +for module_info in pkgutil.iter_modules(models.__path__): + importlib.import_module(f".models.{module_info.name}", package=__package__) +MODELS = DriverChip.get_models() + +DEFAULT_MODEL = "WAVESHARE-400X300" + + +def _model_schema(model: DriverChip) -> cv.Schema: + iseqconf = ( + cv.Required(CONF_INIT_SEQUENCE) + if model.initsequence is None + else cv.Optional(CONF_INIT_SEQUENCE) + ) + return ( + display.FULL_DISPLAY_SCHEMA.extend( + spi.spi_device_schema( + cs_pin_required=False, + default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), + default_mode="MODE0", + ) + ) + .extend(cv.COMPONENT_SCHEMA) + .extend( + { + cv.GenerateID(): cv.declare_id(ST7305), + cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), + model.option(CONF_CS_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + model.option(CONF_DC_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + cv.Optional(CONF_WIDTH): cv.int_range(min=8, max=1024), + cv.Optional(CONF_HEIGHT): cv.int_range(min=8, max=1024), + cv.Optional(CONF_PORTRAIT): cv.boolean, + cv.Optional(CONF_COLUMN_START): cv.int_range(min=0, max=255), + cv.Optional(CONF_COLUMN_END): cv.int_range(min=0, max=255), + cv.Optional(CONF_ROW_START): cv.int_range(min=0, max=255), + cv.Optional(CONF_ROW_END): cv.int_range(min=0, max=255), + iseqconf: cv.ensure_list(map_sequence), + } + ) + ) + + +def _resolve(config: ConfigType, model: DriverChip) -> None: + """Fill in model defaults and validate the geometry and address window.""" + if (width := config.get(CONF_WIDTH)) is None: + if (width := model.get_default(CONF_WIDTH, None)) is None: + raise cv.Invalid("'width' is required for this model", path=[CONF_WIDTH]) + config[CONF_WIDTH] = width + if (height := config.get(CONF_HEIGHT)) is None: + if (height := model.get_default(CONF_HEIGHT, None)) is None: + raise cv.Invalid("'height' is required for this model", path=[CONF_HEIGHT]) + config[CONF_HEIGHT] = height + + if config.get(CONF_PORTRAIT) is None: + if (default := model.get_default(CONF_PORTRAIT, None)) is None: + raise cv.Invalid( + "'portrait' is required for this model", path=[CONF_PORTRAIT] + ) + config[CONF_PORTRAIT] = default + + if config[CONF_PORTRAIT]: + if width % 12 or height % 2: + raise cv.Invalid( + "portrait panels require a width divisible by 12 and a height divisible by 2" + ) + columns = width // 12 + rows = height // 2 + else: + if width % 2 or height % 12: + raise cv.Invalid( + "landscape panels require a width divisible by 2 and a height divisible by 12" + ) + columns = height // 12 + rows = width // 2 + + column_start = config.get( + CONF_COLUMN_START, model.get_default(CONF_COLUMN_START, 0) + ) + row_start = config.get(CONF_ROW_START, model.get_default(CONF_ROW_START, 0)) + column_end = config.get( + CONF_COLUMN_END, model.get_default(CONF_COLUMN_END, column_start + columns - 1) + ) + row_end = config.get( + CONF_ROW_END, model.get_default(CONF_ROW_END, row_start + rows - 1) + ) + if column_end < column_start or row_end < row_start: + raise cv.Invalid("address window end must not be before its start") + if not ( + 0 <= column_start <= 255 + and 0 <= column_end <= 255 + and 0 <= row_start <= 255 + and 0 <= row_end <= 255 + ): + raise cv.Invalid("address window values must be between 0 and 255") + if (column_end - column_start + 1) * (row_end - row_start + 1) * 3 < ( + width * height // 8 + ): + raise cv.Invalid("address window is too small for the display dimensions") + config[CONF_COLUMN_START] = column_start + config[CONF_COLUMN_END] = column_end + config[CONF_ROW_START] = row_start + config[CONF_ROW_END] = row_end + + if config.get(CONF_INIT_SEQUENCE) is None: + config[CONF_INIT_SEQUENCE] = model.initsequence + + for key, label in ((CONF_CS_PIN, "cs_pin"), (CONF_DC_PIN, "dc_pin")): + if key not in config: + raise cv.Invalid(f"'{label}' is required for this model", path=[key]) + + +def model_schema(config: ConfigType) -> cv.Schema: + """Return the schema for the model selected in the config.""" + return _model_schema(MODELS[config[CONF_MODEL]]) + + +@model_schema_extractor(MODELS, model_schema) +def customise_schema(config: ConfigType) -> ConfigType: + config = cv.Schema( + { + cv.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.one_of( + *MODELS, upper=True + ) + }, + extra=cv.ALLOW_EXTRA, + )(config) + model = MODELS[config[CONF_MODEL]] + config = _model_schema(model)(config) + _resolve(config, model) + + display.add_metadata( + config[CONF_ID], + config[CONF_WIDTH], + config[CONF_HEIGHT], + has_hardware_rotation=False, + byte_order=cv.UNDEFINED, + has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True + or config.get(CONF_PAGES) is not None + or config.get(CONF_LAMBDA) is not None + or config.get(CONF_SHOW_TEST_CARD) is True, + rotation=config.get(CONF_ROTATION, 0), + ) + return config + + +CONFIG_SCHEMA = customise_schema + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "st7305", require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + sequence = flatten_sequence(config[CONF_INIT_SEQUENCE]) + table = cg.shared_progmem_array("st7305_init_sequence", cg.uint8, sequence) + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_MODEL], + config[CONF_WIDTH], + config[CONF_HEIGHT], + Orientation.ORIENTATION_PORTRAIT + if config[CONF_PORTRAIT] + else Orientation.ORIENTATION_LANDSCAPE, + config[CONF_COLUMN_START], + config[CONF_COLUMN_END], + config[CONF_ROW_START], + config[CONF_ROW_END], + table, + len(sequence), + ) + + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=True) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await display.add_writer(var, config) diff --git a/esphome/components/st7305/models/__init__.py b/esphome/components/st7305/models/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/st7305/models/waveshare.py b/esphome/components/st7305/models/waveshare.py new file mode 100644 index 00000000000..e9cde5879a2 --- /dev/null +++ b/esphome/components/st7305/models/waveshare.py @@ -0,0 +1,60 @@ +from esphome.components.mipi import DriverChip, delay + +# Waveshare ESP32-S3-RLCD-4.2, 4.2" reflective panel. The same controller command set is +# used by the 300x400 ST7306 panels. Values follow the vendor demo code, which is the sequence +# proven on this panel; the datasheet's referential sequence is close but not identical. +DriverChip( + "WAVESHARE-400X300", + width=400, + height=300, + portrait=False, + cs_pin=40, + dc_pin=5, + reset_pin=41, + column_start=0x12, + column_end=0x2A, + row_start=0x00, + row_end=0xC7, + initsequence=( + (0xD6, 0x17, 0x02), # NVM load control + (0xD1, 0x01), # booster enable + (0xC0, 0x11, 0x04), # gate voltage + (0xC1, 0x69, 0x69, 0x69, 0x69), # VSHP + (0xC2, 0x19, 0x19, 0x19, 0x19), # VSLP + (0xC4, 0x4B, 0x4B, 0x4B, 0x4B), # VSHN + (0xC5, 0x19, 0x19, 0x19, 0x19), # VSLN + (0xD8, 0x80, 0xE9), # oscillator + (0xB2, 0x02), # frame rate + ( + 0xB3, + 0xE5, + 0xF6, + 0x05, + 0x46, + 0x77, + 0x77, + 0x77, + 0x77, + 0x76, + 0x45, + ), # HPM gate EQ + (0xB4, 0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45), # LPM gate EQ + (0x62, 0x32, 0x03, 0x1F), # gate timing + (0xB7, 0x13), # source EQ + (0xB0, 0x64), # gate line setting, 400 lines + (0x11,), # sleep out + delay(200), + (0xC9, 0x00), # source voltage select + (0x36, 0x48), # MADCTL: MX=1, DO=1 + (0x3A, 0x11), # 1-bit data format, 3 bytes per 24-pixel block + (0xB9, 0x20), # gamma, mono + (0xB8, 0x29), # panel setting + (0x21,), # display inversion on + (0x2A, 0x12, 0x2A), # column address window + (0x2B, 0x00, 0xC7), # row address window + (0x35, 0x00), # tearing effect line on + (0xD0, 0xFF), # auto power down + (0x38,), # high power mode + (0x29,), # display on + ), +) diff --git a/esphome/components/st7305/st7305.cpp b/esphome/components/st7305/st7305.cpp new file mode 100644 index 00000000000..36b58fdb240 --- /dev/null +++ b/esphome/components/st7305/st7305.cpp @@ -0,0 +1,258 @@ +#include "st7305.h" + +#include +#include + +#include "esphome/core/log.h" + +namespace esphome::st7305 { + +ESPHOME_LOG_TAG(TAG, "st7305"); + +void ST7305::setup() { + this->spi_setup(); + this->dc_pin_->setup(); + if (this->reset_pin_ != nullptr) + this->reset_pin_->setup(); + + if (!this->init_buffer_()) + return; + + this->reset_(); +} + +bool ST7305::init_buffer_() { + this->init_internal_(this->buffer_length_); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Buffer allocation failed")); + return false; + } + memset(this->buffer_, 0x00, this->buffer_length_); + this->clear_dirty_(); + return true; +} + +void ST7305::reset_() { + static constexpr uint32_t RESET_HIGH_MS = 50; + static constexpr uint32_t RESET_LOW_MS = 20; + static constexpr uint32_t RESET_SETTLE_MS = 50; + static constexpr uint32_t SOFT_RESET_MS = 5; + + if (this->reset_pin_ == nullptr) { + this->enable(); + this->send_command_(0x01); // software reset + this->disable(); + this->set_timeout(STARTUP_TIMEOUT_ID, SOFT_RESET_MS, [this]() { this->send_init_sequence_(); }); + return; + } + this->reset_pin_->digital_write(true); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_HIGH_MS, [this]() { + this->reset_pin_->digital_write(false); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_LOW_MS, [this]() { + this->reset_pin_->digital_write(true); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_SETTLE_MS, [this]() { this->send_init_sequence_(); }); + }); + }); +} + +void ST7305::send_init_sequence_() { + while (this->init_index_ < this->init_sequence_length_) { + if (this->init_sequence_length_ - this->init_index_ < 2) { + this->mark_failed(LOG_STR("Malformed init sequence")); + return; + } + const uint8_t command = progmem_read_byte(&this->init_sequence_[this->init_index_++]); + const uint8_t count = progmem_read_byte(&this->init_sequence_[this->init_index_++]); + if (count == 0xFF) { + // Resume the sequence after the delay instead of blocking the main loop. + this->set_timeout(STARTUP_TIMEOUT_ID, command, [this]() { this->send_init_sequence_(); }); + return; + } + if (this->init_sequence_length_ - this->init_index_ < count) { + this->mark_failed(LOG_STR("Malformed init sequence")); + return; + } + this->enable(); + this->send_command_(command); + if (count != 0) { + this->dc_pin_->digital_write(true); + for (uint8_t i = 0; i < count; i++) + this->write_byte(progmem_read_byte(&this->init_sequence_[this->init_index_ + i])); + } + this->disable(); + this->init_index_ += count; + } + this->finish_init_(); +} + +void ST7305::finish_init_() { + this->write_full_frame_(); + this->clear_dirty_(); + this->ready_ = true; +} + +void ST7305::write_full_frame_() { + const uint8_t caset[] = {this->column_start_, this->column_end_}; + const uint8_t raset[] = {this->row_start_, this->row_end_}; + this->enable(); + this->send_command_(0x2A); + this->send_data_(caset, sizeof(caset)); + this->send_command_(0x2B); + this->send_data_(raset, sizeof(raset)); + this->send_command_(0x2C); + this->send_data_(this->buffer_, this->buffer_length_); + this->disable(); +} + +ST7305::Window ST7305::compute_window_() const { + Window window{}; + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + // RASET rows are x pairs; CASET columns are 12-pixel bands of y blocks. + window.stride = static_cast(this->height_ / 4); + window.outer_start = static_cast(this->x_low_ >> 1); + window.outer_end = static_cast(this->x_high_ >> 1); + const int inv_min = this->height_ - 1 - this->y_high_; + const int inv_max = this->height_ - 1 - this->y_low_; + window.block_start = static_cast((inv_min >> 2) / 3 * 3); + window.block_end = + static_cast(std::min(((inv_max >> 2) / 3) * 3 + 2, static_cast(window.stride) - 1)); + } else { + // RASET rows are y pairs; CASET columns are 12-pixel bands of x blocks. + window.stride = static_cast(this->width_ / 4); + window.outer_start = static_cast(this->y_low_ >> 1); + window.outer_end = static_cast(this->y_high_ >> 1); + const int block_min = this->x_low_ >> 2; + const int block_max = this->x_high_ >> 2; + window.block_start = static_cast(block_min / 3 * 3); + window.block_end = static_cast(std::min((block_max / 3) * 3 + 2, static_cast(window.stride) - 1)); + } + window.caset_start = static_cast(this->column_start_ + window.block_start / 3); + window.caset_end = static_cast(this->column_start_ + window.block_end / 3); + return window; +} + +void ST7305::write_window_(const Window &window) { + const uint8_t caset[] = {window.caset_start, window.caset_end}; + const uint8_t raset[] = {static_cast(this->row_start_ + window.outer_start), + static_cast(this->row_start_ + window.outer_end)}; + this->enable(); + this->send_command_(0x2A); + this->send_data_(caset, sizeof(caset)); + this->send_command_(0x2B); + this->send_data_(raset, sizeof(raset)); + this->send_command_(0x2C); + if (window.block_start == 0 && window.block_end == window.stride - 1) { + // Full-height window: every outer unit's data is contiguous with the next. + this->send_data_(this->buffer_ + window.outer_start * window.stride, + static_cast(window.outer_end - window.outer_start + 1) * window.stride); + } else { + const size_t length = window.block_end - window.block_start + 1; + for (uint16_t outer = window.outer_start; outer <= window.outer_end; outer++) + this->send_data_(this->buffer_ + outer * window.stride + window.block_start, length); + } + this->disable(); +} + +void ST7305::update() { + if (!this->ready_) + return; + + this->do_update_(); + if (!this->is_dirty_()) + return; + + const Window window = this->compute_window_(); + if (window.data_length() > this->buffer_length_ / 2) { + this->write_full_frame_(); + } else { + this->write_window_(window); + } + this->clear_dirty_(); +} + +void HOT ST7305::draw_absolute_pixel_internal(int x, int y, Color color) { + if (x < 0 || y < 0 || x >= this->width_ || y >= this->height_) + return; + size_t index; + uint8_t mask; + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + const int inv_y = this->height_ - 1 - y; + index = static_cast(x >> 1) * (this->height_ / 4) + (inv_y >> 2); + mask = static_cast(1 << (7 - (((inv_y & 3) << 1) | (x & 1)))); + } else { + index = static_cast(y >> 1) * (this->width_ / 4) + (x >> 2); + mask = static_cast(1 << (7 - (((x & 3) << 1) | (y & 1)))); + } + // The init sequence enables INVON, so a set bit is white and a clear bit is black. + uint8_t &value = this->buffer_[index]; + const uint8_t previous = value; + if (color.is_on()) { + value |= mask; + } else { + value &= ~mask; + } + if (value == previous) + return; + this->extend_dirty_(x, y, x, y); +} + +void ST7305::fill(Color color) { + if (this->get_clipping().is_set()) { + display::Display::fill(color); + return; + } + if (this->buffer_ == nullptr) + return; + + const uint8_t value = color.is_on() ? 0xFF : 0x00; + size_t first = this->buffer_length_; + size_t last = 0; + for (size_t index = 0; index < this->buffer_length_; index++) { + if (this->buffer_[index] == value) + continue; + this->buffer_[index] = value; + if (first == this->buffer_length_) + first = index; + last = index; + } + if (first == this->buffer_length_) + return; + + // Widen the changed index range to whole outer units, and the other axis to the full + // display. The extra pixels are current in the buffer, so sending them is harmless. + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + const int stride = this->height_ / 4; + this->extend_dirty_(static_cast(first / stride) * 2, 0, static_cast(last / stride) * 2 + 1, + this->height_ - 1); + } else { + const int stride = this->width_ / 4; + this->extend_dirty_(0, static_cast(first / stride) * 2, this->width_ - 1, + static_cast(last / stride) * 2 + 1); + } +} + +void ST7305::extend_dirty_(int x0, int y0, int x1, int y1) { + if (x0 < this->x_low_) + this->x_low_ = x0; + if (x1 > this->x_high_) + this->x_high_ = x1; + if (y0 < this->y_low_) + this->y_low_ = y0; + if (y1 > this->y_high_) + this->y_high_ = y1; +} + +void ST7305::dump_config() { + LOG_DISPLAY("", "ST7305", this); + ESP_LOGCONFIG(TAG, " Model: %s", this->model_); + ESP_LOGCONFIG(TAG, " Orientation: %s", + this->orientation_ == Orientation::ORIENTATION_LANDSCAPE ? LOG_STR_LITERAL("landscape") + : LOG_STR_LITERAL("portrait")); + ESP_LOGCONFIG(TAG, " Window: columns %u..%u, rows %u..%u", this->column_start_, this->column_end_, this->row_start_, + this->row_end_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::st7305 diff --git a/esphome/components/st7305/st7305.h b/esphome/components/st7305/st7305.h new file mode 100644 index 00000000000..02b00b729df --- /dev/null +++ b/esphome/components/st7305/st7305.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::st7305 { + +enum Orientation : uint8_t { + ORIENTATION_LANDSCAPE = 0, + ORIENTATION_PORTRAIT = 1, +}; + +/// Sitronix ST7305/ST7306 reflective monochrome LCD, 1 bit per pixel. +/// +/// The controller RAM is addressed in 12-column x 2-row blocks (3 bytes per address). With a +/// landscape panel one byte covers 2 pixels in x and 4 in y; with a portrait panel it covers +/// 4 pixels in x and 2 in y. +class ST7305 : public display::DisplayBuffer, + public spi::SPIDevice { + public: + ST7305(const char *model, int width, int height, Orientation orientation, uint8_t column_start, uint8_t column_end, + uint8_t row_start, uint8_t row_end, const uint8_t *init_sequence, size_t init_sequence_length) + : model_(model), + width_(width), + height_(height), + orientation_(orientation), + column_start_(column_start), + column_end_(column_end), + row_start_(row_start), + row_end_(row_end), + buffer_length_(static_cast(width) * static_cast(height) / 8), + init_sequence_(init_sequence), + init_sequence_length_(init_sequence_length) {} + + void setup() override; + void update() override; + void dump_config() override; + + void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + + void fill(Color color) override; + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_BINARY; } + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + protected: + /// An addressable write window. RASET addresses are the outer index (x pairs in landscape, + /// y pairs in portrait); CASET addresses are 12-pixel bands of the inner index. + struct Window { + uint16_t outer_start; + uint16_t outer_end; + uint16_t block_start; + uint16_t block_end; + uint16_t stride; + uint8_t caset_start; + uint8_t caset_end; + + size_t data_length() const { + return static_cast(this->outer_end - this->outer_start + 1) * + static_cast(this->block_end - this->block_start + 1); + } + }; + + void draw_absolute_pixel_internal(int x, int y, Color color) override; + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + + bool init_buffer_(); + void reset_(); + void send_init_sequence_(); + void finish_init_(); + void write_full_frame_(); + void write_window_(const Window &window); + Window compute_window_() const; + + /// Scheduler id for the non-blocking reset and init sequence. The steps are sequential, + /// so one id is enough; a new timeout replaces any pending one. + static constexpr uint32_t STARTUP_TIMEOUT_ID = 0; + + void send_command_(uint8_t command) { + this->dc_pin_->digital_write(false); + this->write_byte(command); + } + void send_data_(const uint8_t *data, size_t length) { + this->dc_pin_->digital_write(true); + this->write_array(data, length); + } + + bool is_dirty_() const { return this->x_high_ >= this->x_low_ && this->y_high_ >= this->y_low_; } + void clear_dirty_() { + this->x_low_ = this->width_; + this->x_high_ = -1; + this->y_low_ = this->height_; + this->y_high_ = -1; + } + void extend_dirty_(int x0, int y0, int x1, int y1); + + GPIOPin *dc_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + + const char *model_; + int width_; + int height_; + Orientation orientation_; + uint8_t column_start_; + uint8_t column_end_; + uint8_t row_start_; + uint8_t row_end_; + size_t buffer_length_; + const uint8_t *init_sequence_; + size_t init_sequence_length_; + size_t init_index_{0}; + bool ready_{false}; + + int x_low_{0}; + int x_high_{-1}; + int y_low_{0}; + int y_high_{-1}; +}; + +} // namespace esphome::st7305 diff --git a/tests/component_tests/st7305/test_st7305.py b/tests/component_tests/st7305/test_st7305.py new file mode 100644 index 00000000000..016252cdf28 --- /dev/null +++ b/tests/component_tests/st7305/test_st7305.py @@ -0,0 +1,74 @@ +"""Tests for the st7305 configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.st7305.display import ( + CONF_COLUMN_END, + CONF_COLUMN_START, + CONF_PORTRAIT, + CONF_ROW_END, + MODELS, + _resolve, +) +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, CONF_HEIGHT, CONF_WIDTH + + +def _config(**overrides): + config = { + CONF_WIDTH: 400, + CONF_HEIGHT: 300, + CONF_PORTRAIT: False, + CONF_CS_PIN: 1, + CONF_DC_PIN: 2, + } + config.update(overrides) + return config + + +def test_landscape_derives_window() -> None: + config = _config() + _resolve(config, MODELS["CUSTOM"]) + assert config[CONF_COLUMN_START] == 0 + assert config[CONF_COLUMN_END] == 24 + assert config[CONF_ROW_END] == 199 + + +@pytest.mark.parametrize("height", [160, 200, 296, 308]) +def test_landscape_rejects_inner_not_multiple_of_12(height: int) -> None: + with pytest.raises(cv.Invalid, match="height divisible by 12"): + _resolve(_config(**{CONF_HEIGHT: height}), MODELS["CUSTOM"]) + + +def test_portrait_derives_window() -> None: + config = _config( + **{ + CONF_WIDTH: 168, + CONF_HEIGHT: 384, + CONF_PORTRAIT: True, + CONF_COLUMN_START: 0x17, + } + ) + _resolve(config, MODELS["CUSTOM"]) + assert config[CONF_COLUMN_END] == 0x24 + assert config[CONF_ROW_END] == 191 + + +@pytest.mark.parametrize("width", [160, 200, 208]) +def test_portrait_rejects_inner_not_multiple_of_12(width: int) -> None: + with pytest.raises(cv.Invalid, match="width divisible by 12"): + _resolve( + _config( + **{ + CONF_WIDTH: width, + CONF_HEIGHT: 384, + CONF_PORTRAIT: True, + } + ), + MODELS["CUSTOM"], + ) + + +def test_window_ends_must_fit_in_a_byte() -> None: + with pytest.raises(cv.Invalid, match="between 0 and 255"): + _resolve(_config(**{CONF_WIDTH: 1024, CONF_HEIGHT: 300}), MODELS["CUSTOM"]) diff --git a/tests/components/st7305/common.h b/tests/components/st7305/common.h new file mode 100644 index 00000000000..856e1b83b01 --- /dev/null +++ b/tests/components/st7305/common.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/spi/spi.h" +#include "esphome/components/st7305/st7305.h" +#include "esphome/core/hal.h" + +namespace esphome::st7305::testing { + +class TestableST7305 : public ST7305 { + public: + TestableST7305(int width = 400, int height = 300, Orientation orientation = Orientation::ORIENTATION_LANDSCAPE, + uint8_t column_start = 0x12, uint8_t column_end = 0x2A, uint8_t row_start = 0x00, + uint8_t row_end = 0xC7) + : ST7305("TEST", width, height, orientation, column_start, column_end, row_start, row_end, nullptr, 0) {} + + bool init_buffer() { return this->init_buffer_(); } + void clear_dirty() { this->clear_dirty_(); } + bool is_dirty() const { return this->is_dirty_(); } + int x_low() const { return this->x_low_; } + int x_high() const { return this->x_high_; } + int y_low() const { return this->y_low_; } + int y_high() const { return this->y_high_; } + Window compute_window() const { return this->compute_window_(); } + void write_window() { this->write_window_(this->compute_window_()); } + uint8_t *buffer() { return this->buffer_; } + void attach_spi(spi::SPIDelegate *delegate, GPIOPin *dc_pin) { + this->delegate_ = delegate; + this->set_dc_pin(dc_pin); + } + + size_t buffer_length() const { return this->buffer_length_; } + int width() const { return this->width_; } + int height() const { return this->height_; } +}; + +/// GPIO pin that remembers the last level written to it. +class RecordingPin : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::Flags::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool value) override { this->level = value; } + size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "recording"); } + + bool level{true}; +}; + +/// SPI delegate that records every payload and whether it was a command or data phase. +class RecordingDelegate : public spi::SPIDelegate { + public: + explicit RecordingDelegate(const RecordingPin *dc) + : spi::SPIDelegate(10'000'000, spi::BIT_ORDER_MSB_FIRST, spi::MODE0, nullptr), dc_(dc) {} + + 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); } + + std::vector bytes; + std::vector> chunks; // (data phase, length) + + protected: + void record_(const uint8_t *ptr, size_t length) { + this->chunks.emplace_back(this->dc_->level, length); + this->bytes.insert(this->bytes.end(), ptr, ptr + length); + } + + const RecordingPin *dc_; +}; + +} // namespace esphome::st7305::testing diff --git a/tests/components/st7305/common.yaml b/tests/components/st7305/common.yaml new file mode 100644 index 00000000000..064ddcb45aa --- /dev/null +++ b/tests/components/st7305/common.yaml @@ -0,0 +1,6 @@ +display: + - platform: st7305 + model: WAVESHARE-400X300 + id: st7305_display + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/st7305/display/test_st7305.cpp b/tests/components/st7305/display/test_st7305.cpp new file mode 100644 index 00000000000..e447e09e14d --- /dev/null +++ b/tests/components/st7305/display/test_st7305.cpp @@ -0,0 +1,255 @@ +#include + +#include "../common.h" + +namespace esphome::st7305::testing { + +class ST7305Test : public ::testing::Test { + protected: + void SetUp() override { ASSERT_TRUE(this->display_.init_buffer()); } + + void reset_screen_() { + this->display_.fill(Color::BLACK); + this->display_.clear_dirty(); + } + + TestableST7305 display_; +}; + +TEST_F(ST7305Test, FillSetsAllBytes) { + this->display_.fill(Color::WHITE); + for (size_t i = 0; i < this->display_.buffer_length(); i++) + EXPECT_EQ(this->display_.buffer()[i], 0xFF) << "byte " << i; + + this->display_.fill(Color::BLACK); + for (size_t i = 0; i < this->display_.buffer_length(); i++) + EXPECT_EQ(this->display_.buffer()[i], 0x00) << "byte " << i; +} + +TEST_F(ST7305Test, PixelMapping) { + struct PixelCase { + int x; + int y; + size_t index; + uint8_t mask; + }; + const PixelCase cases[] = { + {0, 299, 0, 0x80}, {1, 299, 0, 0x40}, {0, 298, 0, 0x20}, {0, 297, 0, 0x08}, {0, 296, 0, 0x02}, + {2, 299, 75, 0x80}, {0, 0, 74, 0x02}, {1, 0, 74, 0x01}, {200, 150, 7537, 0x20}, {399, 0, 14999, 0x01}, + }; + + for (const auto &pixel : cases) { + this->display_.draw_pixel_at(pixel.x, pixel.y, Color::WHITE); + EXPECT_EQ(this->display_.buffer()[pixel.index], pixel.mask) << "pixel " << pixel.x << "," << pixel.y; + this->display_.draw_pixel_at(pixel.x, pixel.y, Color::BLACK); + EXPECT_EQ(this->display_.buffer()[pixel.index], 0x00) << "pixel " << pixel.x << "," << pixel.y; + } +} + +TEST_F(ST7305Test, ClearingOnePixelLeavesNeighbours) { + this->display_.draw_pixel_at(0, 299, Color::WHITE); + this->display_.draw_pixel_at(1, 299, Color::WHITE); + EXPECT_EQ(this->display_.buffer()[0], 0xC0); + + this->display_.draw_pixel_at(0, 299, Color::BLACK); + EXPECT_EQ(this->display_.buffer()[0], 0x40); +} + +TEST_F(ST7305Test, DirtyBoundsFollowChanges) { + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.draw_pixel_at(10, 20, Color::WHITE); + EXPECT_TRUE(this->display_.is_dirty()); + EXPECT_EQ(this->display_.x_low(), 10); + EXPECT_EQ(this->display_.x_high(), 10); + EXPECT_EQ(this->display_.y_low(), 20); + EXPECT_EQ(this->display_.y_high(), 20); + + this->display_.clear_dirty(); + this->display_.draw_pixel_at(10, 20, Color::WHITE); + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.draw_pixel_at(10, 20, Color::BLACK); + EXPECT_TRUE(this->display_.is_dirty()); + + this->display_.clear_dirty(); + this->display_.draw_pixel_at(10, 20, Color::WHITE); + this->display_.draw_pixel_at(100, 200, Color::WHITE); + EXPECT_EQ(this->display_.x_low(), 10); + EXPECT_EQ(this->display_.x_high(), 100); + EXPECT_EQ(this->display_.y_low(), 20); + EXPECT_EQ(this->display_.y_high(), 200); +} + +TEST_F(ST7305Test, FillOnlyDirtiesChangedBytes) { + this->display_.clear_dirty(); + this->display_.fill(Color::BLACK); + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.fill(Color::WHITE); + EXPECT_TRUE(this->display_.is_dirty()); + EXPECT_EQ(this->display_.x_low(), 0); + EXPECT_EQ(this->display_.x_high(), 399); + EXPECT_EQ(this->display_.y_low(), 0); + EXPECT_EQ(this->display_.y_high(), 299); + + this->display_.clear_dirty(); + this->display_.fill(Color::WHITE); + EXPECT_FALSE(this->display_.is_dirty()); +} + +TEST_F(ST7305Test, WindowAlignsToAddressBlocks) { + this->reset_screen_(); + this->display_.draw_pixel_at(0, 299, Color::WHITE); + this->display_.draw_pixel_at(1, 299, Color::WHITE); + + const auto window = this->display_.compute_window(); + EXPECT_EQ(window.outer_start, 0); + EXPECT_EQ(window.outer_end, 0); + EXPECT_EQ(window.block_start, 0); + EXPECT_EQ(window.block_end, 2); + EXPECT_EQ(window.caset_start, 0x12); + EXPECT_EQ(window.caset_end, 0x12); + EXPECT_EQ(window.data_length(), 3u); + + this->reset_screen_(); + this->display_.draw_pixel_at(200, 150, Color::WHITE); + + const auto middle = this->display_.compute_window(); + EXPECT_EQ(middle.outer_start, 100); + EXPECT_EQ(middle.outer_end, 100); + EXPECT_EQ(middle.block_start, 36); + EXPECT_EQ(middle.block_end, 38); + EXPECT_EQ(middle.caset_start, 0x1E); + EXPECT_EQ(middle.caset_end, 0x1E); + EXPECT_EQ(middle.data_length(), 3u); + + this->reset_screen_(); + this->display_.draw_pixel_at(0, 0, Color::WHITE); + this->display_.draw_pixel_at(0, 299, Color::WHITE); + + const auto full_height = this->display_.compute_window(); + EXPECT_EQ(full_height.outer_start, 0); + EXPECT_EQ(full_height.outer_end, 0); + EXPECT_EQ(full_height.block_start, 0); + EXPECT_EQ(full_height.block_end, 74); + EXPECT_EQ(full_height.caset_start, 0x12); + EXPECT_EQ(full_height.caset_end, 0x2A); + EXPECT_EQ(full_height.data_length(), 75u); +} + +TEST_F(ST7305Test, WriteWindowEmitsWindowCommands) { + for (size_t i = 0; i < this->display_.buffer_length(); i++) + this->display_.buffer()[i] = static_cast(i & 0xFF); + + RecordingPin dc_pin; + RecordingDelegate delegate(&dc_pin); + this->display_.attach_spi(&delegate, &dc_pin); + + this->display_.draw_pixel_at(200, 151, Color::WHITE); + this->display_.write_window(); + + const uint8_t expected[] = { + 0x2A, + 0x1E, + 0x1E, + 0x2B, + 100, + 100, + 0x2C, + this->display_.buffer()[100 * 75 + 36], + this->display_.buffer()[100 * 75 + 37], + this->display_.buffer()[100 * 75 + 38], + }; + ASSERT_EQ(delegate.bytes.size(), sizeof(expected)); + for (size_t i = 0; i < sizeof(expected); i++) + EXPECT_EQ(delegate.bytes[i], expected[i]) << "byte " << i; + + ASSERT_EQ(delegate.chunks.size(), 6u); + EXPECT_EQ(delegate.chunks[0], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[1], std::make_pair(true, size_t{2})); + EXPECT_EQ(delegate.chunks[2], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[3], std::make_pair(true, size_t{2})); + EXPECT_EQ(delegate.chunks[4], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[5], std::make_pair(true, size_t{3})); +} + +TEST_F(ST7305Test, PortraitPixelMapping) { + TestableST7305 portrait(168, 384, Orientation::ORIENTATION_PORTRAIT, 0x17, 0x24, 0x00, 0xBF); + ASSERT_TRUE(portrait.init_buffer()); + ASSERT_EQ(portrait.get_width(), 168); + ASSERT_EQ(portrait.get_height(), 384); + + struct PixelCase { + int x; + int y; + size_t index; + uint8_t mask; + }; + const PixelCase cases[] = { + {0, 0, 0, 0x80}, {1, 0, 0, 0x20}, {2, 0, 0, 0x08}, {3, 0, 0, 0x02}, + {0, 1, 0, 0x40}, {4, 0, 1, 0x80}, {0, 2, 42, 0x80}, {167, 383, 8063, 0x01}, + }; + + for (const auto &pixel : cases) { + portrait.draw_pixel_at(pixel.x, pixel.y, Color::WHITE); + EXPECT_EQ(portrait.buffer()[pixel.index], pixel.mask) << "pixel " << pixel.x << "," << pixel.y; + portrait.draw_pixel_at(pixel.x, pixel.y, Color::BLACK); + EXPECT_EQ(portrait.buffer()[pixel.index], 0x00) << "pixel " << pixel.x << "," << pixel.y; + } +} + +TEST_F(ST7305Test, PortraitWindowAlignsToAddressBlocks) { + TestableST7305 portrait(168, 384, Orientation::ORIENTATION_PORTRAIT, 0x17, 0x24, 0x00, 0xBF); + ASSERT_TRUE(portrait.init_buffer()); + + portrait.draw_pixel_at(84, 192, Color::WHITE); + const auto window = portrait.compute_window(); + EXPECT_EQ(window.outer_start, 96); + EXPECT_EQ(window.outer_end, 96); + EXPECT_EQ(window.stride, 42); + EXPECT_EQ(window.block_start, 21); + EXPECT_EQ(window.block_end, 23); + EXPECT_EQ(window.caset_start, 0x1E); + EXPECT_EQ(window.caset_end, 0x1E); + EXPECT_EQ(window.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(0, 0, Color::WHITE); + portrait.draw_pixel_at(3, 1, Color::WHITE); + const auto corner = portrait.compute_window(); + EXPECT_EQ(corner.outer_start, 0); + EXPECT_EQ(corner.outer_end, 0); + EXPECT_EQ(corner.block_start, 0); + EXPECT_EQ(corner.block_end, 2); + EXPECT_EQ(corner.caset_start, 0x17); + EXPECT_EQ(corner.caset_end, 0x17); + EXPECT_EQ(corner.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(167, 200, Color::WHITE); + const auto right_edge = portrait.compute_window(); + EXPECT_EQ(right_edge.outer_start, 100); + EXPECT_EQ(right_edge.outer_end, 100); + EXPECT_EQ(right_edge.block_start, 39); + EXPECT_EQ(right_edge.block_end, 41); + EXPECT_EQ(right_edge.caset_start, 0x24); + EXPECT_EQ(right_edge.caset_end, 0x24); + EXPECT_EQ(right_edge.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(167, 383, Color::WHITE); + const auto bottom_right = portrait.compute_window(); + EXPECT_EQ(bottom_right.outer_start, 191); + EXPECT_EQ(bottom_right.outer_end, 191); + EXPECT_EQ(bottom_right.block_start, 39); + EXPECT_EQ(bottom_right.block_end, 41); + EXPECT_EQ(bottom_right.caset_start, 0x24); + EXPECT_EQ(bottom_right.caset_end, 0x24); + EXPECT_EQ(bottom_right.data_length(), 3u); +} + +} // namespace esphome::st7305::testing diff --git a/tests/components/st7305/test.esp32-s3-idf.yaml b/tests/components/st7305/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..0cc297c380e --- /dev/null +++ b/tests/components/st7305/test.esp32-s3-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + clk_pin: GPIO18 + mosi_pin: GPIO17 + miso_pin: GPIO16 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + st7305: !include common.yaml diff --git a/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml b/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml new file mode 100644 index 00000000000..55314946e14 --- /dev/null +++ b/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml @@ -0,0 +1,21 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: st7305 + model: CUSTOM + width: 400 + height: 300 + portrait: false + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + init_sequence: + - [0x01] + - [0x11, 0x00] + - [0x29] + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/st7305/validate-custom.esp32-idf.yaml b/tests/components/st7305/validate-custom.esp32-idf.yaml new file mode 100644 index 00000000000..c29874aa190 --- /dev/null +++ b/tests/components/st7305/validate-custom.esp32-idf.yaml @@ -0,0 +1,22 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: st7305 + model: CUSTOM + width: 168 + height: 384 + portrait: true + column_start: 0x17 + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + init_sequence: + - [0x01] + - [0x11, 0x00] + - [0x29] + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height());