[it8951] Add IT8951 e-paper controller support to epaper_spi (#15346)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Citric Li <37475446+limengdu@users.noreply.github.com>
Co-authored-by: koosoli <koosoli@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
alorente
2026-06-28 21:14:05 +10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] Copilot Citric Li koosoli Cursor Clyde Stubbs Claude Opus 4.8
parent d3892b8399
commit 8e23065b86
8 changed files with 2179 additions and 0 deletions
+1
View File
@@ -266,6 +266,7 @@ esphome/components/integration/* @OttoWinter
esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core
esphome/components/ir_rf_proxy/* @kbx81
esphome/components/it8951/* @koosoli @limengdu @Passific
esphome/components/jsn_sr04t/* @Mafus1
esphome/components/json/* @esphome/core
esphome/components/kamstrup_kmp/* @cfeenstra1024
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@Passific", "@koosoli", "@limengdu"]
+433
View File
@@ -0,0 +1,433 @@
"""
ESPHome configuration for the IT8951 e-paper controller.
"""
from esphome import automation, core, pins
import esphome.codegen as cg
from esphome.components import display, spi
from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation
import esphome.config_validation as cv
from esphome.config_validation import update_interval
from esphome.const import (
CONF_BUSY_PIN,
CONF_CS_PIN,
CONF_DATA_RATE,
CONF_DIMENSIONS,
CONF_ENABLE_PIN,
CONF_FULL_UPDATE_EVERY,
CONF_HEIGHT,
CONF_ID,
CONF_INVERT_COLORS,
CONF_LAMBDA,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODE,
CONF_MODEL,
CONF_PAGES,
CONF_RESET_DURATION,
CONF_RESET_PIN,
CONF_ROTATION,
CONF_SLEEP_WHEN_DONE,
CONF_SWAP_XY,
CONF_TRANSFORM,
CONF_UPDATE_INTERVAL,
CONF_WIDTH,
)
from esphome.cpp_generator import RawExpression
from esphome.final_validate import full_config
AUTO_LOAD = ["split_buffer"]
DEPENDENCIES = ["spi"]
CONF_VCOM = "vcom"
CONF_VCOM_REGISTER = "vcom_register"
CONF_FORCE_TEMPERATURE = "force_temperature"
CONF_GRAYSCALE = "grayscale"
CONF_DITHERING = "dithering"
CONF_UPDATE_MODE = "update_mode"
CONF_USE_LEGACY_DPY_AREA = "use_legacy_dpy_area"
# VCOM SET sub-command selectors. The IT8951 firmware accepts different
# values across panels; most respond to 0x0001, but a few — e.g. the Seeed
# reTerminal E1003 — only respond to 0x0002 and silently drop 0x0001.
VCOM_REGISTER_DEFAULT = 0x0001
VCOM_REGISTER_ALT = 0x0002
VCOM_REGISTER_OPTIONS = (VCOM_REGISTER_DEFAULT, VCOM_REGISTER_ALT)
it8951_ns = cg.esphome_ns.namespace("it8951")
IT8951Display = it8951_ns.class_("IT8951Display", display.Display, spi.SPIDevice)
IT8951UpdateAction = it8951_ns.class_("IT8951UpdateAction", automation.Action)
# Hardware waveform modes exposed to YAML. Strings are mapped to the C++
# UpdateMode enum so the runtime can store the mode as a uint16_t rather
# than a std::string (avoiding a heap-resident member; see ESPHome
# CLAUDE.md "STL Container Guidelines"). "fast" and "full" are
# convenience aliases for DU and GC16 respectively.
UpdateMode = it8951_ns.enum("UpdateMode")
UPDATE_MODE_OPTIONS = {
"INIT": UpdateMode.UPDATE_MODE_INIT,
"DU": UpdateMode.UPDATE_MODE_DU,
"GC16": UpdateMode.UPDATE_MODE_GC16,
"GL16": UpdateMode.UPDATE_MODE_GL16,
"GLR16": UpdateMode.UPDATE_MODE_GLR16,
"GLD16": UpdateMode.UPDATE_MODE_GLD16,
"DU4": UpdateMode.UPDATE_MODE_DU4,
"A2": UpdateMode.UPDATE_MODE_A2,
"FAST": UpdateMode.UPDATE_MODE_DU,
"FULL": UpdateMode.UPDATE_MODE_GC16,
}
# Maps the YAML mode string directly to the C++ UpdateMode enum value, so the
# config option and the it8951.update action share one validator.
update_mode = cv.enum(UPDATE_MODE_OPTIONS, upper=True)
# Transform flag values mirror the C++ TRANSFORM_* constants.
_TRANSFORM_NONE = 0
_TRANSFORM_MIRROR_X = 1
_TRANSFORM_MIRROR_Y = 2
_TRANSFORM_SWAP_XY = 4
_TRANSFORM_FLAGS = {
CONF_MIRROR_X: _TRANSFORM_MIRROR_X,
CONF_MIRROR_Y: _TRANSFORM_MIRROR_Y,
CONF_SWAP_XY: _TRANSFORM_SWAP_XY,
}
class IT8951Model:
"""A specific board / panel preset for the IT8951 controller."""
models: dict[str, "IT8951Model"] = {}
def __init__(self, name: str, **defaults):
name = name.upper()
self.name = name
self.defaults = defaults
IT8951Model.models[name] = self
def get_default(self, key, fallback=None):
return self.defaults.get(key, fallback)
def get_dimensions(self, config) -> tuple[int, int]:
# If dimensions are in config, use them; otherwise fall back to model defaults.
if CONF_DIMENSIONS in config:
dimensions = config[CONF_DIMENSIONS]
if isinstance(dimensions, dict):
return dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT]
return tuple(dimensions)
# Model must have defaults if dimensions not in config.
return self.get_default(CONF_WIDTH), self.get_default(CONF_HEIGHT)
# --- Model presets ----------------------------------------------------------
# The generic model leaves dimensions and pin choices up to the user.
IT8951Model("it8951", vcom=2300, sleep_when_done=True, data_rate=12_000_000)
IT8951Model(
"m5stack-m5paper",
width=960,
height=540,
busy_pin=27,
reset_pin=23,
cs_pin=15,
vcom=2300,
sleep_when_done=True,
data_rate=20_000_000,
)
IT8951Model(
"seeed-reterminal-e1003",
width=1872,
height=1404,
busy_pin=13,
reset_pin=12,
cs_pin=10,
# Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply
# (GPIO11). Driven high during setup so no separate power_supply is needed.
enable_pin=[21, 11],
vcom=1400,
# reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET
# selector; using the default 0x0001 leaves VCOM unchanged and breaks
# grayscale waveforms (GC16/GL16) — INIT still works because it does
# not depend on VCOM accuracy.
vcom_register=VCOM_REGISTER_ALT,
# The reTerminal E1003 ships with on-die temperature sensing disabled,
# so the host must declare an operating temperature; otherwise the
# waveform LUT defaults to a value that produces no visible change
# for grayscale modes.
force_temperature=25,
sleep_when_done=False,
data_rate=20_000_000,
mirror_x=True,
)
IT8951Model(
"seeed-ee03",
width=1872,
height=1404,
busy_pin=4,
reset_pin=38,
cs_pin=44,
vcom=1400,
sleep_when_done=False,
data_rate=4_000_000,
)
# ---------------------------------------------------------------------------
DIMENSION_SCHEMA = cv.Schema(
{
cv.Required(CONF_WIDTH): cv.int_,
cv.Required(CONF_HEIGHT): cv.int_,
}
)
def _model_pin_option(model, key, schema):
default = model.get_default(key)
if default is None:
return cv.Required(key), schema
return cv.Optional(key, default=default), schema
def _model_schema(config):
model = IT8951Model.models[config[CONF_MODEL]]
has_default_dimensions = (
model.get_default(CONF_WIDTH) is not None
and model.get_default(CONF_HEIGHT) is not None
)
dimensions_key = (
cv.Optional(
CONF_DIMENSIONS,
default={
CONF_WIDTH: model.get_default(CONF_WIDTH),
CONF_HEIGHT: model.get_default(CONF_HEIGHT),
},
)
if has_default_dimensions
else cv.Required(CONF_DIMENSIONS)
)
schema = display.FULL_DISPLAY_SCHEMA.extend(
spi.spi_device_schema(
cs_pin_required=False,
default_mode="MODE0",
default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000),
)
).extend(
{
cv.GenerateID(): cv.declare_id(IT8951Display),
cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True, space="-"),
cv.Optional(CONF_ROTATION, default=0): validate_rotation,
cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): update_interval,
cv.Optional(CONF_FULL_UPDATE_EVERY, default=30): cv.int_range(1, 255),
cv.Optional(CONF_TRANSFORM): cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
cv.Optional(CONF_SWAP_XY, default=False): cv.boolean,
}
),
cv.Optional(
CONF_INVERT_COLORS, default=model.get_default(CONF_INVERT_COLORS, False)
): cv.boolean,
cv.Optional(
CONF_SLEEP_WHEN_DONE,
default=model.get_default(CONF_SLEEP_WHEN_DONE, False),
): cv.boolean,
# Pixel format: true = 4bpp grayscale, false = packed 1bpp
# monochrome. Monochrome halves the framebuffer and enables fast DU
# partial refreshes; grayscale gives 16 levels but always uses GC16.
cv.Optional(
CONF_GRAYSCALE, default=model.get_default(CONF_GRAYSCALE, True)
): cv.boolean,
# Monochrome only: ordered-dither pale colours so they render as
# visible stipple. Disable for a crisp hard black/white threshold
# (better for purely black/white text). No effect in grayscale mode.
cv.Optional(
CONF_DITHERING, default=model.get_default(CONF_DITHERING, True)
): cv.boolean,
cv.Optional(
CONF_VCOM, default=model.get_default(CONF_VCOM, 2300)
): cv.int_range(0, 5000),
cv.Optional(
CONF_VCOM_REGISTER,
default=model.get_default(CONF_VCOM_REGISTER, VCOM_REGISTER_DEFAULT),
): cv.one_of(*VCOM_REGISTER_OPTIONS, int=True),
**(
{
cv.Optional(
CONF_FORCE_TEMPERATURE,
default=model.get_default(CONF_FORCE_TEMPERATURE),
): cv.int_range(min=-40, max=85)
}
if model.get_default(CONF_FORCE_TEMPERATURE) is not None
else {}
),
cv.Optional(
CONF_USE_LEGACY_DPY_AREA,
default=model.get_default(CONF_USE_LEGACY_DPY_AREA, False),
): cv.boolean,
cv.Optional(CONF_UPDATE_MODE): update_mode,
# One or more GPIOs driven high during setup to power on the panel
# (e.g. board power-enable rails), before reset and init.
cv.Optional(
CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, [])
): cv.ensure_list(pins.gpio_output_pin_schema),
cv.Optional(CONF_RESET_DURATION): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=core.TimePeriod(milliseconds=500)),
),
dimensions_key: DIMENSION_SCHEMA,
}
)
# Pin options: required if the model doesn't supply a default.
pin_specs = (
(CONF_BUSY_PIN, pins.gpio_input_pin_schema),
(CONF_RESET_PIN, pins.gpio_output_pin_schema),
(CONF_CS_PIN, pins.gpio_output_pin_schema),
)
pin_extra = {}
for key, schema_value in pin_specs:
opt, sv = _model_pin_option(model, key, schema_value)
pin_extra[opt] = sv
return schema.extend(pin_extra)
def _customise_schema(config):
config = cv.Schema(
{
cv.Required(CONF_MODEL): cv.one_of(
*IT8951Model.models, upper=True, space="-"
)
},
extra=cv.ALLOW_EXTRA,
)(config)
model_config = _model_schema(config)(config)
model = IT8951Model.models[config[CONF_MODEL].upper()]
width, height = model.get_dimensions(model_config)
display.add_metadata(
model_config[CONF_ID],
width,
height,
# Rotation is applied per-pixel in draw_pixel_at at no extra cost, so we
# advertise hardware rotation: LVGL routes its rotation to the driver via
# set_rotation rather than rotating the framebuffer in software.
has_hardware_rotation=True,
has_writer=any(
model_config.get(key)
for key in (CONF_LAMBDA, CONF_PAGES, CONF_SHOW_TEST_CARD)
),
# Report the configured rotation so LVGL can detect (and reject) a
# rotation set in the display config instead of the LVGL config.
rotation=model_config.get(CONF_ROTATION, 0),
# The IT8951 snaps partial display refreshes to a 32-pixel X boundary
# (see prepare_update_region_), so have LVGL round its redraw areas to
# 32px too — this keeps flush rectangles aligned with what the panel
# actually refreshes and avoids redundant re-rounding/over-draw.
draw_rounding=32,
)
return model_config
CONFIG_SCHEMA = _customise_schema
def _final_validate(config):
# IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required.
spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)(
config
)
global_config = full_config.get()
from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN
if CONF_LAMBDA not in config and CONF_PAGES not in config:
if LVGL_DOMAIN in global_config:
if CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("never")
else:
config[CONF_SHOW_TEST_CARD] = True
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
model = IT8951Model.models[config[CONF_MODEL]]
width, height = model.get_dimensions(config)
var = cg.new_Pvariable(config[CONF_ID], model.name, width, height)
await display.register_display(var, config)
await spi.register_spi_device(var, config, write_only=False)
if lambda_config := config.get(CONF_LAMBDA):
lambda_ = await cg.process_lambda(
lambda_config, [(display.DisplayRef, "it")], return_type=cg.void
)
cg.add(var.set_writer(lambda_))
if reset_pin := config.get(CONF_RESET_PIN):
cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin)))
if busy_pin := config.get(CONF_BUSY_PIN):
cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin)))
if enable_pins := config.get(CONF_ENABLE_PIN):
cg.add(
var.set_enable_pins(
[await cg.gpio_pin_expression(pin) for pin in enable_pins]
)
)
cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY]))
if (reset_duration := config.get(CONF_RESET_DURATION)) is not None:
cg.add(var.set_reset_duration(reset_duration))
if config.get(CONF_INVERT_COLORS):
cg.add(var.set_invert_colors(True))
if config.get(CONF_SLEEP_WHEN_DONE):
cg.add(var.set_sleep_when_done(True))
cg.add(var.set_vcom(config[CONF_VCOM]))
cg.add(var.set_vcom_register(config[CONF_VCOM_REGISTER]))
if CONF_FORCE_TEMPERATURE in config:
cg.add(var.set_force_temperature(config[CONF_FORCE_TEMPERATURE]))
if config.get(CONF_USE_LEGACY_DPY_AREA):
cg.add(var.set_use_legacy_dpy_area(True))
cg.add(var.set_grayscale(config[CONF_GRAYSCALE]))
cg.add(var.set_dithering(config[CONF_DITHERING]))
if (mode := config.get(CONF_UPDATE_MODE)) is not None:
cg.add(var.set_update_mode(mode))
transform = config.get(
CONF_TRANSFORM,
{
CONF_MIRROR_X: model.get_default(CONF_MIRROR_X),
CONF_MIRROR_Y: model.get_default(CONF_MIRROR_Y),
},
)
transform_value = sum(
flag for key, flag in _TRANSFORM_FLAGS.items() if transform.get(key)
)
if transform_value:
cg.add(var.set_transform(RawExpression(str(transform_value))))
@automation.register_action(
"it8951.update",
IT8951UpdateAction,
automation.maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(IT8951Display),
cv.Optional(CONF_MODE): cv.templatable(update_mode),
}
),
synchronous=True,
)
async def it8951_update_action_to_code(config, action_id, template_arg, args):
display_var = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, display_var)
if mode := config.get(CONF_MODE):
mode = await cg.templatable(mode, args, UpdateMode)
cg.add(var.set_mode(mode))
return var
File diff suppressed because it is too large Load Diff
+373
View File
@@ -0,0 +1,373 @@
#pragma once
#include <cstddef>
#include <utility>
#include <vector>
#include "esphome/components/display/display.h"
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "it8951_defs.h"
namespace esphome::it8951 {
using namespace display;
// --- Bounded op queue --------------------------------------------------------
// Fixed-capacity ring buffer used by the loop scheduler. Replaces std::deque
// to comply with ESPHome's STL container guidelines (std::deque allocates in
// 512-byte blocks regardless of element size). Size analysis: the deepest
// observed scenario is UPDATE_REFRESH (10 enqueued ops) + CHECK_LUT_IDLE's
// 5 push_front rescheduling = 14 simultaneous entries. We use 32 for a
// comfortable margin while keeping RAM cost low (~192 bytes per instance vs
// 512+ bytes for std::deque).
template<typename T, size_t N> class StaticOpQueue {
public:
bool empty() const { return this->count_ == 0; }
size_t size() const { return this->count_; }
static constexpr size_t capacity() { return N; }
bool push_back(const T &value) {
if (this->count_ >= N)
return false;
this->data_[(this->head_ + this->count_) % N] = value;
++this->count_;
return true;
}
bool push_front(const T &value) {
if (this->count_ >= N)
return false;
this->head_ = (this->head_ + N - 1) % N;
this->data_[this->head_] = value;
++this->count_;
return true;
}
void pop_front() {
if (this->count_ == 0)
return;
this->head_ = (this->head_ + 1) % N;
--this->count_;
}
const T &front() const { return this->data_[this->head_]; }
T &front() { return this->data_[this->head_]; }
void clear() {
this->head_ = 0;
this->count_ = 0;
}
private:
T data_[N]{};
size_t head_{0};
size_t count_{0};
};
// Op queue capacity. See StaticOpQueue comment for sizing analysis.
static constexpr size_t OP_QUEUE_SIZE = 32;
// --- Op queue ---------------------------------------------------------------
// Each Op is a single CS-asserted SPI transaction (or a tiny bookkeeping
// step). The loop processes one Op per iteration after gating on HW_RDY, so
// the natural ESPHome loop cadence (~8-16 ms) provides inter-op pacing
// without any blocking waits.
//
// Compound Ops (READ_DEV_INFO, XFER_*, DPY_BUF_AREA, ENABLE_1BPP, ...) are
// short self-contained methods that do all their SPI work inside a single
// CS cycle (or a small handful of cycles) and complete well under 2ms, so
// they don't break the no-blocking budget.
//
// Each write-type op is a SINGLE CS-asserted transaction. The loop-level
// HW_RDY gate ensures the controller is ready before dispatching any op, so
// no blocking waits are needed within write ops.
//
// Read ops are decomposed: the command/address that triggers data preparation
// is sent as write ops (CMD, WRITE_W), then a separate read op runs only
// after the loop confirms HW_RDY is back HIGH (data ready). No blocking.
enum class OpType : uint8_t {
CMD, // single CS: CMD preamble + command word (a)
WRITE_W, // single CS: WRITE preamble + data word (a)
WRITE_REG, // single CS: WRITE preamble + addr(a) + value(b)
// (caller must enqueue CMD(TCON_REG_WR) before this)
READ_DEV_INFO, // single CS: READ preamble + dummy + read DevInfo struct
// (caller enqueues CMD(GET_DEV_INFO) first; loop HW_RDY gate
// ensures data is ready before this op runs)
READ_WORD, // single CS: READ preamble + dummy + read one 16-bit word
// into read_result_. Loop HW_RDY gate ensures data ready.
CHECK_LUT_IDLE, // checks read_result_; if non-zero, re-enqueues read sequence
SET_1BPP, // uses read_result_ to set UP1SR bit 2, enqueues writes
XFER_LISAR, // set image-buffer target address (2× reg write: 4 CS transactions)
XFER_AREA_CMD, // single CS: CMD preamble + TCON_LD_IMG_AREA
XFER_AREA_ARGS, // single CS: WRITE preamble + 5 area-parameter words
XFER_ROWS, // single CS: WRITE preamble + row pixel data (time-sliced)
XFER_AREA_END, // single CS: CMD preamble + TCON_LD_IMG_END
DPY_BUF_CMD, // single CS: CMD preamble + I80_CMD_DPY_BUF_AREA
DPY_BUF_ARGS, // single CS: WRITE preamble + 7 display-area words
GPIO_RESET_LOW, // drive RESET pin low
GPIO_RESET_HIGH, // drive RESET pin high
DELAY_MS, // park `delay_until_` for a few ms (no SPI)
};
struct Op {
OpType type;
uint16_t a{0};
uint16_t b{0};
};
// High-level controller phases. Each phase enqueues a sequence of Ops; when
// the queue drains, advance_phase_() runs the next phase.
// This separation keeps per-Op work tiny and predictable.
enum class Phase : uint8_t {
IDLE,
// Initialisation
INIT_RESET, // reset pulse + wake controller + packed-write enable
INIT_DEV_INFO, // GET_DEV_INFO and validate
INIT_VCOM, // write configured VCOM
INIT_TEMP, // force temperature for waveform LUT selection
INIT_DONE, // allocate framebuffer; transition to IDLE
// Update flow
UPDATE_PREPARE, // do_update_, compute dirty region, decide 4bpp/1bpp
UPDATE_TRANSFER, // one LD_IMG_AREA, time-sliced row streaming, one LD_IMG_END
UPDATE_REFRESH, // wait LUT idle, optionally enable 1bpp, send DPY_BUF_AREA
UPDATE_SLEEP, // optional deep sleep
};
class IT8951Display : public Display,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_2MHZ> {
public:
IT8951Display(const char *name, uint16_t width, uint16_t height) : name_(name), width_(width), height_(height) {
this->row_width_ = this->compute_row_width_();
this->buffer_length_ = static_cast<size_t>(this->row_width_) * static_cast<size_t>(height);
}
// --- Component lifecycle ---
void setup() override;
void loop() override;
void dump_config() override;
void on_safe_shutdown() override;
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
// --- Config setters (called from generated code) ---
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
void set_busy_pin(GPIOPin *pin) { this->busy_pin_ = pin; }
void set_enable_pins(std::vector<GPIOPin *> pins) { this->enable_pins_ = std::move(pins); }
void set_reset_duration(uint32_t ms) { this->reset_duration_ = ms; }
void set_full_update_every(uint8_t n) {
this->full_update_every_ = n;
// Seed the counter so the very first update trips the full-update branch in
// prepare_update_region_, giving a freshly-booted panel a clean GC16 refresh
// before any partial (fast-waveform) updates begin.
this->partial_update_count_ = n;
}
void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; }
void set_sleep_when_done(bool s) { this->sleep_when_done_ = s; }
void set_vcom(uint16_t vcom_mv) { this->vcom_ = vcom_mv; }
void set_vcom_register(uint16_t selector) { this->vcom_register_ = selector; }
void set_force_temperature(int16_t celsius) {
this->force_temperature_ = celsius;
this->force_temperature_set_ = true;
}
void set_use_legacy_dpy_area(bool use) { this->use_legacy_dpy_area_ = use; }
// Pixel format: true = 4bpp grayscale framebuffer, false = packed 1bpp
// monochrome framebuffer. Chosen at config time; the framebuffer is stored
// in this native format and every update uses the matching transfer path.
void set_grayscale(bool g) { this->grayscale_ = g; }
// Monochrome only: ordered-dither pale colours (true) vs a hard 50% threshold.
void set_dithering(bool d) { this->dithering_ = d; }
void set_update_mode(uint16_t m) { this->default_update_mode_ = static_cast<UpdateMode>(m); }
void set_transform(uint8_t t) {
this->transform_ = t;
this->update_effective_transform_();
}
void set_rotation(DisplayRotation rotation) override {
Display::set_rotation(rotation);
this->update_effective_transform_();
}
// --- Display API ---
void update() override;
void update_mode(UpdateMode mode);
DisplayType get_display_type() override { return this->grayscale_ ? DISPLAY_TYPE_GRAYSCALE : DISPLAY_TYPE_BINARY; }
void fill(Color color) override;
void clear() override { this->fill(Color::WHITE); }
void draw_pixel_at(int x, int y, Color color) override;
// Bulk pixel blit (used by LVGL and image rendering). Overridden to write
// straight into the framebuffer, avoiding the base class's per-pixel
// draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps).
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; }
int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; }
protected:
int get_height_internal() override { return this->height_; }
int get_width_internal() override { return this->width_; }
// --- Coord transform / dirty region ---
void update_effective_transform_();
// Map display (logical) coordinates to native framebuffer coordinates by
// applying effective_transform_ (swap/mirror). Shared by rotate_coordinates_
// and the bulk draw_pixels_at path.
void apply_transform_(int &x, int &y) const;
bool rotate_coordinates_(int &x, int &y);
void reset_dirty_region_();
// --- Framebuffer geometry / monochrome packing ---
// Bytes per row for the configured pixel format: 4bpp grayscale packs two
// pixels per byte; monochrome packs eight bits per byte, rounded up to a
// whole 16-pixel group (matching the controller's 8bpp-load / 1bpp trick).
uint16_t compute_row_width_() const {
return this->grayscale_ ? static_cast<uint16_t>((static_cast<uint32_t>(this->width_) + 1) / 2)
: static_cast<uint16_t>(((static_cast<uint32_t>(this->width_) + 15) / 16) * 2);
}
void set_mono_pixel_(uint16_t x, uint16_t y, bool value) const;
// Write a 4bpp grayscale nibble into the framebuffer (two pixels per byte).
void set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const;
// Convert a color and write it at native framebuffer coordinates: a 4bpp
// nibble in grayscale mode, or an ordered-dithered bit in monochrome mode.
void write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const;
// --- Op queue / loop machinery ---
void enqueue_(OpType type, uint16_t a = 0, uint16_t b = 0);
void prepend_(OpType type, uint16_t a = 0, uint16_t b = 0);
bool is_busy_() const;
void process_op_(const Op &op);
void advance_phase_();
void set_phase_(Phase next);
void start_update_(UpdateMode mode);
// --- SPI primitives (each is one CS-asserted burst, fully non-blocking) ---
void spi_cmd_(uint16_t cmd);
void spi_write_word_(uint16_t value);
void spi_write_reg_(uint16_t addr, uint16_t value);
void spi_write_args_(const uint16_t *args, uint16_t count);
uint16_t spi_read_word_(); // non-blocking: HW_RDY confirmed by loop gate
void spi_read_dev_info_(); // non-blocking: HW_RDY confirmed by loop gate
// --- Compound Ops (small bounded helpers) ---
void op_xfer_lisar_();
void op_xfer_area_args_();
void op_xfer_area_end_();
bool op_xfer_rows_(); // returns true when current update area fully sent
void op_dpy_buf_args_();
void op_check_lut_idle_();
void op_set_1bpp_();
// --- Phase enqueuers ---
void enqueue_init_reset_();
void enqueue_init_dev_info_();
void enqueue_init_vcom_();
void enqueue_init_temp_();
void enqueue_update_transfer_();
void enqueue_update_refresh_();
void enqueue_update_sleep_();
bool prepare_update_region_(UpdateMode &mode);
// --- Recovery ---
void recover_();
// --- State ---
static constexpr uint32_t BUSY_TIMEOUT_MS = 5000;
StaticOpQueue<Op, OP_QUEUE_SIZE> queue_;
Phase phase_{Phase::IDLE};
uint32_t delay_until_{0};
uint32_t phase_started_at_{0};
// Requests a continuous (non-throttled) main loop while streaming image data
// so 20ms transfer slices aren't separated by the ~16ms default loop interval.
HighFrequencyLoopRequester high_freq_;
// Pending update bookkeeping
bool update_pending_{false};
UpdateMode pending_update_mode_{UPDATE_MODE_NONE};
UpdateMode active_mode_{UPDATE_MODE_NONE};
uint16_t area_x_{0}, area_y_{0}, area_w_{0}, area_h_{0};
uint16_t transfer_row_{0};
bool initialised_{false};
// True once TCON_SLEEP has been sent and the controller has not been woken
// since. The next update must issue TCON_SYS_RUN before any SPI op.
bool asleep_{false};
uint32_t partial_update_count_{0};
uint32_t update_started_at_{0};
// Read result storage for decomposed read-modify-write op sequences
uint16_t read_result_{0};
// Device info
DevInfo dev_info_{};
uint16_t img_buf_addr_l_{0};
uint16_t img_buf_addr_h_{0};
// Configured properties
const char *name_;
uint16_t width_;
uint16_t height_;
uint16_t row_width_;
size_t buffer_length_{};
uint8_t *buffer_{};
uint8_t transform_{0};
uint8_t effective_transform_{0};
uint8_t full_update_every_{1};
uint32_t reset_duration_{10};
uint16_t vcom_{2300};
uint16_t vcom_register_{I80_CMD_VCOM_WRITE};
int16_t force_temperature_{DEFAULT_FORCE_TEMP_C};
bool force_temperature_set_{false};
bool use_legacy_dpy_area_{false};
bool invert_colors_{false};
bool sleep_when_done_{false};
// Pixel format selector (see set_grayscale): true = 4bpp grayscale,
// false = packed 1bpp monochrome.
bool grayscale_{true};
// Monochrome dithering (see set_dithering): true = ordered dither.
bool dithering_{true};
UpdateMode default_update_mode_{UPDATE_MODE_NONE};
GPIOPin *reset_pin_{nullptr};
GPIOPin *busy_pin_{nullptr};
// GPIOs driven high during setup to power on the panel (empty if unused).
std::vector<GPIOPin *> enable_pins_;
// Dirty region (pixel coordinates of bounding box of changes since last update)
uint16_t x_low_{0}, y_low_{0}, x_high_{0}, y_high_{0};
// Saved data rate so we can probe slow then run fast
uint32_t configured_data_rate_{0};
// Consecutive recovery attempts; used to give up rather than infinite-loop
// when the controller is unresponsive (e.g. wiring issue).
uint8_t recovery_attempts_{0};
// DevInfo read retry counter (controller often returns garbage on the first
// read after reset; the original driver retried up to 3 times with 100ms
// between attempts).
uint8_t dev_info_attempts_{0};
};
// --- Automation action ---
template<typename... Ts> class IT8951UpdateAction : public Action<Ts...> {
public:
explicit IT8951UpdateAction(IT8951Display *display) : display_(display) {}
TEMPLATABLE_VALUE(UpdateMode, mode)
protected:
void play(const Ts &...x) override {
if (!this->display_->is_ready())
return;
if (this->mode_.has_value()) {
this->display_->update_mode(this->mode_.value(x...));
} else {
this->display_->update();
}
}
IT8951Display *display_;
};
} // namespace esphome::it8951
+168
View File
@@ -0,0 +1,168 @@
#pragma once
#include <cstdint>
namespace esphome::it8951 {
struct DevInfo {
uint16_t panel_width{0};
uint16_t panel_height{0};
uint16_t img_buf_addr_l{0};
uint16_t img_buf_addr_h{0};
uint16_t fw_version[8]{};
uint16_t lut_version[8]{};
};
// --- IT8951 SPI packet preambles ---
static constexpr uint16_t PACKET_TYPE_CMD = 0x6000;
static constexpr uint16_t PACKET_TYPE_WRITE = 0x0000;
static constexpr uint16_t PACKET_TYPE_READ = 0x1000;
// --- Built-in I80 commands ---
static constexpr uint16_t TCON_SYS_RUN = 0x0001;
static constexpr uint16_t TCON_STANDBY = 0x0002;
static constexpr uint16_t TCON_SLEEP = 0x0003;
static constexpr uint16_t TCON_REG_RD = 0x0010;
static constexpr uint16_t TCON_REG_WR = 0x0011;
static constexpr uint16_t TCON_LD_IMG = 0x0020;
static constexpr uint16_t TCON_LD_IMG_AREA = 0x0021;
static constexpr uint16_t TCON_LD_IMG_END = 0x0022;
// --- I80 user-defined commands ---
static constexpr uint16_t I80_CMD_DPY_AREA = 0x0034;
static constexpr uint16_t I80_CMD_GET_DEV_INFO = 0x0302;
static constexpr uint16_t I80_CMD_DPY_BUF_AREA = 0x0037;
static constexpr uint16_t I80_CMD_VCOM = 0x0039;
static constexpr uint16_t I80_CMD_VCOM_READ = 0x0000;
// VCOM write selectors. Different IT8951-driven panels accept different
// selector values for the VCOM SET sub-command. Most panels (m5stack-m5paper,
// generic dev kits) accept 0x0001. Some panels — notably the Seeed
// reTerminal E1003 — only respond to selector 0x0002 and silently ignore
// 0x0001, leaving VCOM at its default and making grayscale waveforms
// (GC16/GL16) ineffective even though INIT still works.
static constexpr uint16_t I80_CMD_VCOM_WRITE = 0x0001;
static constexpr uint16_t I80_CMD_VCOM_WRITE_ALT = 0x0002;
// Force temperature command. The IT8951 selects waveform LUTs based on
// panel temperature; if it is left at the controller default, panels with
// auto-temperature disabled (notably the Seeed reTerminal E1003) will
// run waveforms against a mismatched LUT, leaving pixels visually
// unchanged even though the LUT engine completes a full cycle. The
// selector word selects the operation (0x0001 = write); the value word
// is the temperature in degrees Celsius.
static constexpr uint16_t I80_CMD_FORCE_TEMP = 0x0040;
static constexpr uint16_t I80_CMD_FORCE_TEMP_WRITE = 0x0001;
static constexpr int16_t DEFAULT_FORCE_TEMP_C = 25;
// --- Pixel mode (bits per pixel encoding) ---
static constexpr uint8_t PIXEL_2BPP = 0;
static constexpr uint8_t PIXEL_3BPP = 1;
static constexpr uint8_t PIXEL_4BPP = 2;
static constexpr uint8_t PIXEL_8BPP = 3;
// --- Endian flags for LD_IMG_AREA ---
static constexpr uint8_t LDIMG_L_ENDIAN = 0;
static constexpr uint8_t LDIMG_B_ENDIAN = 1;
// --- SPI probe frequency used for initial controller handshake ---
static constexpr uint32_t SPI_PROBE_FREQUENCY = 1'000'000;
// --- Refresh modes ---
/*
INIT The initialization (INIT) mode is
used to completely erase the display and leave it in the white state. It is
useful for situations where the display information in memory is not a faithful
representation of the optical state of the display, for example, after the
device receives power after it has been fully powered down. This waveform
switches the display several times and leaves it in the white state.
DU
The direct update (DU) is a very fast, non-flashy update. This mode supports
transitions from any graytone to black or white only. It cannot be used to
update to any graytone other than black or white. The fast update time for this
mode makes it useful for response to touch sensor or pen input or menu selection
indictors.
GC16
The grayscale clearing (GC16) mode is used to update the full display and
provide a high image quality. When GC16 is used with Full Display Update the
entire display will update as the new image is written. If a Partial Update
command is used the only pixels with changing graytone values will update. The
GC16 mode has 16 unique gray levels.
GL16
The GL16 waveform is primarily used to update sparse content on a white
background, such as a page of anti-aliased text, with reduced flash. The
GL16 waveform has 16 unique gray levels.
GLR16
The GLR16 mode is used in conjunction with an image preprocessing algorithm to
update sparse content on a white background with reduced flash and reduced image
artifacts. The GLR16 mode supports 16 graytones. If only the even pixel states
are used (0, 2, 4, … 30), the mode will behave exactly as a traditional GL16
waveform mode. If a separately-supplied image preprocessing algorithm is used,
the transitions invoked by the pixel states 29 and 31 are used to improve
display quality. For the AF waveform, it is assured that the GLR16 waveform data
will point to the same voltage lists as the GL16 data and does not need to be
stored in a separate memory.
GLD16
The GLD16 mode is used in conjunction with an image preprocessing algorithm to
update sparse content on a white background with reduced flash and reduced image
artifacts. It is recommended to be used only with the full display update. The
GLD16 mode supports 16 graytones. If only the even pixel states are used (0, 2,
4, … 30), the mode will behave exactly as a traditional GL16 waveform mode. If a
separately-supplied image preprocessing algorithm is used, the transitions
invoked by the pixel states 29 and 31 are used to refresh the background with a
lighter flash compared to GC16 mode following a predetermined pixel map as
encoded in the waveform file, and reduce image artifacts even more compared to
the GLR16 mode. For the AF waveform, it is assured that the GLD16 waveform data
will point to the same voltage lists as the GL16 data and does not need to be
stored in a separate memory.
DU4
The DU4 is a fast update time (similar to DU), non-flashy waveform. This mode
supports transitions from any gray tone to gray tones 1,6,11,16 represented by
pixel states [0 10 20 30]. The combination of fast update time and four gray
tones make it useful for anti-aliased text in menus. There is a moderate
increase in ghosting compared with GC16.
A2
The A2 mode is a fast, non-flash update mode designed for fast paging turning or
simple black/white animation. This mode supports transitions from and to black
or white only. It cannot be used to update to any graytone other than black or
white. The recommended update sequence to transition into repeated A2 updates is
shown in Figure 1. The use of a white image in the transition from 4-bit to
1-bit images will reduce ghosting and improve image quality for A2 updates.
*/
enum UpdateMode : uint16_t {
UPDATE_MODE_INIT = 0,
UPDATE_MODE_DU = 1,
UPDATE_MODE_GC16 = 2,
UPDATE_MODE_GL16 = 3,
UPDATE_MODE_GLR16 = 4,
UPDATE_MODE_GLD16 = 5,
UPDATE_MODE_DU4 = 6,
UPDATE_MODE_A2 = 7,
UPDATE_MODE_NONE = 8,
};
// --- Registers ---
static constexpr uint16_t DISPLAY_REG_BASE = 0x1000;
static constexpr uint16_t UP1SR = DISPLAY_REG_BASE + 0x138;
static constexpr uint16_t LUTAFSR = DISPLAY_REG_BASE + 0x224;
static constexpr uint16_t BGVR = DISPLAY_REG_BASE + 0x250;
static constexpr uint16_t I80CPCR = 0x0004;
static constexpr uint16_t MCSR_BASE_ADDR = 0x0200;
static constexpr uint16_t LISAR = MCSR_BASE_ADDR + 0x0008;
// Display orientation flags
static constexpr uint8_t TRANSFORM_NONE = 0;
static constexpr uint8_t TRANSFORM_MIRROR_X = 1;
static constexpr uint8_t TRANSFORM_MIRROR_Y = 2;
static constexpr uint8_t TRANSFORM_SWAP_XY = 4;
} // namespace esphome::it8951
@@ -0,0 +1,109 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
display:
# Generic IT8951 with explicit dimensions
- platform: it8951
spi_id: spi_bus
model: it8951
dimensions:
width: 1872
height: 1404
cs_pin:
allow_other_uses: true
number: GPIO5
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
enable_pin:
- GPIO17
- GPIO18
vcom: 1500
update_interval: 60s
# Exercise an alias for the update_mode config option.
update_mode: fast
lambda: |-
it.circle(64, 64, 50, Color::BLACK);
# m5stack-m5paper (960x540) — model supplies pin defaults
- platform: it8951
id: m5epd_display
spi_id: spi_bus
model: m5stack-m5paper
cs_pin:
allow_other_uses: true
number: GPIO5
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
full_update_every: 30
invert_colors: false
sleep_when_done: true
grayscale: true
update_mode: GC16
rotation: 270
transform:
mirror_x: false
mirror_y: false
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK);
# seeed-reterminal-e1003 (1872x1404)
- platform: it8951
spi_id: spi_bus
model: seeed-reterminal-e1003
cs_pin:
allow_other_uses: true
number: GPIO5
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
vcom: 1400
sleep_when_done: false
lambda: |-
it.filled_rectangle(0, 0, 128, 128, Color::BLACK);
# seeed-ee03 (1872x1404), monochrome fast path
- platform: it8951
spi_id: spi_bus
model: seeed-ee03
cs_pin:
allow_other_uses: true
number: GPIO5
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
grayscale: false
dithering: false
update_mode: DU
lambda: |-
it.circle(128, 128, 64, Color::BLACK);
# Exercise the it8951.update automation: alias modes, a direct enum-name mode,
# and the bare (default-mode) form.
interval:
- interval: 30s
then:
- it8951.update:
id: m5epd_display
mode: fast
- it8951.update:
id: m5epd_display
mode: full
- it8951.update:
id: m5epd_display
mode: A2
- it8951.update: m5epd_display
+3
View File
@@ -18,6 +18,9 @@ class MockUARTComponent : public uart::UARTComponent {
MOCK_METHOD(size_t, available, (), (override));
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif // USE_ESP8266 || USE_ESP32
};
// Expose protected members for testing.