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

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

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,
),
)
)

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(

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):

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

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(

View File

@@ -4,77 +4,145 @@ from unittest.mock import patch
import pytest
from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE
from esphome.components.display import (
DisplayMetaData,
add_metadata,
get_all_display_metadata,
get_display_metadata,
)
from esphome.cpp_generator import MockObj
from esphome.config import Config
from esphome.core import ID
from esphome.final_validate import full_config
def test_add_metadata_with_string_id():
"""Test adding metadata with a plain string ID."""
def test_add_metadata_basic():
"""Test adding metadata with an ID object."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("my_display", 320, 240, True)
meta = get_display_metadata("my_display")
add_metadata(ID("my_display"), 320, 240)
meta = get_display_metadata(ID("my_display"))
assert meta == DisplayMetaData(
width=320, height=240, has_writer=True, has_hardware_rotation=False
width=320,
height=240,
has_hardware_rotation=False,
byte_order=BYTE_ORDER_BIG,
)
def test_add_metadata_with_mockobj_id():
"""Test adding metadata with a MockObj ID (converted via str())."""
def test_add_metadata_with_all_fields():
"""Test adding metadata with all fields set."""
with patch("esphome.components.display.CORE.data", {}):
mock_id = MockObj("my_display_obj")
add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True)
meta = get_display_metadata("my_display_obj")
add_metadata(
ID("my_display"),
480,
320,
has_hardware_rotation=True,
byte_order=BYTE_ORDER_LITTLE,
)
meta = get_display_metadata(ID("my_display"))
assert meta == DisplayMetaData(
width=480, height=320, has_writer=False, has_hardware_rotation=True
width=480,
height=320,
has_hardware_rotation=True,
byte_order=BYTE_ORDER_LITTLE,
)
def test_add_metadata_hardware_rotation_default():
"""Test that has_hardware_rotation defaults to False."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp", 128, 64, False)
meta = get_display_metadata("disp")
add_metadata(ID("disp"), 128, 64)
meta = get_display_metadata(ID("disp"))
assert meta.has_hardware_rotation is False
assert meta.byte_order == BYTE_ORDER_BIG
def test_get_display_metadata_missing_returns_none():
"""Test that querying a non-existent ID returns None."""
def test_add_metadata_with_byte_order():
"""Test adding metadata with explicit byte_order."""
with patch("esphome.components.display.CORE.data", {}):
data = get_display_metadata("no_such_display")
assert data.width == 0
assert data.height == 0
assert data.has_writer is False
add_metadata(ID("disp"), 240, 320, byte_order=BYTE_ORDER_LITTLE)
meta = get_display_metadata(ID("disp"))
assert meta.byte_order == BYTE_ORDER_LITTLE
def test_get_display_metadata_missing_reads_raw_config():
"""Querying a non-existent ID falls back to raw config lookup."""
with patch("esphome.components.display.CORE.data", {}):
# Set up a minimal full_config with a display entry so the fallback
# path in get_display_metadata can find the display config.
fc = Config()
fc["display"] = [
{
"id": ID("no_such_display", True),
"auto_clear_enabled": True,
"dimensions": {"width": 320, "height": 240},
"byte_order": BYTE_ORDER_LITTLE,
"rotation": 90,
},
{
"id": ID("other_display", True),
"auto_clear_enabled": "undefined",
"dimensions": (1024, 600),
},
]
fc.declare_ids.append((ID("no_such_display", True), ["display", 0, "id"]))
fc.declare_ids.append((ID("other_display", True), ["display", 1, "id"]))
full_config.set(fc)
data = get_display_metadata(ID("no_such_display"))
assert data.width == 320
assert data.height == 240
assert data.has_hardware_rotation is False
assert data.has_writer is True
assert data.byte_order == BYTE_ORDER_LITTLE
assert data.rotation == 90
data = get_display_metadata(ID("other_display"))
assert data.width == 1024
assert data.height == 600
assert data.has_writer is False
def test_add_multiple_displays():
"""Test adding metadata for multiple displays."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp_a", 320, 240, True)
add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True)
add_metadata(ID("disp_a"), 320, 240)
add_metadata(ID("disp_b"), 128, 64, has_hardware_rotation=True)
all_meta = get_all_display_metadata()
assert len(all_meta) == 2
assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False)
assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True)
assert all_meta["disp_a"] == DisplayMetaData(320, 240, False)
assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, BYTE_ORDER_BIG)
def test_add_metadata_overwrites_existing():
"""Test that adding metadata for the same ID overwrites the previous entry."""
def test_add_duplicate_id_asserts():
"""Adding metadata for the same ID object twice should assert."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp", 320, 240, True)
add_metadata("disp", 640, 480, False, has_hardware_rotation=True)
meta = get_display_metadata("disp")
assert meta == DisplayMetaData(640, 480, False, True)
id_obj = ID("disp")
add_metadata(id_obj, 320, 240)
with pytest.raises(AssertionError, match="Duplicate"):
add_metadata(id_obj, 640, 480)
def test_metadata_is_frozen():
"""Test that DisplayMetaData instances are immutable (frozen dataclass)."""
meta = DisplayMetaData(320, 240, True, False)
meta = DisplayMetaData(320, 240, False, BYTE_ORDER_BIG)
with pytest.raises(AttributeError):
meta.width = 640
with pytest.raises(AttributeError):
meta.byte_order = BYTE_ORDER_LITTLE
def test_get_all_metadata_asserts_on_unresolved_id():
"""get_all_display_metadata should assert if any ID has id=None."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata(ID(None), 320, 240)
with pytest.raises(AssertionError, match="resolved"):
get_all_display_metadata()
def test_get_metadata_asserts_on_unresolved_id():
"""get_display_metadata should assert if any ID has id=None."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata(ID(None), 320, 240)
with pytest.raises(AssertionError, match="resolved"):
get_display_metadata(ID("anything"))

