[st7305] Add reflective LCD display driver (#21099)

This commit is contained in:
Clyde Stubbs
2026-10-19 19:57:05 +11:00
committed by GitHub
parent 2087d28684
commit 2d0e3faa3d
14 changed files with 1135 additions and 0 deletions
+1
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@clydebarrow"]
DOMAIN = "st7305"
+224
View File
@@ -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)
@@ -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
),
)
+258
View File
@@ -0,0 +1,258 @@
#include "st7305.h"
#include <algorithm>
#include <cstring>
#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<uint16_t>(this->height_ / 4);
window.outer_start = static_cast<uint16_t>(this->x_low_ >> 1);
window.outer_end = static_cast<uint16_t>(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<uint16_t>((inv_min >> 2) / 3 * 3);
window.block_end =
static_cast<uint16_t>(std::min(((inv_max >> 2) / 3) * 3 + 2, static_cast<int>(window.stride) - 1));
} else {
// RASET rows are y pairs; CASET columns are 12-pixel bands of x blocks.
window.stride = static_cast<uint16_t>(this->width_ / 4);
window.outer_start = static_cast<uint16_t>(this->y_low_ >> 1);
window.outer_end = static_cast<uint16_t>(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<uint16_t>(block_min / 3 * 3);
window.block_end = static_cast<uint16_t>(std::min((block_max / 3) * 3 + 2, static_cast<int>(window.stride) - 1));
}
window.caset_start = static_cast<uint8_t>(this->column_start_ + window.block_start / 3);
window.caset_end = static_cast<uint8_t>(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<uint8_t>(this->row_start_ + window.outer_start),
static_cast<uint8_t>(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<size_t>(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<size_t>(x >> 1) * (this->height_ / 4) + (inv_y >> 2);
mask = static_cast<uint8_t>(1 << (7 - (((inv_y & 3) << 1) | (x & 1))));
} else {
index = static_cast<size_t>(y >> 1) * (this->width_ / 4) + (x >> 2);
mask = static_cast<uint8_t>(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<int>(first / stride) * 2, 0, static_cast<int>(last / stride) * 2 + 1,
this->height_ - 1);
} else {
const int stride = this->width_ / 4;
this->extend_dirty_(0, static_cast<int>(first / stride) * 2, this->width_ - 1,
static_cast<int>(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
+127
View File
@@ -0,0 +1,127 @@
#pragma once
#include <cstddef>
#include <cstdint>
#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<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_10MHZ> {
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<size_t>(width) * static_cast<size_t>(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<size_t>(this->outer_end - this->outer_start + 1) *
static_cast<size_t>(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
@@ -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"])
+77
View File
@@ -0,0 +1,77 @@
#pragma once
#include <cstdio>
#include <utility>
#include <vector>
#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<uint8_t> bytes;
std::vector<std::pair<bool, size_t>> 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
+6
View File
@@ -0,0 +1,6 @@
display:
- platform: st7305
model: WAVESHARE-400X300
id: st7305_display
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
@@ -0,0 +1,255 @@
#include <gtest/gtest.h>
#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<uint8_t>(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
@@ -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
@@ -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());
@@ -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());