From 8e23065b86798ad3a96216c05d9da76f4a1ac7ba Mon Sep 17 00:00:00 2001 From: alorente Date: Sun, 28 Jun 2026 13:14:05 +0200 Subject: [PATCH] [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 Co-authored-by: Citric Li <37475446+limengdu@users.noreply.github.com> Co-authored-by: koosoli Co-authored-by: Cursor Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- CODEOWNERS | 1 + esphome/components/it8951/__init__.py | 1 + esphome/components/it8951/display.py | 433 +++++++ esphome/components/it8951/it8951.cpp | 1091 +++++++++++++++++ esphome/components/it8951/it8951.h | 373 ++++++ esphome/components/it8951/it8951_defs.h | 168 +++ .../components/it8951/test.esp32-s3-idf.yaml | 109 ++ tests/components/ld2450/common.h | 3 + 8 files changed, 2179 insertions(+) create mode 100644 esphome/components/it8951/__init__.py create mode 100644 esphome/components/it8951/display.py create mode 100644 esphome/components/it8951/it8951.cpp create mode 100644 esphome/components/it8951/it8951.h create mode 100644 esphome/components/it8951/it8951_defs.h create mode 100644 tests/components/it8951/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 70ad580e778..21121ff4762 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/it8951/__init__.py b/esphome/components/it8951/__init__.py new file mode 100644 index 00000000000..7fc4ae2cd0e --- /dev/null +++ b/esphome/components/it8951/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@Passific", "@koosoli", "@limengdu"] diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py new file mode 100644 index 00000000000..51c5fc61181 --- /dev/null +++ b/esphome/components/it8951/display.py @@ -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 diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp new file mode 100644 index 00000000000..cc2bddeda7b --- /dev/null +++ b/esphome/components/it8951/it8951.cpp @@ -0,0 +1,1091 @@ +#include "it8951.h" + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::it8951 { + +static const char *const TAG = "it8951"; + +// Soft cap for time spent in a single XFER_ROWS Op so we yield back to the +// loop within one tick budget. +static constexpr uint32_t MAX_TRANSFER_TIME_MS = 20; + +// --- Loop / scheduling ------------------------------------------------------- + +void IT8951Display::enqueue_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_back(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +void IT8951Display::prepend_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_front(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +bool IT8951Display::is_busy_() const { + // IT8951 Hardware Ready (HW_RDY): HIGH = ready, LOW = busy. + return !this->busy_pin_->digital_read(); +} + +void IT8951Display::loop() { + const uint32_t now = millis(); + if (static_cast(now - this->delay_until_) < 0) + return; + + // Nothing queued — either the current phase has more work to enqueue, or + // we're done. + if (this->queue_.empty()) { + if (this->phase_ == Phase::IDLE) { + this->disable_loop(); + return; + } + this->advance_phase_(); + if (this->queue_.empty()) + return; + } + + // Gate SPI ops on HW_RDY. GPIO/DELAY ops run unconditionally — they're how + // we get the controller out of a stuck-busy state in the first place + // (e.g. during reset, HW_RDY is undefined/low until ROM boot completes). + Op queued_op = this->queue_.front(); + const bool needs_hardware_ready = queued_op.type != OpType::GPIO_RESET_LOW && + queued_op.type != OpType::GPIO_RESET_HIGH && queued_op.type != OpType::DELAY_MS; + if (needs_hardware_ready && this->is_busy_()) { + // Signed elapsed: any pending DELAY_MS or scheduled work in the near + // future shows up as <= 0 elapsed and won't trigger a false timeout. + const int32_t elapsed = static_cast(now - this->phase_started_at_); + ESP_LOGV(TAG, "HW_RDY is LOW (busy) in phase %u, elapsed=%" PRId32 "ms", static_cast(this->phase_), + elapsed); + if (elapsed > static_cast(BUSY_TIMEOUT_MS)) { + ESP_LOGW(TAG, "Busy timeout (%" PRIu32 "ms) in phase %u, recovering", elapsed, + static_cast(this->phase_)); + this->recover_(); + } + return; + } + + this->queue_.pop_front(); + this->process_op_(queued_op); +} + +void IT8951Display::process_op_(const Op &op) { + ESP_LOGV(TAG, "Processing op type=%u a=0x%04X b=0x%04X", static_cast(op.type), op.a, op.b); + switch (op.type) { + case OpType::CMD: + this->spi_cmd_(op.a); + break; + case OpType::WRITE_W: + this->spi_write_word_(op.a); + break; + case OpType::WRITE_REG: + this->spi_write_reg_(op.a, op.b); + break; + case OpType::READ_DEV_INFO: + this->spi_read_dev_info_(); + break; + case OpType::READ_WORD: + this->read_result_ = this->spi_read_word_(); + break; + case OpType::CHECK_LUT_IDLE: + this->op_check_lut_idle_(); + break; + case OpType::SET_1BPP: + this->op_set_1bpp_(); + break; + case OpType::XFER_LISAR: + this->op_xfer_lisar_(); + break; + case OpType::XFER_AREA_CMD: + this->spi_cmd_(TCON_LD_IMG_AREA); + break; + case OpType::XFER_AREA_ARGS: + this->op_xfer_area_args_(); + break; + case OpType::XFER_ROWS: + // Stream rows into the single open LD_IMG_AREA load. The load stays open + // across loop iterations (CS toggles between bursts, matching the + // reference driver), so a partial slice just re-queues another XFER_ROWS + // pass to resume; only when all rows are sent do we close it with one + // LD_IMG_END. This avoids an LD_IMG_END / LD_IMG_AREA round-trip per slice. + if (this->op_xfer_rows_()) { + this->enqueue_(OpType::XFER_AREA_END); + } else { + this->enqueue_(OpType::XFER_ROWS); + } + break; + case OpType::XFER_AREA_END: + this->op_xfer_area_end_(); + break; + case OpType::DPY_BUF_CMD: + // Some panel firmwares (notably Seeed reTerminal E1003) silently drop + // I80_CMD_DPY_BUF_AREA (0x0037) — the LUT engine never starts and the + // host eventually times out after ~12s. Fall back to the basic + // I80_CMD_DPY_AREA (0x0034) for those panels; the buffer address is + // already programmed via LISAR during the transfer phase. + this->spi_cmd_(this->use_legacy_dpy_area_ ? I80_CMD_DPY_AREA : I80_CMD_DPY_BUF_AREA); + break; + case OpType::DPY_BUF_ARGS: + this->op_dpy_buf_args_(); + break; + case OpType::GPIO_RESET_LOW: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(false); + break; + case OpType::GPIO_RESET_HIGH: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(true); + break; + case OpType::DELAY_MS: + this->delay_until_ = millis() + op.a; + break; + } +} + +void IT8951Display::set_phase_(Phase next) { + ESP_LOGV(TAG, "Phase %u -> %u", static_cast(this->phase_), static_cast(next)); + // Run the loop continuously for the whole active sequence, returning to normal + // throttling only at IDLE. Each queued op is processed one per loop iteration, + // so at the default ~16ms loop interval the dozens of small ops in the refresh + // and restore phases (register polls, 1bpp enable/restore, DPY) would dominate + // a partial update's latency. The LUT-idle polls are DELAY_MS-paced, so this + // doesn't hammer SPI — it only spends a little extra CPU during the (short, + // infrequent) update instead of sleeping between ops. start()/stop() are + // idempotent, so driving them off the transition is safe. + if (next == Phase::IDLE) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + } + this->phase_ = next; + this->phase_started_at_ = millis(); +} + +void IT8951Display::advance_phase_() { + switch (this->phase_) { + case Phase::IDLE: + if (this->initialised_ && this->update_pending_) { + this->update_pending_ = false; + this->active_mode_ = this->pending_update_mode_; + this->update_started_at_ = millis(); + this->set_phase_(Phase::UPDATE_PREPARE); + this->advance_phase_(); + } else { + this->disable_loop(); + } + break; + + case Phase::INIT_RESET: + this->set_phase_(Phase::INIT_DEV_INFO); + this->enqueue_init_dev_info_(); + break; + + case Phase::INIT_DEV_INFO: + if (this->dev_info_.panel_width == 0 || this->dev_info_.panel_width > 2048 || this->dev_info_.panel_height == 0 || + this->dev_info_.panel_height > 2048 || this->dev_info_.panel_width == 0xFFFF || + this->dev_info_.panel_height == 0xFFFF) { + if (++this->dev_info_attempts_ < 5) { + ESP_LOGW(TAG, "DevInfo attempt %u returned invalid data (W=%u H=%u), retrying...", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + // Give the controller more time, then re-read. + this->enqueue_(OpType::DELAY_MS, 100); + this->enqueue_init_dev_info_(); + return; + } + ESP_LOGE(TAG, "DevInfo invalid after %u attempts (W=%u H=%u)", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("Failed to read IT8951 device info")); + this->set_phase_(Phase::IDLE); + return; + } + + if (this->dev_info_.panel_width != this->width_ || this->dev_info_.panel_height != this->height_) { + ESP_LOGE(TAG, "Panel dimension mismatch: configured=%ux%u, DevInfo=%ux%u. Check model/dimensions settings.", + this->width_, this->height_, this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("IT8951 panel dimensions do not match DevInfo")); + this->set_phase_(Phase::IDLE); + return; + } + + this->dev_info_attempts_ = 0; + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + this->img_buf_addr_l_ = this->dev_info_.img_buf_addr_l; + this->img_buf_addr_h_ = this->dev_info_.img_buf_addr_h; + ESP_LOGI(TAG, "DevInfo: %ux%u, ImgBuf 0x%04X%04X", this->width_, this->height_, this->img_buf_addr_h_, + this->img_buf_addr_l_); + this->set_phase_(Phase::INIT_VCOM); + this->enqueue_init_vcom_(); + break; + + case Phase::INIT_VCOM: + this->set_phase_(Phase::INIT_TEMP); + if (this->force_temperature_set_) { + this->enqueue_init_temp_(); + } else { + this->advance_phase_(); + } + break; + + case Phase::INIT_TEMP: + this->set_phase_(Phase::INIT_DONE); + this->advance_phase_(); + break; + + case Phase::INIT_DONE: + if (this->configured_data_rate_ != 0 && this->configured_data_rate_ != this->data_rate_) { + this->spi_teardown(); + this->set_data_rate(this->configured_data_rate_); + this->spi_setup(); + } + this->initialised_ = true; + this->recovery_attempts_ = 0; + ESP_LOGCONFIG(TAG, "IT8951 setup complete"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + + case Phase::UPDATE_PREPARE: { + this->do_update_(); + UpdateMode mode = this->active_mode_; + if (!this->prepare_update_region_(mode)) { + ESP_LOGD(TAG, "Nothing to update"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + return; + } + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_TRANSFER); + this->enqueue_update_transfer_(); + break; + } + + case Phase::UPDATE_TRANSFER: + this->set_phase_(Phase::UPDATE_REFRESH); + this->enqueue_update_refresh_(); + break; + + case Phase::UPDATE_REFRESH: + // Fire-and-forget: don't block here waiting for the refresh to complete. + // The next update's pre-display LUT-idle poll (and the HW_RDY-gated + // TCON_SLEEP) wait as needed, so the refresh time stays off this update's + // critical path. The 1bpp display mode is left enabled rather than + // restored after every update: on a monochrome display every update + // (DU partials and the periodic GC16 cleans) runs in 1bpp mode, so the + // bit never needs clearing — and clearing it required a full + // refresh-length LUT-idle wait. + this->set_phase_(Phase::UPDATE_SLEEP); + this->enqueue_update_sleep_(); + break; + + case Phase::UPDATE_SLEEP: + ESP_LOGV(TAG, "Update took %" PRIu32 "ms (mode=%u area=%ux%u@%u,%u)", millis() - this->update_started_at_, + static_cast(this->active_mode_), this->area_w_, this->area_h_, this->area_x_, this->area_y_); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + } +} + +// --- Setup ------------------------------------------------------------------- + +void IT8951Display::setup() { + ESP_LOGCONFIG(TAG, "Setting up IT8951..."); + this->configured_data_rate_ = this->data_rate_; + this->data_rate_ = SPI_PROBE_FREQUENCY; + this->spi_setup(); + + // Power on the panel before reset and the init handshake. + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + if (this->busy_pin_ != nullptr) { + this->busy_pin_->setup(); + } + + this->update_effective_transform_(); + this->reset_dirty_region_(); + + // Allocate the framebuffer now: its size is fixed by the configured pixel + // format and dimensions, so there's no need to defer to the async controller + // init. LVGL (and other writers) can push pixels via draw_pixels_at as soon + // as the component is set up — before init completes — and without a buffer + // those writes would dereference a null pointer and crash. + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + RAMAllocator allocator{}; + this->buffer_ = allocator.allocate(this->buffer_length_); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate IT8951 framebuffer")); + return; + } + // The allocator does not zero memory; start blank (white) so undrawn regions + // (e.g. with auto_clear disabled) don't show garbage on the first update. + this->fill(Color::WHITE); + + // Kick off async init via the queue. Reset pulse + boot delay + wake + + // packed-write enable; everything blocking lives as DELAY_MS Ops gated by + // the loop scheduler. + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->enable_loop(); +} + +void IT8951Display::on_safe_shutdown() { + // Best-effort synchronous sleep — runs during shutdown so we don't queue. + this->spi_cmd_(TCON_SLEEP); +} + +// --- Init op enqueuers ------------------------------------------------------- + +void IT8951Display::enqueue_init_reset_() { + // A reset (including recovery) re-runs SYS_RUN below, so the controller is + // awake once this sequence completes. + this->asleep_ = false; + // Reset pulse: high -> low (reset_duration) -> high -> wait for ROM boot. + this->enqueue_(OpType::GPIO_RESET_HIGH); + this->enqueue_(OpType::GPIO_RESET_LOW); + this->enqueue_(OpType::DELAY_MS, static_cast(this->reset_duration_)); + this->enqueue_(OpType::GPIO_RESET_HIGH); + // SPI ROM boot. HW_RDY gating in loop() handles the actual wait, but a small + // floor avoids hammering SPI before HW_RDY has settled high. 300ms matches + // what most IT8951 reference drivers use for safety. + this->enqueue_(OpType::DELAY_MS, 300); + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->enqueue_(OpType::CMD, TCON_REG_WR); // packed write mode + this->enqueue_(OpType::WRITE_REG, I80CPCR, 0x0001); +} + +void IT8951Display::enqueue_init_dev_info_() { + // CMD triggers the controller to prepare DevInfo. HW_RDY drops while it works. + // The loop-level HW_RDY gate non-blockingly waits before dispatching READ_DEV_INFO. + this->enqueue_(OpType::CMD, I80_CMD_GET_DEV_INFO); + this->enqueue_(OpType::READ_DEV_INFO); +} + +void IT8951Display::enqueue_init_vcom_() { + // Always write configured VCOM. The IT8951 stores it in OTP-backed RAM; + // rewriting the same value is harmless. The VCOM SET selector is + // panel-specific (see I80_CMD_VCOM_WRITE / I80_CMD_VCOM_WRITE_ALT in + // it8951_defs.h) and is supplied via the model preset. + this->enqueue_(OpType::CMD, I80_CMD_VCOM); + this->enqueue_(OpType::WRITE_W, this->vcom_register_); + this->enqueue_(OpType::WRITE_W, this->vcom_); +} + +void IT8951Display::enqueue_init_temp_() { + // Force panel temperature (in degrees C) so the controller selects the + // correct waveform LUT. Some panels (e.g. Seeed reTerminal E1003) ship + // with auto-temperature disabled and rely on the host to declare the + // operating temperature; without this, grayscale waveforms run against + // a mismatched LUT and pixels do not visibly change even though the LUT + // engine completes a full cycle. + this->enqueue_(OpType::CMD, I80_CMD_FORCE_TEMP); + this->enqueue_(OpType::WRITE_W, I80_CMD_FORCE_TEMP_WRITE); + this->enqueue_(OpType::WRITE_W, static_cast(this->force_temperature_)); +} + +// --- Update op enqueuers ----------------------------------------------------- + +void IT8951Display::enqueue_update_transfer_() { + // If the controller was put to sleep after the previous update, wake it + // before touching the display engine. TCON_SLEEP gates off all clocks; a + // register read (e.g. the LUTAFSR poll in UPDATE_REFRESH) returns a frozen + // value while asleep, so without this the next update stalls forever in + // op_check_lut_idle_(). SRAM/registers (packed-write mode, VCOM, LUT) are + // retained across sleep, so SYS_RUN + a short settle is all that's needed. + if (this->asleep_) { + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->asleep_ = false; + } + this->transfer_row_ = 0; + // Open a single LD_IMG_AREA load for the whole region. XFER_ROWS streams into + // it across as many time-sliced passes as needed and emits the one matching + // LD_IMG_END when the last row is sent (see the XFER_ROWS handler). + this->enqueue_(OpType::XFER_LISAR); + this->enqueue_(OpType::XFER_AREA_CMD); + this->enqueue_(OpType::XFER_AREA_ARGS); + this->enqueue_(OpType::XFER_ROWS); +} + +void IT8951Display::enqueue_update_refresh_() { + ESP_LOGV(TAG, "Enqueueing refresh ops: grayscale=%u", this->grayscale_); + // Poll LUT idle: CMD(REG_RD) → WRITE_W(LUTAFSR) → READ_WORD → CHECK_LUT_IDLE + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, LUTAFSR); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::CHECK_LUT_IDLE); + if (!this->grayscale_) { + // Read UP1SR+2: CMD(REG_RD) → WRITE_W(UP1SR+2) → READ_WORD → SET_1BPP + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, static_cast(UP1SR + 2)); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::SET_1BPP); + } + this->enqueue_(OpType::DPY_BUF_CMD); + this->enqueue_(OpType::DPY_BUF_ARGS); +} + +void IT8951Display::enqueue_update_sleep_() { + if (this->sleep_when_done_) { + this->enqueue_(OpType::CMD, TCON_SLEEP); + // Remember that the controller is now asleep so the next update wakes it + // (see enqueue_update_transfer_) before polling any register. + this->asleep_ = true; + } +} + +// --- SPI primitives ---------------------------------------------------------- +// +// IT8951 SPI protocol: no DC pin. 16-bit preamble word identifies whether +// the transaction is command (0x6000), write-data (0x0000), or read-data +// (0x1000). +// +// All ops are fully non-blocking at the loop level. The loop-level HW_RDY gate +// guarantees the controller is ready before any op is dispatched. +// +// Within a single CS-asserted transaction, the IT8951 requires HW_RDY to be +// checked after the preamble word before sending the first data word. This +// is a hardware protocol requirement — the controller needs a few clock +// cycles to latch the preamble and configure its internal bus direction. +// In practice this completes in <1µs for write ops; we use a short spin +// (max ~50µs) that never triggers under normal operation. + +static constexpr uint32_t INTRA_CS_READY_TIMEOUT_US = 50; + +static inline void wait_for_hardware_ready(GPIOPin *busy_pin) { + if (busy_pin == nullptr) + return; + uint32_t waited = 0; + while (!busy_pin->digital_read()) { + if (waited >= INTRA_CS_READY_TIMEOUT_US) + return; + delayMicroseconds(1); + waited += 1; + } +} + +void IT8951Display::spi_cmd_(uint16_t cmd) { + this->enable(); + this->write_byte16(PACKET_TYPE_CMD); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(cmd); + this->disable(); +} + +void IT8951Display::spi_write_word_(uint16_t value) { + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_reg_(uint16_t addr, uint16_t value) { + // Single CS transaction: WRITE preamble + addr + value. + // Caller must have already sent CMD(TCON_REG_WR) as a prior op. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(addr); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_args_(const uint16_t *args, uint16_t count) { + // Single CS transaction: WRITE preamble + N data words. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + for (uint16_t i = 0; i < count; i++) + this->write_byte16(args[i]); + this->disable(); +} + +uint16_t IT8951Display::spi_read_word_() { + // Single CS read transaction. HW_RDY was confirmed HIGH by the loop gate + // before this op was dispatched, so data is ready. + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy — provides clock cycles for controller + wait_for_hardware_ready(this->busy_pin_); + // Read byte-by-byte: a 2-byte transfer_array can lose the low byte on + // ESP-IDF SPI DMA due to 4-byte alignment requirements. + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + this->disable(); + return encode_uint16(hi, lo); +} + +void IT8951Display::spi_read_dev_info_() { + // Read DevInfo struct. The CMD(GET_DEV_INFO) was already sent as a prior op, + // and the loop HW_RDY gate waited for the controller to prepare data. + std::memset(&this->dev_info_, 0, sizeof(this->dev_info_)); + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy + wait_for_hardware_ready(this->busy_pin_); + auto *words = reinterpret_cast(&this->dev_info_); + constexpr uint32_t word_count = sizeof(this->dev_info_) / sizeof(uint16_t); + for (uint32_t i = 0; i < word_count; i++) { + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + words[i] = encode_uint16(hi, lo); + } + this->disable(); +} + +// --- Compound Ops ------------------------------------------------------------ + +void IT8951Display::op_xfer_lisar_() { + // Set image-buffer target address. Two register writes = 4 CS transactions. + // Push to FRONT in reverse order so they execute before the rest of the queue. + this->prepend_(OpType::WRITE_REG, LISAR, this->img_buf_addr_l_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, static_cast(LISAR + 2), this->img_buf_addr_h_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +void IT8951Display::op_xfer_area_args_() { + // Single CS transaction: WRITE preamble + 5 area-parameter words describing + // the full update region. Sent once when the load is opened (transfer_row_ is + // 0); XFER_ROWS then streams every row into this one area. + uint16_t args[5]; + if (this->grayscale_) { + args[0] = static_cast((LDIMG_B_ENDIAN << 8) | (PIXEL_4BPP << 4)); + args[1] = this->area_x_; + args[2] = this->area_y_; + args[3] = this->area_w_; + args[4] = this->area_h_; + } else { + // Monochrome is loaded via the 8bpp-packed trick: x and width are expressed + // in bytes (8 pixels each) and the controller unpacks one bit per pixel. + args[0] = static_cast((LDIMG_L_ENDIAN << 8) | (PIXEL_8BPP << 4)); + args[1] = static_cast(this->area_x_ / 8); + args[2] = this->area_y_; + args[3] = static_cast(this->area_w_ / 8); + args[4] = this->area_h_; + } + this->spi_write_args_(args, 5); +} + +void IT8951Display::op_xfer_area_end_() { this->spi_cmd_(TCON_LD_IMG_END); } + +bool IT8951Display::op_xfer_rows_() { + const uint32_t start_time = millis(); + const uint16_t area_y = this->area_y_; + const uint16_t area_h = this->area_h_; + + // Bytes per source row, and the byte offset of area_x within a row, in the + // framebuffer's native packing. These match the per-row byte count the + // controller expects from op_xfer_area_args_: area_w/2 for 4bpp grayscale, + // area_w/8 for the 1bpp-packed monochrome trick. area_x / area_w are + // 16-pixel aligned (see prepare_update_region_), so both divisions are exact. + const uint16_t bytes_per_row = + this->grayscale_ ? static_cast(this->area_w_ >> 1) : static_cast(this->area_w_ >> 3); + const uint16_t row_x_bytes = + this->grayscale_ ? static_cast(this->area_x_ >> 1) : static_cast(this->area_x_ >> 3); + + // Single CS write transaction — HW_RDY was confirmed high by the loop gate. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + + // Each source row is a contiguous slice of the framebuffer in both formats — + // the buffer already holds the wire bytes — so stream it straight to SPI with + // no per-pixel packing or temporary buffer. + while (this->transfer_row_ < area_h) { + const uint32_t offset = (static_cast(area_y) + this->transfer_row_) * this->row_width_ + row_x_bytes; + this->write_array(&this->buffer_[offset], bytes_per_row); + this->transfer_row_++; + if (millis() - start_time >= MAX_TRANSFER_TIME_MS) + break; + } + + this->disable(); + return this->transfer_row_ >= area_h; +} + +void IT8951Display::op_dpy_buf_args_() { + // I80_CMD_DPY_BUF_AREA (0x0037) takes 7 args (with explicit buffer addr). + // I80_CMD_DPY_AREA (0x0034) takes 5 args; the buffer address is taken + // from LISAR which we program during the transfer phase, so this is safe. + if (this->use_legacy_dpy_area_) { + const uint16_t args[5] = { + this->area_x_, this->area_y_, this->area_w_, this->area_h_, static_cast(this->active_mode_), + }; + this->spi_write_args_(args, 5); + return; + } + const uint16_t args[7] = { + this->area_x_, + this->area_y_, + this->area_w_, + this->area_h_, + static_cast(this->active_mode_), + this->img_buf_addr_l_, + this->img_buf_addr_h_, + }; + this->spi_write_args_(args, 7); +} + +void IT8951Display::op_check_lut_idle_() { + ESP_LOGV(TAG, "Checking LUT idle, read_result_=0x%04X", this->read_result_); + // read_result_ holds LUTAFSR value from the preceding READ_WORD op. + if (this->read_result_ != 0) { + // LUT still busy — re-enqueue the full read sequence after a short delay. + this->prepend_(OpType::CHECK_LUT_IDLE, 0, 0); + this->prepend_(OpType::READ_WORD, 0, 0); + this->prepend_(OpType::WRITE_W, LUTAFSR, 0); + this->prepend_(OpType::CMD, TCON_REG_RD, 0); + this->prepend_(OpType::DELAY_MS, 5, 0); + } +} + +void IT8951Display::op_set_1bpp_() { + // read_result_ holds UP1SR+2 value. Set bit 2 and write back, then set BGVR. + // Push to FRONT in reverse order so they execute before DPY_BUF_CMD/ARGS + // that are already in the queue. + const uint16_t modified = static_cast(this->read_result_ | (1U << 2)); + this->prepend_(OpType::WRITE_REG, BGVR, 0xFF00); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, UP1SR + 2, modified); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +// --- Update prep / public API ------------------------------------------------ + +bool IT8951Display::prepare_update_region_(UpdateMode &mode) { + this->partial_update_count_++; + const bool full_update = this->partial_update_count_ >= this->full_update_every_; + if (full_update) { + this->partial_update_count_ = 0; + mode = UPDATE_MODE_GC16; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } else { + // Align the partial region's X extent to 32 pixels. The IT8951's partial + // display refresh snaps the X start/width to a 32-pixel boundary (the panel + // source driver fetches 32-pixel chunks); refreshing a region whose X is + // only 16-aligned makes the panel snap it down to the previous boundary, + // shifting that update ~16px to the left. 32-alignment also satisfies the + // load constraints (4bpp X must be a multiple of 4; the 8bpp-packed mono + // load needs x/8 even, i.e. X a multiple of 16). + this->x_low_ &= 0xFFE0; + uint16_t temp_max = this->x_high_ > 0 ? static_cast(this->x_high_ - 1) : 0; + temp_max = static_cast(temp_max | 0x001F); + if (temp_max >= this->width_) + temp_max = static_cast(this->width_ - 1); + this->x_high_ = static_cast(temp_max + 1); + } + + if (this->x_high_ <= this->x_low_ || this->y_high_ <= this->y_low_) { + this->reset_dirty_region_(); + return false; + } + + const uint16_t x = this->x_low_; + const uint16_t y = this->y_low_; + const uint16_t width = static_cast(this->x_high_ - this->x_low_); + const uint16_t height = static_cast(this->y_high_ - this->y_low_); + + if (x >= this->width_ || y >= this->height_ || (x + width) > this->width_ || (y + height) > this->height_) { + ESP_LOGE(TAG, "Dirty region (%u,%u %ux%u) out of bounds", x, y, width, height); + this->reset_dirty_region_(); + return false; + } + + this->area_x_ = x; + this->area_y_ = y; + this->area_w_ = width; + this->area_h_ = height; + this->transfer_row_ = 0; + + // On non-full updates, downgrade monochrome frames from the full, flashy GC16 + // clear to DU — a fast, low-flash absolute waveform — so full_update_every + // buys cheaper refreshes between the periodic GC16 cleans that clear + // accumulated ghosting. + // + // Grayscale frames are deliberately left on GC16: every reduced grayscale + // waveform this controller exposes (the non-flashing GL family GL16/GLR16/ + // GLD16, and the 4-tone DU4) renders incorrectly on the supported panels — + // a white background is driven to grey rather than staying white. GC16 is the + // only waveform that reproduces grayscale faithfully, so we keep it. + // + // An explicitly configured non-GC16 update_mode is honoured as-is. + if (!full_update && mode == UPDATE_MODE_GC16 && !this->grayscale_) + mode = UPDATE_MODE_DU; + + this->reset_dirty_region_(); + + ESP_LOGV(TAG, "Update: %ux%u@%u,%u mode=%u (%s)", width, height, x, y, static_cast(mode), + this->grayscale_ ? "grayscale" : "mono"); + return true; +} + +void IT8951Display::reset_dirty_region_() { + this->x_low_ = this->width_; + this->x_high_ = 0; + this->y_low_ = this->height_; + this->y_high_ = 0; +} + +void IT8951Display::start_update_(UpdateMode mode) { + if (this->phase_ == Phase::IDLE && this->initialised_) { + this->update_started_at_ = millis(); + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_PREPARE); + this->enable_loop(); + this->advance_phase_(); + } else { + // Coalesce: latest pending mode wins. + this->update_pending_ = true; + this->pending_update_mode_ = mode; + this->enable_loop(); + } +} + +void IT8951Display::update() { + if (!this->is_ready()) + return; + if (this->default_update_mode_ != UPDATE_MODE_NONE) { + this->start_update_(this->default_update_mode_); + return; + } + this->start_update_(UPDATE_MODE_GC16); +} + +void IT8951Display::update_mode(UpdateMode mode) { + if (!this->is_ready()) + return; + if (mode == UPDATE_MODE_NONE) { + ESP_LOGW(TAG, "Unknown update mode"); + return; + } + this->start_update_(mode); +} + +// --- Recovery ---------------------------------------------------------------- + +void IT8951Display::recover_() { + if (++this->recovery_attempts_ > 3) { + ESP_LOGE(TAG, "Recovery failed after %u attempts; giving up. Check BUSY pin wiring and power.", + this->recovery_attempts_); + this->mark_failed(LOG_STR("IT8951 recovery exhausted")); + this->queue_.clear(); + this->set_phase_(Phase::IDLE); + this->disable_loop(); + return; + } + ESP_LOGW(TAG, "Recovering (attempt %u): hardware-resetting controller (was in phase %u)", this->recovery_attempts_, + static_cast(this->phase_)); + this->queue_.clear(); + this->update_pending_ = false; + this->transfer_row_ = 0; + this->initialised_ = false; + this->dev_info_attempts_ = 0; + + // Drop SPI clock back to the safe probe rate for the re-init handshake. + if (this->configured_data_rate_ != 0 && this->data_rate_ != SPI_PROBE_FREQUENCY) { + this->spi_teardown(); + this->set_data_rate(SPI_PROBE_FREQUENCY); + this->spi_setup(); + } + + // Force a full redraw on next opportunity. + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->update_pending_ = true; + this->pending_update_mode_ = UPDATE_MODE_GC16; + this->enable_loop(); +} + +// --- Coordinate transform ---------------------------------------------------- + +void IT8951Display::update_effective_transform_() { + switch (this->rotation_) { + case DISPLAY_ROTATION_90_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_180_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_MIRROR_Y | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_270_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_Y); + break; + default: + this->effective_transform_ = this->transform_; + break; + } +} + +void IT8951Display::apply_transform_(int &x, int &y) const { + if (this->effective_transform_ & TRANSFORM_SWAP_XY) + std::swap(x, y); + if (this->effective_transform_ & TRANSFORM_MIRROR_X) + x = this->width_ - x - 1; + if (this->effective_transform_ & TRANSFORM_MIRROR_Y) + y = this->height_ - y - 1; +} + +bool IT8951Display::rotate_coordinates_(int &x, int &y) { + if (!this->get_clipping().inside(x, y)) + return false; + this->apply_transform_(x, y); + if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) + return false; + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); + return true; +} + +// --- Color / drawing --------------------------------------------------------- + +static uint8_t quantize_8bit_to_nibble(uint8_t value) { + uint8_t nibble = static_cast((static_cast(value) + 8) >> 4); + return nibble > 0x0F ? 0x0F : nibble; +} + +static uint8_t color_to_nibble(const Color &color) { + // Grayscale images are emitted as Color(gray, gray, gray, 0xFF). + // Handle this shape first so endpoint values don't alias COLOR_ON/OFF. + if (color.w == 0xFF && color.r == color.g && color.g == color.b) + return quantize_8bit_to_nibble(color.r); + + if (color.raw_32 == 0) + return 0x00; // black + if (color.raw_32 == 0xFFFFFFFF) + return 0x0F; // white + + // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by + // 256). Rec.601 is the standard for converting SDR images to grayscale and + // spreads saturated colours across the mid-range; Rec.709 instead crams them + // against white/black where the 16 panel levels are hard to tell apart. + auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); + return quantize_8bit_to_nibble(luma); +} + +// 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). +// A pixel whose luma is below the threshold renders black, so lighter pixels +// produce progressively sparser black dots instead of vanishing to white. The +// matrix averages to 32768, matching the conventional monochrome cut, while the +// per-pixel variation reproduces intermediate gray levels. +static uint16_t dither_threshold(uint16_t x, uint16_t y) { + static const uint8_t BAYER4[16] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5}; + return static_cast(BAYER4[((y & 3) << 2) | (x & 3)] * 4096u + 2048u); +} + +void IT8951Display::fill(Color color) { + if (this->buffer_ == nullptr) + return; + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + uint8_t packed = color_to_nibble(color); + if (this->invert_colors_) + packed = 0x0F - packed; + uint8_t fill_byte; + if (this->grayscale_) { + fill_byte = static_cast((packed << 4) | packed); + } else { + fill_byte = (packed <= 0x07) ? 0xFF : 0x00; + } + memset(this->buffer_, fill_byte, this->buffer_length_); + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { + if (this->buffer_ == nullptr) + return; + App.feed_wdt(); + if (!this->rotate_coordinates_(x, y)) + return; + this->write_pixel_native_(static_cast(x), static_cast(y), color); +} + +void HOT IT8951Display::write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const { + if (this->grayscale_) { + uint8_t nibble = color_to_nibble(color); + if (this->invert_colors_) + nibble = static_cast(0x0F - nibble); + this->set_gray_pixel_(x, y, nibble); + } else { + // Rec.601 luma (see color_to_nibble). Weights sum to 257 so white maps to + // exactly 65535, using the full 16-bit range without overflow. + auto lum = static_cast(77u * color.r + 151u * color.g + 29u * color.b); + if (this->invert_colors_) + lum = static_cast(65535u - lum); + // Set the bit (foreground/black) when this pixel is darker than its + // threshold. With dithering the threshold varies per pixel so pale colours + // render as visible texture; otherwise it's the fixed ~50% cut (r+g+b<32768). + const uint16_t threshold = this->dithering_ ? dither_threshold(x, y) : 32768; + this->set_mono_pixel_(x, y, lum < threshold); + } +} + +void HOT IT8951Display::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) { + // A writer (e.g. LVGL) may push pixels before the framebuffer is ready or + // after an allocation failure; ignore those rather than dereferencing null. + if (this->buffer_ == nullptr) + return; + // A clipping rectangle would need a per-pixel test; that's rare for the bulk + // blit callers (LVGL, images), so fall back to the base per-pixel path then. + if (this->get_clipping().is_set()) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + + const size_t line_stride = static_cast(x_offset) + w + x_pad; // source line length in pixels + for (int y = 0; y < h; y++) { + App.feed_wdt(); + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x < w; x++, source_idx++) { + uint32_t color_value; + switch (bitness) { + case COLOR_BITNESS_565: { + const size_t i = source_idx * 2; + color_value = big_endian ? (static_cast(ptr[i]) << 8) | ptr[i + 1] + : ptr[i] | (static_cast(ptr[i + 1]) << 8); + break; + } + case COLOR_BITNESS_888: { + const size_t i = source_idx * 3; + color_value = + big_endian + ? (static_cast(ptr[i]) << 16) | (static_cast(ptr[i + 1]) << 8) | ptr[i + 2] + : ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + break; + } + default: + color_value = ptr[source_idx]; + break; + } + int nx = x_start + x; + int ny = y_start + y; + this->apply_transform_(nx, ny); + if (nx < 0 || ny < 0 || nx >= this->width_ || ny >= this->height_) + continue; + this->write_pixel_native_(static_cast(nx), static_cast(ny), + ColorUtil::to_color(color_value, order, bitness)); + } + } + + // Expand the dirty bounding box once from the transformed block corners: the + // image of an axis-aligned rectangle under swap/mirror is still axis-aligned, + // so its two opposite corners bound it. + int x0 = x_start, y0 = y_start; + int x1 = x_start + w - 1, y1 = y_start + h - 1; + this->apply_transform_(x0, y0); + this->apply_transform_(x1, y1); + const int nx_lo = std::max(0, std::min(x0, x1)); + const int ny_lo = std::max(0, std::min(y0, y1)); + const int nx_hi = std::min(this->width_ - 1, std::max(x0, x1)); + const int ny_hi = std::min(this->height_ - 1, std::max(y0, y1)); + if (nx_hi >= nx_lo && ny_hi >= ny_lo) { + this->x_low_ = clamp_at_most(this->x_low_, nx_lo); + this->x_high_ = clamp_at_least(this->x_high_, nx_hi + 1); + this->y_low_ = clamp_at_most(this->y_low_, ny_lo); + this->y_high_ = clamp_at_least(this->y_high_, ny_hi + 1); + } +} + +void IT8951Display::set_mono_pixel_(uint16_t x, uint16_t y, bool value) const { + // The monochrome framebuffer holds the exact bytes streamed to the + // controller for the 8bpp-load / 1bpp-display trick (L_ENDIAN). Pixels are + // grouped in 16s; on the wire the high byte (pixels 8..15) precedes the low + // byte (pixels 0..7), and the bit index within a byte is the pixel's offset + // (LSB = lowest x). Storing in that order lets op_xfer_rows_ copy rows + // verbatim with no packing or byte-swapping. + const uint16_t group = static_cast(x >> 4); + const uint8_t sub = static_cast(x & 0x0F); + const uint16_t byte_index = static_cast(group * 2u + (sub < 8u ? 1u : 0u)); + const uint8_t mask = static_cast(1u << (sub & 0x07)); + const uint32_t index = static_cast(y) * this->row_width_ + byte_index; + if (value) { + this->buffer_[index] |= mask; + } else { + this->buffer_[index] &= static_cast(~mask); + } +} + +void IT8951Display::set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const { + const uint32_t index = static_cast(y) * this->row_width_ + (static_cast(x) >> 1); + uint8_t buf = this->buffer_[index]; + if (x & 0x1) { + buf = (buf & 0xF0) | nibble; + } else { + buf = (buf & 0x0F) | static_cast(nibble << 4); + } + this->buffer_[index] = buf; +} + +// --- Diagnostics ------------------------------------------------------------- + +void IT8951Display::dump_config() { + LOG_DISPLAY("", "IT8951 E-Paper", this); + char force_temperature[24]; + if (this->force_temperature_set_) { + snprintf(force_temperature, sizeof(force_temperature), "%d °C", this->force_temperature_); + } else { + strncpy(force_temperature, "(controller default)", sizeof(force_temperature)); + force_temperature[sizeof(force_temperature) - 1] = '\0'; + } + ESP_LOGCONFIG(TAG, + " Model preset: %s" + "\n Dimensions: %dx%d" + "\n Buffer: %u bytes" + "\n Image buffer addr: 0x%04X%04X" + "\n VCOM: %.02fV (set selector 0x%04X)" + "\n Force temperature: %s" + "\n Display command: %s" + "\n Sleep when done: %s" + "\n Full update every: %u" + "\n Inverted colors: %s" + "\n Pixel format: %s" + "\n Reset duration: %" PRIu32 "ms", + this->name_ != nullptr ? this->name_ : "(unknown)", this->get_width_internal(), + this->get_height_internal(), static_cast(this->buffer_length_), this->img_buf_addr_h_, + this->img_buf_addr_l_, static_cast(this->vcom_) / 1000.0f, this->vcom_register_, + force_temperature, this->use_legacy_dpy_area_ ? "DPY_AREA (0x0034, legacy)" : "DPY_BUF_AREA (0x0037)", + YESNO(this->sleep_when_done_), this->full_update_every_, YESNO(this->invert_colors_), + this->grayscale_ ? "4bpp grayscale" : "1bpp monochrome", this->reset_duration_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Busy Pin: ", this->busy_pin_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h new file mode 100644 index 00000000000..a5ed03e8c4b --- /dev/null +++ b/esphome/components/it8951/it8951.h @@ -0,0 +1,373 @@ +#pragma once + +#include +#include +#include + +#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 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 { + 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(this->row_width_) * static_cast(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 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(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((static_cast(this->width_) + 1) / 2) + : static_cast(((static_cast(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 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 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 class IT8951UpdateAction : public Action { + 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 diff --git a/esphome/components/it8951/it8951_defs.h b/esphome/components/it8951/it8951_defs.h new file mode 100644 index 00000000000..9a7291eb4ad --- /dev/null +++ b/esphome/components/it8951/it8951_defs.h @@ -0,0 +1,168 @@ +#pragma once + +#include + +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 diff --git a/tests/components/it8951/test.esp32-s3-idf.yaml b/tests/components/it8951/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..c362f7f28c4 --- /dev/null +++ b/tests/components/it8951/test.esp32-s3-idf.yaml @@ -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 diff --git a/tests/components/ld2450/common.h b/tests/components/ld2450/common.h index 304634edcaa..de912ddcbca 100644 --- a/tests/components/ld2450/common.h +++ b/tests/components/ld2450/common.h @@ -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.