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

This commit is contained in:
J. Nick Koston
2026-10-09 18:28:16 -10:00
1171 changed files with 7016 additions and 3286 deletions
@@ -0,0 +1,44 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
logger:
binary_sensor:
- platform: template
id: single_and
on_press:
- if:
condition:
- lambda: return true;
then:
- logger.log: single and
- platform: template
id: single_or
on_press:
- if:
any:
- lambda: return true;
then:
- logger.log: single or
- platform: template
id: double_and
on_press:
- if:
condition:
- lambda: return true;
- lambda: return false;
then:
- logger.log: double and
- platform: template
id: single_xor
on_press:
- if:
condition:
xor:
- lambda: return true;
then:
- logger.log: single xor
@@ -0,0 +1,25 @@
"""Tests for and/or condition group codegen."""
from collections.abc import Callable
from pathlib import Path
import re
def test_single_condition_groups_are_unwrapped(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A group of one condition is passed to the action directly."""
main_cpp = generate_main(component_config_path("condition_groups.yaml"))
assert "IfAction<false>(lambdacondition_id);" in main_cpp
assert "IfAction<false>(lambdacondition_id_2);" in main_cpp
group = re.search(
r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);",
main_cpp,
)
assert group is not None
assert f"IfAction<false>({group.group(1)});" in main_cpp
# xor of one condition equals that condition, so it is unwrapped too.
assert "IfAction<false>(lambdacondition_id_5);" in main_cpp
assert "XorCondition<" not in main_cpp
@@ -0,0 +1,17 @@
"""Tests for the dlms_meter provider option removed in 2026.11.0."""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.dlms_meter import CONFIG_SCHEMA
def test_removed_provider_shows_the_netznoe_patterns() -> None:
with pytest.raises(Invalid) as exc_info:
CONFIG_SCHEMA({"provider": "netznoe"})
errors = (
exc_info.value.errors
if isinstance(exc_info.value, MultipleInvalid)
else [exc_info.value]
)
assert any('pattern: "L, TSTR"' in str(error) for error in errors)
@@ -0,0 +1,33 @@
"""Tests for the dlms_meter predefined keys removed in 2026.11.0."""
from collections.abc import Callable
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.dlms_meter.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA
from esphome.components.dlms_meter.text_sensor import (
CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA,
)
from esphome.types import ConfigType
def _errors(schema: Callable[[ConfigType], ConfigType], config: ConfigType) -> str:
with pytest.raises(Invalid) as exc_info:
schema(config)
errors = (
exc_info.value.errors
if isinstance(exc_info.value, MultipleInvalid)
else [exc_info.value]
)
return "\n".join(str(error) for error in errors)
def test_removed_sensor_key_names_its_replacement() -> None:
errors = _errors(SENSOR_SCHEMA, {"voltage_l1": {"name": "Voltage"}})
assert 'obis_code: "1.0.32.7.0.255" and unit_of_measurement: V' in errors
def test_removed_text_sensor_key_names_its_replacement() -> None:
errors = _errors(TEXT_SENSOR_SCHEMA, {"timestamp": {"name": "Timestamp"}})
assert 'obis_code: "0.0.1.0.0.255"' in errors
+130 -6
View File
@@ -10,6 +10,7 @@ from typing import Any
import pytest
from esphome.components.const import CONF_HOLD_STATE
from esphome.components.esp32 import (
_ESP_TLS_LINKING_COMPONENTS,
DEFAULT_EXCLUDED_IDF_COMPONENTS,
@@ -38,13 +39,30 @@ from esphome.components.esp32.const import (
KEY_NETWORK_SDKCONFIG,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
VARIANT_ESP32C6,
VARIANT_ESP32C61,
VARIANT_ESP32H2,
VARIANT_ESP32H4,
VARIANT_ESP32H21,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
)
from esphome.components.esp32.gpio import validate_gpio_pin
from esphome.components.esp32.gpio import validate_gpio_pin, validate_supports
import esphome.config_validation as cv
from esphome.const import (
CONF_ESPHOME,
CONF_IGNORE_PIN_VALIDATION_ERROR,
CONF_INPUT,
CONF_MODE,
CONF_NUMBER,
CONF_OPEN_DRAIN,
CONF_OUTPUT,
CONF_PULLDOWN,
CONF_PULLUP,
PlatformFramework,
Toolchain,
)
@@ -57,7 +75,7 @@ def test_esp32_config(
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_ESP32, VARIANT_FRIENDLY
from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_FRIENDLY
# Example ESP32 configuration
config = {
@@ -1722,9 +1740,8 @@ def test_esp32_s31_gpio_validation(
caplog: pytest.LogCaptureFixture,
) -> None:
"""S31: GPIO26-28/30-32 are reserved for the SPI flash interface, GPIO29
and GPIO41 do not exist, GPIO33 is a normal pin, and GPIO36 is a
and GPIO41 do not exist, GPIO35 is a normal pin, and GPIO36 is a
strapping pin."""
from esphome.components.esp32.const import VARIANT_ESP32S31
from esphome.components.esp32.gpio import validate_supports
from esphome.const import CONF_INPUT, CONF_MODE, CONF_OPEN_DRAIN, CONF_OUTPUT
@@ -1735,8 +1752,8 @@ def test_esp32_s31_gpio_validation(
input_mode = {CONF_INPUT: True, CONF_OUTPUT: False, CONF_OPEN_DRAIN: False}
# Not reserved; a normal GPIO
pin = {CONF_NUMBER: 33, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
assert validate_gpio_pin(pin)[CONF_NUMBER] == 33
pin = {CONF_NUMBER: 35, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
assert validate_gpio_pin(pin)[CONF_NUMBER] == 35
# Reserved for the SPI flash interface, but can be bypassed with
# ignore_pin_validation_error
@@ -1764,6 +1781,113 @@ def test_esp32_s31_gpio_validation(
assert "GPIO36 is a strapping pin" in caplog.text
_INPUT_ONLY_SETTINGS = (
(CONF_OUTPUT, "does not support output pin mode"),
(CONF_PULLUP, "does not support pullups"),
(CONF_PULLDOWN, "does not support pulldowns"),
(CONF_HOLD_STATE, "is input-only and cannot be held"),
)
@pytest.mark.parametrize(
("variant", "number", "setting", "error"),
[
pytest.param(
variant, number, setting, error, id=f"{name}-gpio{number}-{setting}"
)
for variant, name, numbers in (
(VARIANT_ESP32, "esp32", range(34, 40)),
(VARIANT_ESP32S2, "s2", (46,)),
)
for number in numbers
for setting, error in _INPUT_ONLY_SETTINGS
]
+ [
pytest.param(
VARIANT_ESP32,
20,
CONF_HOLD_STATE,
"GPIO20 has no hold function",
id="esp32-gpio20-hold_state",
)
],
)
def test_input_only_gpio_rejects_unsupported_modes(
set_core_config: SetCoreConfigCallable,
variant: str,
number: int,
setting: str,
error: str,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant})
mode = {
CONF_INPUT: True,
CONF_OUTPUT: False,
CONF_OPEN_DRAIN: False,
CONF_PULLUP: False,
CONF_PULLDOWN: False,
}
pin = {CONF_NUMBER: number, CONF_MODE: mode}
if setting == CONF_HOLD_STATE:
pin[setting] = True
else:
mode[setting] = True
with pytest.raises(cv.Invalid, match=error):
validate_supports(pin)
@pytest.mark.parametrize(
("variant", "number"),
[
pytest.param(VARIANT_ESP32C3, 18, id="c3-18"),
pytest.param(VARIANT_ESP32C3, 19, id="c3-19"),
pytest.param(VARIANT_ESP32C5, 13, id="c5-13"),
pytest.param(VARIANT_ESP32C5, 14, id="c5-14"),
pytest.param(VARIANT_ESP32C6, 12, id="c6-12"),
pytest.param(VARIANT_ESP32C6, 13, id="c6-13"),
pytest.param(VARIANT_ESP32C61, 12, id="c61-12"),
pytest.param(VARIANT_ESP32C61, 13, id="c61-13"),
pytest.param(VARIANT_ESP32H2, 26, id="h2-26"),
pytest.param(VARIANT_ESP32H2, 27, id="h2-27"),
pytest.param(VARIANT_ESP32H4, 13, id="h4-13"),
pytest.param(VARIANT_ESP32H4, 14, id="h4-14"),
pytest.param(VARIANT_ESP32H21, 17, id="h21-17"),
pytest.param(VARIANT_ESP32H21, 18, id="h21-18"),
pytest.param(VARIANT_ESP32P4, 24, id="p4-24"),
pytest.param(VARIANT_ESP32P4, 25, id="p4-25"),
pytest.param(VARIANT_ESP32S3, 19, id="s3-19"),
pytest.param(VARIANT_ESP32S3, 20, id="s3-20"),
pytest.param(VARIANT_ESP32S31, 33, id="s31-33"),
pytest.param(VARIANT_ESP32S31, 34, id="s31-34"),
],
)
def test_usb_jtag_gpio_hold_state_warns(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
variant: str,
number: int,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: variant})
pin = {
CONF_NUMBER: number,
CONF_MODE: {
CONF_INPUT: True,
CONF_OUTPUT: False,
CONF_OPEN_DRAIN: False,
},
CONF_HOLD_STATE: True,
}
with caplog.at_level(logging.WARNING):
validate_supports(pin)
assert (
f"GPIO{number} cannot hold at low level during wakeup from deep sleep."
in caplog.text
)
_TLS_SERVER_OPTIONS = (
"CONFIG_MBEDTLS_TLS_CLIENT_ONLY",
"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT",
@@ -0,0 +1,5 @@
esphome:
name: test
esp8266:
board: d1_mini
@@ -0,0 +1,17 @@
"""Tests for ESP8266 build flags."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from esphome.core import CORE
def test_esp8266_keeps_switches_out_of_ram(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("minimal.yaml"))
assert "-fno-tree-switch-conversion" in CORE.build_flags
@@ -8,6 +8,7 @@ from esphome.components.esp32 import (
KEY_BOARD,
KEY_IDF_VERSION,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate
@@ -82,3 +83,40 @@ def test_ch390_rejects_clock_speed_above_the_datasheet_maximum(
CORE.name = "ch390-test"
with pytest.raises(Invalid, match="value must be at most 72000000"):
CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"})
_LAN8720_CONFIG = {"type": "LAN8720", "mdc_pin": 23, "mdio_pin": 18}
def _set_esp32_core(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32dev",
KEY_VARIANT: VARIANT_ESP32,
KEY_IDF_VERSION: cv.Version(5, 3, 2),
},
)
CORE.name = "rmii-test"
def test_rmii_rejects_removed_clk_mode(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The removed clk_mode option points at the clk: replacement."""
_set_esp32_core(set_core_config)
with pytest.raises(Invalid, match="GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17"):
CONFIG_SCHEMA(
{
**_LAN8720_CONFIG,
"clk_mode": "GPIO17_OUT",
"clk": {"mode": "CLK_OUT", "pin": 17},
}
)
def test_rmii_requires_clk(set_core_config: SetCoreConfigCallable) -> None:
"""An RMII PHY has no default clock, so clk must be given."""
_set_esp32_core(set_core_config)
with pytest.raises(Invalid, match=r"required key not provided @ data\['clk'\]"):
CONFIG_SCHEMA(_LAN8720_CONFIG)
@@ -0,0 +1,38 @@
"""Tests for GPIO output final validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.const import CONF_HOLD_STATE
from esphome.components.gpio.output import FINAL_VALIDATE_SCHEMA
from esphome.config import Config
from esphome.const import CONF_ID, CONF_PIN, CONF_POWER_SUPPLY, PlatformFramework
from esphome.core import ID
from tests.component_tests.types import SetCoreConfigCallable
@pytest.mark.parametrize("power_supply_holds", [False, True])
def test_output_hold_state_requires_power_supply_hold_state(
set_core_config: SetCoreConfigCallable,
power_supply_holds: bool,
) -> None:
supply_id = ID("supply", is_declaration=True, type="power_supply")
full_config = Config()
full_config["power_supply"] = [
{CONF_ID: supply_id, CONF_PIN: {CONF_HOLD_STATE: power_supply_holds}}
]
full_config.declare_ids.append((supply_id, ["power_supply", 0, CONF_ID]))
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
config = {
CONF_PIN: {CONF_HOLD_STATE: True},
CONF_POWER_SUPPLY: ID("supply", is_declaration=False, type="power_supply"),
}
if power_supply_holds:
FINAL_VALIDATE_SCHEMA(config)
else:
with pytest.raises(
cv.Invalid,
match="The power supply pin must also set hold_state when an output it powers sets it",
):
FINAL_VALIDATE_SCHEMA(config)
@@ -42,18 +42,20 @@ def test_platform_without_ble_server_rejected(
assert "only available on" in capsys.readouterr().out
def test_automations_emit_renamed_triggers(
def test_automations_register_state_callbacks(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("automations.yaml"))
for trigger in (
"ImprovBLEProvisionedTrigger",
"ImprovBLEProvisioningTrigger",
"ImprovBLEStartTrigger",
"ImprovBLEStateTrigger",
"ImprovBLEStoppedTrigger",
assert "Trigger" not in main_cpp
for state in (
"improv::STATE_PROVISIONED",
"improv::STATE_PROVISIONING",
"improv::STATE_AUTHORIZED",
"improv::STATE_STOPPED",
):
assert f"improv_ble::{trigger}" in main_cpp
assert f"state == {state}" in main_cpp
assert main_cpp.count("add_on_state_callback(") == 5
assert "->is_failed()" in main_cpp
assert "set_authorizer" in main_cpp
assert "set_status_indicator" in main_cpp
@@ -2,6 +2,14 @@
from collections.abc import Callable
from pathlib import Path
import re
import pytest
import esphome.codegen as cg
from esphome.components import light
from esphome.components.light import types as light_types
from esphome.core import ID, EsphomeError
def test_default_flash_length_and_empty_effects_are_not_emitted(
@@ -16,4 +24,20 @@ def test_default_flash_length_and_empty_effects_are_not_emitted(
assert "bare_light->set_flash_transition_length(" not in main_cpp
assert "bare_light->add_effects(" not in main_cpp
assert "fancy_light->set_flash_transition_length(500);" in main_cpp
assert "fancy_light->add_effects({" in main_cpp
call = re.search(r"fancy_light->add_effects\((\w+), (\d+)\);", main_cpp)
assert call is not None
# The effect pointers form a flash table; they are address constants, not constexpr
assert re.search(
rf"static light::LightEffect \* const {call.group(1)}\[\] PROGMEM = \{{[^}}]+\}};",
main_cpp,
)
def test_effect_assigned_in_setup_is_rejected() -> None:
"""A pointer assigned in setup() would make the flash table need dynamic init."""
dynamic_effect = cg.Pvariable(
ID("dynamic_effect", is_declaration=True, type=light_types.LightEffect),
cg.RawExpression("make_effect()"),
)
with pytest.raises(EsphomeError, match="dynamic_effect"):
light._effects_table([dynamic_effect])
@@ -10,9 +10,11 @@ import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32P4
# Importing xl9535 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that
# models (e.g. SEEED-RETERMINAL-D1001) that reference xl9535-backed pins in their
# defaults can be validated by the mipi_dsi CONFIG_SCHEMA in this test.
# Importing the I/O expanders registers their pin schemas with
# pins.PIN_SCHEMA_REGISTRY so that models whose defaults reference expander-backed
# pins (e.g. SEEED-RETERMINAL-D1001 uses xl9535, the M5Stack Tab5 models use
# pi4ioe5v6408) can be validated by the mipi_dsi CONFIG_SCHEMA in this test.
import esphome.components.pi4ioe5v6408 # noqa: F401
import esphome.components.xl9535 # noqa: F401
from esphome.const import (
CONF_DIMENSIONS,
@@ -0,0 +1,43 @@
esphome:
name: test
esp32:
board: esp32dev
remote_transmitter:
pin: GPIO4
carrier_duty_percent: 50%
button:
- platform: template
name: Midea A
on_press:
- remote_transmitter.transmit_midea:
code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF]
- platform: template
name: Midea B
on_press:
- remote_transmitter.transmit_midea:
code: [0xA2, 0x08, 0xFF, 0xFF, 0xFF]
- platform: template
name: AEHA
on_press:
- remote_transmitter.transmit_aeha:
address: 0x8008
data: [0x00, 0x02, 0xFD, 0xFF, 0x00, 0x33]
- platform: template
name: Haier lambda
on_press:
- remote_transmitter.transmit_haier:
code: !lambda |-
return {0xA6, 0xDA, 0x00, 0x00};
- platform: template
name: Haier static
on_press:
- remote_transmitter.transmit_haier:
code: [0xA6, 0xDA, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05]
- platform: template
name: Mirage static
on_press:
- remote_transmitter.transmit_mirage:
code: [0x56, 0x6C, 0x00, 0x00, 0x20, 0x1A, 0x00, 0x00, 0x0C, 0x00, 0x0C, 0x00, 0x00, 0x42]
@@ -0,0 +1,24 @@
"""Constant IR byte codes live in shared PROGMEM tables; lambdas stay lambdas."""
from collections.abc import Callable
from pathlib import Path
import re
def test_ir_codes_use_shared_progmem_tables(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("ir_codes.yaml"))
tables = re.findall(
r"static constexpr uint8_t (remote_base_code\w*)\[\] PROGMEM", main_cpp
)
assert len(tables) == 4 # Midea (shared by two actions), AEHA, Haier, Mirage
midea = tables[0]
assert main_cpp.count(f"->set_code_static({midea}, 5);") == 2
assert re.search(r"->set_data_static\(remote_base_code\w*, 6\);", main_cpp)
assert re.search(r"->set_code_static\(remote_base_code\w*, 13\);", main_cpp)
assert re.search(r"->set_code_static\(remote_base_code\w*, 14\);", main_cpp)
assert "->set_code_template([](" in main_cpp
assert "std::vector<uint8_t>{" not in main_cpp
@@ -20,7 +20,7 @@ def test_proxy_claims_its_slots_through_the_shared_accounting(
# A default (3-slot) proxy build records one claim per slot, attributed
# to the consumer, and passes final validation.
generate_main(component_config_path("rp2_proxy_default.yaml"))
used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS]
used = CORE.data[rp2040_ble.DOMAIN][rp2040_ble.KEY_USED_CONNECTION_SLOTS]
assert used == ["bluetooth_proxy"] * 3
@@ -1,24 +1,42 @@
"""Validation tests for the hub's unpaired access policy."""
"""Validation tests for the unpaired access policy.
The hub's `unpaired_access` key and an `unpaired_access` switch both set the
policy, so the switch platform rejects a config that has both.
"""
import logging
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import (
CONF_UNPAIRED_ACCESS,
CONFIG_SCHEMA as HUB_CONFIG_SCHEMA,
DOMAIN,
FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA,
_get_data,
request_pairing_code_display_support,
)
from esphome.components.sendspin.switch import (
CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA as SWITCH_FINAL_VALIDATE_SCHEMA,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "sendspin_hub_id"
NO_PAIRING_WARNING = "nothing lets a server pair"
NO_PAIRING_WARNING = "no new server can pair with this device"
def _switch_config(switch_type: str) -> ConfigType:
return SWITCH_CONFIG_SCHEMA(
{"name": "Sendspin Switch", "type": switch_type, "sendspin_id": HUB_ID}
)
def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None:
"""Codegen leaves the hub's built-in default of on in place when the key is not set."""
"""With no default in the schema, the switch can tell whether the key was set."""
set_core_config(PlatformFramework.ESP32_IDF)
assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID})
@@ -36,7 +54,7 @@ def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) -
def test_off_without_pairing_method_warns(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
"""With unpaired access off and no way to pair, no server could ever play."""
"""With unpaired access off and no way to pair, no new server could pair."""
set_core_config(PlatformFramework.ESP32_IDF)
config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False})
@@ -76,3 +94,92 @@ def test_on_does_not_warn(
HUB_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING not in caplog.text
def test_switch_with_hub_key_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Both would set the policy, and the hub's value would be silently ignored."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}},
)
config = _switch_config(CONF_UNPAIRED_ACCESS)
with pytest.raises(cv.Invalid, match="set the switch's restore_mode instead"):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
def test_switch_without_hub_key_accepted(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}})
config = _switch_config(CONF_UNPAIRED_ACCESS)
assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config
def test_switch_without_pairing_method_warns(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
"""Turning the switch off would leave no way for a new server to pair."""
set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}})
config = _switch_config(CONF_UNPAIRED_ACCESS)
with caplog.at_level(logging.WARNING):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING in caplog.text
def test_switch_with_pairing_method_does_not_warn(
set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, "static_pairing_code": "01234567"}},
)
config = _switch_config(CONF_UNPAIRED_ACCESS)
with caplog.at_level(logging.WARNING):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
assert NO_PAIRING_WARNING not in caplog.text
def test_enabled_switch_ignores_hub_key(set_core_config: SetCoreConfigCallable) -> None:
"""Only the unpaired access switch competes with the hub key."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}},
)
config = _switch_config("enabled")
assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config
@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS])
def test_duplicate_switch_type_rejected(
set_core_config: SetCoreConfigCallable, switch_type: str
) -> None:
"""Two switches of one type would fight over the same hub setting."""
entry = {"platform": DOMAIN, "type": switch_type}
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={DOMAIN: {"id": HUB_ID}, "switch": [entry, dict(entry)]},
)
config = _switch_config(switch_type)
with pytest.raises(cv.Invalid, match="Only one sendspin"):
SWITCH_FINAL_VALIDATE_SCHEMA(config)
@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS])
def test_switch_requests_its_type(
set_core_config: SetCoreConfigCallable, switch_type: str
) -> None:
"""The hub skips the codegen value for a setting a switch drives, so the client
waits for that switch to restore before its first start."""
set_core_config(PlatformFramework.ESP32_IDF)
_switch_config(switch_type)
assert _get_data().switch_types == {switch_type}
@@ -0,0 +1,24 @@
"""timeout (value last) followed by throttle_with_priority with the same period becomes one filter."""
from collections.abc import Callable
from pathlib import Path
import re
def test_timeout_throttle_pair_is_merged(
generate_main: Callable[[str | Path], str],
) -> None:
main_cpp = generate_main("tests/component_tests/sensor/timeout_throttle.yaml")
# Only the `merged` sensor's adjacent pair becomes one object
assert (
len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp))
== 1
)
merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp)
assert merged is not None
assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp
# Every other shape keeps its two filters
for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"):
call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp)
assert call is not None and len(call.group(1).split(",")) == 2, sensor_id
@@ -0,0 +1,52 @@
esphome:
name: test
esp32:
board: esp32dev
sensor:
- platform: template
id: merged
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 1s
- platform: template
id: other_periods
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 2s
- platform: template
id: other_order
filters:
- throttle_with_priority: 1s
- timeout:
timeout: 1s
value: last
- platform: template
id: value_list
filters:
- timeout:
timeout: 1s
value: last
- throttle_with_priority:
timeout: 1s
value: [0.0]
- platform: template
id: configured_value
filters:
- timeout:
timeout: 1s
value: 0.0
- throttle_with_priority: 1s
- platform: template
id: inside_or
filters:
- or:
- timeout:
timeout: 1s
value: last
- throttle_with_priority: 1s
+19 -18
View File
@@ -8,7 +8,8 @@ from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate
from esphome.components.sntp import DOMAIN as SNTP_DOMAIN
from esphome.components.sntp.time import _sntp_final_validate
from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME
from esphome.core import ID
import esphome.final_validate as fv
@@ -20,7 +21,7 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
}
@@ -33,12 +34,12 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2"],
},
@@ -51,12 +52,12 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"],
},
@@ -69,17 +70,17 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_3", is_manual=False),
CONF_SERVERS: ["pool3.ntp.org"],
},
@@ -95,7 +96,7 @@ import esphome.final_validate as fv
pytest.param(
[
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: [
"192.168.1.1",
@@ -105,7 +106,7 @@ import esphome.final_validate as fv
],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"],
},
@@ -143,7 +144,7 @@ def test_sntp_instance_merging(
sntp_instances = [
tc
for tc in updated_conf[CONF_TIME]
if tc.get(CONF_PLATFORM) == CONF_SNTP
if tc.get(CONF_PLATFORM) == SNTP_DOMAIN
]
assert len(sntp_instances) == expected_count
@@ -169,12 +170,12 @@ def test_sntp_inconsistent_manual_ids() -> None:
# Create configs with manual IDs that are inconsistent
time_configs = [
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=True),
CONF_SERVERS: ["192.168.1.1"],
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=True),
CONF_SERVERS: ["192.168.1.2"],
},
@@ -197,7 +198,7 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non
"""Test that SNTP merging doesn't affect other time platforms."""
time_configs = [
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_1", is_manual=False),
CONF_SERVERS: ["192.168.1.1"],
},
@@ -206,7 +207,7 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non
CONF_ID: ID("homeassistant_time", is_manual=False),
},
{
CONF_PLATFORM: CONF_SNTP,
CONF_PLATFORM: SNTP_DOMAIN,
CONF_ID: ID("sntp_time_2", is_manual=False),
CONF_SERVERS: ["192.168.1.2"],
},
@@ -226,11 +227,11 @@ def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> Non
# Find the platforms
platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]}
assert platforms == {CONF_SNTP, "homeassistant"}
assert platforms == {SNTP_DOMAIN, "homeassistant"}
# Verify SNTP was merged
sntp_instances = [
tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP
tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == SNTP_DOMAIN
]
assert len(sntp_instances) == 1
assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"]
@@ -0,0 +1,23 @@
esphome:
name: test
esp8266:
board: d1_mini
alarm_control_panel:
- platform: template
id: panel_a
name: Panel A
codes:
- "1234"
- "5678"
- platform: template
id: panel_b
name: Panel B
codes:
- "1234"
- "5678"
- platform: template
id: panel_none
name: Panel None
codes: []
@@ -0,0 +1,20 @@
"""Tests for the template alarm control panel codes codegen."""
from collections.abc import Callable
from pathlib import Path
def test_codes_share_one_progmem_table(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Identical code lists share one nullptr-terminated flash table; empty lists set none."""
main_cpp = generate_main(component_config_path("alarm_codes.yaml"))
assert (
'static constexpr const char * alarm_codes[] PROGMEM = {"1234", "5678", nullptr};'
in main_cpp
)
assert "panel_a->set_codes(alarm_codes);" in main_cpp
assert "panel_b->set_codes(alarm_codes);" in main_cpp
assert "panel_none->set_codes(" not in main_cpp
+3 -3
View File
@@ -1,5 +1,5 @@
"""automation.cpp (CronTrigger and SyncTrigger) is only compiled when an
on_time or on_time_sync automation exists, so the define must follow them."""
"""automation.cpp (CronTrigger) is only compiled when an on_time automation
exists, so the define must follow it; on_time_sync needs no class."""
from collections.abc import Callable
from pathlib import Path
@@ -14,7 +14,7 @@ from esphome.core import CORE
[
("no_triggers.yaml", False),
("on_time.yaml", True),
("on_time_sync.yaml", True),
("on_time_sync.yaml", False),
],
)
def test_triggers_define_follows_automations(
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: test
password: password1
time:
- platform: sntp
id: sntp_time
wireguard:
time_id: sntp_time
address: 172.16.34.100
netmask: 255.255.255.0
private_key: wPBMxtNYH3mChicrbpsRpZIasIdPq3yZuthn23FbGG8=
peer_public_key: Hs2JfikvYU03/Kv3YoAs1hrUIPPTEkpsZKSPUljE9yc=
peer_endpoint: wg.server.example
peer_allowed_ips:
- 172.16.34.0/24
- 192.168.0.0/16
@@ -0,0 +1,21 @@
"""Tests for the wireguard allowed IPs codegen."""
from collections.abc import Callable
from pathlib import Path
def test_allowed_ips_are_a_progmem_table(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Allowed IPs live in a flash table ended by an empty entry instead of a heap vector."""
main_cpp = generate_main(component_config_path("allowed_ips.yaml"))
table = main_cpp.split(
"wireguard::AllowedIP wireguard_allowed_ips[] PROGMEM = ", 1
)[1]
table = table.split(";", 1)[0]
assert '.ip = "172.16.34.0"' in table
assert '.netmask = "255.255.0.0"' in table
assert ".ip = nullptr" in table
assert "->set_allowed_ips(wireguard_allowed_ips);" in main_cpp