Merge remote-tracking branch 'origin/dev' into integration

This commit is contained in:
J. Nick Koston
2026-07-18 10:21:31 -10:00
466 changed files with 14801 additions and 3978 deletions
@@ -16,7 +16,7 @@ class ZWaveProxy {
public:
api::APIConnection *get_api_connection() { return nullptr; }
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
void send_frame(const uint8_t *data, size_t length) {}
void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {}
void api_connection_authenticated(api::APIConnection *conn) {}
uint32_t get_feature_flags() const { return 0; }
uint32_t get_home_id() { return 0; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,33 @@
# New `image:` `platform: animation` form. Exercises animation/image.py through
# the real platform loader and codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
image:
- platform: animation
id: test_animation
file: anim.gif
type: rgb565
loop:
start_frame: 0
end_frame: 2
repeat: 3
- platform: animation
id: test_animation_no_loop
file: anim.gif
type: rgb565
spi:
mosi_pin: 6
clk_pin: 7
psram:
mode: quad
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,28 @@
# Legacy top-level `animation:` form. Exercises the deprecation shim and the
# shared codegen path through the real read_config/codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
animation:
- id: test_animation
file: anim.gif
type: rgb565
loop:
start_frame: 0
end_frame: 2
repeat: 3
spi:
mosi_pin: 6
clk_pin: 7
psram:
mode: quad
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,81 @@
"""Tests for the animation image platform and the legacy `animation:` shim."""
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
from esphome.components.animation import (
DOMAIN,
LEGACY_REMOVAL_VERSION,
_capture_legacy_entry,
_warn_legacy_animation,
)
from esphome.core import CORE
from esphome.types import ConfigType
# ---------------------------------------------------------------------------
# Legacy top-level `animation:` deprecation shim -- REMOVE these tests after
# 2027.1.0 together with the shim in esphome/components/animation/__init__.py.
# ---------------------------------------------------------------------------
def test_warn_legacy_animation_warns_once(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecation warning fires exactly once and never mutates the config."""
config: ConfigType = {"id": "test_animation", "file": "anim.gif", "type": "rgb565"}
# A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the
# one-shot warning can print a pasteable migrated block.
assert _capture_legacy_entry(config) is config
with caplog.at_level(logging.WARNING):
# First call: flag not yet set -> warns and records the flag.
assert _warn_legacy_animation(config) is config
# Second call: flag already set -> stays silent (the dedup branch).
assert _warn_legacy_animation(config) is config
assert CORE.data[DOMAIN]["legacy_warning_shown"] is True
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "deprecated" in caplog.text
assert "platform: animation" in caplog.text
assert LEGACY_REMOVAL_VERSION in caplog.text
def test_legacy_animation_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The legacy `animation:` block validates, warns, and generates codegen
through the real read_config/codegen pipeline."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(component_config_path("animation_test.yaml"))
# Deprecation warning surfaced through the real validation pipeline.
assert "animation" in caplog.text
assert "deprecated" in caplog.text
# setup_animation ran: Animation object constructed and loop configured.
assert "new(test_animation) animation::Animation(" in main_cpp
assert "test_animation->set_loop(0, 2, 3);" in main_cpp
def test_animation_platform_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The `image:` `platform: animation` form generates codegen through the
real platform loader (animation/image.py) without any deprecation warning."""
main_cpp = generate_main(component_config_path("animation_platform_test.yaml"))
assert "new(test_animation) animation::Animation(" in main_cpp
assert "test_animation->set_loop(0, 2, 3);" in main_cpp
# The loop-less entry constructs the object but never configures a loop.
assert "new(test_animation_no_loop) animation::Animation(" in main_cpp
assert "test_animation_no_loop->set_loop(" not in main_cpp
+35
View File
@@ -0,0 +1,35 @@
"""Config-validation tests for the aqi sensor component."""
import pytest
from voluptuous import Invalid
from esphome.components.aqi import CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE
from esphome.components.aqi.sensor import _validate_extended_range
def test_extended_range_rejected_with_caqi():
"""extended_range has no meaning for CAQI (no spec maximum) and must be rejected."""
with pytest.raises(Invalid, match="CAQI"):
_validate_extended_range(
{CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: True}
)
def test_extended_range_rejected_with_caqi_even_when_false():
"""The option is not allowed at all with CAQI, regardless of its value."""
with pytest.raises(Invalid, match="CAQI"):
_validate_extended_range(
{CONF_CALCULATION_TYPE: "CAQI", CONF_EXTENDED_RANGE: False}
)
def test_extended_range_allowed_with_aqi():
"""extended_range is valid for the US AQI calculation."""
config = {CONF_CALCULATION_TYPE: "AQI", CONF_EXTENDED_RANGE: True}
assert _validate_extended_range(config) is config
def test_caqi_without_extended_range_ok():
"""CAQI is fine as long as extended_range is not set."""
config = {CONF_CALCULATION_TYPE: "CAQI"}
assert _validate_extended_range(config) is config
@@ -33,6 +33,43 @@ def test_deep_sleep_run_duration_simple(generate_main):
assert "deepsleep->set_run_duration(10000);" in main_cpp
def test_deep_sleep_on_wake_trigger(generate_main):
"""
When deep sleep is configured with a component-level on_wake automation,
a WakeTrigger component should be registered with the wakeup cause as
the automation argument.
"""
main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml")
assert "deep_sleep::WakeTrigger();" in main_cpp
assert "Automation<deep_sleep::WakeupCause>" in main_cpp
def test_deep_sleep_ext1_on_wake_triggers(generate_main):
"""
Each esp32_ext1_wakeup pin with an on_wake automation should get its own
Ext1WakeTrigger with the pin number, and all pins (including the legacy
bare-pin shorthand) should contribute to the ext1 wakeup mask.
"""
main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep3.yaml")
assert "deep_sleep::Ext1WakeTrigger(2);" in main_cpp
assert "deep_sleep::Ext1WakeTrigger(4);" in main_cpp
# GPIO13 has no on_wake, so no trigger is created for it
assert "deep_sleep::Ext1WakeTrigger(13)" not in main_cpp
# mask covers GPIO2, GPIO4 and GPIO13
assert ".mask = 8212," in main_cpp
def test_deep_sleep_no_on_wake_no_triggers(generate_main):
"""
Without any on_wake automations, no wake trigger code should be generated.
"""
main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep1.yaml")
assert "WakeTrigger" not in main_cpp
def test_deep_sleep_run_duration_dictionary(generate_main):
"""
When deep sleep is configured with dictionary run duration, it should be set.
@@ -0,0 +1,23 @@
esphome:
name: test
esp32:
board: nodemcu-32s
deep_sleep:
id: deepsleep
sleep_duration: 1min
run_duration: 10s
on_wake:
- lambda: 'ESP_LOGD("test", "cause %d", static_cast<int>(cause));'
esp32_ext1_wakeup:
mode: ANY_HIGH
pins:
- pin: GPIO2
on_wake:
- lambda: 'ESP_LOGD("test", "left");'
- pin:
number: GPIO4
on_wake:
- lambda: 'ESP_LOGD("test", "right");'
- number: GPIO13
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_encryption:
key_id: 0
+172
View File
@@ -175,6 +175,29 @@ def test_esp32_default_toolchain_is_esp_idf(
r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]",
id="ignore_efuse_mac_crc_only_on_esp32",
),
pytest.param(
{
"variant": "esp32",
"board": "esp32dev",
"framework": {
"type": "esp-idf",
"advanced": {"nvs_encryption": {"key_id": 0}},
},
},
r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]",
id="nvs_encryption_unsupported_on_esp32",
),
pytest.param(
{
"variant": "esp32s3",
"framework": {
"type": "esp-idf",
"advanced": {"nvs_encryption": {"key_id": 6}},
},
},
r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]",
id="nvs_encryption_key_id_out_of_range",
),
],
)
def test_esp32_configuration_errors(
@@ -214,6 +237,21 @@ def test_execute_from_psram_p4_sdkconfig(
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
def test_nvs_encryption_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that nvs_encryption sets the HMAC scheme sdkconfig options."""
generate_main(component_config_path("nvs_encryption_s3.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True
assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True
assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0
# The permanent/irreversible eFuse burn is warned about at config time.
assert "PERMANENT and IRREVERSIBLE" in caplog.text
@pytest.mark.parametrize(
("fixture", "expect_warning"),
[
@@ -563,6 +601,40 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
def test_esp32_build_internals_are_yaml_only() -> None:
"""ESP32 raw framework / build inputs are ``YAML_ONLY``.
The framework block's PlatformIO package pins, raw ESP-IDF
sdkconfig options, the low-level ``advanced`` block, extra IDF
component sources, plus the partition table and toolchain override
on the main schema are build internals — never UI form fields.
User-facing choices (framework type/version, board, variant, …)
stay on the main form.
"""
from esphome.components.esp32 import CONFIG_SCHEMA, FRAMEWORK_SCHEMA
fw_markers = {str(k): k for k in FRAMEWORK_SCHEMA.schema}
for field in (
"release",
"source",
"platform_version",
"sdkconfig_options",
"advanced",
"components",
):
assert fw_markers[field].visibility is cv.Visibility.YAML_ONLY, field
# Framework type/version remain user-facing.
assert fw_markers["type"].visibility is None
assert fw_markers["version"].visibility is None
main_markers = {str(k): k for k in CONFIG_SCHEMA.validators[0].schema}
assert main_markers["partitions"].visibility is cv.Visibility.YAML_ONLY
# toolchain is a real but rarely-touched override -> advanced disclosure.
assert main_markers["toolchain"].visibility is cv.Visibility.ADVANCED
assert main_markers["board"].visibility is None
assert main_markers["flash_size"].visibility is None
def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None:
assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == []
@@ -593,3 +665,103 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None:
# No project version and no signing -> two distinct errors.
errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False)
assert len(errs) == 2
@pytest.mark.parametrize(
"config",
[
# V2 schemes: signing key (sign during build) or no key at all
# (external signing; the public key travels in the signature block).
{"signing_scheme": "rsa3072", "signing_key": "key.pem"},
{"signing_scheme": "rsa3072"},
{"signing_scheme": "ecdsa256", "signing_key": "key.pem"},
{"signing_scheme": "ecdsa256"},
# V1 ECDSA: exactly one of signing key / verification key.
{"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"},
{"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"},
],
)
def test_signed_ota_keys_valid_combinations(config: dict) -> None:
from esphome.components.esp32 import _validate_signed_ota_keys
assert _validate_signed_ota_keys(config) is config
@pytest.mark.parametrize("value", [None, {}])
def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None:
"""A bare `signed_ota_verification:` block is valid: the default V2
scheme embeds the public key in the signature block, so verifying
externally-signed binaries needs no keys in the config."""
from esphome.components.esp32 import _validate_signed_ota_verification
config = _validate_signed_ota_verification(value)
assert config == {"signing_scheme": "rsa3072"}
@pytest.mark.parametrize(
("config", "match"),
[
# A verification key is meaningless with the V2 schemes -- the public
# key is embedded in each image's signature block.
(
{"signing_scheme": "rsa3072", "verification_key": "key.bin"},
"only used with signing scheme 'ecdsa_v1'",
),
(
{"signing_scheme": "ecdsa256", "verification_key": "key.bin"},
"only used with signing scheme 'ecdsa_v1'",
),
# V1 ECDSA needs a key either way.
(
{"signing_scheme": "ecdsa_v1"},
"Signing scheme 'ecdsa_v1' requires either",
),
# Never both keys at once.
(
{
"signing_scheme": "rsa3072",
"signing_key": "key.pem",
"verification_key": "key.bin",
},
"not both",
),
(
{
"signing_scheme": "ecdsa_v1",
"signing_key": "key.pem",
"verification_key": "key.bin",
},
"not both",
),
],
)
def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
from esphome.components.esp32 import _validate_signed_ota_keys
with pytest.raises(cv.Invalid, match=match):
_validate_signed_ota_keys(config)
@pytest.mark.parametrize(
("value", "expected"),
[
# Full x.y.z versions are rewritten into pioarduino release URLs
(
"55.3.30",
"https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30/platform-espressif32.zip",
),
(
"55.3.31-2",
"https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-2/platform-espressif32.zip",
),
# Non-version values pass through untouched
(
"https://github.com/pioarduino/platform-espressif32.git#develop",
"https://github.com/pioarduino/platform-espressif32.git#develop",
),
],
)
def test_parse_pio_platform_version(value: str, expected: str) -> None:
from esphome.components.esp32 import _parse_pio_platform_version
assert _parse_pio_platform_version(value) == expected
+260
View File
@@ -0,0 +1,260 @@
"""Tests for the gsl3670 touchscreen configuration validation."""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.components.gsl3670 import touchscreen as gsl
from esphome.const import (
CONF_CALIBRATION,
CONF_INTERRUPT_PIN,
CONF_MODEL,
CONF_RESET_PIN,
CONF_TRANSFORM,
PlatformFramework,
)
from tests.component_tests.types import SetCoreConfigCallable
VALID_URL = "https://example.com/fw.bin"
def _make_firmware(blocks: int = 2) -> bytes:
"""Build a structurally valid firmware blob with ``blocks`` blocks.
Each block is ``_FW_BLK_SIZE`` bytes: a 4-byte header (page address <= 0xEF
followed by the 1/2/3 marker bytes) and a 128-byte payload.
"""
out = bytearray()
for i in range(blocks):
out += bytes([i, 1, 2, 3]) + bytes(gsl._FW_BLK_SIZE - 4)
return bytes(out)
def _write_firmware(tmp_path: Path, data: bytes | None = None) -> Path:
"""Write firmware bytes to a temp file and return its path."""
path = tmp_path / "fw.bin"
path.write_bytes(_make_firmware() if data is None else data)
return path
# ---------------------------------------------------------------------------
# _validate_firmware_data - blob structure
# ---------------------------------------------------------------------------
def test_validate_firmware_data_accepts_valid_blob() -> None:
"""A correctly structured blob passes validation."""
gsl._validate_firmware_data(_make_firmware(3), "test")
@pytest.mark.parametrize("length", [0, gsl._FW_BLK_SIZE - 1, gsl._FW_BLK_SIZE + 1])
def test_validate_firmware_data_rejects_bad_length(length: int) -> None:
"""The blob length must be a non-zero multiple of the block size."""
with pytest.raises(cv.Invalid, match="length is incorrect"):
gsl._validate_firmware_data(bytes(length), "test")
@pytest.mark.parametrize(
"index,value",
[
(0, 0xF0), # page address must be <= 0xEF
(1, 0x00), # marker byte must be 1
(2, 0x00), # marker byte must be 2
(3, 0x00), # marker byte must be 3
],
)
def test_validate_firmware_data_rejects_corrupted_header(
index: int, value: int
) -> None:
"""A block whose header bytes are wrong is reported as corrupted."""
data = bytearray(_make_firmware(2))
# Corrupt the header of the second block.
data[gsl._FW_BLK_SIZE + index] = value
with pytest.raises(cv.Invalid, match="Corrupted firmware at block 1"):
gsl._validate_firmware_data(bytes(data), "test")
# ---------------------------------------------------------------------------
# _cache_path / firmware_path
# ---------------------------------------------------------------------------
def test_cache_path_is_deterministic_per_url(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The cache path is derived from (and stable for) the URL."""
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
first = gsl._cache_path(VALID_URL)
assert first == gsl._cache_path(VALID_URL)
assert first != gsl._cache_path("https://example.com/other.bin")
assert first.parent == tmp_path
def test_firmware_path_prefers_local_file(tmp_path: Path) -> None:
"""A ``file`` source is returned as-is, without consulting the cache."""
path = _write_firmware(tmp_path)
assert gsl.firmware_path({"file": path}) == path
def test_firmware_path_uses_cache_for_url(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A ``url`` source resolves to the cache path for that URL."""
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL)
# ---------------------------------------------------------------------------
# _validate_firmware / FIRMWARE_SCHEMA
# ---------------------------------------------------------------------------
def test_firmware_requires_exactly_one_source(tmp_path: Path) -> None:
"""Supplying both, or neither, of url/file is an error."""
path = _write_firmware(tmp_path)
with pytest.raises(cv.Invalid, match="Exactly one"):
gsl._validate_firmware({"url": VALID_URL, "file": path})
with pytest.raises(cv.Invalid, match="Exactly one"):
gsl._validate_firmware({})
def test_firmware_file_valid(tmp_path: Path) -> None:
"""A valid firmware file passes the full FIRMWARE_SCHEMA."""
path = _write_firmware(tmp_path)
result = gsl.FIRMWARE_SCHEMA({"file": str(path)})
assert result["file"] == path
def test_firmware_file_corrupt_rejected(tmp_path: Path) -> None:
"""A file whose contents fail the structural check is rejected."""
path = _write_firmware(tmp_path, data=b"\x00" * (gsl._FW_BLK_SIZE * 2))
with pytest.raises(cv.Invalid, match="Corrupted firmware"):
gsl._validate_firmware({"file": path})
def test_firmware_url_downloads_and_validates(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A url source downloads the content and validates its structure."""
data = _make_firmware()
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data)
assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL}
def test_firmware_url_sha256_mismatch_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A configured SHA-256 that does not match the download is rejected."""
data = _make_firmware()
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data)
with pytest.raises(cv.Invalid, match="SHA-256 mismatch"):
gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32})
def test_firmware_url_invalid_structure_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Downloaded content that is not a valid blob is rejected."""
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
monkeypatch.setattr(
gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02"
)
with pytest.raises(cv.Invalid, match="length is incorrect"):
gsl._validate_firmware({"url": VALID_URL})
# ---------------------------------------------------------------------------
# CONFIG_SCHEMA
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _esp32_core(set_core_config: SetCoreConfigCallable) -> None:
"""Configure the core as an ESP32 target for the schema tests."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
def test_config_custom_model_minimal(tmp_path: Path) -> None:
"""The CUSTOM model validates with an explicit firmware file and pins."""
fw = _write_firmware(tmp_path)
result = gsl.CONFIG_SCHEMA(
{
"model": "custom",
"interrupt_pin": 16,
"reset_pin": 4,
"firmware": {"file": str(fw)},
}
)
assert result[CONF_MODEL] == "CUSTOM"
assert "id" in result
# The CUSTOM model supplies no transform/calibration defaults.
assert CONF_TRANSFORM not in result
assert CONF_CALIBRATION not in result
def test_config_custom_model_requires_firmware() -> None:
"""The firmware option is required for the CUSTOM model (no default)."""
with pytest.raises(cv.Invalid, match=r"required key not provided.*firmware"):
gsl.CONFIG_SCHEMA({"model": "custom", "interrupt_pin": 16, "reset_pin": 4})
def test_config_invalid_model_rejected() -> None:
"""An unknown model name is rejected."""
with pytest.raises(cv.Invalid, match="model"):
gsl.CONFIG_SCHEMA({"model": "nonexistent"})
def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None:
"""The SEEED model populates transform and calibration defaults.
``reset_pin`` is overridden with a plain GPIO so the test does not depend on
the model's default IO-expander pin.
"""
fw = _write_firmware(tmp_path)
result = gsl.CONFIG_SCHEMA(
{
"model": "seeed-reterminal-d1001",
"reset_pin": 4,
"firmware": {"file": str(fw)},
}
)
assert result[CONF_MODEL] == "SEEED-RETERMINAL-D1001"
# Transform defaults from the model.
assert result[CONF_TRANSFORM] == {
"swap_xy": True,
"mirror_x": True,
"mirror_y": True,
}
# Calibration defaults from the model.
assert result[CONF_CALIBRATION]["x_min"] == 20
assert result[CONF_CALIBRATION]["x_max"] == 872
assert result[CONF_CALIBRATION]["y_min"] == 20
assert result[CONF_CALIBRATION]["y_max"] == 1644
# The interrupt pin default (16) is applied without being specified.
assert CONF_INTERRUPT_PIN in result
assert CONF_RESET_PIN in result
def test_config_rejects_non_dict() -> None:
"""A non-dict configuration is rejected."""
with pytest.raises(cv.Invalid, match="expected a dictionary"):
gsl.CONFIG_SCHEMA("not a dict")
@@ -4,6 +4,9 @@ esphome:
esp32:
board: esp32s3box
psram:
mode: octal
image:
defaults:
type: rgb565
+303 -143
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
@@ -11,28 +12,36 @@ from PIL import Image as PILImage
import pytest
from esphome import config_validation as cv
from esphome.components.const import CONF_BYTE_ORDER
from esphome.components.file import image as file_image
from esphome.components.file.image import validate_image_final, write_image
from esphome.components.image import (
CONF_ALPHA_CHANNEL,
CONF_INVERT_ALPHA,
CONF_OPAQUE,
CONF_TRANSPARENCY,
CONFIG_SCHEMA,
PLATFORM_FILE,
_flatten_legacy_image_config,
_is_legacy_image_format,
_is_new_image_format,
_migrate_legacy_image_config,
get_all_image_metadata,
get_image_metadata,
write_image,
)
from esphome.const import CONF_DITHER, CONF_FILE, CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE
from esphome.const import (
CONF_DITHER,
CONF_FILE,
CONF_ID,
CONF_PLATFORM,
CONF_RAW_DATA_ID,
CONF_TYPE,
)
from esphome.core import CORE
@pytest.mark.parametrize(
("config", "error_match"),
[
pytest.param(
"a string",
"Badly formed image configuration, expected a list or a dictionary",
id="invalid_string_config",
),
pytest.param(
{"id": "image_id", "type": "rgb565"},
r"required key not provided @ data\['file'\]",
@@ -43,6 +52,11 @@ from esphome.core import CORE
r"required key not provided @ data\['id'\]",
id="missing_id",
),
pytest.param(
{"id": "image_id", "file": "image.png"},
r"required key not provided @ data\['type'\]",
id="missing_type",
),
pytest.param(
{"id": "mdi_id", "file": "mdi:weather-##", "type": "rgb565"},
"Could not parse mdi icon name",
@@ -84,155 +98,301 @@ from esphome.core import CORE
"File can't be opened as image",
id="invalid_image_file",
),
pytest.param(
{"defaults": {}, "images": [{"id": "image_id", "file": "image.png"}]},
"Type is required either in the image config or in the defaults",
id="missing_type_in_defaults",
),
],
)
def test_image_configuration_errors(
def test_file_platform_configuration_errors(
config: Any,
error_match: str,
) -> None:
"""Test detection of invalid configuration."""
"""Invalid single-entry ``platform: file`` configs are rejected."""
with pytest.raises(cv.Invalid, match=error_match):
CONFIG_SCHEMA(config)
file_image.CONFIG_SCHEMA(config)
def test_file_platform_configuration_success() -> None:
"""A fully-specified ``platform: file`` entry validates and keeps its keys."""
result = file_image.CONFIG_SCHEMA(
{
"id": "image_id",
"file": "image.png",
"type": "rgb565",
"transparency": "chroma_key",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
}
)
for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID):
assert key in result, f"Missing key {key} in validated image configuration"
# ---------------------------------------------------------------------------
# Legacy `image:` config migration -- REMOVE these tests after 2027.1.0 together
# with the migration shim in esphome/components/image/__init__.py.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("config", "expected"),
[
pytest.param(
[{CONF_PLATFORM: "file", "id": "a"}], True, id="new_platform_list"
),
pytest.param([], True, id="empty_list"),
pytest.param([{"id": "a", "file": "x.png"}], False, id="legacy_bare_list"),
pytest.param([{CONF_PLATFORM: "file"}, {"id": "a"}], False, id="mixed_list"),
pytest.param(
[{CONF_PLATFORM: "file"}, "not-a-dict"], False, id="non_dict_entry"
),
pytest.param({"defaults": {}}, False, id="legacy_dict"),
],
)
def test_is_new_image_format(config: object, expected: bool) -> None:
assert _is_new_image_format(config) is expected
def test_flatten_bare_list_filters_non_dicts() -> None:
out = _flatten_legacy_image_config(
[{"id": "a", "file": "x.png", "type": "binary"}, "not-a-dict"]
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_non_dict_non_list_yields_nothing() -> None:
assert _flatten_legacy_image_config("a string") == []
def test_flatten_single_dict_with_id() -> None:
config = {"id": "a", "file": "x.png", "type": "binary"}
assert _flatten_legacy_image_config(config) == [config]
def test_flatten_single_dict_with_file_only() -> None:
config = {"file": "x.png", "type": "binary"}
assert _flatten_legacy_image_config(config) == [config]
def test_flatten_defaults_images_list() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"type": "rgb565", "byte_order": "little_endian"},
"images": [{"id": "a", "file": "x.png"}],
}
)
assert out == [
{
"id": "a",
"file": "x.png",
"type": "rgb565",
"byte_order": "little_endian",
}
]
def test_flatten_defaults_images_single_dict() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"type": "rgb565"},
"images": {"id": "a", "file": "x.png"},
}
)
assert out == [{"id": "a", "file": "x.png", "type": "rgb565"}]
def test_flatten_type_grouped_list() -> None:
out = _flatten_legacy_image_config({"binary": [{"id": "a", "file": "x.png"}]})
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_type_grouped_transparency_list() -> None:
out = _flatten_legacy_image_config(
{"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}]}}
)
assert out == [
{
"id": "a",
"file": "x.png",
"type": "rgb565",
"transparency": "alpha_channel",
}
]
def test_flatten_type_grouped_transparency_single_dict() -> None:
out = _flatten_legacy_image_config(
{"rgb565": {"alpha_channel": {"id": "a", "file": "x.png"}}}
)
assert out == [
{
"id": "a",
"file": "x.png",
"type": "rgb565",
"transparency": "alpha_channel",
}
]
def test_flatten_type_grouped_dict_without_transparency() -> None:
out = _flatten_legacy_image_config({"binary": {"id": "a", "file": "x.png"}})
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_drops_byte_order_for_non_endian_type() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"byte_order": "little_endian"},
"binary": [{"id": "a", "file": "x.png"}],
}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
assert CONF_BYTE_ORDER not in out[0]
def test_flatten_keeps_byte_order_for_endian_type() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"byte_order": "little_endian"},
"rgb565": [{"id": "a", "file": "x.png"}],
}
)
assert out[0][CONF_BYTE_ORDER] == "little_endian"
def test_flatten_skips_meta_and_unknown_keys() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"type": "binary"},
"images": [],
"not_a_type": [{"id": "a", "file": "x.png"}],
}
)
assert out == []
def test_flatten_images_list_skips_non_dict_entries() -> None:
out = _flatten_legacy_image_config(
{
"defaults": {"type": "binary"},
"images": [{"id": "a", "file": "x.png"}, "not-a-dict"],
}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_type_grouped_list_skips_non_dict_entries() -> None:
out = _flatten_legacy_image_config(
{"binary": [{"id": "a", "file": "x.png"}, "not-a-dict"]}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
def test_flatten_type_grouped_scalar_value_is_ignored() -> None:
# A known type key whose value is neither a list nor a dict yields nothing.
assert _flatten_legacy_image_config({"binary": "not-a-list-or-dict"}) == []
def test_flatten_type_grouped_transparency_skips_non_dict_entries() -> None:
out = _flatten_legacy_image_config(
{"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}, "not-a-dict"]}}
)
assert out == [
{
"id": "a",
"file": "x.png",
"type": "rgb565",
"transparency": "alpha_channel",
}
]
def test_migrate_returns_none_for_new_format() -> None:
assert _migrate_legacy_image_config([{CONF_PLATFORM: "file", "id": "a"}]) is None
def test_migrate_legacy_warns_and_prepends_platform(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
out = _migrate_legacy_image_config(
[{"id": "a", "file": "x.png", "type": "binary"}]
)
assert out == [
{CONF_PLATFORM: PLATFORM_FILE, "id": "a", "file": "x.png", "type": "binary"}
]
assert "deprecated" in caplog.text
assert f"platform: {PLATFORM_FILE}" in caplog.text
@pytest.mark.parametrize(
("config", "expected"),
[
# Recognised legacy shapes -> migrate.
pytest.param([{"id": "a", "file": "x.png"}], True, id="bare_list_of_dicts"),
pytest.param({"id": "a", "file": "x.png"}, True, id="single_image_dict"),
pytest.param({"file": "x.png"}, True, id="single_dict_file_only"),
pytest.param({"defaults": {}, "images": []}, True, id="defaults_images"),
pytest.param({"rgb565": [{"id": "a"}]}, True, id="type_grouped"),
# Shapes the legacy schema never accepted -> not migrated.
pytest.param([], False, id="empty_list"),
pytest.param(["bad"], False, id="list_with_non_dict"),
pytest.param([{"id": "a"}, "bad"], False, id="list_mixed_dict_and_non_dict"),
pytest.param(
[{CONF_PLATFORM: "file", "id": "a"}], False, id="already_platform_tagged"
),
pytest.param({"foo": 1}, False, id="dict_unknown_keys"),
pytest.param("a string", False, id="scalar"),
],
)
def test_is_legacy_image_format(config: object, expected: bool) -> None:
assert _is_legacy_image_format(config) is expected
@pytest.mark.parametrize(
"config",
[
pytest.param(
{
"id": "image_id",
"file": "image.png",
"type": "rgb565",
"transparency": "chroma_key",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
},
id="single_image_all_options",
),
pytest.param(
[
{
"id": "image_id",
"file": "image.png",
"type": "binary",
}
],
id="list_of_images",
),
pytest.param(
{
"defaults": {
"type": "rgb565",
"transparency": "chroma_key",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
},
"images": [
{
"id": "image_id",
"file": "image.png",
}
],
},
id="images_with_defaults",
),
pytest.param(
{
"rgb565": {
"alpha_channel": [
{
"id": "image_id",
"file": "image.png",
"transparency": "alpha_channel",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
}
]
},
"binary": [
{
"id": "image_id",
"file": "image.png",
"transparency": "opaque",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
}
],
},
id="type_based_organization",
),
pytest.param(
{
"defaults": {
"type": "binary",
"transparency": "chroma_key",
"byte_order": "little_endian",
"dither": "FloydSteinberg",
"resize": "100x100",
"invert_alpha": False,
},
"rgb565": {
"alpha_channel": [
{
"id": "image_id",
"file": "image.png",
"transparency": "alpha_channel",
"dither": "none",
}
]
},
"binary": [
{
"id": "image_id",
"file": "image.png",
"transparency": "opaque",
}
],
},
id="type_based_with_defaults",
),
pytest.param(
{
"defaults": {
"type": "rgb565",
"transparency": "alpha_channel",
},
"binary": {
"opaque": [
{
"id": "image_id",
"file": "image.png",
}
],
},
},
id="binary_with_defaults",
),
pytest.param(["bad"], id="list_with_non_dict"),
pytest.param([{"id": "a"}, "bad"], id="list_mixed"),
pytest.param({"foo": 1}, id="dict_unknown_keys"),
],
)
def test_image_configuration_success(
config: dict[str, Any] | list[dict[str, Any]],
def test_migrate_returns_none_for_invalid_legacy_shapes(
config: object, caplog: pytest.LogCaptureFixture
) -> None:
"""Test successful configuration validation."""
result = CONFIG_SCHEMA(config)
# All valid configurations should return a list of images
assert isinstance(result, list)
for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID):
assert all(key in x for x in result), (
f"Missing key {key} in image configuration"
"""Unrecognised shapes are not migrated (and emit no warning) so normal
platform validation surfaces a proper error instead of silently dropping
the offending input."""
with caplog.at_level(logging.WARNING):
assert _migrate_legacy_image_config(config) is None
assert "deprecated" not in caplog.text
# --------------------------- end legacy migration --------------------------
def test_validate_image_final_defaults_to_little_endian() -> None:
out = validate_image_final({CONF_FILE: "x.png"})
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
def test_validate_image_final_keeps_little_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
out = validate_image_final(
{CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
)
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
assert "big-endian" not in caplog.text
def test_validate_image_final_warns_on_big_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING):
out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"})
assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN"
assert "big-endian" in caplog.text
def test_image_generation(
@@ -369,7 +529,7 @@ def test_get_all_image_metadata_empty() -> None:
@pytest.fixture
def mock_progmem_array():
"""Mock progmem_array to avoid needing a proper ID object in tests."""
with patch("esphome.components.image.cg.progmem_array") as mock_progmem:
with patch("esphome.components.file.image.cg.progmem_array") as mock_progmem:
mock_progmem.return_value = MagicMock()
yield mock_progmem
@@ -0,0 +1,92 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
spi:
- id: spi_bus
clk_pin: GPIO18
mosi_pin: GPIO23
display:
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: tft_display
dimensions:
width: 240
height: 320
cs_pin: GPIO22
dc_pin: GPIO21
auto_clear_enabled: false
invert_colors: false
update_interval: never
lvgl:
id: lvgl_id
displays: tft_display
pages:
- id: main_page
widgets:
# A flex container whose layout options are changed at runtime.
- obj:
id: flex_box
layout:
type: flex
flex_flow: row
widgets:
- label:
text: a
- label:
text: b
# A grid container whose alignment options are changed at runtime.
# The grid structure (rows/columns) is fixed here at creation.
- obj:
id: grid_box
layout:
type: grid
grid_rows: [content, content]
grid_columns: [fr(1), fr(1)]
widgets:
- label:
text: c
- label:
text: d
# Button hosting all of the update actions under test.
- button:
id: btn_actions
on_click:
# Update flex container options (type unchanged).
- lvgl.widget.update:
id: flex_box
layout:
flex_flow: column
flex_align_main: center
flex_align_cross: end
pad_row: 7px
# Update grid container alignment options (structure unchanged).
- lvgl.widget.update:
id: grid_box
layout:
grid_column_align: space_between
grid_row_align: center
# Top-level layout applies to the active screen.
- lvgl.update:
layout:
flex_flow: column
pad_column: 5px
# Layout applied to the top display layer.
- lvgl.update:
top_layer:
layout:
flex_flow: row
# Styling applied to the bottom display layer (exercises the
# layers code path that previously generated no code).
- lvgl.update:
bottom_layer:
bg_color: 0x123456
@@ -0,0 +1,26 @@
esphome:
name: test-not-paused
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
widgets:
- obj:
id: root_obj
@@ -0,0 +1,27 @@
esphome:
name: test-paused
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
paused: true
widgets:
- obj:
id: root_obj
@@ -0,0 +1,201 @@
"""Tests for the LVGL animation schema and configuration validation."""
from __future__ import annotations
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.lvgl.animation import (
ANIMABLE_STYLES,
ANIMATION_SCHEMA,
TIMING_SCHEMA,
from_to,
literal_color,
)
from esphome.components.lvgl.defines import LValidator
from esphome.core import Lambda
def _animation(**overrides) -> dict:
"""A minimal valid animation config, with optional overrides applied."""
config = {
"id": "anim_id",
"widgets": [{"id": "widget_id", "x": {"from": 0, "to": 100}}],
}
config.update(overrides)
return config
# ---------------------------------------------------------------------------
# Animatable property set
# ---------------------------------------------------------------------------
class TestAnimableStyles:
def test_all_entries_are_animatable_validators(self) -> None:
"""Every animatable style must be an LValidator marked animatable."""
assert ANIMABLE_STYLES
assert all(
isinstance(v, LValidator) and v.animatable for v in ANIMABLE_STYLES.values()
)
def test_known_animatable_present(self) -> None:
for prop in ("x", "y", "opa", "bg_color", "transform_rotation"):
assert prop in ANIMABLE_STYLES
def test_non_animatable_absent(self) -> None:
# width/height set size but are not animatable; layout/padding never are.
for prop in ("width", "height", "radius", "pad_all", "align"):
assert prop not in ANIMABLE_STYLES
# ---------------------------------------------------------------------------
# Animation schema
# ---------------------------------------------------------------------------
class TestAnimationSchema:
def test_defaults(self) -> None:
config = ANIMATION_SCHEMA(_animation())
assert config["duration"].total_milliseconds == 5000
assert config["start_delay"].total_milliseconds == 0
assert config["auto_start"] is False
assert config["loop"] is False
assert config["timing"] == []
def test_values_preserved(self) -> None:
config = ANIMATION_SCHEMA(
_animation(duration="2s", start_delay="250ms", auto_start=True, loop=True)
)
assert config["duration"].total_milliseconds == 2000
assert config["start_delay"].total_milliseconds == 250
assert config["auto_start"] is True
assert config["loop"] is True
def test_id_required(self) -> None:
with pytest.raises((Invalid, MultipleInvalid)):
ANIMATION_SCHEMA({"widgets": [{"id": "widget_id"}]})
def test_widgets_required(self) -> None:
with pytest.raises((Invalid, MultipleInvalid)):
ANIMATION_SCHEMA({"id": "anim_id"})
def test_multiple_properties_and_widgets(self) -> None:
config = ANIMATION_SCHEMA(
_animation(
widgets=[
{
"id": "w1",
"x": {"from": 0, "to": 100},
"opa": {"from": "0%", "to": "100%"},
},
{"id": "w2", "y": {"from": 10, "to": 50}},
]
)
)
assert len(config["widgets"]) == 2
def test_unknown_property_rejected(self) -> None:
with pytest.raises((Invalid, MultipleInvalid)):
ANIMATION_SCHEMA(
_animation(widgets=[{"id": "w1", "not_a_style": {"from": 0, "to": 1}}])
)
class TestAnimatedColorLiteral:
"""A color animated via from/to must be a literal, not a lambda."""
def test_color_lambda_rejected_directly(self) -> None:
with pytest.raises(Invalid, match="lambda"):
literal_color(Lambda("return lv_color_hex(0xFF0000);"))
def test_color_literal_accepted_directly(self) -> None:
# A literal color value validates without error.
literal_color(0xFF0000)
def test_color_lambda_rejected_in_animation(self) -> None:
with pytest.raises((Invalid, MultipleInvalid), match="lambda"):
ANIMATION_SCHEMA(
_animation(
widgets=[
{
"id": "w1",
"text_color": {
"from": Lambda("return lv_color_hex(0xFF0000);"),
"to": 0x00FF00,
},
}
]
)
)
def test_color_literals_accepted_in_animation(self) -> None:
config = ANIMATION_SCHEMA(
_animation(
widgets=[{"id": "w1", "text_color": {"from": 0xFF0000, "to": 0x00FF00}}]
)
)
assert config["widgets"][0]["id"].id == "w1"
def test_non_color_property_allows_lambda(self) -> None:
# Only colors are restricted; numeric properties may use lambdas.
config = ANIMATION_SCHEMA(
_animation(
widgets=[{"id": "w1", "x": {"from": Lambda("return 5;"), "to": 100}}]
)
)
assert config["widgets"][0]["id"].id == "w1"
class TestFromTo:
def test_requires_both(self) -> None:
validator = from_to(lambda value: value)
with pytest.raises((Invalid, MultipleInvalid)):
validator({"from": 1})
with pytest.raises((Invalid, MultipleInvalid)):
validator({"to": 1})
def test_accepts_both(self) -> None:
validator = from_to(lambda value: value)
assert validator({"from": 1, "to": 2}) == {"from": 1, "to": 2}
# ---------------------------------------------------------------------------
# Timing schema
# ---------------------------------------------------------------------------
class TestTimingSchema:
def test_round_trip_string(self) -> None:
assert TIMING_SCHEMA("round_trip")["type"] == "round_trip"
def test_ease_in_out_default_weight(self) -> None:
result = TIMING_SCHEMA("ease_in_out")
assert result["type"] == "ease_in_out"
assert result["weight"] == pytest.approx(2.0)
def test_ease_in_out_custom_weight(self) -> None:
result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3})
assert result["weight"] == pytest.approx(3.0)
def test_gravity_defaults(self) -> None:
result = TIMING_SCHEMA("gravity")
assert result["type"] == "gravity"
assert result["bounce"] == pytest.approx(0.5)
assert result["acceleration"] == pytest.approx(0.5)
def test_gravity_custom(self) -> None:
result = TIMING_SCHEMA({"type": "gravity", "bounce": 0.3, "acceleration": 0.8})
assert result["bounce"] == pytest.approx(0.3)
assert result["acceleration"] == pytest.approx(0.8)
def test_unknown_type_rejected(self) -> None:
with pytest.raises((Invalid, MultipleInvalid)):
TIMING_SCHEMA({"type": "not_a_timing"})
def test_timing_list_in_animation(self) -> None:
config = ANIMATION_SCHEMA(
_animation(timing=["round_trip", {"type": "gravity", "bounce": 0.3}])
)
types = [t["type"] for t in config["timing"]]
assert types == ["round_trip", "gravity"]
@@ -0,0 +1,208 @@
"""Tests for updating LVGL layout options via the update actions.
The ``lvgl.update`` and ``lvgl.widget.update`` (and per-widget
``lvgl.<widget>.update``) actions can change a container's layout *options* at
runtime. The layout ``type`` and the grid ``grid_rows``/``grid_columns``
structure are fixed at widget creation (they determine the cells/options
available to child widgets), so only the simple style options - those applied
via ``lv_obj_set_style_...`` calls - may be changed.
These tests cover both the ``layout_validator`` (schema/normalisation) and the
generated C++ for each target: a widget, the active screen (top-level
``lvgl.update``) and the display layers.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from voluptuous import Invalid
from esphome.__main__ import generate_cpp_contents
from esphome.components.lvgl.defines import TYPE_FLEX, TYPE_GRID, get_lv_uses
from esphome.components.lvgl.layout import layout_validator
from esphome.config import read_config
from esphome.core import CORE
# ---------------------------------------------------------------------------
# layout_validator - schema and normalisation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
({"flex_flow": "row"}, {"flex_flow": "LV_FLEX_FLOW_ROW"}),
({"flex_align_main": "center"}, {"flex_align_main": "LV_FLEX_ALIGN_CENTER"}),
({"flex_align_cross": "end"}, {"flex_align_cross": "LV_FLEX_ALIGN_END"}),
(
{"grid_column_align": "space_between"},
{"grid_column_align": "LV_GRID_ALIGN_SPACE_BETWEEN"},
),
({"grid_row_align": "center"}, {"grid_row_align": "LV_GRID_ALIGN_CENTER"}),
({"pad_row": "7px"}, {"pad_row": 7}),
({"pad_column": "5px"}, {"pad_column": 5}),
],
)
def test_layout_validator_normalises_options(value: dict, expected: dict) -> None:
"""Each supported option is accepted and normalised to its LVGL form."""
assert layout_validator(value) == expected
def test_layout_validator_accepts_multiple_options() -> None:
"""Several options may be combined in one update."""
result = layout_validator(
{"flex_flow": "column", "flex_align_main": "center", "pad_row": "4px"}
)
assert result == {
"flex_flow": "LV_FLEX_FLOW_COLUMN",
"flex_align_main": "LV_FLEX_ALIGN_CENTER",
"pad_row": 4,
}
@pytest.mark.parametrize(
"value",
[
{"type": "flex"},
{"type": "grid", "grid_column_align": "center"},
{"grid_rows": 3},
{"grid_columns": ["fr(1)"]},
{"grid_rows": [1, 2], "flex_flow": "row"},
],
)
def test_layout_validator_rejects_structural_keys(value: dict) -> None:
"""The layout type and grid structure are fixed at creation and must not
be changeable via an update action."""
with pytest.raises(Invalid, match="extra keys not allowed"):
layout_validator(value)
def test_layout_validator_rejects_empty() -> None:
"""An update must specify at least one layout option."""
with pytest.raises(Invalid, match="at least one layout option"):
layout_validator({})
def test_layout_validator_registers_flex_use() -> None:
"""Validating a flex option registers the flex feature so LV_USE_FLEX is
emitted even when the option is set solely via an update action."""
layout_validator({"flex_flow": "row"})
assert TYPE_FLEX in get_lv_uses()
def test_layout_validator_registers_grid_use() -> None:
"""Validating a grid option registers the grid feature."""
layout_validator({"grid_column_align": "center"})
assert TYPE_GRID in get_lv_uses()
def test_pad_only_update_registers_no_layout_use() -> None:
"""Padding options belong to both layout types, so they alone do not force
either feature on."""
layout_validator({"pad_row": "4px"})
uses = get_lv_uses()
assert TYPE_FLEX not in uses
assert TYPE_GRID not in uses
# ---------------------------------------------------------------------------
# Generated C++ for the update actions
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared layout-update YAML config once
per module (codegen is relatively expensive)."""
config_path = Path(request.fspath).parent / "config" / "layout_update_test.yaml"
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_global_section + CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_widget_flex_update_applies_partial_options(main_cpp: str) -> None:
"""``lvgl.widget.update`` changes only the flex options that are specified,
via the appropriate ``lv_obj_set_style_...``/``lv_obj_set_flex_flow``
calls on the target widget."""
assert "lv_obj_set_flex_flow(flex_box, LV_FLEX_FLOW_COLUMN)" in main_cpp
assert (
"lv_obj_set_style_flex_main_place(flex_box, LV_FLEX_ALIGN_CENTER, LV_STATE_DEFAULT)"
in main_cpp
)
assert (
"lv_obj_set_style_flex_cross_place(flex_box, LV_FLEX_ALIGN_END, LV_STATE_DEFAULT)"
in main_cpp
)
assert "lv_obj_set_style_pad_row(flex_box, 7, LV_STATE_DEFAULT)" in main_cpp
def test_widget_flex_update_does_not_change_type(main_cpp: str) -> None:
"""The update must not re-establish the layout type: ``lv_obj_set_layout``
is emitted once (at creation) and never from the update action."""
assert main_cpp.count("lv_obj_set_layout(flex_box,") == 1
def test_widget_flex_update_is_partial(main_cpp: str) -> None:
"""An option that was not specified in the update (the track placement) is
only set at creation, not by the partial update."""
assert main_cpp.count("lv_obj_set_style_flex_track_place(flex_box,") == 1
def test_widget_grid_update_applies_alignments(main_cpp: str) -> None:
"""``lvgl.widget.update`` on a grid container changes its alignment
options without touching the grid structure."""
assert (
"lv_obj_set_style_grid_column_align(grid_box, LV_GRID_ALIGN_SPACE_BETWEEN, "
"LV_STATE_DEFAULT)" in main_cpp
)
assert (
"lv_obj_set_style_grid_row_align(grid_box, LV_GRID_ALIGN_CENTER, LV_STATE_DEFAULT)"
in main_cpp
)
def test_grid_update_does_not_regenerate_descriptor_arrays(main_cpp: str) -> None:
"""The grid row/column descriptor arrays are structural and generated once
at creation; an update must not regenerate them."""
assert main_cpp.count("grid_box_row_dsc") != 0
# The descriptor array is declared once and referenced once at creation.
assert main_cpp.count("grid_box_row_dsc") == main_cpp.count("grid_box_column_dsc")
assert "lv_obj_set_layout(grid_box," in main_cpp
assert main_cpp.count("lv_obj_set_layout(grid_box,") == 1
def test_top_level_layout_targets_active_screen(main_cpp: str) -> None:
"""A top-level ``lvgl.update: { layout: ... }`` applies to the active
screen, not to the LVGL component object."""
assert (
"lv_obj_set_flex_flow(lvgl_id->get_screen_active(), LV_FLEX_FLOW_COLUMN)"
in main_cpp
)
assert (
"lv_obj_set_style_pad_column(lvgl_id->get_screen_active(), 5, LV_STATE_DEFAULT)"
in main_cpp
)
def test_top_layer_layout_applied(main_cpp: str) -> None:
"""A layout under ``top_layer`` is applied to the display's top layer."""
assert "lv_display_get_layer_top(lvgl_id->get_disp())" in main_cpp
assert "lv_obj_set_flex_flow(top_layer_VAR_, LV_FLEX_FLOW_ROW)" in main_cpp
def test_bottom_layer_styling_applied(main_cpp: str) -> None:
"""A ``bottom_layer`` style update generates code (previously the layer
keys of ``lvgl.update`` were silently ignored)."""
assert "lv_display_get_layer_bottom(lvgl_id->get_disp())" in main_cpp
assert (
"lv_obj_set_style_bg_color(bottom_layer_VAR_, lv_color_make(18, 52, 86), "
"LV_PART_MAIN)" in main_cpp
)
+35
View File
@@ -0,0 +1,35 @@
"""Tests for the LVGL ``paused`` option code generation."""
from __future__ import annotations
import re
_SET_PAUSED_RE = re.compile(r"->set_paused\((.+?)\);")
def _extract_set_paused(main_cpp: str) -> list[str]:
"""Return the normalised argument text of every set_paused() call found.
Whitespace within and around the arguments is collapsed so unrelated
code-generation formatting changes don't break these tests.
"""
return [" ".join(m.group(1).split()) for m in _SET_PAUSED_RE.finditer(main_cpp)]
class TestPausedCodeGeneration:
"""Verify that the ``paused`` option drives the set_paused() call."""
def test_paused_true_generates_set_paused(
self, generate_main, component_config_path
):
"""``paused: true`` emits a set_paused(true, false) call."""
main_cpp = generate_main(component_config_path("paused.yaml"))
calls = _extract_set_paused(main_cpp)
assert calls == ["true, false"]
def test_paused_default_omits_set_paused(
self, generate_main, component_config_path
):
"""Without ``paused`` (default false) no set_paused call is generated."""
main_cpp = generate_main(component_config_path("not_paused.yaml"))
assert _extract_set_paused(main_cpp) == []
@@ -0,0 +1,110 @@
"""Tests for micro_wake_word local model validation."""
import json
import logging
from pathlib import Path
from typing import Any
import pytest
from esphome.components.micro_wake_word import LOCAL_SCHEMA
from esphome.core import CORE
MANIFEST: dict[str, Any] = {
"type": "micro",
"model": "hey_jarvis.tflite",
"author": "someone",
"version": 2,
"wake_word": "hey jarvis",
"trained_languages": ["en"],
"micro": {
"feature_step_size": 10,
"tensor_arena_size": 30000,
"probability_cutoff": 0.97,
"sliding_window_size": 5,
"minimum_esphome_version": "2024.7.0",
},
}
def _registered_files() -> list[Path]:
"""Files components registered for bundling this run."""
data = CORE.data.get("bundle")
return list(data.extra_files) if data else []
@pytest.fixture
def config_dir(tmp_path: Path) -> Path:
"""A config dir holding a manifest and its model file."""
(tmp_path / "models").mkdir()
(tmp_path / "models" / "hey_jarvis.tflite").write_bytes(b"fake model")
(tmp_path / "models" / "hey_jarvis.json").write_text(json.dumps(MANIFEST))
CORE.config_path = tmp_path / "test.yaml"
return tmp_path
def test_local_schema_registers_model_file(config_dir: Path) -> None:
"""The model file named by the manifest is registered so bundles include it."""
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"]
def test_local_schema_registers_model_file_in_subdirectory(config_dir: Path) -> None:
"""The model reference is resolved relative to the manifest, not the config dir."""
nested = config_dir / "models" / "nested"
nested.mkdir()
(nested / "model.tflite").write_bytes(b"fake model")
(config_dir / "models" / "nested.json").write_text(
json.dumps({**MANIFEST, "model": "nested/model.tflite"})
)
LOCAL_SCHEMA({"path": "models/nested.json"})
assert _registered_files() == [nested / "model.tflite"]
def test_local_schema_leaves_config_untouched(config_dir: Path) -> None:
"""Registration is a side effect; the model file is not a config key."""
config = LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
assert config == {"path": config_dir / "models" / "hey_jarvis.json"}
def test_local_schema_missing_model_file_still_validates(config_dir: Path) -> None:
"""A model file that does not exist is registered, not rejected.
Raising here would be swallowed by the shorthand validator, which would then
report a confusing error about a missing file in a git repository.
"""
(config_dir / "models" / "hey_jarvis.tflite").unlink()
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
assert _registered_files() == [config_dir / "models" / "hey_jarvis.tflite"]
@pytest.mark.parametrize(
"contents",
[
pytest.param("{not valid json", id="malformed"),
pytest.param(json.dumps({"type": "micro"}), id="no_model_key"),
pytest.param(json.dumps(["a", "list"]), id="not_an_object"),
pytest.param(json.dumps({"model": 42}), id="model_not_a_string"),
],
)
def test_local_schema_bad_manifest_does_not_raise(
config_dir: Path, contents: str, caplog: pytest.LogCaptureFixture
) -> None:
"""Manifest problems are left to later stages, which report them better.
The skipped registration is logged so a bundle built without the model file can
be diagnosed.
"""
(config_dir / "models" / "hey_jarvis.json").write_text(contents)
with caplog.at_level(logging.DEBUG):
LOCAL_SCHEMA({"path": "models/hey_jarvis.json"})
assert _registered_files() == []
assert "Not registering a model file" in caplog.text
@@ -1,6 +1,7 @@
"""Tests for mpi_dsi configuration validation."""
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
@@ -71,6 +72,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 +129,59 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None:
CONFIG_SCHEMA(config)
def test_deprecated_model_warning(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecated M5Stack-Tab5-v2 alias warns and points at the replacement models."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4},
)
from esphome.components.mipi_dsi.display import CONFIG_SCHEMA
with caplog.at_level(logging.WARNING):
CONFIG_SCHEMA({"id": "deprecated_display", "model": "M5Stack-Tab5-v2"})
assert "M5STACK-TAB5-V2 is deprecated" in caplog.text
# The warning names the replacement models so users know what to switch to.
assert "M5STACK-TAB5-ST7123" in caplog.text
# The replacement models validate without emitting a deprecation warning.
caplog.clear()
with caplog.at_level(logging.WARNING):
CONFIG_SCHEMA({"id": "st7123_display", "model": "M5Stack-Tab5-ST7123"})
CONFIG_SCHEMA({"id": "st7121_display", "model": "M5Stack-Tab5-ST7121"})
assert "deprecated" not in caplog.text
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"
+74 -3
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(
@@ -361,7 +361,8 @@ def test_native_generation(
"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
)
assert "set_init_sequence({240, 1, 8, 242" 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
@@ -377,6 +378,76 @@ def test_lvgl_generation(
"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
)
assert "set_init_sequence({177, 3, 1, 44, 45, 178" 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
@@ -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"})
@@ -0,0 +1,33 @@
# New `image:` `platform: online_image` form. Exercises online_image/image.py
# through the real platform loader and codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
wifi:
ssid: MySSID
password: password1
http_request:
verify_ssl: false
image:
- platform: online_image
id: test_online_image
url: http://example.com/image.png
format: png
type: rgb565
spi:
mosi_pin: 6
clk_pin: 7
psram:
mode: quad
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,32 @@
# Legacy top-level `online_image:` form. Exercises the deprecation shim and the
# shared codegen path through the real read_config/codegen pipeline.
esphome:
name: test
esp32:
board: esp32s3box
wifi:
ssid: MySSID
password: password1
http_request:
verify_ssl: false
online_image:
- id: test_online_image
url: http://example.com/image.png
format: png
type: rgb565
spi:
mosi_pin: 6
clk_pin: 7
psram:
mode: quad
display:
- platform: mipi_spi
id: lcd_display
model: s3box
@@ -0,0 +1,76 @@
"""Tests for the online_image platform and the legacy `online_image:` shim."""
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
from esphome.components.online_image import (
DOMAIN,
LEGACY_REMOVAL_VERSION,
_capture_legacy_entry,
_warn_legacy_online_image,
)
from esphome.core import CORE
from esphome.types import ConfigType
# ---------------------------------------------------------------------------
# Legacy top-level `online_image:` deprecation shim -- REMOVE these tests after
# 2027.1.0 together with the shim in esphome/components/online_image/__init__.py.
# ---------------------------------------------------------------------------
def test_warn_legacy_online_image_warns_once(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecation warning fires exactly once and never mutates the config."""
config: ConfigType = {"id": "test_online_image", "url": "http://example.com/i.png"}
# A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the
# one-shot warning can print a pasteable migrated block.
assert _capture_legacy_entry(config) is config
with caplog.at_level(logging.WARNING):
# First call: flag not yet set -> warns and records the flag.
assert _warn_legacy_online_image(config) is config
# Second call: flag already set -> stays silent (the dedup branch).
assert _warn_legacy_online_image(config) is config
assert CORE.data[DOMAIN]["legacy_warning_shown"] is True
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
assert "deprecated" in caplog.text
assert "platform: online_image" in caplog.text
assert LEGACY_REMOVAL_VERSION in caplog.text
def test_legacy_online_image_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""The legacy `online_image:` block validates, warns, and generates codegen
through the real read_config/codegen pipeline."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(component_config_path("online_image_test.yaml"))
# Deprecation warning surfaced through the real validation pipeline.
assert "online_image" in caplog.text
assert "deprecated" in caplog.text
# setup_online_image ran: OnlineImage object constructed and parented.
assert "new(test_online_image) online_image::OnlineImage(" in main_cpp
def test_online_image_platform_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The `image:` `platform: online_image` form generates codegen through the
real platform loader (online_image/image.py) without a deprecation warning."""
main_cpp = generate_main(component_config_path("online_image_platform_test.yaml"))
assert "new(test_online_image) online_image::OnlineImage(" in main_cpp
@@ -0,0 +1,84 @@
"""Tests for the provisioning component config validation."""
from __future__ import annotations
import logging
import pytest
from esphome import config_validation as cv
from esphome.components.provisioning import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
register_source,
report_hardcoded_credentials,
)
from esphome.const import CONF_TIMEOUT, PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def test_provisioning_requires_a_source(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Provisioning with no registered source is a config error.
Sources register themselves during their own config validation; with none
registered the window could never resolve, so validation fails.
"""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="provisioning-capable component"):
FINAL_VALIDATE_SCHEMA({})
def test_provisioning_accepts_a_registered_source(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A component that registered as a provisioning source satisfies validation."""
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
# Should not raise.
assert FINAL_VALIDATE_SCHEMA({}) == {}
def test_provisioning_warns_on_hardcoded_credentials(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A source with credentials set in the config triggers a warning."""
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
report_hardcoded_credentials("wifi")
with caplog.at_level(logging.WARNING):
assert FINAL_VALIDATE_SCHEMA({}) == {}
assert "wifi" in caplog.text
assert "credentials" in caplog.text
def test_provisioning_no_warning_without_hardcoded_credentials(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""No credentials warning when no source reports hardcoded credentials."""
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
with caplog.at_level(logging.WARNING):
assert FINAL_VALIDATE_SCHEMA({}) == {}
assert "credentials" not in caplog.text
def test_provisioning_rejects_zero_timeout(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A zero timeout would leave the window open forever, so it is rejected."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({CONF_TIMEOUT: "0s"})
def test_provisioning_accepts_positive_timeout(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A positive timeout is accepted."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA({CONF_TIMEOUT: "5min"})
assert config[CONF_TIMEOUT].total_milliseconds == 300000
@@ -0,0 +1,86 @@
"""Tests for web_server Private Network Access / allowed_origins validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.web_server import (
CONF_ALLOWED_ORIGINS,
validate_origin,
validate_private_network_access,
)
from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS
from esphome.types import ConfigType
def test_pna_enabled_without_origins_fails() -> None:
"""Enabling PNA without allowed_origins must fail validation."""
config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True}
with pytest.raises(cv.Invalid) as exc_info:
validate_private_network_access(config)
error_msg = str(exc_info.value)
assert CONF_ALLOWED_ORIGINS in error_msg
assert "must be set" in error_msg
def test_pna_enabled_with_origins_passes() -> None:
"""Enabling PNA with at least one allowed origin passes validation."""
config: ConfigType = {
CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True,
CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"],
}
assert validate_private_network_access(config) == config
def test_origins_without_pna_passes() -> None:
"""allowed_origins can be set without enabling PNA (they are independent)."""
config: ConfigType = {
CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False,
CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"],
}
assert validate_private_network_access(config) == config
def test_pna_disabled_without_origins_passes() -> None:
"""PNA disabled and no origins specified passes validation."""
config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False}
assert validate_private_network_access(config) == config
def test_validate_origin_wildcard() -> None:
"""The '*' wildcard is accepted as-is."""
assert validate_origin("*") == "*"
@pytest.mark.parametrize(
"value",
[
"https://example.com",
"http://example.com:8080",
"https://192.168.1.5",
],
)
def test_validate_origin_valid(value: str) -> None:
"""Well-formed origins pass through unchanged."""
assert validate_origin(value) == value
def test_validate_origin_lowercased() -> None:
"""Scheme and host are normalized to lowercase to match the browser Origin header."""
assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com"
@pytest.mark.parametrize(
"value",
[
"https://example.com/", # trailing slash
"https://example.com/path", # path segment
"example.com", # missing scheme
"", # empty
],
)
def test_validate_origin_invalid(value: str) -> None:
"""Malformed origins are rejected at config time instead of silently 403ing."""
with pytest.raises(cv.Invalid, match="not a valid origin"):
validate_origin(value)
@@ -0,0 +1,65 @@
"""Tests for web_server authentication codegen."""
from collections.abc import Callable
import pytest
from esphome.core import CORE
_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0"
def _has_define(name: str) -> bool:
return any(d.name == name for d in CORE.defines)
def test_web_server_auth_default_is_basic_with_deprecation_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth without an explicit type builds Basic and warns about the upcoming default change."""
main_cpp = generate_main(
"tests/component_tests/web_server/web_server_auth_default.yaml"
)
assert '->set_auth_username("admin");' in main_cpp
assert '->set_auth_password("password");' in main_cpp
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING in caplog.text
def test_web_server_auth_explicit_basic_no_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type basic builds Basic and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml")
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_auth_explicit_digest(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type digest builds Digest and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml")
assert _has_define("USE_WEBSERVER_AUTH")
assert _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_without_auth(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without an auth block, no auth is compiled in and no warning is emitted."""
generate_main("tests/component_tests/web_server/web_server_no_auth.yaml")
assert not _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: basic
@@ -0,0 +1,17 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: digest
@@ -0,0 +1,14 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
+11 -8
View File
@@ -1,23 +1,26 @@
animation:
- id: rgb565_animation
image:
- platform: animation
id: rgb565_animation
file: $component_dir/anim.gif
type: RGB565
transparency: opaque
resize: 50x50
- id: rgb_animation
- platform: animation
id: rgb_animation
file: $component_dir/anim.apng
type: RGB
transparency: chroma_key
resize: 50x50
- id: grayscale_animation
- platform: animation
id: grayscale_animation
file: $component_dir/anim.apng
type: grayscale
display:
lambda: |-
id(rgb565_animation).next_frame();
id(rgb_animation1).next_frame();
id(grayscale_animation2).next_frame();
id(rgb_animation).next_frame();
id(grayscale_animation).next_frame();
it.image(0, 0, rgb565_animation);
it.image(120, 0, rgb_animation1);
it.image(240, 0, grayscale_animation2);
it.image(120, 0, rgb_animation);
it.image(240, 0, grayscale_animation);
@@ -0,0 +1,16 @@
# Legacy top-level `animation:` form (deprecated; migrates to
# `platform: animation`). Config-only test exercising the deprecation path.
display:
- platform: sdl
id: animation_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
animation:
- id: legacy_animation
file: $component_dir/anim.gif
type: RGB565
transparency: opaque
resize: 50x50
@@ -1,5 +1,11 @@
<<: !include common-base.yaml
packages:
common: !include common-base.yaml
wifi:
ssid: MySSID
password: password1
# Encryption enabled without a key: compiles both frame helpers so the key
# can be provisioned at runtime (zero-PSK noise or deprecated plaintext)
api:
encryption:
+16
View File
@@ -0,0 +1,16 @@
# Declares the component graph the C++ unit test build needs so that the aqi
# component's sources (which include sensor.h) compile. to_code is suppressed by
# the test harness; this only pulls the sensor + aqi source/include paths in.
# Loaded with plain yaml.safe_load, so avoid lambdas / ESPHome-tagged values here.
sensor:
- platform: template
id: pm25_sensor
name: "PM2.5"
- platform: template
id: pm10_sensor
name: "PM10"
- platform: aqi
name: "AQI"
pm_2_5: pm25_sensor
pm_10_0: pm10_sensor
calculation_type: AQI
+7
View File
@@ -20,3 +20,10 @@ sensor:
pm_2_5: pm25_sensor
pm_10_0: pm10_sensor
calculation_type: CAQI
- platform: aqi
name: "Air Quality Index (AQI, extended)"
pm_2_5: pm25_sensor
pm_10_0: pm10_sensor
calculation_type: AQI
extended_range: true
@@ -0,0 +1,85 @@
#include <gtest/gtest.h>
#include "esphome/components/aqi/aqi_calculator.h"
#include "esphome/components/aqi/caqi_calculator.h"
namespace esphome::aqi::testing {
// US AQI (EPA 2024): PM2.5 225.5-500.4 -> 301-500, PM10 425-604 -> 301-500.
TEST(USAQI, LowRangeUnaffectedByExtendedFlag) {
AQICalculator calc;
// PM2.5 25 drives over PM10 50; well below the top band, so the flag changes nothing.
EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 81);
EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, true), 81);
}
TEST(USAQI, HazardousInterpolatesNotPinnedAt301) {
AQICalculator calc;
// Regression guard: the old FLT_MAX top bucket collapsed every hazardous reading to 301.
EXPECT_EQ(calc.get_aqi(225.5f, 0.0f, false), 301); // band start
EXPECT_EQ(calc.get_aqi(250.0f, 0.0f, false), 319); // interpolated, not 301
EXPECT_EQ(calc.get_aqi(500.4f, 0.0f, false), 500); // band top
}
TEST(USAQI, DefaultClampsAtStandardMaximum) {
AQICalculator calc;
EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, false), 500);
EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, false), 500);
EXPECT_EQ(calc.get_aqi(0.0f, 604.0f, false), 500); // PM10 top breakpoint
}
TEST(USAQI, ExtendedRangeExtrapolatesBeyond500) {
AQICalculator calc;
EXPECT_EQ(calc.get_aqi(600.0f, 0.0f, true), 572);
EXPECT_EQ(calc.get_aqi(1000.0f, 0.0f, true), 862);
EXPECT_EQ(calc.get_aqi(0.0f, 700.0f, true), 607); // PM10 extrapolated past 500
}
TEST(USAQI, ExtendedRangeSaturatesUint16NoWraparound) {
AQICalculator calc;
// An absurd concentration would overflow uint16_t; it must saturate, not wrap to a small value.
EXPECT_EQ(calc.get_aqi(100000.0f, 0.0f, true), 65535);
}
TEST(USAQI, WorseOfTwoPollutantsWins) {
AQICalculator calc;
// PM10 604 -> 500 dominates PM2.5 25 -> 81.
EXPECT_EQ(calc.get_aqi(25.0f, 604.0f, false), 500);
}
// CAQI (CITEAIR): no maximum by spec -- the top ">100" class is open, so it is always unbounded
// and the extended_range flag does not apply.
TEST(CAQI, LowRange) {
CAQICalculator calc;
EXPECT_EQ(calc.get_aqi(25.0f, 50.0f, false), 50);
}
TEST(CAQI, ContinuousAt100NoPinAt101) {
CAQICalculator calc;
// Old code pinned everything above the top breakpoint to 101; now it reaches exactly 100.
EXPECT_EQ(calc.get_aqi(110.1f, 0.0f, false), 100);
}
TEST(CAQI, UnboundedAboveTopBand) {
CAQICalculator calc;
EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, false), 139);
EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, false), 925);
}
TEST(CAQI, ExtendedRangeFlagIsIgnored) {
CAQICalculator calc;
// CAQI is always unbounded, so the flag must make no difference either way.
EXPECT_EQ(calc.get_aqi(200.0f, 0.0f, true), calc.get_aqi(200.0f, 0.0f, false));
EXPECT_EQ(calc.get_aqi(2000.0f, 0.0f, true), calc.get_aqi(2000.0f, 0.0f, false));
}
TEST(CAQI, SaturatesUint16NoWraparound) {
CAQICalculator calc;
// CAQI is unbounded, so an extreme reading can extrapolate past uint16_t; it must saturate,
// not wrap around to a small (falsely "good") value.
EXPECT_EQ(calc.get_aqi(200000.0f, 0.0f, false), 65535);
}
} // namespace esphome::aqi::testing
+9
View File
@@ -17,6 +17,15 @@ cc1101:
sync0: 0x91
sync1: 0xD3
num_preamble: 2
foc_bs_cs_gate: true
foc_pre_k: "2K"
foc_post_k: "K/2"
foc_limit: "BW/4"
bs_pre_ki: "3KI"
bs_pre_kp: "4KP"
bs_post_ki: "KI/2"
bs_post_kp: "KP"
bs_limit: "12.5%"
on_packet:
then:
- lambda: |-
@@ -6,9 +6,15 @@ deep_sleep:
sleep_duration: 50s
wakeup_pin: ${wakeup_pin}
wakeup_pin_mode: INVERT_WAKEUP
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
esp32_ext1_wakeup:
pins:
- number: GPIO2
- pin: GPIO2
on_wake:
- logger.log: Woken by ext1 pin GPIO2
- number: GPIO13
mode: ANY_HIGH
touch_wakeup: true
@@ -5,8 +5,15 @@ deep_sleep:
sleep_duration: 50s
wakeup_pin: ${wakeup_pin}
wakeup_pin_mode: INVERT_WAKEUP
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
esp32_ext1_wakeup:
pins:
- number: GPIO2
- pin:
number: GPIO2
on_wake:
- logger.log: Woken by ext1 pin GPIO2
- number: GPIO5
mode: ANY_HIGH
@@ -5,3 +5,7 @@ deep_sleep:
sleep_duration: 50s
wakeup_pin: ${wakeup_pin}
wakeup_pin_mode: INVERT_WAKEUP
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
@@ -1,6 +1,10 @@
deep_sleep:
run_duration: 30s
sleep_duration: 12h
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
wakeup_pin:
- pin:
number: P6
@@ -1,5 +1,9 @@
deep_sleep:
run_duration: 10s
sleep_duration: 50s
on_wake:
- logger.log:
format: "Woke up, cause %d"
args: ["static_cast<int>(cause)"]
<<: !include common.yaml
+115
View File
@@ -0,0 +1,115 @@
# Combined DS248x test covering all chip variants and options:
# - DS2482-100: active pullup, multiple sensors + index access
# - DS2482-101: sleep pin, bus_sleep / hub_sleep
# - DS2482-800: all 8 channels
# - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection
ds248x:
- id: ds2482_100
address: 0x18
type: ds2482-100
active_pullup: true
- id: ds2482_101
address: 0x19
type: ds2482-101
active_pullup: true
sleep_pin:
number: GPIO12
inverted: false
bus_sleep: true
hub_sleep: true
- id: ds2482_800
address: 0x1a
type: ds2482-800
active_pullup: true
- id: ds2484_hub
address: 0x1b
type: ds2484
active_pullup: true
# DS2484-specific 1-Wire timing parameters (optional fine-tuning)
reset_low_time: 8 # tRSTL: Reset low time
master_sample_time: 8 # tMSP: Master sample point
write_0_low_time: 8 # tW0L: Write-0 low time
recovery_time: 8 # tREC0: Recovery time
active_pullup_resistance: 1000ohm # RWPU: weak pullup resistor selection
one_wire:
- platform: ds248x
ds248x_id: ds2482_100
channel: 0
id: ow_100
- platform: ds248x
ds248x_id: ds2482_101
channel: 0
id: ow_101
- platform: ds248x
ds248x_id: ds2482_800
channel: 0
id: ow_800_0
- platform: ds248x
ds248x_id: ds2482_800
channel: 1
id: ow_800_1
- platform: ds248x
ds248x_id: ds2482_800
channel: 2
id: ow_800_2
- platform: ds248x
ds248x_id: ds2482_800
channel: 3
id: ow_800_3
- platform: ds248x
ds248x_id: ds2482_800
channel: 4
id: ow_800_4
- platform: ds248x
ds248x_id: ds2482_800
channel: 5
id: ow_800_5
- platform: ds248x
ds248x_id: ds2482_800
channel: 6
id: ow_800_6
- platform: ds248x
ds248x_id: ds2482_800
channel: 7
id: ow_800_7
- platform: ds248x
ds248x_id: ds2484_hub
channel: 0
id: ow_2484
sensor:
# DS2482-100: explicit address + index-based access on the same bus
- platform: dallas_temp
one_wire_id: ow_100
address: 0x1c0000031edd2a28
name: Temp 100 by address
resolution: 12
- platform: dallas_temp
one_wire_id: ow_100
index: 0
name: Temp 100 by index
# DS2482-101 (sleep variant)
- platform: dallas_temp
one_wire_id: ow_101
address: 0x578295491f64ff28
name: Temp 101
# DS2482-800: sensors on a few of the eight channels
- platform: dallas_temp
one_wire_id: ow_800_0
address: 0x1c0000031edd2a28
name: Temp 800 CH0
- platform: dallas_temp
one_wire_id: ow_800_3
index: 0
name: Temp 800 CH3 by index
- platform: dallas_temp
one_wire_id: ow_800_7
address: 0x2800000123456789
name: Temp 800 CH7
# DS2484 (adjustable timing)
- platform: dallas_temp
one_wire_id: ow_2484
address: 0x1c0000031edd2a28
name: Temp 2484
resolution: 12
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,9 @@
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
nvs_encryption:
key_id: 0
<<: !include common.yaml
@@ -0,0 +1,11 @@
# Secure Boot V2 schemes carry the public key inside each image's signature
# block, so verifying externally-signed binaries needs no key in the config:
# a bare block enables verification with the default rsa3072 scheme.
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
<<: !include common.yaml
+17
View File
@@ -0,0 +1,17 @@
image:
- platform: file
id: file_binary_image
file: ../../pnglogo.png
type: BINARY
dither: FloydSteinberg
- platform: file
id: file_rgb565_image
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
resize: 50x50
- platform: file
id: file_mdi_image
file: mdi:alert-circle-outline
type: BINARY
resize: 24x24
+14
View File
@@ -0,0 +1,14 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
display:
- platform: ili9xxx
id: file_main_lcd
spi_id: spi_bus
model: ili9342
cs_pin: 15
dc_pin: 13
reset_pin: 21
invert_colors: true
<<: !include common.yaml
+9
View File
@@ -0,0 +1,9 @@
display:
- platform: sdl
id: file_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
<<: !include common.yaml
@@ -0,0 +1,22 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
xl9535:
id: expander
touchscreen:
# Firmware downloaded from the model's default release URL and cached.
- platform: gsl3670
model: seeed-reterminal-d1001
display: test_display_screen
interrupt_pin: 18
# Explicit firmware URL + SHA-256 override.
- platform: gsl3670
model: seeed-reterminal-d1001
display: test_display_screen
reset_pin: 10
interrupt_pin: 11
firmware:
url: https://github.com/esphome-libs/gsl3670-firmware/releases/download/v1.0.0/seeed-d1001-fw.bin
sha256: 2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4
+1 -12
View File
@@ -1,19 +1,8 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: gt911_ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
- id: gt911_page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
touchscreen:
- platform: gt911
i2c_id: i2c_bus
id: gt911_touchscreen
display: gt911_ssd1306_i2c_display
display: test_display_screen
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "12"
reset_pin: "13"
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
+2 -3
View File
@@ -1,9 +1,8 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
<<: !include common.yaml
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
gt911: !include common.yaml
+38 -19
View File
@@ -1,85 +1,104 @@
image:
- id: binary_image
- platform: file
id: binary_image
file: ../../pnglogo.png
type: BINARY
dither: FloydSteinberg
- id: transparent_transparent_image
- platform: file
id: transparent_transparent_image
file: ../../pnglogo.png
type: BINARY
transparency: chroma_key
- id: rgba_image
- platform: file
id: rgba_image
file: ../../pnglogo.png
type: RGB
transparency: alpha_channel
resize: 50x50
- id: rgb24_image
- platform: file
id: rgb24_image
file: ../../pnglogo.png
type: RGB
transparency: chroma_key
- id: rgb_image
- platform: file
id: rgb_image
file: ../../pnglogo.png
type: RGB
transparency: opaque
- id: rgb565_image
- platform: file
id: rgb565_image
file: ../../pnglogo.png
type: RGB565
transparency: opaque
- id: rgb565_ck_image
- platform: file
id: rgb565_ck_image
file: ../../pnglogo.png
type: RGB565
transparency: chroma_key
- id: rgb565_alpha_image
- platform: file
id: rgb565_alpha_image
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
- id: grayscale_alpha_image
- platform: file
id: grayscale_alpha_image
file: ../../pnglogo.png
type: grayscale
transparency: alpha_channel
resize: 50x50
- id: grayscale_ck_image
- platform: file
id: grayscale_ck_image
file: ../../pnglogo.png
type: grayscale
transparency: chroma_key
- id: grayscale_image
- platform: file
id: grayscale_image
file: ../../pnglogo.png
type: grayscale
transparency: opaque
- id: web_svg_image
- platform: file
id: web_svg_image
file: https://media.esphome.io/logo/logo.svg
resize: 256x48
type: BINARY
transparency: chroma_key
- id: web_tiff_image
- platform: file
id: web_tiff_image
file: https://media.esphome.io/tests/images/SIPI_Jelly_Beans_4.1.07.tiff
type: RGB
resize: 48x48
- id: web_redirect_image
- platform: file
id: web_redirect_image
file: https://media.esphome.io/logo/logo.png
type: RGB
resize: 48x48
- id: mdi_alert
- platform: file
id: mdi_alert
type: BINARY
file: mdi:alert-circle-outline
resize: 50x50
- id: another_alert_icon
- platform: file
id: another_alert_icon
file: mdi:alert-outline
type: BINARY
- file: mdil:arrange-bring-to-front
- platform: file
file: mdil:arrange-bring-to-front
id: mdil_id
resize: 50x50
type: binary
transparency: chroma_key
- file: mdi:beer
- platform: file
file: mdi:beer
id: mdi_id
resize: 50x50
type: binary
transparency: chroma_key
- file: memory:alert-octagon
- platform: file
file: memory:alert-octagon
id: memory_id
resize: 50x50
type: binary
+3 -4
View File
@@ -12,12 +12,11 @@ display:
invert_colors: true
image:
defaults:
- platform: file
id: test_image
file: ../../pnglogo.png
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
images:
- id: test_image
file: ../../pnglogo.png
+57 -40
View File
@@ -7,43 +7,60 @@ display:
height: 480
image:
binary:
- id: binary_image
file: ../../pnglogo.png
dither: FloydSteinberg
- id: transparent_transparent_image
file: ../../pnglogo.png
transparency: chroma_key
rgb:
alpha_channel:
- id: rgba_image
file: ../../pnglogo.png
resize: 50x50
chroma_key:
- id: rgb24_image
file: ../../pnglogo.png
type: RGB
opaque:
- id: rgb_image
file: ../../pnglogo.png
rgb565:
- id: rgb565_image
file: ../../pnglogo.png
transparency: opaque
- id: rgb565_ck_image
file: ../../pnglogo.png
transparency: chroma_key
- id: rgb565_alpha_image
file: ../../pnglogo.png
transparency: alpha_channel
grayscale:
- id: grayscale_alpha_image
file: ../../pnglogo.png
transparency: alpha_channel
resize: 50x50
- id: grayscale_ck_image
file: ../../pnglogo.png
transparency: chroma_key
- id: grayscale_image
file: ../../pnglogo.png
transparency: opaque
- platform: file
id: binary_image
file: ../../pnglogo.png
type: binary
dither: FloydSteinberg
- platform: file
id: transparent_transparent_image
file: ../../pnglogo.png
type: binary
transparency: chroma_key
- platform: file
id: rgba_image
file: ../../pnglogo.png
type: rgb
transparency: alpha_channel
resize: 50x50
- platform: file
id: rgb24_image
file: ../../pnglogo.png
type: RGB
transparency: chroma_key
- platform: file
id: rgb_image
file: ../../pnglogo.png
type: rgb
transparency: opaque
- platform: file
id: rgb565_image
file: ../../pnglogo.png
type: rgb565
transparency: opaque
- platform: file
id: rgb565_ck_image
file: ../../pnglogo.png
type: rgb565
transparency: chroma_key
- platform: file
id: rgb565_alpha_image
file: ../../pnglogo.png
type: rgb565
transparency: alpha_channel
- platform: file
id: grayscale_alpha_image
file: ../../pnglogo.png
type: grayscale
transparency: alpha_channel
resize: 50x50
- platform: file
id: grayscale_ck_image
file: ../../pnglogo.png
type: grayscale
transparency: chroma_key
- platform: file
id: grayscale_image
file: ../../pnglogo.png
type: grayscale
transparency: opaque
@@ -0,0 +1,25 @@
# Legacy top-level `image:` defaults/images form (deprecated; migrates to
# `platform: file`). Config-only test exercising the deprecation/migration path,
# including the per-type byte_order drop when an entry overrides to a non-endian
# type (binary).
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
defaults:
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
images:
- id: legacy_defaults_image
file: ../../pnglogo.png
- id: legacy_defaults_binary
file: ../../pnglogo.png
type: binary
@@ -0,0 +1,24 @@
# Legacy top-level `image:` structured form using single-dict (non-list) values
# for `images:`, a type group, and a transparency group -- the old `ensure_list`
# accepted a bare dict in each of these places. Deprecated; migrates to
# `platform: file`. Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
images:
id: legacy_images_single_dict
file: ../../pnglogo.png
type: rgb565
rgb565:
id: legacy_grouped_type_single_dict
file: ../../pnglogo.png
rgb:
alpha_channel:
id: legacy_grouped_transparency_single_dict
file: ../../pnglogo.png
@@ -0,0 +1,25 @@
# Legacy top-level `image:` type-grouped form (deprecated; migrates to
# `platform: file`). Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
binary:
- id: legacy_grouped_binary
file: ../../pnglogo.png
rgb:
alpha_channel:
- id: legacy_grouped_rgba
file: ../../pnglogo.png
opaque:
- id: legacy_grouped_rgb
file: ../../pnglogo.png
rgb565:
- id: legacy_grouped_rgb565
file: ../../pnglogo.png
transparency: chroma_key
@@ -0,0 +1,16 @@
# Legacy top-level `image:` single-dict form (a bare image dict instead of a
# list; deprecated, migrates to `platform: file`). Config-only test exercising
# the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
id: legacy_single_image
file: ../../pnglogo.png
type: RGB565
transparency: opaque
+18
View File
@@ -0,0 +1,18 @@
# Legacy top-level `image:` list form (deprecated; migrates to `platform: file`).
# Config-only test exercising the deprecation/migration path.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- id: legacy_list_binary
file: ../../pnglogo.png
type: BINARY
- id: legacy_list_rgb565
file: ../../pnglogo.png
type: RGB565
transparency: alpha_channel
+2 -2
View File
@@ -91,8 +91,8 @@ binary_sensor:
animation: move_right
time: 600ms
- platform: lvgl
id: button_checker
name: LVGL button
id: common_button_checker
name: Common button
widget: spin_up
on_state:
then:
+122 -17
View File
@@ -24,20 +24,6 @@ binary_sensor:
name: Button A checked
widget: button_a
state: checked
- platform: lvgl
id: button_checker
name: LVGL button
widget: button_button
state: checked
on_state:
then:
- lvgl.checkbox.update:
id: checkbox_id
state:
checked: !lambda |-
auto y = x; // block inlining of one line return
return y;
- platform: lvgl
id: button_presser
name: Button pressed
@@ -49,6 +35,9 @@ lvgl:
rotation: 90
log_level: debug
resume_on_input: true
paused: true
update_when_display_idle: true
refresh_interval: 30ms
on_pause:
- logger.log: LVGL is Paused
- lvgl.display.set_rotation: 90
@@ -57,6 +46,40 @@ lvgl:
- lvgl.display.set_rotation:
rotation: 0
lvgl_id: lvgl_id
- lvgl.display.set_rotation:
rotation: !lambda "return 180;"
lvgl_id: lvgl_id
on_landscape:
- logger.log: LVGL display is now landscape
# Re-layout a container in response to orientation changes. The layout type
# and grid structure are fixed at creation; only the style options change.
- lvgl.widget.update:
id: grid_rows_only_shorthand
layout:
grid_column_align: center
grid_row_align: space_between
pad_row: 4px
- lvgl.update:
top_layer:
layout:
flex_flow: row
on_portrait:
- logger.log: LVGL display is now portrait
- lvgl.widget.update:
id: grid_rows_only_shorthand
layout:
grid_column_align: start
pad_row: 2px
# Top-level layout applies to the active screen
- lvgl.update:
layout:
flex_flow: column
pad_row: 8px
- lvgl.update:
top_layer:
layout:
flex_flow: column
flex_align_main: center
on_boot:
- logger.log: LVGL has started
@@ -64,6 +87,12 @@ lvgl:
id: meter_arc_indicator
start_value: 0
end_value: 180
- lvgl.animation.start:
id:
- anim_slide
- anim_color
duration: 3s
loop: true
on_invalidate_area:
logger.log: Invalidate area
on_resolution_change:
@@ -108,6 +137,52 @@ lvgl:
- obj:
bg_color: 0x000000
bg_opa: cover
top_layer:
widgets:
- obj:
id: anim_box
x: 0
y: 0
width: 50
height: 50
bg_color: 0xFF0000
- label:
id: anim_label
text: anim
animations:
- id: anim_slide
duration: 1s
start_delay: 100ms
auto_start: true
loop: true
timing: ease_in_out
on_start:
- logger.log: anim started
on_stop:
- logger.log: anim stopped
widgets:
- id: anim_box
x:
from: 0
to: 100
y:
from: 0
to: !lambda "return 80;"
opa:
from: 50%
to: 100%
- id: anim_color
duration: 2s
timing:
- round_trip
- type: gravity
bounce: 0.3
acceleration: 0.8
widgets:
- id: anim_label
text_color:
from: 0xFF0000
to: color_id
theme:
dark_mode: true
obj:
@@ -210,6 +285,11 @@ lvgl:
on_click:
then:
- lvgl.display.set_rotation: 0
- lvgl.animation.stop: anim_slide
- lvgl.animation.stop:
id:
- anim_slide
- anim_color
- lvgl.widget.hide: message_box
- lvgl.style.update:
id: style_test
@@ -217,6 +297,9 @@ lvgl:
bg_opa: !lambda return 0.5;
- lvgl.image.update:
id: lv_image
src:
mapping: image_map
value: !lambda return round(1.0);
scale: !lambda return 512;
rotation: !lambda return 100;
pivot_x: !lambda return 20;
@@ -342,9 +425,16 @@ lvgl:
text_font: montserrat_40
border_post: true
on_press:
lvgl.label.update:
id: hello_label
text: Goodbye
- lvgl.label.update:
id: hello_label
text:
mapping: lvgl_string_map
value: !lambda return 2;
- lvgl.label.update:
id: hello_label
text:
mapping: lvgl_string_map
value: 2
on_click:
then:
- lvgl.animimg.stop: anim_img
@@ -1450,6 +1540,21 @@ image:
invert_alpha: true
transparency: alpha_channel
mapping:
- id: image_map
from: int
to: image
entries:
0: cat_image
1: dog_image
- id: lvgl_string_map
from: int
to: string
entries:
0: "First"
1: "Second"
2: "Third"
color:
- id: light_blue
hex: "3340FF"
+2 -3
View File
@@ -1,7 +1,8 @@
packages:
lvgl: !include lvgl-package.yaml
lvgl_package: !include lvgl-package.yaml
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
lvgl: !include common.yaml
sensor:
- platform: rotary_encoder
@@ -77,5 +78,3 @@ lvgl:
- component.update: tft_display
- delay: 60s
- lvgl.resume:
<<: !include common.yaml
+38 -1
View File
@@ -22,6 +22,36 @@ lvgl:
displays: sdl0
rotation: 180
top_layer:
widgets:
- obj:
id: anim_box
x: 0
y: 0
width: 40
height: 40
bg_color: 0xFF0000
animations:
- id: anim_slide
duration: 1s
start_delay: 100ms
auto_start: true
loop: true
timing:
- round_trip
- type: ease_in_out
weight: 3
on_start:
- logger.log: anim started
on_stop:
- logger.log: anim stopped
widgets:
- id: anim_box
x:
from: 0
to: !lambda "return 100;"
opa:
from: 50%
to: 100%
- id: lvgl_1
displays: sdl1
@@ -42,7 +72,14 @@ lvgl:
- label:
text: Click ME
on_click:
logger.log: Clicked
then:
- logger.log: Clicked
- lvgl.animation.stop:
id: anim_slide
lvgl_id: lvgl_0
- lvgl.animation.start:
id: anim_slide
lvgl_id: lvgl_0
font:
- file: "gfonts://Roboto"
@@ -0,0 +1,7 @@
# No api, web_server or extra services so the fallback _http service
# (with version, mac and config_hash TXT records) is compiled.
wifi:
ssid: MySSID
password: password1
mdns:
@@ -0,0 +1,9 @@
# web_server without the native api so the version, mac and config_hash
# TXT records are attached to the web_server _http service.
wifi:
ssid: MySSID
password: password1
web_server:
mdns:
@@ -1,5 +1,5 @@
packages:
- !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
psram:
mode: octal
@@ -37,6 +37,9 @@ class MockUARTComponent : public uart::UARTComponent {
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif // defined(USE_ESP8266) || defined(USE_ESP32)
};
class TestableMitsubishiCN105 : public MitsubishiCN105 {
@@ -0,0 +1,178 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <span>
#include "esphome/components/modbus/modbus.h"
namespace esphome::modbus::testing {
namespace {
// Exposes the protected tx queue and waiting-for-response slot so tests can drive the
// no-response path without a UART: force_send_front() mimics send_next_frame_() moving the
// front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling.
class NoResponseProbeHub : public ModbusClientHub {
public:
size_t queued_frames() const { return this->tx_buffer_.size(); }
const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); }
bool waiting() const { return this->waiting_for_response_.has_value(); }
const ModbusDeviceCommand &waiting_command() const {
EXPECT_TRUE(this->waiting_for_response_.has_value());
return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access)
}
void force_send_front() {
this->waiting_for_response_ = std::move(this->tx_buffer_.front());
this->tx_buffer_.pop_front();
}
// Drives the real unexpected-frame branch in process_modbus_server_frame().
void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) {
this->process_modbus_server_frame(address, function_code, data, len);
}
void timeout_waiting() {
if (this->waiting_for_response_.has_value())
this->notify_no_response_(*this->waiting_for_response_);
this->waiting_for_response_.reset();
}
};
// A device with a scripted answer to on_modbus_no_response().
class RetryingDevice : public ModbusClientDevice {
public:
RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {}
bool on_modbus_no_response() override {
this->no_response_count_++;
return this->retry_;
}
int no_response_count_{0};
protected:
bool retry_{false};
};
// A device that clears its own queued traffic from inside the no-response callback, then asks for a retry.
class ClearingRetryDevice : public ModbusClientDevice {
public:
ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
bool on_modbus_no_response() override {
this->no_response_count_++;
this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback
return true; // and still requests a retry
}
int no_response_count_{0};
};
constexpr uint8_t READ_PDU[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 holding registers at 0x100
StaticVector<uint8_t, MAX_PDU_SIZE> read_pdu() {
StaticVector<uint8_t, MAX_PDU_SIZE> pdu;
pdu.assign(READ_PDU, READ_PDU + sizeof(READ_PDU));
return pdu;
}
} // namespace
// A device that requests a retry gets the frame the hub was holding re-queued on its behalf,
// byte-identical and still routed to the same device.
TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.send_pdu(read_pdu());
ASSERT_EQ(hub.queued_frames(), 1u);
hub.force_send_front();
ASSERT_EQ(hub.queued_frames(), 0u);
ASSERT_TRUE(hub.waiting());
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_FALSE(hub.waiting());
ASSERT_EQ(hub.queued_frames(), 1u);
const ModbusDeviceCommand &requeued = hub.front();
EXPECT_EQ(requeued.device, &device);
// address + PDU + CRC
ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3);
EXPECT_EQ(requeued.frame.data.data()[0], 0x02);
EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU)));
}
// A device that declines the retry has the frame dropped.
TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/false);
device.send_pdu(read_pdu());
hub.force_send_front();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.queued_frames(), 0u);
}
// After the device is detached from the waiting frame (e.g. clear_tx_queue_for_device on
// destruction), a timeout must not deliver a callback or re-queue anything.
TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) {
NoResponseProbeHub hub;
{
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.send_pdu(read_pdu());
hub.force_send_front();
// device destructor clears its queue entries, including the waiting frame's device pointer
}
ASSERT_TRUE(hub.waiting());
EXPECT_EQ(hub.waiting_command().device, nullptr);
hub.timeout_waiting();
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.queued_frames(), 0u);
}
// An unexpected frame interrupts the transaction: the retry is re-queued immediately, but the
// waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the
// send-wait timeout clears it - without a second no-response callback or a duplicate requeue.
TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) {
NoResponseProbeHub hub;
RetryingDevice device(&hub, 0x02, /*retry=*/true);
device.send_pdu(read_pdu());
hub.force_send_front();
// A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch.
const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload));
EXPECT_EQ(device.no_response_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue...
EXPECT_EQ(hub.front().device, &device);
ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot
EXPECT_TRUE(hub.waiting_command().interrupted);
EXPECT_EQ(hub.waiting_command().device, nullptr);
// The send-wait timeout clears the shell without a second callback or another requeue.
hub.timeout_waiting();
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(hub.queued_frames(), 1u);
}
// A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request:
// no orphaned frame with a null device is re-queued.
TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) {
NoResponseProbeHub hub;
ClearingRetryDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
hub.force_send_front();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(hub.queued_frames(), 0u); // the retry was not re-queued for a detached device
EXPECT_FALSE(hub.waiting());
}
} // namespace esphome::modbus::testing
@@ -181,17 +181,17 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) {
TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) {
const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0);
EXPECT_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value());
}
TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) {
const std::vector<uint8_t> data{0x12, 0x34, 0x56};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0);
EXPECT_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_DWORD, 0, 0xFFFFFFFF).has_value());
}
TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) {
const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234);
EXPECT_EQ(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234);
}
// --- registers_to_number ---------------------------------------------------
@@ -218,16 +218,15 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) {
const uint16_t registers[] = {0x8001, 0x0002};
const std::vector<uint8_t> bytes{0x80, 0x01, 0x00, 0x02};
for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) {
EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF))
EXPECT_EQ(registers_to_number(registers, 2, value_type),
payload_to_number(std::span<const uint8_t>(bytes), value_type, 0, 0xFFFFFFFF))
<< "value_type=" << static_cast<int>(value_type);
}
}
TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
const uint16_t registers[] = {0x1234};
bool error = false;
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0);
EXPECT_TRUE(error);
EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value());
}
} // namespace esphome::modbus::helpers
+17 -12
View File
@@ -2,11 +2,9 @@ wifi:
ssid: MySSID
password: password1
# Purposely test that `online_image:` does auto-load `image:`
# Keep the `image:` undefined.
# image:
online_image:
- id: online_binary_image
image:
- platform: online_image
id: online_binary_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: BINARY
@@ -21,34 +19,41 @@ online_image:
} else {
ESP_LOGD("online_image", "Cache miss: fresh download");
}
- id: online_binary_transparent_image
- platform: online_image
id: online_binary_transparent_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
type: BINARY
transparency: chroma_key
format: png
- id: online_rgba_image
- platform: online_image
id: online_rgba_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: RGB
transparency: alpha_channel
- id: online_rgb24_image
- platform: online_image
id: online_rgb24_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
format: PNG
type: RGB
transparency: chroma_key
- id: online_binary_bmp
- platform: online_image
id: online_binary_bmp
url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp
format: BMP
type: BINARY
- id: online_rgb_bmp_8bit
- platform: online_image
id: online_rgb_bmp_8bit
url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp
format: BMP
type: RGB
- id: online_jpeg_image
- platform: online_image
id: online_jpeg_image
url: http://www.faqs.org/images/library.jpg
format: JPEG
type: RGB
- id: online_jpg_image
- platform: online_image
id: online_jpg_image
url: http://www.faqs.org/images/library.jpg
format: JPG
type: RGB565
@@ -0,0 +1,22 @@
# Legacy top-level `online_image:` form (deprecated; migrates to
# `platform: online_image`). Config-only test exercising the deprecation path.
wifi:
ssid: MySSID
password: password1
http_request:
display:
- platform: sdl
id: online_image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
online_image:
- id: legacy_online_image
url: http://www.example.org/example.png
format: PNG
type: RGB565
resize: 50x50
+11
View File
@@ -4,6 +4,11 @@ audio_dac:
i2c_id: i2c_bus
address: 0x4D
bits_per_sample: 32bit
analog_gain: -6db
channel_mix: swapped
volume_min_db: -60dB
volume_max_db: -3dB
enable_pin: GPIO12
output:
- platform: gpio
@@ -22,3 +27,9 @@ binary_sensor:
number: 4
mode:
input: true
switch:
- platform: pcm5122
pcm5122: pcm5122_dac
name: PCM5122 Power Down
power_mode: powerdown
@@ -0,0 +1,25 @@
# Exercises the provisioning window: api registers as a provisioning source
# (encryption enabled, no key), the on_timeout automation, and the wifi +
# esp32_improv cross-component guards. improv_serial is intentionally NOT gated.
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
api:
encryption:
wifi:
ssid: MySSID
password: password1
improv_serial:
binary_sensor:
- platform: gpio
pin: 0
id: io0_button
esp32_improv:
authorizer: io0_button
@@ -0,0 +1,16 @@
# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source
# and the wifi reboot guard. improv_serial is present and intentionally NOT gated.
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
api:
encryption:
wifi:
ssid: MySSID
password: password1
improv_serial:
@@ -0,0 +1,15 @@
# A device provisioned over the network (wifi / Improv) with no api: network
# connectivity alone satisfies provisioning, so `provisioning:` is valid without an
# api encryption source. Config-only -- exercises the network provisioning-source
# validation path (the Improv-only case from the review).
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
wifi:
ssid: MySSID
password: password1
improv_serial:
+13
View File
@@ -0,0 +1,13 @@
# The test_display platform (and its external_components entry) is provided by
# the shared package included from the test.*.yaml files. These extra instances
# exercise the remaining `dimensions` code paths: the width/height map form and
# the default when omitted. The package's own `test_display_screen` covers the
# "WIDTHxHEIGHT" string form.
display:
- platform: test_display
id: test_display_wh_dimensions
dimensions:
width: 320
height: 240
- platform: test_display
id: test_display_default_dimensions
@@ -0,0 +1,36 @@
import esphome.codegen as cg
from esphome.components import display
import esphome.config_validation as cv
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, CONF_WIDTH
from esphome.core import CoroPriority, coroutine_with_priority
test_display_ns = cg.esphome_ns.namespace("test_display")
TestDisplay = test_display_ns.class_("TestDisplay", display.Display)
CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(TestDisplay),
cv.Optional(CONF_DIMENSIONS, default="100x100"): cv.Any(
cv.dimensions,
cv.Schema(
{
cv.Required(CONF_WIDTH): cv.int_,
cv.Required(CONF_HEIGHT): cv.int_,
}
),
),
}
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await display.register_display(var, config)
dimensions = config[CONF_DIMENSIONS]
if isinstance(dimensions, dict):
width, height = dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT]
else:
width, height = dimensions
cg.add(var.set_dimensions(width, height))
@@ -0,0 +1,36 @@
#pragma once
#include "esphome/components/display/display.h"
#include "esphome/core/color.h"
namespace esphome::test_display {
/** A no-op display that draws nothing and uses no pins.
*
* It exists purely to satisfy components that require a display (for example
* touchscreens, which read the display dimensions) in configurations - most
* notably YAML build tests - where a real display driver would only get in the
* way by occupying GPIO pins and pulling in bus dependencies.
*/
class TestDisplay : public display::Display {
public:
void update() override { this->do_update_(); }
void set_dimensions(int width, int height) {
this->width_ = width;
this->height_ = height;
}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
void draw_pixel_at(int x, int y, Color color) override {}
protected:
int get_width_internal() override { return this->width_; }
int get_height_internal() override { return this->height_; }
int width_{0};
int height_{0};
};
} // namespace esphome::test_display
@@ -0,0 +1,3 @@
packages:
test_display: !include ../../test_build_components/common/test_display/test_display.yaml
test_display_extra: !include common.yaml

Some files were not shown because too many files have changed in this diff Show More