From 3edb476c2d6f3501ec34047b7c4aeae24f9de004 Mon Sep 17 00:00:00 2001 From: clyde <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 27 May 2026 19:39:52 +1000 Subject: [PATCH 1/2] [lvgl][mipi_spi][mipi_rgb][mipi_dsi] Metadata validation --- esphome/components/display/__init__.py | 67 +++++-- esphome/components/lvgl/__init__.py | 48 +++-- esphome/components/mipi_dsi/display.py | 17 +- esphome/components/mipi_rgb/display.py | 17 +- esphome/components/mipi_spi/display.py | 28 ++- .../display/test_display_metadata.py | 91 ++++++--- tests/component_tests/lvgl/test_validation.py | 182 ++++++++++++++++++ .../mipi_spi/test_display_metadata.py | 100 ++++------ 8 files changed, 423 insertions(+), 127 deletions(-) create mode 100644 tests/component_tests/lvgl/test_validation.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 744b5d16c495..cadf3f051a44 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -3,7 +3,7 @@ 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, KEY_METADATA import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, @@ -18,8 +18,7 @@ CONF_UPDATE_INTERVAL, 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 DOMAIN = "display" IS_PLATFORM_COMPONENT = True @@ -159,29 +158,71 @@ 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: str) -> DisplayMetaData: + """Get display metadata by ID string. + + Must not be called before IDs have been finalised. + """ + for id_, meta in _get_metadata_list(): + assert id_.id is not None, ( + "get_display_metadata called before display IDs have been resolved" + ) + if id_.id == display_id: + return meta + return DisplayMetaData() 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, + ), + ) ) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6e005f897e58..1cd87faf778b 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -7,6 +7,7 @@ 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.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,31 +213,50 @@ def multi_conf_validate(configs: list[dict]): def final_validation(config_list): + global_config = full_config.get() + # Resolve byte_order from display metadata before multi-config validation + for config in config_list: + display_byte_orders = set() + for display_id in config[df.CONF_DISPLAYS]: + meta = get_display_metadata(display_id.id) + if meta.byte_order: + display_byte_orders.add(meta.byte_order) + 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: + LOGGER.warning( + "LVGL byte_order '%s' does not match display byte_order '%s'", + config[CONF_BYTE_ORDER], + display_order, + ) + else: + config[CONF_BYTE_ORDER] = display_order + if CONF_BYTE_ORDER not in config: + config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG if len(config_list) != 1: multi_conf_validate(config_list) - global_config = full_config.get() for config in config_list: 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: + meta = get_display_metadata(str(display_id)) + if meta.has_writer: raise cv.Invalid( - "Using lambda: or pages: in display config is not compatible with LVGL" + "Using lambda:, pages:, or auto_clear_enabled: true in display config is not compatible with LVGL" ) # treating 0 as false is intended here. - if display.get(CONF_ROTATION): + if meta.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): + if meta.draw_rounding: config[CONF_DRAW_ROUNDING] = max( - draw_rounding, config[CONF_DRAW_ROUNDING] + meta.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: @@ -583,7 +601,7 @@ def _theme_schema(value: dict) -> dict: 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( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 026c21456923..346bf5bfdd84 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -37,6 +37,7 @@ ) 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, + 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 def _final_validate(config): diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 4952bda95f0a..b38ddad49149 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -39,6 +39,7 @@ ) 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 diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 364ada90463a..3c5a84594eb4 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -30,6 +30,7 @@ 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 @@ 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( diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py index ef3f12cb735c..4ed2f75e8706 100644 --- a/tests/component_tests/display/test_display_metadata.py +++ b/tests/component_tests/display/test_display_metadata.py @@ -4,77 +4,116 @@ 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.core import ID -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) + add_metadata(ID("my_display"), 320, 240) meta = get_display_metadata("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("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) + add_metadata(ID("disp"), 128, 64) meta = get_display_metadata("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", {}): + add_metadata(ID("disp"), 240, 320, byte_order=BYTE_ORDER_LITTLE) + meta = get_display_metadata("disp") + assert meta.byte_order == BYTE_ORDER_LITTLE + + +def test_get_display_metadata_missing_returns_default(): + """Test that querying a non-existent ID returns default metadata.""" 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 assert data.has_hardware_rotation is False + assert data.byte_order == BYTE_ORDER_BIG 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("anything") diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py new file mode 100644 index 000000000000..7bbcf24c55e8 --- /dev/null +++ b/tests/component_tests/lvgl/test_validation.py @@ -0,0 +1,182 @@ +"""Tests for LVGL final_validation display metadata checks.""" + +from __future__ import annotations + +import logging + +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 TestByteOrderExplicitMismatchWarning: + """Test that LVGL warns when explicit byte_order doesn't match display.""" + + def test_warns_on_mismatch(self, caplog) -> None: + """Explicit LVGL byte_order different from display should warn.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE) + configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)] + with caplog.at_level(logging.WARNING): + final_validation(configs) + assert any("does not match" in msg for msg in caplog.messages) + # Explicit value is preserved despite the warning + assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG + + def test_no_warning_when_matching(self, caplog) -> None: + """Explicit LVGL byte_order matching display should not warn.""" + add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG) + configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)] + with caplog.at_level(logging.WARNING): + final_validation(configs) + assert not any("does not match" in msg for msg in caplog.messages) + + +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) # should not raise + + +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) # should not raise + + +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 # default from _make_lvgl_config diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index c11c7816e4e1..85b1ae48f7c8 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -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) + config = CONFIG_SCHEMA({"model": "JC3636W518", "id": "jc3232w518"}) meta = get_display_metadata(str(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"])) 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"])) 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_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) + meta = get_display_metadata("jc3248w535") 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 From 6dc30ac796d6ab8128fef0735eb6925c2f5a9e20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 11:13:39 +0000 Subject: [PATCH 2/2] Initial plan