mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 01:10:22 +00:00
Merge remote-tracking branch 'origin/dev' into integration
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
Reference in New Issue
Block a user