View File

@@ -0,0 +1,177 @@
"""Tests for LVGL final_validation display metadata checks."""
from __future__ import annotations
import pytest
from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER
from esphome.components.display import add_metadata
from esphome.components.lvgl import final_validation
from esphome.config import Config
from esphome.config_validation import Invalid
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE, ID
from esphome.final_validate import full_config
@pytest.fixture(autouse=True)
def _setup_core():
"""Ensure CORE.data has enough context for final_validation."""
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "host",
KEY_TARGET_FRAMEWORK: "",
}
full_config.set(Config())
yield
CORE.reset()
def _register_displays(*display_ids: str) -> None:
"""Register display IDs in full_config so get_path_for_id works."""
fc = full_config.get()
display_list = [{"id": ID(d, True)} for d in display_ids]
fc["display"] = display_list
for i, disp_id in enumerate(display_ids):
fc.declare_ids.append((ID(disp_id, True), ["display", i, "id"]))
def _make_lvgl_config(
display_ids: list[str],
byte_order: str | None = None,
) -> dict:
"""Build a minimal LVGL config dict for final_validation."""
_register_displays(*display_ids)
config = {
"displays": [ID(d, True) for d in display_ids],
"log_level": "WARN",
"color_depth": 16,
"transparency_key": 0x000400,
"draw_rounding": 2,
"buffer_size": 0,
}
if byte_order is not None:
config[CONF_BYTE_ORDER] = byte_order
return config
class TestByteOrderAutoConfig:
"""Test that LVGL auto-configures byte_order from display metadata."""
def test_inherits_big_endian_from_display(self) -> None:
"""LVGL should inherit big_endian from display metadata."""
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG
def test_inherits_little_endian_from_display(self) -> None:
"""LVGL should inherit little_endian from display metadata."""
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE
def test_defaults_to_big_endian_when_no_metadata(self) -> None:
"""LVGL should default to big_endian when display has no metadata."""
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG
class TestByteOrderExplicitMismatchError:
"""Test that LVGL rejects explicit byte_order mismatch with display."""
def test_raises_on_mismatch(self) -> None:
"""Explicit LVGL byte_order different from display should raise."""
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)]
with pytest.raises(
Invalid, match="LVGL byte order must match the display byte order"
):
final_validation(configs)
def test_no_error_when_matching(self) -> None:
"""Explicit LVGL byte_order matching display should pass."""
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG)
configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)]
final_validation(configs)
class TestByteOrderMultipleDisplays:
"""Test byte_order validation with multiple displays."""
def test_consistent_displays_inherit(self) -> None:
"""All displays with same byte_order should set LVGL byte_order."""
add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE)
configs = [_make_lvgl_config(["disp_a", "disp_b"])]
final_validation(configs)
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE
def test_inconsistent_displays_raises(self) -> None:
"""Displays with different byte_order should raise an error."""
add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_BIG)
add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE)
configs = [_make_lvgl_config(["disp_a", "disp_b"])]
with pytest.raises(Invalid, match="same byte_order"):
final_validation(configs)
class TestHasWriterCheck:
"""Test that LVGL rejects displays with has_writer set."""
def test_display_with_writer_raises(self) -> None:
"""Display with lambda/pages/auto_clear should be rejected."""
add_metadata(ID("my_disp"), 320, 240, has_writer=True)
configs = [_make_lvgl_config(["my_disp"])]
with pytest.raises(Invalid, match="not compatible with LVGL"):
final_validation(configs)
def test_display_without_writer_passes(self) -> None:
"""Display without writer should pass."""
add_metadata(ID("my_disp"), 320, 240, has_writer=False)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
class TestRotationCheck:
"""Test that LVGL rejects displays with non-zero rotation."""
def test_display_with_rotation_raises(self) -> None:
"""Display with rotation should be rejected."""
add_metadata(ID("my_disp"), 320, 240, rotation=90)
configs = [_make_lvgl_config(["my_disp"])]
with pytest.raises(Invalid, match="rotation.*not compatible with LVGL"):
final_validation(configs)
def test_display_without_rotation_passes(self) -> None:
"""Display with rotation=0 should pass."""
add_metadata(ID("my_disp"), 320, 240, rotation=0)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
class TestDrawRoundingMerge:
"""Test that display draw_rounding is merged into LVGL config."""
def test_display_draw_rounding_overrides_lower(self) -> None:
"""Display draw_rounding higher than LVGL default should win."""
add_metadata(ID("my_disp"), 320, 240, draw_rounding=8)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0]["draw_rounding"] == 8
def test_display_draw_rounding_does_not_lower(self) -> None:
"""Display draw_rounding lower than LVGL config should not reduce it."""
add_metadata(ID("my_disp"), 320, 240, draw_rounding=1)
configs = [_make_lvgl_config(["my_disp"])]
configs[0]["draw_rounding"] = 4
final_validation(configs)
assert configs[0]["draw_rounding"] == 4
def test_zero_draw_rounding_no_change(self) -> None:
"""Display with draw_rounding=0 should not affect LVGL config."""
add_metadata(ID("my_disp"), 320, 240, draw_rounding=0)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0]["draw_rounding"] == 2

