[mipi][mipi_spi][mipi_dsi][mipi_rgb] Transform cleanup (#17405)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Clyde Stubbs
2026-07-10 22:06:40 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 83aaed71e1
commit e1719cd85d
38 changed files with 630 additions and 130 deletions
@@ -24,6 +24,9 @@ spi:
mosi_pin: 6
clk_pin: 7
psram:
mode: quad
display:
- platform: mipi_spi
id: lcd_display
@@ -19,6 +19,9 @@ spi:
mosi_pin: 6
clk_pin: 7
psram:
mode: quad
display:
- platform: mipi_spi
id: lcd_display
@@ -4,6 +4,9 @@ esphome:
esp32:
board: esp32s3box
psram:
mode: octal
image:
defaults:
type: rgb565
@@ -71,6 +71,18 @@ def test_configuration_errors(set_core_config: SetCoreConfigCallable) -> None:
}
)
# DSI displays cannot swap axes; enabling swap_xy reports a clear error.
with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"):
CONFIG_SCHEMA(
{
"model": "custom",
"init_sequence": [[0xA0, 0x01]],
"lane_bit_rate": "1.5Gbps",
"dimensions": {"width": 320, "height": 240},
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": True},
}
)
def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None:
"""Test successful configuration validation."""
@@ -116,6 +128,33 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None:
CONFIG_SCHEMA(config)
def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None:
"""A configured display rotation is recorded in the metadata.
LVGL relies on this to flag a rotation set in the display config (see the
mipi_spi tests for the end-to-end LVGL rejection).
"""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4},
)
from esphome.components.display import get_display_metadata
from esphome.components.mipi_dsi.display import CONFIG_SCHEMA
base = {
"model": "custom",
"init_sequence": [[0xA0, 0x01]],
"lane_bit_rate": "1.5Gbps",
"dimensions": {"width": 320, "height": 240},
}
config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90})
assert get_display_metadata(config["id"]).rotation == 90
config = CONFIG_SCHEMA({**base, "id": "unrotated"})
assert get_display_metadata(config["id"]).rotation == 0
def test_code_generation(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
@@ -0,0 +1,89 @@
"""Tests for mipi_rgb configuration validation, in particular the per-model
``requires`` component check (see esphome.components.mipi.DriverChip.check_requirements)."""
from __future__ import annotations
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that
# models (e.g. SEEED-INDICATOR-D1) that reference pca9554-backed pins in their
# defaults can be validated by the mipi_rgb CONFIG_SCHEMA in this test.
import esphome.components.pca9554 # noqa: F401
from esphome.const import PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _validated(config: ConfigType) -> ConfigType:
"""Run the component config schema followed by the final validation."""
config = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(config)
return config
def test_model_requires_psram(set_core_config: SetCoreConfigCallable) -> None:
"""A model known to have PSRAM on its board rejects a config without it.
RGB parallel displays always need a full framebuffer, so every model in this
component is expected to carry ``requires={"psram", ...}``. This board has no
other requirements, so its check is exercised in isolation here.
"""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
CORE.raw_config = {}
with pytest.raises(
cv.Invalid,
match=r"ESP32-8048S070 requires component 'psram' to be configured",
):
_validated({"model": "ESP32-8048S070"})
def test_model_requires_psram_satisfied(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""The same board model validates once PSRAM is configured."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
config = _validated({"model": "ESP32-8048S070"})
assert config["model"] == "ESP32-8048S070"
def test_model_requires_psram_and_expander(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""A model that also depends on an I2C GPIO expander lists both when missing."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
# Only satisfy one of the two requirements.
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
with pytest.raises(
cv.Invalid,
match=r"SEEED-INDICATOR-D1 requires component 'pca9554' to be configured",
):
_validated(
{
"model": "SEEED-INDICATOR-D1",
"spi_id": "spi_bus",
}
)
@@ -0,0 +1,137 @@
"""Tests for mipi_rgb configuration validation."""
import pytest
from esphome import config_validation as cv
# Importing these registers their pin schemas with pins.PIN_SCHEMA_REGISTRY so that
# models referencing IO-expander-backed pins in their defaults (e.g. the LilyGO
# T-RGB boards via xl9535, SEEED-INDICATOR-D1 via pca9554, or the Waveshare panels
# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test.
import esphome.components.ch422g # noqa: F401
from esphome.components.display import get_display_metadata
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3
import esphome.components.pca9554 # noqa: F401
import esphome.components.xl9535 # noqa: F401
from esphome.const import (
CONF_BLUE,
CONF_DIMENSIONS,
CONF_GREEN,
CONF_HEIGHT,
CONF_INIT_SEQUENCE,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_RED,
CONF_SWAP_XY,
CONF_WIDTH,
KEY_VARIANT,
PlatformFramework,
)
from tests.component_tests.types import SetCoreConfigCallable
# A generic set of data pins so that models without a default pin assignment
# (e.g. CUSTOM and RPI) still validate.
DATA_PINS = {
CONF_RED: [1, 2, 3, 4, 5],
CONF_GREEN: [6, 7, 8, 9, 10, 11],
CONF_BLUE: [12, 13, 14, 15, 16],
}
def _set_s3(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None:
"""Every predefined model validates once required defaults are supplied."""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
for name, model in MODELS.items():
config = {"model": name, "data_pins": DATA_PINS, "pclk_pin": 21}
if model.initsequence is None:
config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]]
if not model.get_default(CONF_WIDTH):
config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480}
CONFIG_SCHEMA(config)
def test_transform_matches_model_support(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The transform schema only accepts the axes a model actually supports."""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
# ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays
# never support axis swapping).
model = MODELS["ESP32-8048S070"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}})
# An unsupported axis may be explicitly disabled (a harmless no-op)...
CONFIG_SCHEMA(
{**base, "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": False}}
)
# ...but enabling it reports a clear, model-specific error.
with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"):
CONFIG_SCHEMA(
{
**base,
"transform": {"mirror_x": True, "mirror_y": False, "swap_xy": True},
}
)
def test_st7701s_only_supports_mirror_x(
set_core_config: SetCoreConfigCallable,
) -> None:
"""ST7701S panels shorter than full height only expose mirror_x.
mirror_y only works at full height (864px), so the LilyGO 480px panels must
reject a mirror_y transform.
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
model = MODELS["T-RGB-2.1"]
assert model.transforms == {CONF_MIRROR_X}
assert CONF_SWAP_XY not in model.transforms
base = {"model": "T-RGB-2.1"}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}})
with pytest.raises(cv.Invalid, match="'mirror_y' is not supported by this model"):
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}})
def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A configured display rotation is recorded in the metadata.
LVGL relies on this to flag a rotation set in the display config (see the
mipi_spi tests for the end-to-end LVGL rejection).
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90})
assert get_display_metadata(config["id"]).rotation == 90
config = CONFIG_SCHEMA({**base, "id": "unrotated"})
assert get_display_metadata(config["id"]).rotation == 0
@@ -3,6 +3,9 @@
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
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 (
@@ -13,6 +16,7 @@ from esphome.components.esp32 import (
)
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
from esphome.const import PlatformFramework
from esphome.core import ID
from tests.component_tests.types import SetCoreConfigCallable
@@ -23,6 +27,18 @@ def validated_config(config):
return config
def _lvgl_config(display_id: str) -> dict:
"""Build a minimal LVGL config dict referencing the given display id."""
return {
"displays": [ID(display_id, True)],
"log_level": "WARN",
"color_depth": 16,
"transparency_key": 0x000400,
"draw_rounding": 2,
"buffer_size": 0,
}
def test_metadata_native_quad_default_test_card(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -91,7 +107,7 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
# JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only
# JC3248W535 has transforms={mirror_x, mirror_y} only
config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"})
meta = get_display_metadata(config["id"])
assert meta is not None
@@ -166,3 +182,69 @@ def test_metadata_via_code_generation_lvgl(
assert meta.height == 160
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG
def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A configured display rotation is recorded in the metadata."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = CONFIG_SCHEMA(
{"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90}
)
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.rotation == 90
def test_metadata_rotation_defaults_to_zero(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A display without a rotation reports rotation 0 in its metadata."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"})
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.rotation == 0
def test_rotation_flagged_when_used_with_lvgl(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A display with a rotation is rejected when driven by LVGL.
LVGL manages its own rotation, so a rotation set in the display config must be
flagged and the user directed to configure it in the LVGL block instead. This
exercises the full chain: the mipi_spi schema records the rotation in the
display metadata, and LVGL's final validation reports it.
"""
from esphome.components.lvgl import final_validation
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90})
with pytest.raises(cv.Invalid, match="rotation.*not compatible with LVGL"):
final_validation([_lvgl_config("rotated")])
def test_no_rotation_accepted_with_lvgl(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A display without a rotation validates cleanly when driven by LVGL."""
from esphome.components.lvgl import final_validation
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"})
# Should not raise.
final_validation([_lvgl_config("unrotated")])
@@ -6,10 +6,13 @@ from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.display import CONF_SHOW_TEST_CARD
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.components.mipi import DriverChip
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
from esphome.const import CONF_BUFFER_SIZE, PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
@@ -183,3 +186,77 @@ def test_buffer_size_selected_when_lvgl_with_test_card(
)
assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4)
def test_requires_missing_single_component_raises() -> None:
"""A model that requires a single component raises when it is absent."""
CORE.raw_config = {}
chip = DriverChip("TEST-REQUIRES-PSRAM", requires={"psram"})
with pytest.raises(
cv.Invalid,
match=r"TEST-REQUIRES-PSRAM requires component 'psram' to be configured",
):
chip.check_requirements()
def test_requires_missing_multiple_components_raises() -> None:
"""A model that requires several components lists all the missing ones, pluralized."""
CORE.raw_config = {}
chip = DriverChip("TEST-REQUIRES-MULTI", requires={"psram", "pca9554"})
with pytest.raises(
cv.Invalid,
match=r"TEST-REQUIRES-MULTI requires components '.*' to be configured",
) as excinfo:
chip.check_requirements()
assert "psram" in str(excinfo.value)
assert "pca9554" in str(excinfo.value)
def test_requires_satisfied_does_not_raise() -> None:
"""No error is raised once all the required components are configured."""
CORE.raw_config = {"psram": True, "pca9554": []}
chip = DriverChip("TEST-REQUIRES-SATISFIED", requires={"psram", "pca9554"})
chip.check_requirements() # Should not raise
def test_requires_absent_does_not_raise() -> None:
"""Models without a requires set are unaffected by the check."""
CORE.raw_config = {}
chip = DriverChip("TEST-REQUIRES-NONE")
chip.check_requirements() # Should not raise
def test_predefined_model_requires_psram(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A predefined board model known to have PSRAM rejects a config without it."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
CORE.raw_config = {}
with pytest.raises(
cv.Invalid, match=r"S3BOX requires component 'psram' to be configured"
):
_validated({"model": "s3box"})
def test_predefined_model_requires_psram_satisfied(
set_core_config: SetCoreConfigCallable,
set_component_config: Any,
) -> None:
"""The same board model validates once PSRAM is configured."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("psram", True)
CORE.raw_config = {"psram": True}
config = _validated({"model": "s3box"})
assert config["model"] == "S3BOX"
+1 -1
View File
@@ -136,7 +136,7 @@ def test_dimension_validation(
"model": "JC3248W535",
"transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True},
},
"Axis swapping not supported by this model",
"'swap_xy' is not supported by this model",
id="axis_swapping_not_supported",
),
pytest.param(
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
@@ -222,6 +223,7 @@ class TestNewModelVariants:
def test_m5core2_with_native_dimensions(
self,
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Test M5CORE2 variant with reset native_width and native_height."""
set_core_config(
@@ -231,6 +233,8 @@ class TestNewModelVariants:
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# M5CORE2 has PSRAM on board and requires it to be configured
set_component_config("psram", True)
# M5CORE2 should validate successfully
config = validated_config({"model": "M5CORE2"})
@@ -24,6 +24,9 @@ spi:
mosi_pin: 6
clk_pin: 7
psram:
mode: quad
display:
- platform: mipi_spi
id: lcd_display
@@ -23,6 +23,9 @@ spi:
mosi_pin: 6
clk_pin: 7
psram:
mode: quad
display:
- platform: mipi_spi
id: lcd_display