"""ESPHome display platform driving a zEPD engine model via the native replay engine (see engine/engine.h) instead of GxEPD2's own C++ classes. Models are selected via `model:`, each a drivers/.json merged over drivers/default.json (see registry.py) and codegen'd at compile time to a single generated header (see codegen.py) - no per-model panel_*.h, and no `PANELS` dict here. See claude/DESCRIPTOR_REGISTRY_PLAN.md. """ import re import esphome.codegen as cg import esphome.config_validation as cv from esphome import pins from esphome.components import display, spi from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, CONF_DC_PIN, CONF_ID, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN, CONF_SPI_ID, Framework, ) from esphome.core import CORE from esphome.helpers import write_file_if_changed from esphome.types import ConfigType from . import codegen as zepd_codegen from . import registry as zepd_registry CODEOWNERS = ["@zib"] DEPENDENCIES = ["spi"] # See the matching comment in the legacy components/gxepd2/display.py - same # mono-inversion tradeoff, carried forward unchanged. CONF_INVERT_MONO = "invert_mono" # Compiles in ZepdDisplay's dev-time diagnostic methods (currently # diag_update_no_reset()), stashed behind this flag so a production build # doesn't pay flash for bench-only tooling. Off by default. CONF_DIAGNOSTICS = "diagnostics" zepd_ns = cg.esphome_ns.namespace("zepd") ZepdDisplay = zepd_ns.class_("ZepdDisplay", display.DisplayBuffer) MODELS = zepd_registry.list_models() # Human-facing label for test_pattern(): the first OEM part number minus its # GDEx series prefix (GDEQ0583T31 -> 0583T31), same rule as the legacy # _panel_label. Falls back to the registry key itself when a model carries # no oem_part_numbers yet. def _model_label(model_key: str, model: dict) -> str: parts = model.get("oem_part_numbers") or [] part = parts[0] if parts else model_key return re.sub(r"^GDE[A-Z]", "", part) CONFIG_SCHEMA = cv.All( display.FULL_DISPLAY_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(ZepdDisplay), cv.GenerateID(CONF_SPI_ID): cv.use_id(spi.SPIComponent), cv.Required(CONF_MODEL): cv.one_of(*MODELS), cv.Required(CONF_CS_PIN): pins.gpio_output_pin_schema, cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_BUSY_PIN): pins.gpio_input_pin_schema, cv.Optional(CONF_INVERT_MONO): cv.boolean, cv.Optional(CONF_DIAGNOSTICS, default=False): cv.boolean, } ), # Same framework support rule as the legacy component - the runtime C++ # only uses portable ESPHome abstractions. cv.only_with_framework([Framework.ESP_IDF, Framework.ARDUINO]), ) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) model = zepd_registry.load_model(config[CONF_MODEL]) # Codegen the one selected model to a fixed-name generated header at # src/ root - not under this component's own directory, which # writer.copy_src_tree() would otherwise delete on every build (see # claude/DESCRIPTOR_REGISTRY_PLAN.md, "Where generated C++ lands"). cpp_body = zepd_codegen.generate_cpp(model, "ZEPD_MODEL") # Wrapped in esphome::zepd, the same namespace as the adapter # (ZepdDisplay) and the engine core (EngineModel, Engine) both live in - # see engine_model.h/engine.h. Keeping a single namespace avoids the # ambiguity ESPHome's own generated main.cpp would otherwise hit: it # writes unqualified `zepd::Foo` trusting `using namespace esphome;` to # resolve it, which breaks if a second, unrelated top-level `zepd` # namespace also exists. generated = ( "#pragma once\n" '#include "esphome/components/zepd/engine_model.h"\n\n' "namespace esphome::zepd {\n\n" f"{cpp_body}\n" "} // namespace esphome::zepd\n" ) write_file_if_changed(CORE.relative_src_path("zepd_model_generated.h"), generated) cg.add(var.set_descriptor(cg.RawExpression("&esphome::zepd::ZEPD_MODEL"))) cg.add(var.set_cs_pin(await cg.gpio_pin_expression(config[CONF_CS_PIN]))) cg.add(var.set_dc_pin(await cg.gpio_pin_expression(config[CONF_DC_PIN]))) 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))) spi_parent = await cg.get_variable(config[CONF_SPI_ID]) cg.add(var.set_spi_parent(spi_parent)) cg.add(var.set_invert_mono(config.get(CONF_INVERT_MONO, False))) cg.add(var.set_model_name(_model_label(config[CONF_MODEL], model))) cg.add(var.set_verified(bool(model.get("verified", False)))) if config[CONF_DIAGNOSTICS]: cg.add_define("USE_ZEPD_DIAGNOSTICS") # A top-level `lambda:` must be processed and attached via set_writer() # here - register_display() only wires up `pages:`. if CONF_LAMBDA in config: lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void) cg.add(var.set_writer(lambda_)) await display.register_display(var, config)