View File

@@ -3,22 +3,15 @@
from collections.abc import Callable
from pathlib import Path
from esphome.components.display import (
DisplayMetaData,
get_all_display_metadata,
get_display_metadata,
)
from esphome.components.const import BYTE_ORDER_BIG
from esphome.components.display import get_all_display_metadata, get_display_metadata
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.mipi_spi.display import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
get_instance,
)
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
@@ -38,38 +31,32 @@ def test_metadata_native_quad_default_test_card(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
config = validated_config({"model": "JC3636W518"})
get_instance(config)
meta = get_display_metadata(str(config["id"]))
config = CONFIG_SCHEMA({"model": "JC3636W518", "id": "jc3232w518"})
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 360
assert meta.height == 360
# final validation auto-enables show_test_card when no drawing methods are configured
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG
def test_metadata_single_mode_with_dc_pin(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A single-mode display with no explicit drawing gets a test card from final validation."""
"""A single-mode display with no explicit drawing gets metadata from schema validation."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "ST7735",
"dc_pin": 18,
}
config = CONFIG_SCHEMA(
{"model": "ST7735", "dc_pin": 18, "id": "single_mode_with_dc_pin"}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 128
assert meta.height == 160
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG
def test_metadata_custom_dimensions(
@@ -80,47 +67,22 @@ def test_metadata_custom_dimensions(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
config = CONFIG_SCHEMA(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 480, "height": 320},
"init_sequence": [[0xA0, 0x01]],
"id": "custom_dimensions",
}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 480
assert meta.height == 320
# final validation auto-enables show_test_card
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
def test_metadata_with_test_card_has_writer(
set_core_config: SetCoreConfigCallable,
) -> None:
"""When show_test_card is enabled, has_writer should be True."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 240, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"show_test_card": True,
}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
assert meta is not None
assert meta.has_writer is True
def test_metadata_no_swap_xy_not_full_hardware_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -130,9 +92,8 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation(
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
# JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only
config = validated_config({"model": "JC3248W535"})
get_instance(config)
meta = get_display_metadata(str(config["id"]))
config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"})
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.has_hardware_rotation is False
@@ -145,7 +106,7 @@ def test_metadata_multiple_displays_independent(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config_a = validated_config(
CONFIG_SCHEMA(
{
"id": "disp_a",
"model": "custom",
@@ -154,7 +115,7 @@ def test_metadata_multiple_displays_independent(
"init_sequence": [[0xA0, 0x01]],
}
)
config_b = validated_config(
CONFIG_SCHEMA(
{
"id": "disp_b",
"model": "custom",
@@ -163,13 +124,16 @@ def test_metadata_multiple_displays_independent(
"init_sequence": [[0xA0, 0x01]],
}
)
get_instance(config_a)
get_instance(config_b)
all_meta = get_all_display_metadata()
# final validation auto-enables show_test_card for both
assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True)
assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True)
assert all_meta["disp_a"].width == 320
assert all_meta["disp_a"].height == 240
assert all_meta["disp_a"].has_hardware_rotation is True
assert all_meta["disp_a"].byte_order == BYTE_ORDER_BIG
assert all_meta["disp_b"].width == 128
assert all_meta["disp_b"].height == 64
assert all_meta["disp_b"].has_hardware_rotation is True
assert all_meta["disp_b"].byte_order == BYTE_ORDER_BIG
def test_metadata_via_code_generation_native(
@@ -179,12 +143,13 @@ def test_metadata_via_code_generation_native(
"""Full code generation for native.yaml should produce correct metadata."""
generate_main(component_fixture_path("native.yaml"))
all_meta = get_all_display_metadata()
# native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation
# native.yaml: model JC3636W518 -> 360x360, full hardware rotation
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
assert meta == DisplayMetaData(
width=360, height=360, has_writer=True, has_hardware_rotation=True
)
assert meta.width == 360
assert meta.height == 360
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG
def test_metadata_via_code_generation_lvgl(
@@ -194,9 +159,10 @@ def test_metadata_via_code_generation_lvgl(
"""Full code generation for lvgl.yaml should produce correct metadata."""
generate_main(component_fixture_path("lvgl.yaml"))
all_meta = get_all_display_metadata()
# lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation
# lvgl.yaml: model ST7735 -> 128x160, full hw rotation
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
assert meta == DisplayMetaData(
width=128, height=160, has_writer=False, has_hardware_rotation=True
)
assert meta.width == 128
assert meta.height == 160
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG