Merge remote-tracking branch 'upstream/dev' into neutral-ble-client

This commit is contained in:
J. Nick Koston
2026-08-26 19:49:11 -05:00
298 changed files with 13885 additions and 2073 deletions
@@ -0,0 +1,63 @@
"""Tests for variables handling in homeassistant.event and homeassistant.action."""
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml"
def test_plain_string_with_return_is_compiled_as_lambda_with_warning(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A plain string with a return statement compiles as a lambda and warns."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2
assert "return millis();" in main_cpp
# The source text must not be sent as a static string value.
assert '"return millis();"' not in main_cpp
assert "missing the !lambda tag" in caplog.text
def test_static_string_is_kept_as_static_value(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A static string stays static, PROGMEM wrapped, with no warning."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert (
main_cpp.count(
'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));'
)
== 2
)
assert "static value" not in caplog.text
def test_static_id_value_stays_literal_with_hint(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Lambda source without a return stays literal text but warns."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp
assert "sent as literal text" in caplog.text
def test_explicit_lambda_tag_is_compiled_as_lambda(
generate_main: Callable[[str | Path], str],
) -> None:
"""A !lambda value keeps working unchanged."""
main_cpp = generate_main(CONFIG)
assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp
assert "return App.get_name();" in main_cpp
@@ -0,0 +1,32 @@
esphome:
name: test
on_boot:
then:
# Plain strings with a return statement compile as lambdas
- homeassistant.event:
event: esphome.test_event
data_template:
message: "{{ lambda_var }} {{ static_var }} {{ tagged_var }}"
variables:
lambda_var: |-
return millis();
static_var: static value
tagged_var: !lambda return App.get_name();
hint_var: id(test_sensor).state
- homeassistant.action:
action: notify.notify
data_template:
message: "{{ lambda_var }} {{ static_var }}"
variables:
lambda_var: |-
return millis();
static_var: static value
esp32:
board: esp32dev
wifi:
ssid: SomeNetwork
password: SomePassword
api:
+8
View File
@@ -57,6 +57,14 @@ def reset_core() -> Generator[None]:
CORE.reset()
@pytest.fixture(autouse=True)
def reset_full_config() -> Generator[None]:
"""Give each test a clean final-validate config and restore it after."""
token = final_validate.full_config.set({})
yield
final_validate.full_config.reset(token)
@pytest.fixture
def set_core_config() -> Generator[SetCoreConfigCallable]:
"""Fixture to set up the core configuration for tests."""
@@ -6,9 +6,28 @@ from esphome.components import sensor
from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_EMPTY,
UNIT_HERTZ,
UNIT_PULSES,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
@@ -61,9 +80,25 @@ def _make_config(tag: str) -> dict:
("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0),
("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0),
("PF1", STATE_CLASS_MEASUREMENT, 2),
("AP1", STATE_CLASS_MEASUREMENT, 2),
("AP12", STATE_CLASS_MEASUREMENT, 2),
# Frequency: reported as a single, un-numbered tag
("F", STATE_CLASS_MEASUREMENT, 2),
# Unknown / free-form tags fall back to generic defaults
("CUSTOM1", STATE_CLASS_MEASUREMENT, 0),
("X", STATE_CLASS_MEASUREMENT, 0),
# "F1" is not the exact "F" tag, so it falls back to generic defaults
("F1", STATE_CLASS_MEASUREMENT, 0),
# "PULSE" (no index) is how some real emonTx firmware reports a
# single pulse counter, so it still resolves to the PULSE defaults
("PULSE", STATE_CLASS_TOTAL_INCREASING, 0),
# Real firmware sends this lowercase; tag_upper's case-folding must
# still match it against the PULSE pattern
("pulse", STATE_CLASS_TOTAL_INCREASING, 0),
# PF/AP require a numeric index; the bare prefix alone (no index)
# falls back to generic defaults
("PF", STATE_CLASS_MEASUREMENT, 0),
("AP", STATE_CLASS_MEASUREMENT, 0),
],
)
def test_apply_tag_defaults(tag, expected_state_class, expected_decimals):
@@ -76,6 +111,80 @@ def test_apply_tag_defaults(tag, expected_state_class, expected_decimals):
assert result[CONF_ACCURACY_DECIMALS] == expected_decimals
@pytest.mark.parametrize(
("tag", "expected_unit", "expected_device_class"),
[
# Known numeric-index prefixes
("E1", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY),
("E12", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY),
("P1", UNIT_WATT, DEVICE_CLASS_POWER),
("V1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE),
("I1", UNIT_AMPERE, DEVICE_CLASS_CURRENT),
("T1", UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE),
# Known patterns
("PULSE1", UNIT_PULSES, DEVICE_CLASS_ENERGY),
("PULSE12", UNIT_PULSES, DEVICE_CLASS_ENERGY),
# Bare "PULSE" (no index), as reported by some real emonTx firmware
("PULSE", UNIT_PULSES, DEVICE_CLASS_ENERGY),
# Real firmware sends this lowercase; tag_upper's case-folding must
# still match it against the PULSE pattern
("pulse", UNIT_PULSES, DEVICE_CLASS_ENERGY),
("PF1", UNIT_EMPTY, DEVICE_CLASS_POWER_FACTOR),
("AP1", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
("AP12", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
# Frequency: reported as a single, un-numbered tag
("F", UNIT_HERTZ, DEVICE_CLASS_FREQUENCY),
],
)
def test_apply_tag_defaults_unit_and_device_class(
tag, expected_unit, expected_device_class
):
"""apply_tag_defaults must inject the correct, validated unit_of_measurement
and device_class for each tag type when no user overrides are present."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert result[CONF_UNIT_OF_MEASUREMENT] == sensor.validate_unit_of_measurement(
expected_unit
)
assert result[CONF_DEVICE_CLASS] == sensor.validate_device_class(
expected_device_class
)
@pytest.mark.parametrize(
"tag",
[
"CUSTOM1",
"X",
# Non-numeric suffixes must not collide with a PATTERN_CONFIGS prefix
# (e.g. "APPLE" starting with "AP", "PFX" starting with "PF").
"APPLE",
"PFX",
"PULSE_A",
# "F1" is not the exact "F" tag
"F1",
# Bare "PF"/"AP" (no numeric index) don't match; unlike "PULSE",
# real firmware never reports these without an index
"PF",
"AP",
],
)
def test_apply_tag_defaults_unknown_tag_has_no_unit_or_device_class(tag):
"""Unknown / free-form tags only get generic state_class and
accuracy_decimals defaults; unit_of_measurement and device_class are left
for the user to set explicitly."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert CONF_UNIT_OF_MEASUREMENT not in result
assert CONF_DEVICE_CLASS not in result
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
STATE_CLASS_MEASUREMENT
)
assert result[CONF_ACCURACY_DECIMALS] == 0
@pytest.mark.parametrize(
("tag", "user_state_class", "user_decimals"),
[
@@ -0,0 +1,9 @@
esphome:
name: test
libraries:
- NetworkClientSecure
esp32:
board: esp32dev
framework:
type: arduino
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
use_full_certificate_bundle: true
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
http_request:
verify_ssl: true
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE: y
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
esp32_camera_web_server:
port: 8080
mode: stream
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: y
CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC: y
CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID: "0"
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: n
+101 -1
View File
@@ -17,6 +17,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
RawSdkconfigValue,
_ota_downgrade_protection_errors,
_reconcile_network_sdkconfig,
_reconcile_vfs_fatfs_sdkconfig,
@@ -131,6 +132,20 @@ def test_esp32_rejects_unsupported_toolchains(
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
def test_esp32_rejects_unsupported_cli_toolchain(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A --toolchain the platform cannot serve fails instead of silently
building with PlatformIO (the CLI path bypasses the YAML validator)."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
CONFIG_SCHEMA({"variant": VARIANT_ESP32})
@pytest.mark.parametrize(
("config", "error_match"),
[
@@ -260,9 +275,24 @@ def test_esp32_configuration_errors(
),
pytest.param(
"exclusion_reincludes_web_server.yaml",
("esp-tls",),
("esp-tls", "esp_http_server"),
id="web_server_idf",
),
pytest.param(
"nvs_encryption_s3.yaml",
("nvs_sec_provider",),
id="nvs_encryption",
),
pytest.param(
"exclusion_reincludes_nvs_sdkconfig.yaml",
("nvs_sec_provider",),
id="nvs_encryption_raw_sdkconfig",
),
pytest.param(
"exclusion_reincludes_camera_web_server.yaml",
("esp_http_server",),
id="esp32_camera_web_server",
),
pytest.param(
"exclusion_reincludes_nextion.yaml",
("esp-tls", "esp_http_client"),
@@ -290,6 +320,76 @@ def test_default_exclusions_reincluded_by_owning_components(
# Components no part of this config touches stay excluded.
assert "unity" in excluded
assert "fatfs" in excluded
# The HTTP server only comes back for configs that run one.
assert ("esp_http_server" in excluded) == ("esp_http_server" not in reincluded)
def test_nvs_sec_provider_stays_excluded_when_encryption_is_off(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""An explicit CONFIG_NVS_ENCRYPTION=n keeps nvs_sec_provider excluded."""
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
generate_main(component_config_path("exclusion_stays_nvs_sdkconfig_off.yaml"))
assert "nvs_sec_provider" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
_BUNDLE_OPTIONS = (
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE",
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN",
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL",
)
@pytest.mark.parametrize(
("config_file", "expected"),
[
pytest.param("exclusion_reincludes.yaml", (False, None, None), id="no_tls"),
pytest.param(
"certificate_bundle_http_request.yaml",
(True, True, False),
id="http_request",
),
pytest.param(
"exclusion_reincludes_http_request.yaml",
(False, None, None),
id="http_request_no_verify",
),
pytest.param(
"certificate_bundle_full.yaml", (True, None, True), id="full_option"
),
pytest.param(
"certificate_bundle_arduino_tls.yaml",
(True, True, False),
id="arduino_network_client_secure",
),
],
)
def test_certificate_bundle_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
expected: tuple[bool | None, ...],
) -> None:
"""The bundle and its CMN/FULL variant are written only when requested."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _BUNDLE_OPTIONS) == expected
def test_user_sdkconfig_certificate_bundle_wins(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A raw sdkconfig_options bundle setting is kept and still pins CMN."""
generate_main(component_config_path("certificate_bundle_sdkconfig.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
value = sdkconfig["CONFIG_MBEDTLS_CERTIFICATE_BUNDLE"]
assert isinstance(value, RawSdkconfigValue)
assert value.value == "y"
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN") is True
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL") is False
def test_execute_from_psram_s3_sdkconfig(
@@ -12,6 +12,7 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
@@ -221,3 +222,54 @@ def test_connection_scan_window_codegen(
assert window_call in main_cpp
assert ("set_connection_scan_window(48)" in main_cpp) == connection_call
assert ("'connection_scan_window' has no effect" in caplog.text) == warns
@pytest.mark.parametrize(
("wifi", "params", "expect_warning"),
[
(True, {"interval": "1100ms", "window": "1100ms"}, True),
(True, {"interval": "1100ms", "window": "601ms"}, True),
(True, {"interval": "1100ms", "window": "600ms"}, False),
(False, {"interval": "1100ms", "window": "1100ms"}, False),
],
)
def test_long_window_with_wifi_warns(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
wifi: bool,
params: ConfigType,
expect_warning: bool,
) -> None:
"""A scan window above 600 ms warns only when wifi shares the radio."""
stage_esp32("5.5.5", wifi=wifi)
with caplog.at_level(logging.WARNING):
_scan_params({"scan_parameters": params})
assert ("starves wifi" in caplog.text) is expect_warning
def test_long_window_warns_with_coexistence_disabled(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Disabling the arbiter is the worst case for a long window, so it still warns."""
stage_esp32("5.5.5", wifi=True)
with caplog.at_level(logging.WARNING):
_scan_params(
{
CONF_SOFTWARE_COEXISTENCE: False,
"scan_parameters": {"interval": "1100ms", "window": "1100ms"},
}
)
assert "BLE scan window of 1100ms" in caplog.text
def test_raised_window_warning_points_at_interval(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
) -> None:
"""When the window was raised to a long interval, the warning names the interval."""
stage_esp32("5.5.5", wifi=True)
with caplog.at_level(logging.WARNING):
_scan_params({"scan_parameters": {"interval": "1s"}})
assert "BLE scan interval of 1s" in caplog.text
assert "BLE scan window of" not in caplog.text
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
- id: spi_bus
interface: spi2
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
ethernet:
id: eth_component
type: W5500
spi_id: spi_bus
cs_pin: GPIO5
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 20MHz
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
board: esp32dev
ethernet:
id: eth_component
type: W5500
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
cs_pin: GPIO5
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 20MHz
@@ -27,14 +27,6 @@ _CH390_CONFIG = {
}
@pytest.fixture(autouse=True)
def _reset_full_config():
"""Reset fv.full_config so each test starts with a clean slate."""
token = fv.full_config.set({})
yield
fv.full_config.reset(token)
def test_rejects_wifi_and_ethernet_without_priority() -> None:
"""Wi-Fi + ethernet without a network: priority: list must be rejected."""
fv.full_config.set({"wifi": {}, "ethernet": {}})
@@ -0,0 +1,235 @@
"""Tests for the ethernet `spi_id:` option (attach to a shared spi bus)."""
from collections.abc import Callable
from pathlib import Path
import pytest
from voluptuous import Invalid
from esphome import config_validation as cv
from esphome.components.esp32 import (
KEY_BOARD,
KEY_IDF_VERSION,
KEY_VARIANT,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import CONF_INTERFACE, CONFIG_SCHEMA, _final_validate
from esphome.components.rp2.const import KEY_BOARD as RP2_KEY_BOARD
# Registers the rp2 pin schema so RP2 configs can validate pins.
import esphome.components.rp2.gpio # noqa: F401
from esphome.components.spi import CONF_INTERFACE_INDEX
from esphome.const import (
CONF_CLK_PIN,
CONF_ID,
CONF_MISO_PIN,
CONF_MOSI_PIN,
CONF_SPI,
CONF_SPI_ID,
CONF_TYPE,
PlatformFramework,
)
from esphome.core import CORE, ID
import esphome.final_validate as fv
from ..types import SetCoreConfigCallable
_W5500_PIN_CONFIG = {
"type": "W5500",
"clk_pin": 47,
"mosi_pin": 48,
"miso_pin": 14,
"cs_pin": 21,
}
_W5500_SPI_ID_CONFIG = {
"type": "W5500",
"spi_id": "spi_bus",
"cs_pin": 21,
}
def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
KEY_IDF_VERSION: cv.Version(5, 3, 2),
},
)
# _validate derives use_address from the node name, which has no default here.
CORE.name = "spi-id-test"
def test_spi_id_accepted_without_pins_or_interface(
set_core_config: SetCoreConfigCallable,
) -> None:
"""With spi_id set, the pin options are not required and no interface is defaulted."""
_set_esp32_s3(set_core_config)
config = CONFIG_SCHEMA(dict(_W5500_SPI_ID_CONFIG))
assert config[CONF_SPI_ID] == ID("spi_bus")
# The interface comes from the referenced bus; no default may be injected.
assert CONF_INTERFACE not in config
@pytest.mark.parametrize(
("key", "value"),
[
(CONF_CLK_PIN, 47),
(CONF_MOSI_PIN, 48),
(CONF_MISO_PIN, 14),
(CONF_INTERFACE, "spi2"),
],
)
def test_spi_id_rejects_bus_options(
set_core_config: SetCoreConfigCallable, key: str, value: int | str
) -> None:
"""Options provided by the referenced bus must be rejected alongside spi_id."""
_set_esp32_s3(set_core_config)
with pytest.raises(Invalid, match=f"'{key}' cannot be used together with 'spi_id'"):
CONFIG_SCHEMA({**_W5500_SPI_ID_CONFIG, key: value})
@pytest.mark.parametrize("key", [CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN])
def test_bus_pins_still_required_without_spi_id(
set_core_config: SetCoreConfigCallable, key: str
) -> None:
"""Without spi_id, the bus pin options stay required."""
_set_esp32_s3(set_core_config)
config = {k: v for k, v in _W5500_PIN_CONFIG.items() if k != key}
with pytest.raises(
Invalid, match=f"'{key}' is a required option when 'spi_id' is not set"
):
CONFIG_SCHEMA(config)
def test_spi_id_rejected_on_rp2(set_core_config: SetCoreConfigCallable) -> None:
"""spi_id is ESP32-only; the RP2 path is unchanged."""
set_core_config(
PlatformFramework.RP2_ARDUINO, platform_data={RP2_KEY_BOARD: "rpipicow"}
)
CORE.name = "spi-id-test"
config = {
"type": "W5500",
"spi_id": "spi_bus",
"clk_pin": 18,
"mosi_pin": 19,
"miso_pin": 16,
"cs_pin": 17,
}
with pytest.raises(Invalid, match="only available on"):
CONFIG_SCHEMA(config)
def _eth_spi_id_final_config() -> dict:
return {CONF_TYPE: "W5500", CONF_SPI_ID: ID("spi_bus")}
class _FakeFinalConfig(dict):
"""Dict-backed FinalValidateConfig with just enough ID resolution for
fv.id_declaration_match_schema to find an spi bus fragment."""
def get_path_for_id(self, id: ID) -> list:
for index, conf in enumerate(self[CONF_SPI]):
if conf[CONF_ID] == id:
return [CONF_SPI, index, CONF_ID]
raise KeyError(id)
def get_config_for_path(self, path: list) -> dict:
return self[path[0]][path[1]]
def _set_spi_buses(*buses: dict) -> None:
fv.full_config.set(_FakeFinalConfig({CONF_SPI: list(buses)}))
_SHAREABLE_BUS = {
CONF_ID: ID("spi_bus"),
CONF_INTERFACE_INDEX: 0,
CONF_MISO_PIN: {},
CONF_MOSI_PIN: {},
}
def test_final_validate_accepts_hardware_bus_with_data_pins(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A hardware spi bus that declares miso_pin and mosi_pin may be shared."""
_set_esp32_s3(set_core_config)
# An unrelated bus first: the ID lookup must skip past it.
_set_spi_buses({CONF_ID: ID("other_bus"), CONF_INTERFACE_INDEX: 1}, _SHAREABLE_BUS)
_final_validate(_eth_spi_id_final_config())
def test_final_validate_rejects_software_bus(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A software spi bus (no hardware interface index) cannot be shared."""
_set_esp32_s3(set_core_config)
bus = {k: v for k, v in _SHAREABLE_BUS.items() if k != CONF_INTERFACE_INDEX}
_set_spi_buses(bus)
with pytest.raises(Invalid, match="requires this spi bus to use a hardware"):
_final_validate(_eth_spi_id_final_config())
@pytest.mark.parametrize("pin_key", [CONF_MISO_PIN, CONF_MOSI_PIN])
def test_final_validate_rejects_bus_without_data_pin(
set_core_config: SetCoreConfigCallable, pin_key: str
) -> None:
"""The shared bus must declare both data pins to drive the ethernet chip."""
_set_esp32_s3(set_core_config)
bus = {k: v for k, v in _SHAREABLE_BUS.items() if k != pin_key}
_set_spi_buses(bus)
with pytest.raises(Invalid, match=f"requires this spi bus to declare a {pin_key}"):
_final_validate(_eth_spi_id_final_config())
def test_final_validate_rejects_colliding_host_without_spi_id(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Without spi_id, claiming the same host as an spi bus stays an error."""
_set_esp32_s3(set_core_config)
fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]})
config = {CONF_TYPE: "W5500", CONF_INTERFACE: "spi2"}
with pytest.raises(Invalid, match="both using interface 'SPI2_HOST'"):
_final_validate(config)
def test_final_validate_accepts_distinct_host_without_spi_id(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Without spi_id, a different host than the spi bus is accepted."""
_set_esp32_s3(set_core_config)
fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]})
_final_validate({CONF_TYPE: "W5500", CONF_INTERFACE: "spi3"})
def test_generated_code_uses_spi_parent(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""With spi_id, codegen wires the spi parent and skips the bus options."""
main_cpp = generate_main(component_config_path("spi_id_shared_bus.yaml"))
assert "eth_component->set_spi_parent(spi_bus);" in main_cpp
assert "eth_component->set_cs_pin(5);" in main_cpp
assert "eth_component->set_clk_pin(" not in main_cpp
assert "eth_component->set_miso_pin(" not in main_cpp
assert "eth_component->set_mosi_pin(" not in main_cpp
assert "eth_component->set_interface(" not in main_cpp
def test_generated_code_without_spi_id_initializes_own_bus(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without spi_id, codegen still emits the pin and interface setters."""
main_cpp = generate_main(component_config_path("spi_own_bus.yaml"))
assert "eth_component->set_spi_parent(" not in main_cpp
assert "eth_component->set_clk_pin(18);" in main_cpp
assert "eth_component->set_miso_pin(19);" in main_cpp
assert "eth_component->set_mosi_pin(23);" in main_cpp
assert "eth_component->set_cs_pin(5);" in main_cpp
assert "eth_component->set_interface(::SPI3_HOST);" in main_cpp
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
watchdog_timeout: 20s
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: nodemcu-32s
watchdog_timeout: 60s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
@@ -0,0 +1,13 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
verify_ssl: false
@@ -0,0 +1,13 @@
esphome:
name: test
rp2:
board: rpipicow
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
verify_ssl: false
@@ -0,0 +1,42 @@
"""Tests for the http_request watchdog timeout default."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.const import CONF_WATCHDOG_TIMEOUT
from esphome.core import CORE, TimePeriodMilliseconds
@pytest.mark.parametrize(
("yaml_file", "expected_ms"),
[
# stock 4.5s timeout: 3 x 4.5s plus 1s margin
("test_esp32_stock.yaml", 14500),
# 3 x 10s plus 1s margin
("test_esp32_default.yaml", 31000),
# esp32.watchdog_timeout: 60s is wider than the derived value and wins
("test_esp32_platform_wider.yaml", 60000),
# explicit value is kept as is
("test_esp32_explicit.yaml", 20000),
],
)
def test_esp32_watchdog_timeout(
component_config_path: Callable[[str], Path], yaml_file: str, expected_ms: int
) -> None:
CORE.config_path = component_config_path(yaml_file)
config = read_config({})
assert config["http_request"][CONF_WATCHDOG_TIMEOUT] == TimePeriodMilliseconds(
milliseconds=expected_ms
)
@pytest.mark.parametrize("yaml_file", ["test_esp8266.yaml", "test_rp2040.yaml"])
def test_other_platforms_leave_watchdog_unset(
component_config_path: Callable[[str], Path], yaml_file: str
) -> None:
CORE.config_path = component_config_path(yaml_file)
config = read_config({})
assert CONF_WATCHDOG_TIMEOUT not in config["http_request"]
@@ -0,0 +1,36 @@
esphome:
name: test-dropdown-update-event
on_boot:
- lvgl.dropdown.update:
id: test_dropdown
selected_index: 2
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: GPIO3
lvgl:
widgets:
- dropdown:
id: test_dropdown
options:
- First
- Second
- Third
on_update:
- lambda: |-
ESP_LOGD("test", "dropdown updated");
@@ -0,0 +1,36 @@
esphome:
name: test-list-on-add-lvgl-action
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: GPIO3
lvgl:
widgets:
- label:
id: later_label
text: orig
- list:
id: test_list
on_add:
- lvgl.label.update:
id: later_label
text: "changed"
on_remove:
- lvgl.label.update:
id: later_label
text: "removed"
@@ -0,0 +1,41 @@
esphome:
name: test-list-outside-block
on_boot:
priority: -100
then:
- lvgl.list.add:
id: test_list
switch:
transform_rotation: 100
drop_shadow_color: 0x000000
bg_image_src: my_image
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: GPIO3
image:
- platform: file
file: mdi:battery
id: my_image
resize: 8x8
type: binary
lvgl:
widgets:
- list:
id: test_list
@@ -0,0 +1,77 @@
esphome:
name: test-list
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: GPIO3
lvgl:
theme:
label:
bg_color: 0xFF0000
widgets:
- list:
id: test_list
pad_row: 4
on_add:
- delay: 10ms
- delay: 20ms
on_remove:
- delay: 10ms
- button:
id: trigger_button
text: "Trigger"
on_click:
- lvgl.list.add_text:
id: test_list
text: "Header"
- lvgl.list.add_text:
id: test_list
text: "Pinned"
index: 0
- lvgl.list.add:
id: test_list
button:
text: "Entry"
checkable: true
- lvgl.list.add:
id: test_list
index: 1
obj:
widgets:
- label:
text: "Nested"
- dropdown:
options:
- "One"
- "Two"
- lvgl.list.add:
id: test_list
obj:
widgets:
- obj:
widgets:
- label:
text: "Grandchild"
- lvgl.list.remove:
id: test_list
index: 0
- lvgl.list.clear:
id: test_list
- lvgl.list.update:
id: test_list
pad_row: 8
@@ -0,0 +1,41 @@
"""Regression test: lvgl.dropdown.update with selected_index must fire on_value/on_update.
LvSelect (backing both dropdown and roller) did not set `value_property`, so the generic
update-action machinery in automation.py never sent the synthetic update event for a
`selected_index:` change made via `lvgl.dropdown.update`/`lvgl.roller.update`, unlike `value:`
on number widgets or `text:` on text widgets. Fixed by setting `LvSelect.value_property` to
`CONF_SELECTED_INDEX`.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "dropdown_update_fires_event_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_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_dropdown_update_sends_update_event(main_cpp: str) -> None:
assert (
"lv_obj_send_event(test_dropdown->obj, lvgl::lv_update_event, nullptr)"
in main_cpp
)
+403
View File
@@ -0,0 +1,403 @@
"""Tests for the LVGL ``list`` widget: schema validation for its actions
(``lvgl.list.add_text``/``add``/``remove``/``clear``) and the code they generate.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.components.lvgl.widgets.lv_list import (
LIST_CREATE_SCHEMA,
LIST_REMOVE_SCHEMA,
LIST_SCHEMA,
list_add_schema,
)
from esphome.config import read_config
import esphome.config_validation as cv
from esphome.core import CORE
# ---------------------------------------------------------------------------
# lvgl.list.add schema: id + optional index + exactly one widget-type key
# ---------------------------------------------------------------------------
class TestListAddSchema:
def test_valid_single_widget(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert result["id"].id == "my_list"
assert "widget" in result
def test_index_optional_and_templatable(self) -> None:
result = list_add_schema({"id": "my_list", "index": 2, "label": {"text": "hi"}})
assert result["index"] == 2
def test_index_omitted_when_not_given(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "index" not in result
def test_missing_id_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="required key 'id' not provided"):
list_add_schema({"label": {"text": "hi"}})
def test_no_widget_key_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema({"id": "my_list"})
def test_two_widget_keys_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema(
{
"id": "my_list",
"label": {"text": "a"},
"button": {"text": "b"},
}
)
def test_non_mapping_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="Expected a mapping"):
list_add_schema("not_a_mapping")
def test_any_registered_widget_type_accepted(self) -> None:
for widget_key, widget_conf in (
("checkbox", {"text": "Option"}),
("switch", {}),
("spinner", {}),
("obj", {}),
("dropdown", {"options": ["a", "b"]}),
):
result = list_add_schema({"id": "my_list", widget_key: widget_conf})
assert widget_key in result["widget"][0]
@pytest.mark.parametrize(
("widget_key", "widget_conf"),
[
("buttonmatrix", {"rows": [{"buttons": [{"text": "A"}]}]}),
("tabview", {"tabs": [{"name": "Tab1"}]}),
("tileview", {"tiles": [{"row": 0, "column": 0}]}),
("meter", {"scales": [{"range_from": 0, "range_to": 100}]}),
("canvas", {"width": 20, "height": 20}),
],
)
def test_dynamic_widget_unsupported_rejected(
self, widget_key: str, widget_conf: dict
) -> None:
"""buttonmatrix/tabview/tileview all register their own child widgets into
the global widget map from inside their to_code - fine for a widget built
once at boot, but broken if lvgl.list.add re-enters that on every call.
meter/canvas are rejected for a related but distinct reason: they declare a
Pvariable (meter's scale/indicator objects; canvas's draw buffer) with
cg.Pvariable()/cg.new_Pvariable(), which emits its assignment wherever code
is currently being generated -- fine at the top level of a boot-time
to_code, but lvgl.list.add's do_add runs inside a lambda. meter's assignment
would then end up outside the very lambda that declares the local object it
refers to (doesn't compile); canvas's Pvariable is declared once per config
site rather than per call, so every call overwrites its one draw buffer
(compiles, but leaks the old buffer and shares one buffer across every row).
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema({"id": "my_list", widget_key: widget_conf})
def test_dynamic_widget_unsupported_rejected_when_nested(self) -> None:
"""The check must recurse into `widgets:` so a tabview hidden a few levels
deep inside another widget is caught too, not just at the top level.
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"tabview": {"tabs": [{"name": "Tab1"}]}}]},
}
)
def test_explicit_id_rejected(self) -> None:
"""A dynamically-added widget is LocalVariable-scoped and rebuilt fresh
on every call, never registered anywhere an id could be looked up by --
an explicit id: would otherwise validate fine and then fail confusingly
(an uncaught traceback, not a clean config error) the moment anything
tries to reference it.
"""
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{"id": "my_list", "label": {"id": "dyn_label", "text": "hi"}}
)
def test_explicit_id_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"label": {"id": "dyn_label", "text": "hi"}}]},
}
)
def test_no_explicit_id_still_valid(self) -> None:
"""An id is auto-generated (and simply unused) when none is given --
only an explicit one is rejected."""
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "id" in result["widget"][0]["label"]
@pytest.mark.parametrize(
("key", "conf"),
[
("on_swipe_left", [{"logger.log": "swiped"}]),
("on_swipe_right", [{"logger.log": "swiped"}]),
("on_swipe_up", [{"logger.log": "swiped"}]),
("on_swipe_down", [{"logger.log": "swiped"}]),
("on_boot", [{"logger.log": "booted"}]),
("align_to", {"id": "some_other_widget", "align": "OUT_LEFT_TOP"}),
],
)
def test_unsupported_trigger_rejected(self, key: str, conf: list) -> None:
"""_wire_dynamic_triggers only wires LV_EVENT_TRIGGERS/on_value/on_update --
on_swipe_*/on_boot would otherwise validate fine and then silently generate
nothing at all for a widget added via lvgl.list.add. align_to is in the same
bucket: it's only ever consumed by generate_triggers() reading
get_widget_map(), which a widget built via lvgl.list.add never enters.
"""
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema({"id": "my_list", "obj": {key: conf}})
def test_unsupported_trigger_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema(
{
"id": "my_list",
"obj": {
"widgets": [
{
"label": {
"text": "hi",
"on_swipe_left": [{"logger.log": "swiped"}],
}
}
]
},
}
)
# ---------------------------------------------------------------------------
# lvgl.list.remove: index must be non-negative -- LVGL treats a negative index as
# counting back from the end, which would silently delete the wrong row while
# reporting a list_index that matches nothing real to on_remove.
# ---------------------------------------------------------------------------
class TestListRemoveSchema:
def test_negative_index_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="at least 0"):
LIST_REMOVE_SCHEMA({"id": "my_list", "index": -1})
def test_zero_index_accepted(self) -> None:
result = LIST_REMOVE_SCHEMA({"id": "my_list", "index": 0})
assert result["index"] == 0
# ---------------------------------------------------------------------------
# The list widget's own schema: pad_row is shared between create/update, but
# on_add/on_remove only make sense at creation time.
# ---------------------------------------------------------------------------
class TestListCreateVsModifySchema:
def test_create_schema_has_pad_row_and_triggers(self) -> None:
keys = {str(k) for k in LIST_CREATE_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" in keys
assert "on_remove" in keys
def test_modify_schema_has_pad_row_but_not_triggers(self) -> None:
"""``lvgl.list.update`` can change pad_row but can't (re-)declare triggers."""
keys = {str(k) for k in LIST_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" not in keys
assert "on_remove" not in keys
def test_on_add_single_automation_with_multiple_actions(self) -> None:
"""A bare action list under on_add: is one automation with a multi-step
`then:`, not multiple independent automations.
"""
config = LIST_CREATE_SCHEMA({"on_add": [{"delay": "10ms"}, {"delay": "20ms"}]})
assert len(config["on_add"]) == 1
assert len(config["on_add"][0]["then"]) == 2
def test_on_add_accepts_multiple_independent_automations(self) -> None:
"""Each explicit `then:` entry gets its own Trigger, so on_add can fire
more than one independent automation.
"""
config = LIST_CREATE_SCHEMA(
{
"on_add": [
{"then": [{"delay": "10ms"}]},
{"then": [{"delay": "20ms"}]},
]
}
)
assert len(config["on_add"]) == 2
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared list-widget YAML config once per
module -- see test_widget_state.py for why this is module-scoped and
inlines the generate_main fixture logic rather than depending on it.
"""
config_path = Path(request.fspath).parent / "config" / "list_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_pad_row_set_at_creation(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 4, LV_PART_MAIN);" in main_cpp
def test_pad_row_updated_via_update_action(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 8, LV_PART_MAIN);" in main_cpp
def test_add_text_appends(main_cpp: str) -> None:
assert 'lv_list_add_text(test_list, "Header");' in main_cpp
def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None:
"""The index move must happen before on_add fires, so the reported
list_index reflects the entry's final position, not where it was appended.
"""
assert (
'lv_obj_t *list_entry_VAR_ = lv_list_add_text(test_list, "Pinned");\n'
" lv_obj_move_to_index(list_entry_VAR_, 0);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, list_entry_VAR_));"
) in main_cpp
def test_add_button_with_checkable_flag(main_cpp: str) -> None:
assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp
assert (
"lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));"
in main_cpp
)
assert (
'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp
)
def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None:
"""`obj: {widgets: [label, dropdown]}` builds a plain label child and a
heap-allocated (compound) dropdown child, both parented to the new row.
The child variable names carry a `_1` (depth) suffix, distinguishing them
from the row's own top-level variable -- necessary so that a child of the
*same* widget type as its parent (e.g. `obj: {widgets: [{obj: {...}}]}`)
doesn't declare a C++ variable that shadows its own not-yet-initialized
self, silently parenting the child to garbage.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert (
"lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n"
" lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_1_VAR_, "Nested");'
) in main_cpp
def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None:
"""A widget added via lvgl.list.add must pick up the same `theme:` styling a
statically-declared widget of the same type gets, not render unthemed.
"""
assert (
"lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));"
) in main_cpp
assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp
assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp
assert (
"lvgl::delete_lv_compound_on_delete<LvDropdownType>, LV_EVENT_DELETE, "
"dyn_dropdown_1_VAR_);"
) in main_cpp
def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> None:
"""A child of the same widget type as its parent (`obj: {widgets: [{obj:
...}]}`) must get a distinct C++ variable name (or the child's declaration
would shadow its own not-yet-initialized self, parenting it to garbage --
compiling clean but for a -Wuninitialized warning). A grandchild of a third
type proves depth, not just type, drives the disambiguating suffix.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert "lv_obj_t *dyn_obj_1_VAR_ = lv_obj_create(dyn_obj_VAR_);" in main_cpp
assert (
"lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n"
" lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");'
) in main_cpp
def test_add_moves_row_to_given_index_before_firing_on_add(main_cpp: str) -> None:
assert (
"lv_obj_move_to_index(dyn_obj_VAR_, 1);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, dyn_obj_VAR_));"
) in main_cpp
def test_on_add_fires_once_per_entry_via_shared_trigger(main_cpp: str) -> None:
"""A single on_add: automation means a single Trigger instance, reused by
every lvgl.list.add_text/add call site.
"""
assert main_cpp.count("triggerint_id->trigger(lvgl::lv_list_get_row_index(") == 5
def test_remove_guards_against_missing_child_and_fires_before_delete(
main_cpp: str,
) -> None:
"""The index is materialised into a local once (list_index_VAR_) and reused for
both the child lookup and the on_remove trigger, so a templatable index isn't
evaluated twice.
"""
assert (
"int list_index_VAR_ = 0;\n"
" {\n"
" lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);\n"
" if (list_child_VAR_) {\n"
" triggerint_id_2->trigger(list_index_VAR_);\n"
" lv_obj_del(list_child_VAR_);"
) in main_cpp
def test_remove_out_of_range_lookup_uses_shared_cpp_helper(main_cpp: str) -> None:
"""The out-of-range lookup (and its log line) live in a single C++ helper --
lvgl::lv_list_get_row_for_remove() in lvgl_esphome.cpp -- rather than being
generated inline at every lvgl.list.remove call site, since a config can
have many of them and duplicating that logic (and its log string) at each
one would waste flash for no benefit.
"""
assert (
"lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);"
in main_cpp
)
assert "ESP_LOGV" not in main_cpp
def test_clear_fires_on_remove_for_every_entry_then_cleans(main_cpp: str) -> None:
assert (
"for (int list_index = (int) (lv_obj_get_child_count(test_list)) - 1; "
"list_index >= 0; list_index--) {\n"
" triggerint_id_2->trigger(list_index);\n"
" }\n"
" lv_obj_clean(test_list);"
) in main_cpp
@@ -0,0 +1,47 @@
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.
ListType.to_code() used to build the on_add/on_remove automations directly, during
widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only
resolves once *all* widgets - including the list itself - have finished being
created. Building an automation containing an lvgl action from inside that same
widget-creation walk therefore could never complete: codegen deadlocked with
"Circular dependency detected!". Fixed by deferring the actual build_automation()
call to finish_list_triggers(), run after set_widgets_completed(True) - and,
critically, before generate_triggers(), which is what processes other widgets'
on_click etc. automations that might reference this list (e.g. via lvgl.list.add),
and which therefore need the list's own on_add/on_remove triggers to already exist.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "list_on_add_lvgl_action_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_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_on_add_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "changed");' in main_cpp
def test_on_remove_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "removed");' in main_cpp
@@ -0,0 +1,84 @@
"""Regression test for lvgl.list.add called from outside the lvgl: block.
lv_list.py's list_add_to_code() must call _register_lv_uses() and
_register_dynamic_widget_style_uses() before its first await (get_widgets(),
which can block until the target list is defined) -- for an action referenced
outside the lvgl: block, that wait can outlast lvgl's own to_code, which reads
get_lv_uses()/get_styles_used() and flushes everything they drive (USE_LVGL_*
defines, plus add_lv_use(image)/screen-transparency/A8-draw-support triggered
by style properties) just once, near the end of its run. Every existing
list_test.yaml call site lives inside lvgl: widgets:, so neither ordering
requirement had any coverage.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@dataclass
class GeneratedOutput:
main_cpp: str
define_names: set[str]
lv_define_names: set[str]
@pytest.fixture(scope="module")
def generated(request: pytest.FixtureRequest) -> GeneratedOutput:
config_path = (
Path(request.fspath).parent / "config" / "list_outside_block_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
# Copy out before CORE.reset() below clears these out from under us.
from esphome.components.lvgl import defines as df
return GeneratedOutput(
main_cpp=CORE.cpp_global_section + CORE.cpp_main_section,
define_names={d.name for d in CORE.defines},
lv_define_names=set(df.get_defines()),
)
finally:
CORE.config_path = original_path
CORE.reset()
def test_dynamic_widget_creates_correctly(generated: GeneratedOutput) -> None:
assert (
"lv_obj_t *dyn_switch_VAR_ = lv_switch_create(test_list);" in generated.main_cpp
)
def test_dynamic_widget_type_use_define_is_registered(
generated: GeneratedOutput,
) -> None:
"""The switch type is only ever referenced via the on_boot lvgl.list.add call
(never declared as a static widget), so USE_LVGL_SWITCH can only be present
if _register_lv_uses() ran in time for lvgl's own to_code to flush it.
"""
assert "USE_LVGL_SWITCH" in generated.define_names
assert "USE_LVGL_LIST" in generated.define_names
def test_dynamic_widget_style_use_defines_are_registered(
generated: GeneratedOutput,
) -> None:
"""bg_image_src/transform_rotation/drop_shadow_color are only ever set on
the dynamically-added switch (never on a static widget), so
USE_LVGL_IMAGE/LV_COLOR_SCREEN_TRANSP/LV_DRAW_SW_SUPPORT_A8 can only be
present if _register_dynamic_widget_style_uses() ran in time for lvgl's own
to_code to flush them.
"""
assert "USE_LVGL_IMAGE" in generated.define_names
assert "LV_COLOR_SCREEN_TRANSP" in generated.lv_define_names
assert "LV_DRAW_SW_SUPPORT_A8" in generated.lv_define_names
@@ -1,19 +1,27 @@
"""Schema-level config validation for custom_pdu and the deprecated custom_command alias.
"""Config validation for custom_pdu and the deprecated custom_command alias.
custom_command took a raw frame with a leading device address byte; custom_pdu takes the PDU only.
The old key is still accepted at the schema level and auto-migrated later in final validate (which a
bare-schema test can't reach), so these tests only cover what the schema itself enforces: the two keys
are mutually exclusive, and custom_pdu takes byte-sized values.
Most of these tests cover what the schema itself enforces (the two keys are mutually exclusive, and
custom_pdu takes byte-sized values). The last two reach the final-validate step that a bare-schema
test cannot: a write-coded custom_pdu polled continuously is rejected there.
"""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.modbus_controller import ModbusItemBaseSchema
from esphome.components.modbus_controller import (
ModbusItemBaseSchema,
validate_custom_pdu_item,
)
from esphome.components.modbus_controller.const import (
CONF_CUSTOM_COMMAND,
CONF_CUSTOM_PDU,
CONF_MODBUS_CONTROLLER_ID,
)
from esphome.config import Config
from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_ID
from esphome.core import ID
import esphome.final_validate as fv
def test_custom_command_accepted_at_schema_level() -> None:
@@ -45,3 +53,48 @@ def test_custom_pdu_rejects_non_byte_values() -> None:
"""PDU entries are bytes; a word-sized value is a sign the old raw format is being used."""
with pytest.raises((Invalid, MultipleInvalid)):
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
def _controller_full_config(*, continuous: bool) -> Config:
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
final-validate to resolve the controller (and its continuous flag) from an item's
modbus_controller_id."""
ctl_id = ID("ctl", is_declaration=True)
config = Config()
config["modbus_controller"] = [
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
]
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
return config
@pytest.fixture
def reset_full_config():
token = fv.full_config.set(Config())
yield
fv.full_config.reset(token)
def test_continuous_write_custom_pdu_rejected(reset_full_config) -> None:
"""A write-coded custom_pdu (0x17 = read/write-multiple) under a continuous controller is
rejected at final validate: the hub would strip continuous from the mutating code and warn on
every update."""
fv.full_config.set(_controller_full_config(continuous=True))
with pytest.raises(Invalid, match="can't be polled continuously"):
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x17, 0x00, 0x03, 0x00, 0x01],
}
)
def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
"""A read-coded custom_pdu (0x03) under a continuous controller is fine - only writes stream."""
fv.full_config.set(_controller_full_config(continuous=True))
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
@@ -24,11 +24,9 @@ from tests.component_tests.types import SetCoreConfigCallable
@pytest.fixture(autouse=True)
def _clear_core_data():
"""Wipe CORE.data and reset fv.full_config so each test starts clean."""
"""Wipe CORE.data so each test starts clean."""
CORE.data.clear()
token = fv.full_config.set({})
yield
fv.full_config.reset(token)
CORE.data.clear()
@@ -0,0 +1,48 @@
"""non_blocking is family-gated at config validation; the CI build boards never compile
the ISR paths, so this gate is the only CI-reachable coverage for the platform matrix."""
import pytest
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_RTL8710B,
FAMILY_RTL8720C,
KEY_FAMILY,
KEY_LIBRETINY,
)
from esphome.components.remote_transmitter import _validate_non_blocking_platform
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from esphome.core import CORE
from ..types import SetCoreConfigCallable
@pytest.mark.parametrize(
("platform_framework", "family", "accepted"),
[
(PlatformFramework.ESP32_IDF, None, True),
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True),
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, True),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7238, True),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231T, False),
(PlatformFramework.ESP8266_ARDUINO, None, False),
],
)
def test_non_blocking_platform_gate(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
family: str | None,
accepted: bool,
) -> None:
set_core_config(platform_framework)
if family is not None:
CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family}
if accepted:
assert _validate_non_blocking_platform(True) is True
else:
with pytest.raises(cv.Invalid, match="non_blocking is only supported on"):
_validate_non_blocking_platform(True)
@@ -0,0 +1,45 @@
esphome:
name: rp2-trigger-codegen
on_boot:
then:
- rp2_ble_tracker.start_scan:
continuous: true
# Bare form: restores the configured scan_parameters mode — no
# set_continuous emitted (asserted in the codegen test).
- rp2_ble_tracker.start_scan:
- rp2_ble_tracker.stop_scan
rp2:
board: rpipicow
rp2_ble_tracker:
scan_parameters:
continuous: false
active: false
on_ble_advertise:
- mac_address:
- AC:37:43:77:5F:4C
- 11:22:33:44:55:66
then:
- lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));'
on_ble_service_data_advertise:
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
mac_address: AC:37:43:77:5F:4C
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- service_uuid: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_scan_end:
- then:
- lambda: 'ESP_LOGD("t", "end");'
@@ -0,0 +1,60 @@
"""Codegen tests for the tracker automations.
The shared trigger classes (ble_device_base/automation.h) are compiled by the
rp2040 compile fixtures, but the codegen accounting — the getattr-built setter
spellings, the single set_continuous pin and the listener-count define — is
only checkable from the generated main, mirroring the bk72xx/ln882h tests."""
from collections.abc import Callable
from pathlib import Path
import re
from esphome.components import ble_device_base
from tests.component_tests.helpers import get_define_value
def test_trigger_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("test_automations.yaml"))
# on_ble_advertise: multi-mac filter (two addresses in one initializer list)
assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp
# 128-bit service uuid goes out reversed (BLE wire order); single-mac filter
assert (
"set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
assert "set_address(0xAC3743775F4CULL)" in main_cpp
# 32-bit middle branch of the width dispatch
assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp
# All three manufacturer widths: getattr() builds these names as strings,
# so a misspelling only ever fails here.
assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp
assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp
assert (
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
# active: false (non-default) flows through to the setter.
assert "->set_scan_active(false)" in main_cpp
# Constructor call, not just the declaration: the parent argument is what
# registers the trigger as a listener.
assert re.search(
r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp
)
# Seven triggers register as listeners; an undercount silently drops the
# last trigger at runtime (StaticVector::push_back past capacity), so the
# define is the assertion that matters most.
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7"
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
logger:
time:
- platform: sntp
id: sntp_time
on_time:
- seconds: 0
then:
- logger.log: tick
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
logger:
time:
- platform: sntp
id: sntp_time
on_time_sync:
then:
- logger.log: synced
@@ -0,0 +1,28 @@
"""automation.cpp (CronTrigger and SyncTrigger) is only compiled when an
on_time or on_time_sync automation exists, so the define must follow them."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
@pytest.mark.parametrize(
("fixture", "emits"),
[
("no_triggers.yaml", False),
("on_time.yaml", True),
("on_time_sync.yaml", True),
],
)
def test_triggers_define_follows_automations(
fixture: str,
emits: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
defines = {define.name for define in CORE.defines}
assert ("USE_TIME_TRIGGERS" in defines) is emits
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
sensor:
- platform: uptime
name: Uptime Seconds
type: seconds
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
sensor:
- platform: uptime
name: Uptime Timestamp
type: timestamp
@@ -0,0 +1,27 @@
"""The timestamp uptime sensor source is only compiled when that type is used,
so the define must follow the configured sensor type rather than time: alone."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
@pytest.mark.parametrize(
("fixture", "emits"),
[
("seconds.yaml", False),
("timestamp.yaml", True),
],
)
def test_timestamp_define_follows_sensor_type(
fixture: str,
emits: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
defines = {define.name for define in CORE.defines}
assert ("USE_UPTIME_TIMESTAMP" in defines) is emits