[lvgl][mipi_spi][mipi_rgb][mipi_dsi][display] Metadata (#16702)

This commit is contained in:
Clyde Stubbs authored and GitHub committed 2026-06-03 15:21:33 +10:00
1 parent 792e1ff304
commit 997ab11687
8 files changed
+520 -173

No files matched your search

+89 -13
View File
@@ -3,11 +3,18 @@ from dataclasses import dataclass
from esphome import automation, core
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components.const import KEY_METADATA
from esphome.components.const import (
BYTE_ORDER_BIG,
CONF_BYTE_ORDER,
CONF_DRAW_ROUNDING,
KEY_METADATA,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_DIMENSIONS,
CONF_FROM,
CONF_HEIGHT,
CONF_ID,
CONF_LAMBDA,
CONF_PAGE_ID,
@@ -16,10 +23,11 @@ from esphome.const import (
CONF_TO,
CONF_TRIGGER_ID,
CONF_UPDATE_INTERVAL,
CONF_WIDTH,
SCHEDULER_DONT_RUN,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObj
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.final_validate import full_config
DOMAIN = "display"
IS_PLATFORM_COMPONENT = True
@@ -159,29 +167,97 @@ async def setup_display_core_(var, config):
class DisplayMetaData:
width: int = 0
height: int = 0
has_writer: bool = False
has_hardware_rotation: bool = False
byte_order: str = BYTE_ORDER_BIG
has_writer: bool = False
rotation: int = 0
draw_rounding: int = 0
def _get_metadata_list() -> list[tuple]:
"""Get the raw metadata list. Each entry is (id, DisplayMetaData)."""
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, [])
def get_all_display_metadata() -> dict[str, DisplayMetaData]:
"""Get all display metadata."""
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {})
"""Get all display metadata as a dict keyed by resolved ID strings.
Must not be called before IDs have been finalised.
"""
entries = _get_metadata_list()
assert all(id_.id is not None for id_, _ in entries), (
"get_all_display_metadata called before display IDs have been resolved"
)
return {id_.id: meta for id_, meta in entries}
def get_display_metadata(display_id: str) -> DisplayMetaData | None:
"""Get display metadata by ID for use by other components."""
return get_all_display_metadata().get(display_id, DisplayMetaData())
def get_display_metadata(display_id: ID) -> DisplayMetaData:
"""Get display metadata by ID object
Must not be called before IDs have been finalised.
"""
for id_, meta in _get_metadata_list():
if id_ is display_id:
return meta
assert id_.id is not None, (
"get_display_metadata called before display IDs have been resolved"
)
if id_.id == display_id.id:
return meta
# No metadata found, display driver may not yet support it.
# Read the raw config to populate the returned data
global_config = full_config.get()
path = global_config.get_path_for_id(display_id)[:-1]
disp_config = global_config.get_config_for_path(path)
dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0))
if isinstance(dimensions, dict):
dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0))
elif not isinstance(dimensions, tuple) or len(dimensions) != 2:
dimensions = (0, 0)
meta = DisplayMetaData(
width=dimensions[0],
height=dimensions[1],
has_hardware_rotation=False,
byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED),
has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True
or disp_config.get(CONF_PAGES) is not None
or disp_config.get(CONF_LAMBDA) is not None
or disp_config.get(CONF_SHOW_TEST_CARD) is True,
rotation=disp_config.get(CONF_ROTATION, 0),
draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0),
)
_get_metadata_list().append((display_id, meta))
return meta
def add_metadata(
id: str | MockObj,
id: ID,
width: int,
height: int,
has_writer: bool,
has_hardware_rotation: bool = False,
byte_order: str = BYTE_ORDER_BIG,
has_writer: bool = False,
rotation: int = 0,
draw_rounding: int = 0,
):
get_all_display_metadata()[str(id)] = DisplayMetaData(
width, height, has_writer, has_hardware_rotation
entries = _get_metadata_list()
assert not any(existing_id is id for existing_id, _ in entries), (
f"Duplicate display metadata for ID {id}"
)
entries.append(
(
id,
DisplayMetaData(
width=width,
height=height,
has_hardware_rotation=has_hardware_rotation,
byte_order=byte_order,
has_writer=has_writer,
rotation=rotation,
draw_rounding=draw_rounding,
),
)
)
+63 -53
View File
@@ -7,6 +7,7 @@ import re
from esphome.automation import Trigger, build_automation, validate_automation
import esphome.codegen as cg
from esphome.components.const import (
BYTE_ORDER_BIG,
CONF_BYTE_ORDER,
CONF_COLOR_DEPTH,
CONF_DRAW_ROUNDING,
@@ -30,12 +31,10 @@ from esphome.components.image import (
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BUFFER_SIZE,
CONF_ESPHOME,
CONF_GROUP,
CONF_ID,
CONF_LAMBDA,
CONF_LOG_LEVEL,
CONF_ON_IDLE,
CONF_PAGES,
@@ -214,61 +213,73 @@ def multi_conf_validate(configs: list[dict]):
def final_validation(config_list):
if len(config_list) != 1:
multi_conf_validate(config_list)
global_config = full_config.get()
# Resolve byte_order from display metadata before multi-config validation
for config in config_list:
metas = [get_display_metadata(disp) for disp in config[df.CONF_DISPLAYS]]
if any(m.has_writer for m in metas):
raise cv.Invalid(
"Using lambda:, pages:, auto_clear_enabled: true, or show_test_card: true in display config is not compatible with LVGL"
)
if any(m.rotation != 0 for m in metas):
raise cv.Invalid(
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
)
config[CONF_DRAW_ROUNDING] = max(
[m.draw_rounding for m in metas] + [config[CONF_DRAW_ROUNDING]]
)
display_byte_orders = {
m.byte_order for m in metas if m.byte_order is not cv.UNDEFINED
}
if len(display_byte_orders) > 1:
raise cv.Invalid(
"All displays configured for an LVGL instance must use the same byte_order"
)
if display_byte_orders:
display_order = next(iter(display_byte_orders))
if CONF_BYTE_ORDER in config:
if config[CONF_BYTE_ORDER] != display_order:
raise cv.Invalid(
"LVGL byte order must match the display byte order",
[CONF_BYTE_ORDER],
)
else:
config[CONF_BYTE_ORDER] = display_order
if CONF_BYTE_ORDER not in config:
config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG
if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages):
raise cv.Invalid("At least one page must not be skipped")
for display_id in config[df.CONF_DISPLAYS]:
path = global_config.get_path_for_id(display_id)[:-1]
display = global_config.get_config_for_path(path)
if CONF_LAMBDA in display or CONF_PAGES in display:
raise cv.Invalid(
"Using lambda: or pages: in display config is not compatible with LVGL"
)
# treating 0 as false is intended here.
if display.get(CONF_ROTATION):
raise cv.Invalid(
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
)
if display.get(CONF_AUTO_CLEAR_ENABLED) is True:
raise cv.Invalid(
"Using auto_clear_enabled: true in display config not compatible with LVGL"
)
if draw_rounding := display.get(CONF_DRAW_ROUNDING):
config[CONF_DRAW_ROUNDING] = max(
draw_rounding, config[CONF_DRAW_ROUNDING]
)
buffer_frac = config[CONF_BUFFER_SIZE]
if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config:
df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM")
for w in get_focused_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if (
df.CONF_ADJUSTABLE in widget_conf
and not widget_conf[df.CONF_ADJUSTABLE]
):
raise cv.Invalid(
"A non adjustable arc may not be focused",
path,
)
for w in get_refreshed_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
raise cv.Invalid(
f"Widget '{w}' does not have any dynamic properties to refresh",
)
# Do per-widget type final validation for update actions
for widget_type, update_configs in df.get_updated_widgets().items():
for conf in update_configs:
for id_conf in conf.get(CONF_ID, ()):
name = id_conf[CONF_ID]
path = global_config.get_path_for_id(name)
widget_conf = global_config.get_config_for_path(path[:-1])
widget_type.final_validate(name, conf, widget_conf, path[1:])
if len(config_list) != 1:
multi_conf_validate(config_list)
for w in get_focused_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]:
raise cv.Invalid(
"A non adjustable arc may not be focused",
path,
)
for w in get_refreshed_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
raise cv.Invalid(
f"Widget '{w}' does not have any dynamic properties to refresh",
)
# Do per-widget type final validation for update actions
for widget_type, update_configs in df.get_updated_widgets().items():
for conf in update_configs:
for id_conf in conf.get(CONF_ID, ()):
name = id_conf[CONF_ID]
path = global_config.get_path_for_id(name)
widget_conf = global_config.get_config_for_path(path[:-1])
widget_type.final_validate(name, conf, widget_conf, path[1:])
async def to_code(configs):
@@ -367,8 +378,7 @@ async def to_code(configs):
# options will have CONF_ROTATION true if rotation is changed in an automation.
if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True:
if all(
get_display_metadata(str(disp)).has_hardware_rotation
for disp in displays
get_display_metadata(disp).has_hardware_rotation for disp in displays
):
rotation_type = RotationType.ROTATION_HARDWARE
df.LOGGER.info("LVGL will use hardware rotation via display driver")
@@ -583,7 +593,7 @@ LVGL_SCHEMA = cv.All(
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of(
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"big_endian", "little_endian", lower=True
),
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
+16 -1
View File
@@ -37,6 +37,7 @@ from esphome.components.mipi import (
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_COLOR_ORDER,
CONF_DIMENSIONS,
CONF_DISABLED,
@@ -167,7 +168,21 @@ def _config_schema(config):
},
extra=cv.ALLOW_EXTRA,
)(config)
return model_schema(config)(config)
config = model_schema(config)(config)
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
display.add_metadata(
config[CONF_ID],
width,
height,
has_hardware_rotation=False,
byte_order=config[CONF_BYTE_ORDER],
has_writer=requires_buffer(config)
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
rotation=config.get(CONF_ROTATION, 0),
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
)
return config
def _final_validate(config):
+16 -1
View File
@@ -39,6 +39,7 @@ from esphome.components.rpi_dpi_rgb.display import (
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BLUE,
CONF_COLOR_ORDER,
CONF_CS_PIN,
@@ -226,11 +227,25 @@ def _config_schema(config):
extra=cv.ALLOW_EXTRA,
)(config)
schema = model_schema(config)
return cv.All(
config = cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
)(config)
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
display.add_metadata(
config[CONF_ID],
width,
height,
model.rotation_as_transform(config),
byte_order=config[CONF_BYTE_ORDER],
has_writer=requires_buffer(config)
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
rotation=config.get(CONF_ROTATION, 0),
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
)
return config
CONFIG_SCHEMA = _config_schema
+24 -4
View File
@@ -30,6 +30,7 @@ from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
import esphome.config_validation as cv
from esphome.config_validation import ALLOW_EXTRA
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BRIGHTNESS,
CONF_BUFFER_SIZE,
CONF_COLOR_ORDER,
@@ -47,6 +48,7 @@ from esphome.const import (
CONF_MIRROR_Y,
CONF_MODEL,
CONF_RESET_PIN,
CONF_ROTATION,
CONF_SWAP_XY,
CONF_TRANSFORM,
CONF_WIDTH,
@@ -267,6 +269,28 @@ def customise_schema(config):
if bus_mode != TYPE_QUAD and CONF_DC_PIN not in config:
raise cv.Invalid(f"DC pin is required in {bus_mode} mode")
denominator(config)
model = MODELS[config[CONF_MODEL]]
has_hardware_transform = config.get(
CONF_TRANSFORM
) != CONF_DISABLED and model.transforms == {
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
width, height, _offset_width, _offset_height = model.get_dimensions(
config, not has_hardware_transform
)
display.add_metadata(
config[CONF_ID],
width,
height,
has_hardware_transform,
byte_order=config[CONF_BYTE_ORDER],
has_writer=requires_buffer(config)
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
rotation=config.get(CONF_ROTATION, 0),
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
)
return config
@@ -338,7 +362,6 @@ def get_instance(config):
buffer_type = cg.uint8 if color_depth == 8 else cg.uint16
frac = denominator(config)
madctl = model.get_madctl(model.get_base_transform(config), config)
has_writer = requires_buffer(config)
templateargs = [
buffer_type,
bufferpixels,
@@ -352,9 +375,6 @@ def get_instance(config):
madctl,
has_hardware_transform,
]
display.add_metadata(
config[CONF_ID], width, height, has_writer, has_hardware_transform
)
# If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi
if requires_buffer(config):
templateargs.extend(