mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
454 lines
14 KiB
Python
454 lines
14 KiB
Python
"""Tests for mipi_spi configuration validation."""
|
|
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from esphome import config_validation as cv
|
|
from esphome.components.esp32 import (
|
|
KEY_BOARD,
|
|
KEY_VARIANT,
|
|
VARIANT_ESP32,
|
|
VARIANT_ESP32S3,
|
|
)
|
|
from esphome.components.mipi import CONF_NATIVE_HEIGHT
|
|
from esphome.components.mipi_spi.display import (
|
|
CONF_BUS_MODE,
|
|
CONFIG_SCHEMA,
|
|
FINAL_VALIDATE_SCHEMA,
|
|
MODELS,
|
|
dimension_schema,
|
|
)
|
|
from esphome.const import (
|
|
CONF_DC_PIN,
|
|
CONF_DIMENSIONS,
|
|
CONF_HEIGHT,
|
|
CONF_INIT_SEQUENCE,
|
|
CONF_WIDTH,
|
|
PlatformFramework,
|
|
)
|
|
from esphome.types import ConfigType
|
|
from tests.component_tests.types import SetCoreConfigCallable
|
|
|
|
|
|
def run_schema_validation(config: ConfigType) -> None:
|
|
"""Run schema validation on a configuration."""
|
|
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("config", "error_match"),
|
|
[
|
|
pytest.param(
|
|
"a string",
|
|
"expected a dictionary",
|
|
id="invalid_string_config",
|
|
),
|
|
pytest.param(
|
|
{"id": "display_id"},
|
|
r"required key not provided @ data\['model'\]",
|
|
id="missing_model",
|
|
),
|
|
pytest.param(
|
|
{"id": "display_id", "model": "custom", "init_sequence": [[0x36, 0x01]]},
|
|
r"required key not provided @ data\['dimensions'\]",
|
|
id="missing_dimensions",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"model": "custom",
|
|
"dc_pin": 18,
|
|
"dimensions": {"width": 320, "height": 240},
|
|
},
|
|
r"required key not provided @ data\['init_sequence'\]",
|
|
id="missing_init_sequence",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"id": "display_id",
|
|
"model": "custom",
|
|
"dimensions": {"width": 260, "height": 260},
|
|
"draw_rounding": 13,
|
|
"init_sequence": [[0xA0, 0x01]],
|
|
},
|
|
r"value must be a power of two for dictionary value @ data\['draw_rounding'\]",
|
|
id="invalid_draw_rounding",
|
|
),
|
|
],
|
|
)
|
|
def test_basic_configuration_errors(
|
|
config: str | ConfigType,
|
|
error_match: str,
|
|
set_core_config: SetCoreConfigCallable,
|
|
) -> None:
|
|
"""Test basic configuration validation errors"""
|
|
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF,
|
|
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
|
)
|
|
|
|
with pytest.raises(cv.Invalid, match=error_match):
|
|
run_schema_validation(config)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("rounding", "config", "error_match"),
|
|
[
|
|
pytest.param(
|
|
4,
|
|
{"width": 320},
|
|
r"required key not provided @ data\['height'\]",
|
|
id="missing_height",
|
|
),
|
|
pytest.param(
|
|
32,
|
|
{"width": 320, "height": 111},
|
|
"Dimensions and offsets must be divisible by 32",
|
|
id="dimensions_not_divisible",
|
|
),
|
|
],
|
|
)
|
|
def test_dimension_validation(
|
|
rounding: int,
|
|
config: ConfigType,
|
|
error_match: str,
|
|
set_core_config: SetCoreConfigCallable,
|
|
) -> None:
|
|
"""Test dimension-related validation errors"""
|
|
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF,
|
|
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
|
)
|
|
|
|
with pytest.raises(cv.Invalid, match=error_match):
|
|
dimension_schema(rounding)(config)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("config", "error_match"),
|
|
[
|
|
pytest.param(
|
|
{
|
|
"model": "JC3248W535",
|
|
"transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True},
|
|
},
|
|
"'swap_xy' is not supported by this model",
|
|
id="axis_swapping_not_supported",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"model": "custom",
|
|
"dimensions": {"width": 320, "height": 240},
|
|
"transform": {"mirror_x": False, "mirror_y": True, "swap_xy": False},
|
|
"init_sequence": [[0x36, 0x01]],
|
|
},
|
|
r"transform is not supported when MADCTL \(0X36\) is in the init sequence",
|
|
id="transform_with_madctl",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"model": "custom",
|
|
"dimensions": {"width": 320, "height": 240},
|
|
"init_sequence": [[0x3A, 0x01]],
|
|
},
|
|
r"PIXFMT \(0X3A\) should not be in the init sequence, it will be set automatically",
|
|
id="pixfmt_in_init_sequence",
|
|
),
|
|
],
|
|
)
|
|
def test_transform_and_init_sequence_errors(
|
|
config: ConfigType,
|
|
error_match: str,
|
|
set_core_config: SetCoreConfigCallable,
|
|
) -> None:
|
|
"""Test transform and init sequence validation errors"""
|
|
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF,
|
|
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
|
)
|
|
|
|
with pytest.raises(cv.Invalid, match=error_match):
|
|
run_schema_validation(config)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("config", "error_match"),
|
|
[
|
|
pytest.param(
|
|
{"model": "t4-s3", "dc_pin": 18},
|
|
"DC pin is not supported in quad mode",
|
|
id="dc_pin_not_supported_quad_mode",
|
|
),
|
|
pytest.param(
|
|
{"model": "t4-s3", "color_depth": 18},
|
|
"Unknown value '18', valid options are '16', '16bit",
|
|
id="invalid_color_depth_t4_s3",
|
|
),
|
|
pytest.param(
|
|
{"model": "t-embed", "color_depth": 24},
|
|
"Unknown value '24', valid options are '16', '8",
|
|
id="invalid_color_depth_t_embed",
|
|
),
|
|
pytest.param(
|
|
{"model": "ili9488"},
|
|
"DC pin is required in single mode",
|
|
id="dc_pin_required_single_mode",
|
|
),
|
|
pytest.param(
|
|
{"model": "wt32-sc01-plus", "brightness": 128},
|
|
r"extra keys not allowed @ data\['brightness'\]",
|
|
id="brightness_not_supported",
|
|
),
|
|
],
|
|
)
|
|
def test_esp32s3_specific_errors(
|
|
config: ConfigType,
|
|
error_match: str,
|
|
set_core_config: SetCoreConfigCallable,
|
|
) -> None:
|
|
"""Test ESP32-S3 specific configuration errors"""
|
|
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF,
|
|
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
|
|
)
|
|
|
|
with pytest.raises(cv.Invalid, match=error_match):
|
|
run_schema_validation(config)
|
|
|
|
|
|
def test_custom_model_with_all_options(
|
|
set_core_config: SetCoreConfigCallable,
|
|
) -> None:
|
|
"""Test custom model configuration with all available options."""
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF,
|
|
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
|
|
)
|
|
|
|
run_schema_validation(
|
|
{
|
|
"model": "custom",
|
|
"pixel_mode": "18bit",
|
|
"color_depth": 8,
|
|
"id": "display_id",
|
|
"byte_order": "little_endian",
|
|
"bus_mode": "single",
|
|
"color_order": "rgb",
|
|
"dc_pin": 11,
|
|
"reset_pin": 12,
|
|
"enable_pin": 13,
|
|
"cs_pin": 14,
|
|
"init_sequence": [[0xA0, 0x01]],
|
|
"dimensions": {
|
|
"width": 320,
|
|
"height": 240,
|
|
"offset_width": 32,
|
|
"offset_height": 32,
|
|
},
|
|
"invert_colors": True,
|
|
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
|
|
"spi_mode": "mode0",
|
|
"data_rate": "40MHz",
|
|
"use_axis_flips": True,
|
|
"draw_rounding": 4,
|
|
"spi_16": True,
|
|
"buffer_size": 0.25,
|
|
}
|
|
)
|
|
|
|
|
|
def test_all_predefined_models(
|
|
set_core_config: SetCoreConfigCallable,
|
|
set_component_config: Callable[[str, Any], None],
|
|
choose_variant_with_pins: Callable[[list], None],
|
|
) -> None:
|
|
"""Test all predefined display models validate successfully with appropriate defaults."""
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF,
|
|
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
|
|
)
|
|
|
|
# Enable PSRAM which is required for some models
|
|
set_component_config("psram", True)
|
|
|
|
# Test all models, providing default values where necessary
|
|
for name, model in MODELS.items():
|
|
config = {"model": name}
|
|
|
|
# Get the pins required by this model and find a compatible variant
|
|
pins = []
|
|
for pin_name in ("dc_pin", "reset_pin", "cs_pin", "enable_pin"):
|
|
pin_value = model.get_default(pin_name, None)
|
|
if pin_value is not None:
|
|
if isinstance(pin_value, list):
|
|
pins.extend(pin_value)
|
|
else:
|
|
pins.append(pin_value)
|
|
choose_variant_with_pins(pins)
|
|
|
|
# Add required fields that don't have defaults
|
|
if (
|
|
not model.get_default(CONF_DC_PIN)
|
|
and model.get_default(CONF_BUS_MODE) != "quad"
|
|
):
|
|
config[CONF_DC_PIN] = 14
|
|
if not model.get_default(CONF_NATIVE_HEIGHT):
|
|
config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320}
|
|
if model.initsequence is None:
|
|
config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]]
|
|
|
|
run_schema_validation(config)
|
|
|
|
|
|
def test_single_bus_no_cs_no_mode_warns(
|
|
set_core_config: SetCoreConfigCallable,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default."""
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF,
|
|
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
|
)
|
|
|
|
run_schema_validation({"model": "ili9488", "dc_pin": 14})
|
|
|
|
assert "defaulting to MODE3 due to lack of CS pin" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"config",
|
|
[
|
|
pytest.param(
|
|
{"model": "ili9488", "dc_pin": 14, "cs_pin": 0},
|
|
id="cs_pin_provided",
|
|
),
|
|
pytest.param(
|
|
{"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"},
|
|
id="spi_mode_provided",
|
|
),
|
|
],
|
|
)
|
|
def test_single_bus_no_mode_warning_suppressed(
|
|
config: ConfigType,
|
|
set_core_config: SetCoreConfigCallable,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""No MODE3 warning when a CS pin or an explicit SPI mode is provided."""
|
|
set_core_config(
|
|
PlatformFramework.ESP32_IDF,
|
|
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
|
)
|
|
|
|
run_schema_validation(config)
|
|
|
|
assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text
|
|
|
|
|
|
def test_native_generation(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_fixture_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Test code generation for display."""
|
|
|
|
main_cpp = generate_main(component_fixture_path("native.yaml"))
|
|
assert (
|
|
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
|
|
in main_cpp
|
|
)
|
|
# A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands.
|
|
assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp
|
|
assert "show_test_card();" in main_cpp
|
|
assert "set_write_only(true);" in main_cpp
|
|
|
|
|
|
def test_lvgl_generation(
|
|
generate_main: Callable[[str | Path], str],
|
|
component_fixture_path: Callable[[str], Path],
|
|
) -> None:
|
|
"""Test LVGL generation configuration."""
|
|
|
|
main_cpp = generate_main(component_fixture_path("lvgl.yaml"))
|
|
assert (
|
|
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
|
|
in main_cpp
|
|
)
|
|
# A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands.
|
|
assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp
|
|
assert "show_test_card();" not in main_cpp
|
|
assert "set_auto_clear(false);" in main_cpp
|
|
|
|
|
|
# A 10ms delay (flattened to {10, 0xFF}, where 0xFF is the delay marker byte) is
|
|
# always prepended to the init sequence, since both a software and a hardware reset
|
|
# need to settle before further commands. A custom model has no reset_pin default
|
|
# and does not set no_swreset, so when no reset pin is configured the SWRESET command
|
|
# ({1, 0}: command 0x01 with no parameters) is prepended ahead of that delay.
|
|
_SWRESET_YAML = """
|
|
esphome:
|
|
name: swreset-test
|
|
esp32:
|
|
board: esp32-s3-devkitc-1
|
|
framework:
|
|
type: esp-idf
|
|
spi:
|
|
clk_pin: 1
|
|
mosi_pin: 2
|
|
display:
|
|
- platform: mipi_spi
|
|
model: custom
|
|
id: {display_id}
|
|
dc_pin: 4
|
|
cs_pin: 8
|
|
dimensions:
|
|
width: 320
|
|
height: 240
|
|
init_sequence:
|
|
- [0xA0, 0x01]
|
|
{reset_line}
|
|
"""
|
|
|
|
|
|
def test_swreset_prepended_without_reset_pin(
|
|
generate_main: Callable[[str | Path], str],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A model with no reset pin (and no no_swreset) gets SWRESET prepended."""
|
|
yaml_file = tmp_path / "swreset.yaml"
|
|
yaml_file.write_text(
|
|
_SWRESET_YAML.format(display_id="swreset_display", reset_line="")
|
|
)
|
|
|
|
main_cpp = generate_main(yaml_file)
|
|
|
|
# SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of
|
|
# the model's own commands.
|
|
assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp
|
|
|
|
|
|
def test_swreset_not_prepended_with_reset_pin(
|
|
generate_main: Callable[[str | Path], str],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A hardware reset pin performs the reset, so SWRESET must not be prepended.
|
|
|
|
The post-reset delay is still required, so the sequence starts with the delay.
|
|
"""
|
|
yaml_file = tmp_path / "hwreset.yaml"
|
|
yaml_file.write_text(
|
|
_SWRESET_YAML.format(
|
|
display_id="hwreset_display", reset_line=" reset_pin: 5"
|
|
)
|
|
)
|
|
|
|
main_cpp = generate_main(yaml_file)
|
|
|
|
# The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}).
|
|
assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp
|
|
assert "hwreset_display->set_init_sequence({1, 0," not in main_cpp
|