Merge branch 'esp8266-native-build-spec' into esp8266-native-ninja-emission

This commit is contained in:
J. Nick Koston
2026-09-16 09:29:15 -05:00
330 changed files with 5498 additions and 1330 deletions
@@ -0,0 +1,12 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
api:
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
api:
port: 6054
reboot_timeout: 0s
batch_delay: 0ms
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
api:
port: 6053
reboot_timeout: 15min
batch_delay: 100ms
@@ -0,0 +1,35 @@
"""Tests that the api component only emits setters for non default values."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
def test_default_values_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Port 6053, a 15 min reboot timeout and 100 ms batch delay are C++ initializers.
Both the schema defaults and the same values written explicitly take the skip path.
"""
main_cpp = generate_main(component_config_path(config_file))
assert "api_apiserver_id->set_port(" not in main_cpp
assert "api_apiserver_id->set_reboot_timeout(" not in main_cpp
assert "api_apiserver_id->set_batch_delay(" not in main_cpp
def test_custom_values_are_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Non default values still reach the C++ setters."""
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "api_apiserver_id->set_port(6054);" in main_cpp
assert "api_apiserver_id->set_reboot_timeout(0);" in main_cpp
assert "api_apiserver_id->set_batch_delay(0);" in main_cpp
@@ -0,0 +1,12 @@
esphome:
name: bk-power-save
bk72xx:
board: cb2s
wifi:
ssid: test
password: testtest
power_save_mode: high
bk72xx_ble:
@@ -0,0 +1,20 @@
"""bk72xx_ble keeps WiFi power save off: the Beken SDK's MCU sleep does not
wake up once the station is stopped while the BLE controller runs."""
from collections.abc import Callable
from pathlib import Path
import pytest
def test_power_save_mode_is_not_applied_with_ble(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
main_cpp = generate_main(component_config_path("test_power_save.yaml"))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
assert "set_power_save_mode(" not in main_cpp
assert "power_save_mode HIGH is not applied" in caplog.text
assert "issues/18592" in caplog.text
@@ -0,0 +1,13 @@
"""The codegen hook external filtering components use to turn on the filter slot."""
from esphome.components import bluetooth_proxy
from esphome.core import CORE
def test_enable_advertisement_filter_emits_define() -> None:
"""External components call this rather than emitting the define."""
bluetooth_proxy.enable_advertisement_filter()
assert "USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" in {
define.name for define in CORE.defines
}
@@ -0,0 +1,53 @@
"""Power-user fields are marked as advanced on the shared schemas.
``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs
whose defaults suit nearly every user, so a schema-aware editor should keep
them behind its "advanced settings" disclosure rather than on the main form.
"""
from __future__ import annotations
import importlib
import pytest
from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi
import esphome.config_validation as cv
def _markers(schema: cv.Schema) -> dict[str, object]:
s = schema
if hasattr(s, "validators"):
# cv.All -> the schema is the first validator.
s = s.validators[0]
return {str(k): k for k in s.schema}
def _gpio_switch_schema() -> cv.Schema:
return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA
@pytest.mark.parametrize(
("label", "schema_factory", "fields"),
[
("sensor", sensor.sensor_schema, ["filters"]),
("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]),
("text_sensor", text_sensor.text_sensor_schema, ["filters"]),
("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]),
("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]),
("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]),
("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]),
],
)
def test_power_user_fields_are_advanced(
label: str, schema_factory, fields: list[str]
) -> None:
markers = _markers(schema_factory())
for field in fields:
assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}"
def test_interlock_wait_time_keeps_its_default() -> None:
"""Marking the field advanced must not drop its default."""
markers = _markers(_gpio_switch_schema())
assert markers["interlock_wait_time"].default() == "0ms"
@@ -9,5 +9,5 @@ wifi:
password: password1
# esp32_ble_server is only auto-loaded here, so it has no services of its own.
esp32_improv:
improv_ble:
authorizer: none
@@ -55,7 +55,7 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None:
@pytest.mark.parametrize(
("config_file", "required"),
[
# Auto-loaded by esp32_improv only: nothing to find until Improv asks for it
# Auto-loaded by improv_ble only: nothing to find until Improv asks for it
("improv_only.yaml", False),
# The configuration defines a service clients are meant to connect to
("own_service.yaml", True),
@@ -0,0 +1,19 @@
---
esphome:
name: test
esp8266:
board: d1_mini
output:
- platform: esp8266_pwm
id: default_frequency
pin: GPIO4
frequency: 1kHz
- platform: esp8266_pwm
id: custom_frequency
pin: GPIO5
frequency: 2kHz
- platform: esp8266_pwm
id: schema_default_frequency
pin: GPIO12
@@ -0,0 +1,16 @@
"""Tests for the esp8266_pwm output codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_frequency_is_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The 1 kHz default already lives in the C++ initializer."""
main_cpp = generate_main(component_config_path("frequency.yaml"))
assert "default_frequency->set_frequency(" not in main_cpp
assert "schema_default_frequency->set_frequency(" not in main_cpp
assert "custom_frequency->set_frequency(2000.0f);" in main_cpp
@@ -0,0 +1,31 @@
esphome:
name: improv-ble-automations
esp32:
variant: esp32
framework:
type: esp-idf
logger:
wifi:
ssid: MySSID
password: password1
binary_sensor:
- platform: gpio
pin: 0
id: io0_button
output:
- platform: gpio
pin: 2
id: built_in_led
improv_ble:
authorizer: io0_button
status_indicator: built_in_led
on_provisioned:
- logger.log: provisioned
on_provisioning:
- logger.log: provisioning
on_start:
- logger.log: start
on_state:
- logger.log: state
on_stop:
- logger.log: stop
@@ -0,0 +1,12 @@
esphome:
name: improv-ble-esp32
esp32:
variant: esp32
framework:
type: esp-idf
logger:
wifi:
ssid: MySSID
password: password1
improv_ble:
authorizer: none
@@ -0,0 +1,10 @@
esphome:
name: improv-ble-esp8266
esp8266:
board: nodemcuv2
logger:
wifi:
ssid: MySSID
password: password1
improv_ble:
authorizer: none
@@ -0,0 +1,12 @@
esphome:
name: improv-ble-legacy-key
esp32:
variant: esp32
framework:
type: esp-idf
logger:
wifi:
ssid: MySSID
password: password1
esp32_improv:
authorizer: none
@@ -0,0 +1,59 @@
"""improv_ble is platform neutral; only its BLE server backends are not.
Covers the platform gate (BLE_SERVER_BACKENDS) and the esp32_improv alias that
keeps pre-rename configurations working.
"""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.core import CORE
def test_esp32_generates_component(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("esp32.yaml"))
assert "improv_ble::ImprovBLEComponent" in main_cpp
def test_legacy_key_routes_to_improv_ble(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
main_cpp = generate_main(component_config_path("legacy_key.yaml"))
assert "improv_ble::ImprovBLEComponent" in main_cpp
assert "'esp32_improv:' top-level key is deprecated" in caplog.text
def test_platform_without_ble_server_rejected(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
) -> None:
# AUTO_LOAD finds no backend for esp8266 and pulls in improv_base only, so
# the platform gate in CONFIG_SCHEMA is what has to reject the config.
CORE.config_path = component_config_path("esp8266.yaml")
assert read_config({}) is None
assert "only available on" in capsys.readouterr().out
def test_automations_emit_renamed_triggers(
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 f"improv_ble::{trigger}" in main_cpp
assert "set_authorizer" in main_cpp
assert "set_status_indicator" in main_cpp
@@ -0,0 +1,29 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ledc
id: out_a
pin: GPIO4
- platform: ledc
id: out_b
pin: GPIO5
light:
- platform: monochromatic
id: plain_light
output: out_a
flash_transition_length: 0s
- platform: monochromatic
id: fancy_light
output: out_b
flash_transition_length: 500ms
effects:
- pulse:
- platform: monochromatic
id: bare_light
output: out_a
@@ -0,0 +1,19 @@
"""Tests that light codegen skips setters for default values."""
from collections.abc import Callable
from pathlib import Path
def test_default_flash_length_and_empty_effects_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A 0 ms flash transition and an empty effect list match the C++ defaults."""
main_cpp = generate_main(component_config_path("transitions.yaml"))
assert "plain_light->set_flash_transition_length(" not in main_cpp
assert "plain_light->add_effects(" not in main_cpp
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
@@ -52,3 +52,35 @@ def test_logger_pre_setup_before_other_components(generate_main):
f"Component allocation '{alloc.group()}' at position {alloc.start()} "
f"appears before logger pre_setup() at position {logger_pre_setup.start()}"
)
def test_default_uart_selection_is_not_emitted(generate_main):
"""UART0 is the C++ initializer on ESP8266, so the setter is skipped."""
main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml")
assert "set_uart_selection(" not in main_cpp
def test_custom_uart_selection_is_emitted(generate_main):
"""A non default UART still reaches the setter before pre_setup()."""
main_cpp = generate_main("tests/component_tests/logger/test_logger_uart1.yaml")
assert "set_uart_selection(logger::UART_SELECTION_UART1);" in main_cpp
def test_libretiny_default_uart_selection_is_not_emitted(generate_main):
"""DEFAULT is the C++ initializer on LibreTiny, so the setter is skipped."""
main_cpp = generate_main(
"tests/component_tests/logger/test_logger_libretiny_default.yaml"
)
assert "set_uart_selection(" not in main_cpp
def test_libretiny_uart0_is_emitted(generate_main):
"""UART0 is not the LibreTiny initializer, so it must still be set."""
main_cpp = generate_main(
"tests/component_tests/logger/test_logger_libretiny_uart0.yaml"
)
assert "set_uart_selection(logger::UART_SELECTION_UART0);" in main_cpp
@@ -0,0 +1,8 @@
---
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
@@ -0,0 +1,9 @@
---
esphome:
name: test
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
hardware_uart: UART0
@@ -0,0 +1,9 @@
---
esphome:
name: test
esp8266:
board: d1_mini_lite
logger:
hardware_uart: UART1
@@ -0,0 +1,70 @@
"""request_service_enable_disable() only opts in on platforms whose mDNS stack
can add and remove services after setup, and tells the caller so."""
import pytest
from esphome.components import mdns
from esphome.const import CONF_DISABLED, PlatformFramework
from esphome.core import CORE
from tests.component_tests.types import SetCoreConfigCallable
DEFINE = "USE_MDNS_SUPPORTS_ENABLE_DISABLE"
def _defines() -> set[str]:
return {define.name for define in CORE.defines}
def _set_config(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
config: dict,
) -> None:
set_core_config(platform_framework)
CORE.config = config
@pytest.mark.parametrize(
"platform_framework",
[PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO],
)
def test_esp32_adds_define_and_keeps_services_stored(
set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework
) -> None:
_set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}})
assert mdns.request_service_enable_disable() is True
# Disabled services must stay stored so they can be re-registered later.
assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines()
@pytest.mark.parametrize(
"platform_framework",
[PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO],
)
def test_other_platforms_return_false(
set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework
) -> None:
_set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}})
assert mdns.request_service_enable_disable() is False
assert DEFINE not in _defines()
@pytest.mark.parametrize(
"config",
[
pytest.param({}, id="no_mdns"),
pytest.param({"mdns": {CONF_DISABLED: True}}, id="mdns_disabled"),
pytest.param(
{"mdns": {CONF_DISABLED: False}, "openthread": {}}, id="openthread"
),
],
)
def test_esp32_returns_false_when_services_cannot_be_toggled(
set_core_config: SetCoreConfigCallable, config: dict
) -> None:
_set_config(set_core_config, PlatformFramework.ESP32_IDF, config)
assert mdns.request_service_enable_disable() is False
assert DEFINE not in _defines()
+1 -2
View File
@@ -33,7 +33,6 @@ def test_server_schema_rejects_address_zero() -> None:
def test_client_schema_still_accepts_address_zero() -> None:
# Not rejected for clients today, but not supported either: a client broadcast gets no reply and
# stalls the hub for the full send-wait.
# A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read.
schema = modbus.modbus_device_schema(0x01)
assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0
@@ -7,7 +7,7 @@ guard is a safety property: these tests pin it to every handler slot.
import pytest
from esphome import config_validation as cv
from esphome.components import modbus_client
from esphome.components import modbus, modbus_client
from esphome.components.modbus_client import (
CONF_ON_NO_RESPONSE,
CONF_ON_NOT_SENT,
@@ -126,7 +126,7 @@ def test_on_no_response_retry_lambda_accepted() -> None:
def test_continuous_on_write_pdu_rejected() -> None:
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
with pytest.raises(cv.Invalid, match="does not apply to function code"):
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
@@ -185,3 +185,145 @@ def test_multi_conf_no_default_is_set() -> None:
"""
assert modbus_client.MULTI_CONF is True
assert modbus_client.MULTI_CONF_NO_DEFAULT is True
@pytest.mark.parametrize("key", [CONF_CONTINUOUS, modbus.CONF_ALLOW_BROADCAST_READ])
def test_send_rejects_read_option_on_static_write_pdu(key: str) -> None:
# A read option set true on a static write PDU is refused at validation, naming the key.
config = {
CONF_ADDRESS: 1,
CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01],
key: True,
}
with pytest.raises(
cv.Invalid, match=f"'{key}: true' does not apply to function code"
):
MODBUS_CLIENT_SEND_SCHEMA(config)
def test_send_accepts_allow_broadcast_read_on_read_pdu() -> None:
# allow_broadcast_read defaults to False and is accepted on a read PDU to address 0.
config = MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02]}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is False
config = MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02],
modbus.CONF_ALLOW_BROADCAST_READ: True,
}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
def test_send_rejects_write_option_on_static_read_pdu() -> None:
# The write-side option is refused on a static read PDU, the mirror of the read-option check.
key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
with pytest.raises(
cv.Invalid, match=f"'{key}: true' does not apply to function code"
):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x02], key: True}
)
def test_send_accepts_write_option_on_static_write_pdu() -> None:
config = MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_PDU: [0x06, 0x00, 0x10, 0x00, 0x01],
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
assert config[modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE] is True
def test_write_actions_offer_write_option_only() -> None:
# Every write action takes expect_broadcast_write_response and none of the read options.
from esphome.components.modbus_client import (
_WRITE_MULTIPLE_COILS_SCHEMA,
_WRITE_MULTIPLE_REGISTERS_SCHEMA,
_WRITE_SINGLE_COIL_SCHEMA,
_WRITE_SINGLE_REGISTER_SCHEMA,
CONF_START_ADDRESS,
CONF_VALUE,
CONF_VALUES,
)
write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
base = {CONF_ADDRESS: 0, CONF_START_ADDRESS: 0x10, write_key: True}
for schema, extra in (
(_WRITE_SINGLE_REGISTER_SCHEMA, {CONF_VALUE: 1}),
(_WRITE_SINGLE_COIL_SCHEMA, {CONF_VALUE: True}),
(_WRITE_MULTIPLE_REGISTERS_SCHEMA, {CONF_VALUES: [1, 2]}),
(_WRITE_MULTIPLE_COILS_SCHEMA, {CONF_VALUES: [True, False]}),
):
config = schema({**base, **extra})
assert config[write_key] is True
assert modbus.CONF_ALLOW_BROADCAST_READ not in config
with pytest.raises(cv.Invalid):
schema({**base, **extra, modbus.CONF_ALLOW_BROADCAST_READ: True})
def test_send_options_follow_the_hub_classification() -> None:
# A vendor code is broadcastable, so it takes the write-side flag and refuses the read-side one;
# 0x17 is a read for broadcast purposes, so the reverse holds.
write_key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
read_key = modbus.CONF_ALLOW_BROADCAST_READ
assert MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], write_key: True}
)[write_key]
with pytest.raises(cv.Invalid, match=f"'{read_key}: true' does not apply"):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: [0x41, 0x01], read_key: True}
)
pdu_0x17 = [0x17, 0x00, 0x10, 0x00, 0x01, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0x01]
assert MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, read_key: True}
)[read_key]
with pytest.raises(cv.Invalid, match=f"'{write_key}: true' does not apply"):
MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: 0, CONF_PDU: pdu_0x17, write_key: True}
)
def test_read_write_multiple_offers_allow_broadcast_read_only() -> None:
from esphome.components.modbus_client import (
_READ_WRITE_MULTIPLE_REGISTERS_SCHEMA,
CONF_READ_ADDRESS,
CONF_VALUES,
CONF_WRITE_ADDRESS,
)
config = _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA(
{
CONF_ADDRESS: 0,
CONF_READ_ADDRESS: 0x10,
CONF_WRITE_ADDRESS: 0x20,
CONF_VALUES: [1],
modbus.CONF_ALLOW_BROADCAST_READ: True,
}
)
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
assert CONF_CONTINUOUS not in config
assert modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE not in config
@pytest.mark.parametrize(
"key",
[modbus.CONF_ALLOW_BROADCAST_READ, modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE],
)
def test_broadcast_options_rejected_on_literal_unicast_address(key: str) -> None:
# A broadcast-only option on a literal non-zero address would be silently dropped by the hub.
if key == modbus.CONF_ALLOW_BROADCAST_READ:
pdu = [0x03, 0x00, 0x10, 0x00, 0x01]
else:
pdu = [0x06, 0x00, 0x10, 0x00, 0x01]
with pytest.raises(cv.Invalid, match="only applies to the broadcast address"):
MODBUS_CLIENT_SEND_SCHEMA({CONF_ADDRESS: 1, CONF_PDU: pdu, key: True})
# A templated address is not decidable at validation and passes through.
config = MODBUS_CLIENT_SEND_SCHEMA(
{CONF_ADDRESS: Lambda("return 1;"), CONF_PDU: pdu, key: True}
)
assert config[key] is True
@@ -0,0 +1,79 @@
"""A modbus_controller cannot poll the broadcast address (0) unless allow_broadcast_read says the
device answers it."""
import pytest
from esphome import config_validation as cv
from esphome.components import modbus
from esphome.components.modbus_controller import CONFIG_SCHEMA
from esphome.const import CONF_ADDRESS
from esphome.types import ConfigType
def _controller(address: int, **extra: object) -> ConfigType:
return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: address, **extra})
def test_address_zero_rejected_by_default() -> None:
with pytest.raises(cv.Invalid, match="broadcast address"):
_controller(0)
def test_address_zero_accepted_with_allow_broadcast_read() -> None:
config = _controller(0, **{modbus.CONF_ALLOW_BROADCAST_READ: True})
assert config[CONF_ADDRESS] == 0
assert config[modbus.CONF_ALLOW_BROADCAST_READ] is True
def test_allow_broadcast_read_defaults_false() -> None:
assert _controller(1)[modbus.CONF_ALLOW_BROADCAST_READ] is False
def test_writer_entity_takes_expect_broadcast_write_response() -> None:
# The write-side option lives on the writing platforms, not the controller.
from esphome.components.modbus_controller.const import CONF_MODBUS_CONTROLLER_ID
from esphome.components.modbus_controller.switch import (
CONFIG_SCHEMA as SWITCH_SCHEMA,
)
from esphome.const import CONF_NAME
key = modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE
base = {
CONF_MODBUS_CONTROLLER_ID: "ctl",
CONF_NAME: "Switch",
"register_type": "coil",
CONF_ADDRESS: 0x20,
}
assert SWITCH_SCHEMA(base)[key] is False
assert SWITCH_SCHEMA({**base, CONF_NAME: "Switch 2", key: True})[key] is True
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", CONF_ADDRESS: 1, key: True})
def test_allow_broadcast_read_requires_address_zero() -> None:
# The option only means something at address 0; elsewhere it would be silently inert.
with pytest.raises(cv.Invalid, match="only applies to the broadcast address"):
_controller(5, **{modbus.CONF_ALLOW_BROADCAST_READ: True})
def test_add_command_options_skips_defaults() -> None:
# The setter is only emitted when an option differs from its C++ default.
import esphome.codegen as cg
from esphome.const import CONF_CONTINUOUS
var = cg.MockObj("ctl")
emitted: list = []
original = cg.add
cg.add = emitted.append
try:
modbus.add_command_options(
var, "set_read_options", {CONF_CONTINUOUS: False}, direction="read"
)
assert emitted == []
modbus.add_command_options(
var, "set_read_options", {CONF_CONTINUOUS: True}, direction="read"
)
assert len(emitted) == 1
assert "set_read_options" in str(emitted[0])
finally:
cg.add = original
@@ -9,6 +9,7 @@ test cannot: a write-coded custom_pdu polled continuously is rejected there.
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components import modbus
from esphome.components.modbus_controller import (
ModbusItemBaseSchema,
validate_custom_pdu_item,
@@ -55,14 +56,21 @@ def test_custom_pdu_rejects_non_byte_values() -> None:
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
def _controller_full_config(*, continuous: bool) -> Config:
def _controller_full_config(
*, continuous: bool, allow_broadcast_read: bool = False
) -> 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
final-validate to resolve the controller (and its option flags) 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}
{
CONF_ID: ctl_id,
CONF_ADDRESS: 0 if allow_broadcast_read else 1,
CONF_CONTINUOUS: continuous,
modbus.CONF_ALLOW_BROADCAST_READ: allow_broadcast_read,
}
]
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
return config
@@ -98,3 +106,64 @@ def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
def test_broadcastable_custom_pdu_rejected_under_broadcast_controller(
reset_full_config,
) -> None:
"""A vendor-coded custom_pdu under an allow_broadcast_read controller would be a real broadcast,
never answered, so it is rejected at final validate."""
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
with pytest.raises(Invalid, match="is a real broadcast at address 0"):
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x41, 0x00, 0x03],
}
)
def test_read_custom_pdu_allowed_under_broadcast_controller(reset_full_config) -> None:
"""A read-coded custom_pdu (0x03) is answered under allow_broadcast_read, so it is fine."""
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
def test_write_option_rejected_under_unicast_controller(reset_full_config) -> None:
"""expect_broadcast_write_response on a writer entity whose controller is not at address 0 is
rejected at final validate, where the controller's address is known."""
from esphome.components.modbus_controller import validate_writer_item
fv.full_config.set(_controller_full_config(continuous=False))
with pytest.raises(
Invalid, match="only applies when the 'ctl' modbus_controller is at address 0"
):
validate_writer_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
def test_write_option_allowed_under_broadcast_controller(reset_full_config) -> None:
from esphome.components.modbus_controller import validate_writer_item
fv.full_config.set(
_controller_full_config(continuous=False, allow_broadcast_read=True)
)
validate_writer_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
modbus.CONF_EXPECT_BROADCAST_WRITE_RESPONSE: True,
}
)
@@ -0,0 +1,28 @@
---
esphome:
name: test
esp32:
board: esp32dev
number:
- platform: template
id: auto_number
min_value: 0
max_value: 10
step: 1
optimistic: true
- platform: template
id: box_number
min_value: 0
max_value: 10
step: 1
mode: box
optimistic: true
- platform: template
id: explicit_auto_number
min_value: 0
max_value: 10
step: 1
mode: auto
optimistic: true
@@ -0,0 +1,16 @@
"""Tests for the number component codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_mode_is_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Mode auto is the C++ initializer, so only a non default mode is set."""
main_cpp = generate_main(component_config_path("mode.yaml"))
assert "auto_number->traits.set_mode(" not in main_cpp
assert "explicit_auto_number->traits.set_mode(" not in main_cpp
assert "box_number->traits.set_mode(number::NUMBER_MODE_BOX);" in main_cpp
@@ -0,0 +1,13 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ac_dimmer
id: dimmer
gate_pin: GPIO4
zero_cross_pin: GPIO5
min_power: 0%
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: esp32dev
output:
- platform: ledc
id: default_power
pin: GPIO4
max_power: 100%
min_power: 0%
- platform: ledc
id: custom_power
pin: GPIO5
max_power: 90%
min_power: 1%
@@ -0,0 +1,31 @@
"""Tests for the output platform codegen."""
from collections.abc import Callable
from pathlib import Path
from esphome.core import CORE
def test_default_power_limits_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""max_power 100% and min_power 0% already live in the C++ initializers."""
main_cpp = generate_main(component_config_path("power_limits.yaml"))
assert "default_power->set_max_power(" not in main_cpp
assert "default_power->set_min_power(" not in main_cpp
assert "custom_power->set_max_power(0.9f);" in main_cpp
assert "custom_power->set_min_power(0.01f);" in main_cpp
assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines}
def test_default_min_power_keeps_scaling_fields(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""ac_dimmer reads min_power_ directly, so the define must stay on for min_power 0%."""
main_cpp = generate_main(component_config_path("ac_dimmer_min_power_zero.yaml"))
assert "dimmer->set_min_power(" not in main_cpp
assert "USE_OUTPUT_FLOAT_POWER_SCALING" in {d.name for d in CORE.defines}
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: esp32dev
logger:
switch:
- platform: template
id: plain_switch
turn_on_action:
- logger.log: "on"
- platform: template
id: enabled_switch
optimistic: true
assumed_state: true
@@ -0,0 +1,17 @@
"""Tests for the template switch codegen."""
from collections.abc import Callable
from pathlib import Path
def test_default_flags_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only true optimistic and assumed_state are set; false is the C++ initializer."""
main_cpp = generate_main(component_config_path("switch_defaults.yaml"))
assert "plain_switch->set_optimistic(" not in main_cpp
assert "plain_switch->set_assumed_state(" not in main_cpp
assert "enabled_switch->set_optimistic(true);" in main_cpp
assert "enabled_switch->set_assumed_state(true);" in main_cpp
@@ -0,0 +1,42 @@
"""Tests for the uart_mux component's final validation."""
import pytest
from esphome import config_validation as cv
from esphome.const import CONF_ID, PlatformFramework
from esphome.core import ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
CONF_BRIDGE_ID = "bridge_id"
def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None:
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S3}
)
def _mux_conf(mux_id: str, bridge_id: str) -> ConfigType:
return {CONF_ID: ID(mux_id), CONF_BRIDGE_ID: ID(bridge_id)}
def test_accepts_one_mux_per_bridge(set_core_config: SetCoreConfigCallable) -> None:
_set_esp32_s3(set_core_config)
from esphome.components import uart_mux
uart_mux._final_validate(_mux_conf("mux_0", "bridge_0"))
uart_mux._final_validate(_mux_conf("mux_1", "bridge_1"))
def test_rejects_two_muxes_on_one_bridge(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config)
from esphome.components import uart_mux
uart_mux._final_validate(_mux_conf("mux_0", "bridge_0"))
with pytest.raises(cv.Invalid, match="already routed by another 'uart_mux'"):
uart_mux._final_validate(_mux_conf("mux_1", "bridge_0"))
@@ -0,0 +1,12 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
port: 8080
log: false
include_internal: true
@@ -0,0 +1,15 @@
---
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: test
password: testtest
web_server:
port: 80
log: true
include_internal: false
@@ -0,0 +1,35 @@
"""Tests that web_server only emits setters for non default values."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
def test_default_values_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Port 80, log on and include_internal off already live in the C++ initializers.
Both the schema defaults and the same values written explicitly take the skip path.
"""
main_cpp = generate_main(component_config_path(config_file))
assert "set_port(" not in main_cpp
assert "set_expose_log(" not in main_cpp
assert "set_include_internal(" not in main_cpp
def test_custom_values_are_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Non default values still reach the C++ setters."""
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_port(8080);" in main_cpp
assert "set_expose_log(false);" in main_cpp
assert "set_include_internal(true);" in main_cpp
@@ -0,0 +1,12 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: testtest
ap:
ssid: fallback
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
networks:
- ssid: test
password: testtest
priority: 5
ap:
ssid: fallback
ap_timeout: 2min
reboot_timeout: 0s
power_save_mode: light
min_auth_mode: wpa
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp8266:
board: d1_mini
wifi:
networks:
- ssid: test
password: testtest
priority: 0
ap:
ssid: fallback
ap_timeout: 90s
reboot_timeout: 15min
power_save_mode: none
min_auth_mode: wpa2
@@ -0,0 +1,39 @@
"""Tests that wifi codegen skips setters for default values."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
def test_default_values_are_not_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Priority 0, 90 s AP timeout, 15 min reboot, power save none, WPA2 are C++ defaults.
Both the schema defaults and the same values written explicitly take the skip path.
"""
main_cpp = generate_main(component_config_path(config_file))
assert "set_priority(" not in main_cpp
assert "set_ap_timeout(" not in main_cpp
assert "set_reboot_timeout(" not in main_cpp
assert "set_power_save_mode(" not in main_cpp
assert "set_min_auth_mode(" not in main_cpp
def test_custom_values_are_emitted(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Non default values still reach the C++ setters."""
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_priority(5);" in main_cpp
assert "set_ap_timeout(120000);" in main_cpp
assert "set_reboot_timeout(0);" in main_cpp
assert "set_power_save_mode(wifi::WIFI_POWER_SAVE_LIGHT);" in main_cpp
assert "set_min_auth_mode(wifi::WIFI_MIN_AUTH_MODE_WPA);" in main_cpp
@@ -0,0 +1,46 @@
"""Tests for wifi.force_power_save_off(), the hook platforms use to keep the
station out of power save."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components import wifi
from esphome.core import CORE, EsphomeError
def test_reasons_accumulate_without_duplicates() -> None:
"""Every caller's reason is kept once; a repeated reason is not duplicated."""
wifi.force_power_save_off("first")
wifi.force_power_save_off("first")
wifi.force_power_save_off("second")
assert CORE.data[wifi.POWER_SAVE_OFF_REASONS_KEY] == ["first", "second"]
def test_forced_off_skips_the_setter_and_warns(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
) -> None:
"""With a reason recorded, power_save_mode is reported and not applied."""
wifi.force_power_save_off("the platform cannot sleep")
main_cpp = generate_main(component_config_path("custom.yaml"))
assert "set_power_save_mode(" not in main_cpp
assert (
"power_save_mode LIGHT is not applied: the platform cannot sleep" in caplog.text
)
def test_call_after_wifi_codegen_raises(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Once wifi has generated its code the hook cannot take effect any more."""
generate_main(component_config_path("custom.yaml"))
with pytest.raises(EsphomeError, match="before wifi generates its code"):
wifi.force_power_save_off("too late")
+1
View File
@@ -4,3 +4,4 @@ output:
gate_pin: ${gate_pin}
zero_cross_pin: ${zero_cross_pin}
zero_cross_interrupt_type: ANY
min_power: 0%
+17
View File
@@ -0,0 +1,17 @@
import esphome.codegen as cg
from esphome.core import CORE
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# USE_API compiles every api source, so emit what they need. No socket
# override: an __init__.py there makes pytest import its conftest as socket.conftest.
async def to_code_testing(config):
cg.add_define("USE_API")
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("API_MAX_SEND_QUEUE", 8)
cg.add_define("MAX_API_CONNECTIONS", 1)
cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS")
CORE.register_controller() # api_server registers with the controller registry
manifest.to_code = to_code_testing
+65
View File
@@ -0,0 +1,65 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include "esphome/components/api/api_buffer.h"
namespace esphome::api::testing {
// Pointer plus two 16 bit sizes
static_assert(sizeof(APIBuffer) <= 2 * sizeof(void *));
TEST(APIBuffer, RefusesSizesAbove16Bits) {
APIBuffer buf;
ASSERT_TRUE(buf.resize(16));
EXPECT_FALSE(buf.reserve(UINT16_MAX + 1));
EXPECT_EQ(buf.size(), 16u);
EXPECT_EQ(buf.capacity(), 16u);
EXPECT_TRUE(buf.reserve(UINT16_MAX));
EXPECT_EQ(buf.capacity(), UINT16_MAX);
}
static const uint8_t BYTES[] = {1, 2, 3, 4, 5, 6};
TEST(APIBuffer, AppendReturnsTheNewBytes) {
APIBuffer buf;
ASSERT_TRUE(buf.reserve(8));
uint8_t *first = buf.append(3);
ASSERT_NE(first, nullptr);
std::memcpy(first, BYTES, 3);
EXPECT_EQ(buf.size(), 3u);
EXPECT_EQ(buf.capacity(), 8u);
// Grows through realloc and keeps what was there
uint8_t *second = buf.append(6);
ASSERT_EQ(second, buf.data() + 3);
std::memcpy(second, BYTES + 3, 3);
EXPECT_EQ(buf.size(), 9u);
EXPECT_EQ(buf.capacity(), 9u);
EXPECT_EQ(std::memcmp(buf.data(), BYTES, 6), 0);
}
TEST(APIBuffer, DropFrontSlidesTheRestDown) {
APIBuffer buf;
uint8_t *bytes = buf.append(6);
ASSERT_NE(bytes, nullptr);
std::memcpy(bytes, BYTES, 6);
buf.drop_front(2);
EXPECT_EQ(buf.size(), 4u);
EXPECT_EQ(buf.capacity(), 6u);
EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0);
// Growing afterwards keeps the slid bytes
ASSERT_TRUE(buf.reserve(64));
EXPECT_EQ(buf.size(), 4u);
EXPECT_EQ(std::memcmp(buf.data(), BYTES + 2, 4), 0);
// Dropping everything leaves an empty buffer with its capacity
buf.drop_front(4);
EXPECT_EQ(buf.size(), 0u);
EXPECT_EQ(buf.capacity(), 64u);
}
} // namespace esphome::api::testing
@@ -0,0 +1,510 @@
#include <gtest/gtest.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <memory>
#include <vector>
#include "esphome/components/api/api_overflow_buffer.h"
#ifdef USE_HOST
namespace esphome::api::testing {
// Idle cost is the buffer plus one word of bookkeeping
static_assert(sizeof(APIOverflowBuffer) <= sizeof(APIBuffer) + sizeof(void *));
// Exposes storage so tests can check it is reused, not reallocated
class TestOverflowBuffer : public APIOverflowBuffer {
public:
using APIOverflowBuffer::LEN_PREFIX;
using APIOverflowBuffer::MAX_BYTES;
using APIOverflowBuffer::MAX_LONE_BYTES;
struct Storage {
size_t capacity;
const uint8_t *data;
bool operator==(const Storage &) const = default;
};
size_t capacity() const { return this->buf_.capacity(); }
Storage storage() const { return {this->buf_.capacity(), this->buf_.data()}; }
uint8_t count() const { return this->count_; }
size_t live() const { return this->buf_.size() - this->head_; }
/// Simulates a socket write inside try_drain() re-entering the send path
void set_draining(bool draining) { this->draining_ = draining; }
};
static std::vector<uint8_t> make_message(size_t len, uint8_t seed) {
std::vector<uint8_t> msg(len);
for (size_t i = 0; i < len; i++)
msg[i] = static_cast<uint8_t>(seed + i);
return msg;
}
static bool enqueue(TestOverflowBuffer &buf, const std::vector<uint8_t> &msg, uint16_t skip = 0) {
struct iovec iov = {const_cast<uint8_t *>(msg.data()), msg.size()};
return buf.enqueue_iov(&iov, 1, static_cast<uint16_t>(msg.size()), skip);
}
static void append(std::vector<uint8_t> &dst, const std::vector<uint8_t> &src, size_t skip = 0) {
dst.insert(dst.end(), src.begin() + skip, src.end());
}
static std::vector<uint8_t> concat(std::initializer_list<std::vector<uint8_t>> parts) {
std::vector<uint8_t> out;
for (const auto &part : parts)
append(out, part);
return out;
}
/// The pipe delivers the filler first, then the drained messages.
static void expect_after_filler(const std::vector<uint8_t> &received, size_t filler,
const std::vector<uint8_t> &expected) {
ASSERT_EQ(received.size(), filler + expected.size());
EXPECT_TRUE(std::equal(expected.begin(), expected.end(), received.begin() + filler));
}
// Non-blocking socket pair with small buffers, so the writer fills like a stalled TCP connection
class OverflowBufferTest : public ::testing::Test {
protected:
void SetUp() override {
int fds[2];
ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
int size = 4096;
ASSERT_EQ(::setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)), 0);
ASSERT_EQ(::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)), 0);
ASSERT_EQ(::fcntl(fds[1], F_SETFL, O_NONBLOCK), 0);
this->reader_ = fds[1];
this->sock_ = std::make_unique<socket::Socket>(fds[0]);
ASSERT_EQ(this->sock_->setblocking(false), 0);
}
void TearDown() override { ::close(this->reader_); }
/// Write filler until the socket refuses; returns the bytes accepted
size_t fill_pipe_() {
uint8_t junk[512];
std::memset(junk, 0xEE, sizeof(junk));
size_t total = 0;
for (;;) {
ssize_t written = this->sock_->write(junk, sizeof(junk));
if (written <= 0)
break;
total += static_cast<size_t>(written);
}
return total;
}
/// Append whatever the pipe currently holds.
void read_into_(std::vector<uint8_t> &out) {
uint8_t tmp[1024];
for (;;) {
ssize_t n = ::read(this->reader_, tmp, sizeof(tmp));
if (n <= 0)
break;
out.insert(out.end(), tmp, tmp + n);
}
}
/// Drain once; a refusal must be a would-block, never a hard error.
ssize_t drain_(TestOverflowBuffer &buf) {
ssize_t sent = buf.try_drain(this->sock_.get());
if (sent == -1) {
EXPECT_TRUE(errno == EWOULDBLOCK || errno == EAGAIN);
}
return sent;
}
/// Read and drain until the backlog is empty; returns all bytes received
std::vector<uint8_t> drain_all_(TestOverflowBuffer &buf) {
std::vector<uint8_t> received;
for (int i = 0; i < 10000 && !buf.empty(); i++) {
this->read_into_(received);
// A hard socket error would never clear the backlog; stop instead of spinning
if (this->drain_(buf) == -1 && errno != EWOULDBLOCK && errno != EAGAIN)
break;
}
EXPECT_TRUE(buf.empty());
this->read_into_(received);
return received;
}
struct Stall {
size_t filler;
std::vector<uint8_t> first, second, received;
TestOverflowBuffer::Storage before;
};
/// Park two messages, then drain the first fully and the second part way
void stall_mid_message_(TestOverflowBuffer &buf, Stall &s) {
s.filler = this->fill_pipe_();
s.first = make_message(1500, 20);
ASSERT_GT(s.filler, s.first.size()); // the first message must drain in one go
// Larger than the whole pipe, so a drain always stops inside it
s.second = make_message(std::max<size_t>(s.filler + 1, std::min<size_t>(s.filler * 3, 12000)), 60);
ASSERT_GT(s.second.size(), s.filler);
ASSERT_TRUE(enqueue(buf, s.first));
ASSERT_TRUE(enqueue(buf, s.second));
s.before = buf.storage();
this->read_into_(s.received);
ASSERT_GT(this->drain_(buf), 0);
ASSERT_EQ(buf.count(), 1);
}
int reader_{-1};
std::unique_ptr<socket::Socket> sock_;
};
TEST_F(OverflowBufferTest, IdleBufferOwnsNoStorage) {
TestOverflowBuffer buf;
EXPECT_TRUE(buf.empty());
EXPECT_EQ(buf.capacity(), 0u);
EXPECT_EQ(buf.storage().data, nullptr);
}
TEST_F(OverflowBufferTest, StorageIsReusedAcrossStalls) {
TestOverflowBuffer buf;
auto msg = make_message(1000, 1);
size_t filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, msg));
const auto storage = buf.storage();
EXPECT_GE(storage.capacity, msg.size() + TestOverflowBuffer::LEN_PREFIX);
for (int stall = 0; stall < 5; stall++) {
expect_after_filler(this->drain_all_(buf), filler, msg);
EXPECT_TRUE(buf.empty());
// Same allocation every time: no free, no new allocation
EXPECT_EQ(buf.storage(), storage);
filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, msg));
EXPECT_EQ(buf.storage(), storage);
}
}
TEST_F(OverflowBufferTest, ReleaseWhileQueuedFreesOnceDrained) {
TestOverflowBuffer buf;
auto msg = make_message(1000, 7);
size_t filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, msg));
const size_t capacity = buf.capacity();
// Requested while the backlog still holds data: storage must stay until sent
buf.release();
EXPECT_FALSE(buf.empty());
EXPECT_EQ(buf.capacity(), capacity);
expect_after_filler(this->drain_all_(buf), filler, msg);
EXPECT_TRUE(buf.empty());
EXPECT_EQ(buf.capacity(), 0u);
EXPECT_EQ(buf.storage().data, nullptr);
// A later stall allocates again and keeps it, since nobody asked for a release
filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, msg));
EXPECT_GT(buf.capacity(), 0u);
this->drain_all_(buf);
EXPECT_GT(buf.capacity(), 0u);
}
TEST_F(OverflowBufferTest, ReleaseWhenEmptyFreesImmediately) {
TestOverflowBuffer buf;
auto msg = make_message(100, 3);
this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, msg));
this->drain_all_(buf);
EXPECT_GT(buf.capacity(), 0u);
buf.release();
EXPECT_EQ(buf.capacity(), 0u);
EXPECT_EQ(buf.storage().data, nullptr);
}
TEST_F(OverflowBufferTest, PreservesOrderAndSkipsSentPrefix) {
TestOverflowBuffer buf;
auto first = make_message(700, 10);
auto second_a = make_message(300, 50);
auto second_b = make_message(400, 90);
auto third = make_message(200, 130);
size_t filler = this->fill_pipe_();
// 100 bytes of the first message were already accepted by the socket
ASSERT_TRUE(enqueue(buf, first, 100));
// Two iovecs with the skip covering all of the first one plus part of the second
struct iovec iov[2] = {{second_a.data(), second_a.size()}, {second_b.data(), second_b.size()}};
const uint16_t second_skip = static_cast<uint16_t>(second_a.size() + 5);
ASSERT_TRUE(buf.enqueue_iov(iov, 2, static_cast<uint16_t>(second_a.size() + second_b.size()), second_skip));
ASSERT_TRUE(enqueue(buf, third));
EXPECT_EQ(buf.count(), 3);
// Nothing can go out while the pipe is full
EXPECT_EQ(this->drain_(buf), -1);
EXPECT_EQ(buf.count(), 3);
std::vector<uint8_t> expected;
append(expected, first, 100);
append(expected, second_b, 5);
append(expected, third);
expect_after_filler(this->drain_all_(buf), filler, expected);
}
TEST_F(OverflowBufferTest, RefusesWhenQueueIsFull) {
TestOverflowBuffer buf;
auto msg = make_message(16, 1);
size_t filler = this->fill_pipe_();
for (int i = 0; i < API_MAX_SEND_QUEUE; i++) {
ASSERT_TRUE(enqueue(buf, msg)) << "message " << i;
}
EXPECT_FALSE(enqueue(buf, msg));
EXPECT_EQ(buf.count(), API_MAX_SEND_QUEUE);
// Draining frees the slots again
std::vector<uint8_t> expected;
for (int i = 0; i < API_MAX_SEND_QUEUE; i++)
append(expected, msg);
expect_after_filler(this->drain_all_(buf), filler, expected);
this->fill_pipe_();
EXPECT_TRUE(enqueue(buf, msg));
EXPECT_EQ(buf.count(), 1);
}
TEST_F(OverflowBufferTest, SkipAtIovecBoundary) {
TestOverflowBuffer buf;
auto sent = make_message(300, 50);
auto unsent = make_message(400, 90);
size_t filler = this->fill_pipe_();
// The skip covers the first iovec exactly, so only the second is copied
struct iovec iov[2] = {{sent.data(), sent.size()}, {unsent.data(), unsent.size()}};
ASSERT_TRUE(
buf.enqueue_iov(iov, 2, static_cast<uint16_t>(sent.size() + unsent.size()), static_cast<uint16_t>(sent.size())));
EXPECT_EQ(buf.live(), unsent.size() + TestOverflowBuffer::LEN_PREFIX);
expect_after_filler(this->drain_all_(buf), filler, unsent);
}
TEST_F(OverflowBufferTest, AppendsBehindSentPrefixWhenItFits) {
TestOverflowBuffer buf;
size_t filler = this->fill_pipe_();
auto first = make_message(200, 20);
// Size the second message so the two land half way into a 256 byte step,
// leaving exactly 128 bytes of slack whatever the pipe accepted
const size_t base = std::max<size_t>(filler + 1, std::min<size_t>(filler * 3, 12000));
const size_t second_len = (base / 256 + 1) * 256 + 128 - first.size() - 2 * TestOverflowBuffer::LEN_PREFIX;
auto second = make_message(second_len, 60);
ASSERT_GT(second.size(), filler);
ASSERT_TRUE(enqueue(buf, first));
ASSERT_TRUE(enqueue(buf, second));
const auto storage = buf.storage();
const size_t slack = storage.capacity - first.size() - second.size() - 2 * TestOverflowBuffer::LEN_PREFIX;
ASSERT_EQ(slack, 128u);
auto third = make_message(slack - TestOverflowBuffer::LEN_PREFIX, 200);
std::vector<uint8_t> received;
this->read_into_(received);
ASSERT_GT(this->drain_(buf), 0);
ASSERT_EQ(buf.count(), 1);
const size_t live = buf.live();
// Fits in the tail, so the sent prefix is left alone
ASSERT_TRUE(enqueue(buf, third));
EXPECT_EQ(buf.storage(), storage);
EXPECT_EQ(buf.live(), live + third.size() + TestOverflowBuffer::LEN_PREFIX);
append(received, this->drain_all_(buf));
expect_after_filler(received, filler, concat({first, second, third}));
}
TEST_F(OverflowBufferTest, ReleaseSurvivesFurtherEnqueues) {
TestOverflowBuffer buf;
auto first = make_message(300, 7);
auto second = make_message(300, 70);
size_t filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, first));
buf.release();
ASSERT_TRUE(enqueue(buf, second));
EXPECT_GT(buf.capacity(), 0u);
expect_after_filler(this->drain_all_(buf), filler, concat({first, second}));
EXPECT_EQ(buf.capacity(), 0u);
}
TEST_F(OverflowBufferTest, RefusesWhenByteLimitIsExceeded) {
TestOverflowBuffer buf;
// Two of these fill the byte budget exactly, well before the slot count is reached
static_assert(API_MAX_SEND_QUEUE >= 3);
auto msg = make_message(TestOverflowBuffer::MAX_BYTES / 2 - TestOverflowBuffer::LEN_PREFIX, 1);
this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, msg));
ASSERT_TRUE(enqueue(buf, msg));
EXPECT_FALSE(enqueue(buf, msg));
EXPECT_EQ(buf.count(), 2);
}
TEST_F(OverflowBufferTest, LoneMessageMayExceedByteLimit) {
TestOverflowBuffer buf;
// The oversized message must still fit under the lone message ceiling
static_assert(TestOverflowBuffer::MAX_BYTES + 100 + TestOverflowBuffer::LEN_PREFIX <=
TestOverflowBuffer::MAX_LONE_BYTES);
auto big = make_message(TestOverflowBuffer::MAX_BYTES + 100, 5);
auto small = make_message(16, 9);
// Refusing the only message would drop the connection for nothing
size_t filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, big));
EXPECT_EQ(buf.count(), 1);
// With a backlog present the byte limit applies again
EXPECT_FALSE(enqueue(buf, small));
EXPECT_EQ(buf.count(), 1);
expect_after_filler(this->drain_all_(buf), filler, big);
}
TEST_F(OverflowBufferTest, LoneMessageAboveOffsetLimitIsRefused) {
TestOverflowBuffer buf;
// Payload plus prefix is past the lone message ceiling
auto msg = make_message(TestOverflowBuffer::MAX_LONE_BYTES, 3);
this->fill_pipe_();
EXPECT_FALSE(enqueue(buf, msg));
EXPECT_TRUE(buf.empty());
EXPECT_EQ(buf.capacity(), 0u);
}
TEST_F(OverflowBufferTest, HardSocketErrorLeavesBacklogIntact) {
TestOverflowBuffer buf;
auto msg = make_message(300, 40);
this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, msg));
// A closed socket fails every write outright, unlike a full one
ASSERT_EQ(this->sock_->close(), 0);
errno = 0;
EXPECT_EQ(buf.try_drain(this->sock_.get()), -1);
EXPECT_NE(errno, EWOULDBLOCK);
EXPECT_NE(errno, EAGAIN);
EXPECT_EQ(buf.count(), 1);
EXPECT_EQ(buf.live(), msg.size() + TestOverflowBuffer::LEN_PREFIX);
}
TEST_F(OverflowBufferTest, GrowsWhileReclaimingSentPrefix) {
TestOverflowBuffer buf;
Stall s;
ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s));
// One byte too many to fit even after the sent prefix is reclaimed: grows in one copy
auto third = make_message(s.before.capacity - buf.live() + 1, 200);
ASSERT_TRUE(enqueue(buf, third));
EXPECT_GT(buf.capacity(), s.before.capacity);
EXPECT_EQ(buf.count(), 2);
append(s.received, this->drain_all_(buf));
expect_after_filler(s.received, s.filler, concat({s.first, s.second, third}));
}
TEST_F(OverflowBufferTest, NestedDrainMakesNoProgress) {
TestOverflowBuffer buf;
auto msg = make_message(300, 40);
size_t filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, msg));
std::vector<uint8_t> received;
this->read_into_(received);
// Room is available, but a nested drain must leave the outer one's message alone
buf.set_draining(true);
EXPECT_EQ(this->drain_(buf), 0);
EXPECT_EQ(buf.count(), 1);
std::vector<uint8_t> nothing;
this->read_into_(nothing);
EXPECT_TRUE(nothing.empty());
buf.set_draining(false);
append(received, this->drain_all_(buf));
expect_after_filler(received, filler, msg);
}
TEST_F(OverflowBufferTest, NestedEnqueueAppendsWithinCapacity) {
TestOverflowBuffer buf;
auto first = make_message(500, 10);
auto second = make_message(4, 90);
size_t filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, first));
const auto storage = buf.storage();
ASSERT_GE(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX);
buf.set_draining(true);
EXPECT_TRUE(enqueue(buf, second));
EXPECT_EQ(buf.count(), 2);
EXPECT_EQ(buf.storage(), storage);
buf.set_draining(false);
expect_after_filler(this->drain_all_(buf), filler, concat({first, second}));
}
TEST_F(OverflowBufferTest, NestedEnqueueRefusesToGrow) {
TestOverflowBuffer buf;
auto first = make_message(500, 10);
auto second = make_message(100, 90);
size_t filler = this->fill_pipe_();
ASSERT_TRUE(enqueue(buf, first));
const auto storage = buf.storage();
ASSERT_LT(storage.capacity, first.size() + second.size() + 2 * TestOverflowBuffer::LEN_PREFIX);
// Growing would free the bytes the outer write() is sending from
buf.set_draining(true);
EXPECT_FALSE(enqueue(buf, second));
EXPECT_EQ(buf.count(), 1);
EXPECT_EQ(buf.storage(), storage);
buf.set_draining(false);
expect_after_filler(this->drain_all_(buf), filler, first);
}
TEST_F(OverflowBufferTest, NestedEnqueueRefusesToCompact) {
TestOverflowBuffer buf;
Stall s;
ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s));
auto third = make_message(1000, 200);
// Sliding the remainder down would move the bytes the outer write() points at
buf.set_draining(true);
EXPECT_FALSE(enqueue(buf, third));
EXPECT_EQ(buf.count(), 1);
EXPECT_EQ(buf.storage(), s.before);
buf.set_draining(false);
// Once the drain is over the same enqueue compacts and succeeds
ASSERT_TRUE(enqueue(buf, third));
EXPECT_EQ(buf.storage(), s.before);
append(s.received, this->drain_all_(buf));
expect_after_filler(s.received, s.filler, concat({s.first, s.second, third}));
}
TEST_F(OverflowBufferTest, CompactsInsteadOfGrowingAfterPartialDrain) {
TestOverflowBuffer buf;
Stall s;
ASSERT_NO_FATAL_FAILURE(this->stall_mid_message_(buf, s));
auto third = make_message(1000, 200);
// The sent first message is reclaimed by sliding the remainder down, not by reallocating
ASSERT_TRUE(enqueue(buf, third));
EXPECT_EQ(buf.storage(), s.before);
append(s.received, this->drain_all_(buf));
expect_after_filler(s.received, s.filler, concat({s.first, s.second, third}));
}
} // namespace esphome::api::testing
#endif // USE_HOST
@@ -0,0 +1,9 @@
# A wifi power_save_mode other than NONE is forced off with a warning while
# bk72xx_ble is configured (esphome#18592); this config must still validate.
packages:
bk72xx_ble: !include common.yaml
wifi:
ssid: MySSID
password: password1
power_save_mode: high
@@ -6,15 +6,14 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS;
# emit the backend define so the host build exercises it.
async def to_code_testing(config):
# These defines are global to the merged host test binary; safe
# because no co-compiled test observes them.
# These defines are global to the merged host test binary. The api sources are
# compiled in it too (the api tests define USE_API), and USE_BLUETOOTH_PROXY would make
# them include and call bluetooth_proxy, which has no host build without a BLE hub.
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND")
cg.add_define("USE_BLUETOOTH_PROXY")
# Gates the connection half of the API surface, which is what
# close_service_batch and the GATT response types live behind.
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1)
manifest.to_code = to_code_testing
@@ -0,0 +1,12 @@
# Compile the gated filter path; no external component is in-tree to call
# enable_advertisement_filter(), so the define is forced here.
<<: !include common.yaml
esphome:
build_flags:
- "-DUSE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER"
esp32_ble_tracker:
bluetooth_proxy:
active: true
+71
View File
@@ -348,4 +348,75 @@ TEST(StepToAccuracyDecimals, NonFiniteAndZero) {
EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0);
}
// --- FixedVector::try_init() ---
// Keeps the block observable, else the compiler may drop the malloc and free pair and fold the check
static void escape(const void *p) { asm volatile("" : : "g"(p) : "memory"); }
TEST(FixedVectorTryInit, ReportsExhaustionAndStaysEmpty) {
FixedVector<uint32_t> v;
const bool ok = v.try_init(SIZE_MAX / sizeof(uint32_t));
escape(&v);
EXPECT_FALSE(ok);
EXPECT_EQ(v.capacity(), 0u);
EXPECT_FALSE(v.try_init(SIZE_MAX / sizeof(uint32_t) + 1)); // byte count would wrap
EXPECT_EQ(v.capacity(), 0u);
EXPECT_TRUE(v.try_init(0));
EXPECT_TRUE(v.try_init(4));
v.push_back(7);
EXPECT_EQ(v.size(), 1u);
}
// --- RAMAllocator::make_unique() ---
namespace {
struct Probe {
static inline int live = 0;
int a;
int b;
Probe(int a, int b) : a(a), b(b) { live++; }
~Probe() { live--; }
};
} // namespace
static_assert(sizeof(RAMUniquePtr<Probe>) == sizeof(Probe *), "the deleter must not add storage");
TEST(RAMAllocatorMakeUnique, ForwardsArgsAndDestroysOnce) {
auto p = RAMAllocator<Probe>().make_unique(3, 4);
ASSERT_NE(p, nullptr);
EXPECT_EQ(p->a, 3);
EXPECT_EQ(p->b, 4);
EXPECT_EQ(Probe::live, 1);
p.reset();
EXPECT_EQ(Probe::live, 0);
}
TEST(RAMAllocatorMakeUnique, ValueInitializesLikeMakeUnique) {
struct Plain {
uint32_t words[8];
};
// Dirty a block of the same size first so a recycled allocation is not zero by chance
auto dirty = RAMAllocator<uint8_t>().make_unique_array_for_overwrite(sizeof(Plain));
std::memset(dirty.get(), 0xFF, sizeof(Plain));
dirty.reset();
auto p = RAMAllocator<Plain>().make_unique();
ASSERT_NE(p, nullptr);
// Under ASan fresh blocks are filled with 0xbe, so this holds even when the dirtied block is not reused
EXPECT_TRUE(std::all_of(std::begin(p->words), std::end(p->words), [](uint32_t w) { return w == 0; }));
}
TEST(RAMAllocatorMakeUnique, ArrayFormRejectsOverflowAndZero) {
EXPECT_EQ(RAMAllocator<uint32_t>().make_unique_array_for_overwrite(SIZE_MAX / sizeof(uint32_t) + 1), nullptr);
EXPECT_EQ(RAMAllocator<uint32_t>().make_unique_array_for_overwrite(0), nullptr);
EXPECT_NE(RAMAllocator<uint32_t>().make_unique_array_for_overwrite(1), nullptr);
}
TEST(RAMAllocatorMakeUnique, ArrayFormAllocatesElements) {
RAMUniquePtr<uint8_t[]> buf = RAMAllocator<uint8_t>().make_unique_array_for_overwrite(256);
ASSERT_NE(buf, nullptr);
std::memset(buf.get(), 0xA5, 256);
EXPECT_EQ(buf[0], 0xA5);
EXPECT_EQ(buf[255], 0xA5);
}
} // namespace esphome::core::testing
@@ -52,7 +52,7 @@ TEST(RpcResponseBuilder, GoldenBytes) {
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0xCC}));
}
// esp32_improv calls finish() and build_rpc_response() with no checksum flag,
// improv_ble calls finish() and build_rpc_response() with no checksum flag,
// so the two defaults must agree
TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) {
const std::vector<std::string> urls = {"https://example.com"};
@@ -12,7 +12,7 @@ output:
pin: 2
id: built_in_led
esp32_improv:
improv_ble:
authorizer: io0_button
authorized_duration: 1min
status_indicator: built_in_led
@@ -5,6 +5,6 @@ wifi:
logger:
hardware_uart: UART0
# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_
# next_url compiles the USE_IMPROV_NEXT_URL branch and add_next_url_
improv_serial:
next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}}
+15 -1
View File
@@ -30,6 +30,18 @@ binary_sensor:
widget: button_button
state: pressed
globals:
- id: counter
type: int
script:
- id: add_row
then:
- lvgl.list.add:
id: test_list_id
label:
text: row
lvgl:
id: lvgl_id
rotation: 90
@@ -1291,7 +1303,7 @@ lvgl:
then:
- logger.log:
format: "table selected row %u col %u"
args: [row, column]
args: [(unsigned)row, (unsigned)column]
on_click:
then:
- lvgl.table.cell.update:
@@ -1347,10 +1359,12 @@ lvgl:
- logger.log:
format: "list entry added at %d"
args: [list_index]
- lambda: "id(counter)++;"
on_remove:
- logger.log:
format: "list entry removed at %d"
args: [list_index]
- lambda: "id(counter)--;"
on_click:
- lvgl.list.add_text:
id: test_list_id
@@ -792,6 +792,261 @@ TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) {
EXPECT_EQ(device.sent_count_, 0); // never transmitted
}
// allow_broadcast_read lifts the refusal for a device that answers address 0: the read is queued, sent,
// and waits for a reply like a unicast read, so a reply from address 0 completes it with on_response.
TEST(ModbusClientHubBroadcast, AllowBroadcastReadWaitsAndAcceptsReplyFromZero) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
EXPECT_TRUE(hub.queued(0).options.allow_broadcast_read);
EXPECT_FALSE(hub.queued(0).fire_and_forget());
hub.send_next_for_test();
EXPECT_EQ(device.sent_count_, 1);
EXPECT_TRUE(hub.waiting()); // not fire-and-forget: the reply is expected
EXPECT_EQ(hub.entries(), 1u);
const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02};
hub.receive_frame_for_test(BROADCAST_ADDRESS, reply);
EXPECT_EQ(device.response_count_, 1);
EXPECT_EQ(device.last_response_size_, sizeof(reply));
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
// The address-0 read waits like a unicast one, so the reply must come from address 0 too: a reply from
// another unit id is an unexpected frame and interrupts the transaction as it would for any address.
TEST(ModbusClientHubBroadcast, AllowBroadcastReadRejectsReplyFromOtherAddress) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
hub.send_next_for_test();
ASSERT_TRUE(hub.waiting());
const uint8_t reply[] = {0x03, 0x04, 0x00, 0x01, 0x00, 0x02};
hub.receive_frame_for_test(0x07, reply);
EXPECT_EQ(device.response_count_, 0);
EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED);
}
// An address-scoped clear must not turn a live address-0 entry back into a fire-and-forget broadcast: a
// retry granted after the clear is re-sent with the flag intact, so it still waits and gets its terminal.
TEST(ModbusClientHubBroadcast, AllowBroadcastReadSurvivesClearBeforeRetry) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
RetryingDevice device(&hub, BROADCAST_ADDRESS, true);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
hub.send_next_for_test();
ASSERT_TRUE(hub.waiting());
hub.clear_tx_queue_for_address(BROADCAST_ADDRESS);
EXPECT_EQ(hub.waiting_command().state, FrameState::WAITING_RETIRED);
EXPECT_TRUE(hub.waiting_command().options.allow_broadcast_read);
hub.timeout_waiting(); // retry granted: the entry is READY again
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_FALSE(hub.queued(0).fire_and_forget());
hub.send_next_for_test();
EXPECT_TRUE(hub.waiting()); // the retry still waits for its reply
EXPECT_EQ(hub.entries(), 1u);
}
// The function code check is unchanged by the relaxed address match: a mismatched reply still interrupts.
TEST(ModbusClientHubBroadcast, AllowBroadcastReadStillRejectsWrongFunctionCode) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
hub.send_next_for_test();
ASSERT_TRUE(hub.waiting());
const uint8_t wrong_reply[] = {0x04, 0x04, 0x00, 0x01, 0x00, 0x02};
hub.receive_frame_for_test(BROADCAST_ADDRESS, wrong_reply); // right address, wrong function code
EXPECT_EQ(device.response_count_, 0);
EXPECT_EQ(hub.waiting_command().state, FrameState::INTERRUPTED);
}
// A silent device leaves the read to the normal send-wait timeout, so on_no_response is delivered.
TEST(ModbusClientHubBroadcast, AllowBroadcastReadTimesOutLikeUnicast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
ASSERT_TRUE(device.queue_pdu(read, {.allow_broadcast_read = true}));
hub.send_next_for_test();
ASSERT_TRUE(hub.waiting());
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.response_count_, 0);
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
// allow_broadcast_read is stripped from a broadcastable code (a write or custom code to address 0 is a real broadcast,
// still fire-and-forget) and from a unicast frame (nothing to allow).
TEST(ModbusClientHubBroadcast, AllowBroadcastReadIgnoredForWritesAndUnicast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS);
BroadcastProbeDevice unicast_device(&hub, 0x01);
const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01};
ASSERT_TRUE(broadcast_device.queue_pdu(write, {.allow_broadcast_read = true}));
EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read);
EXPECT_TRUE(hub.queued(0).fire_and_forget());
hub.send_next_for_test();
EXPECT_EQ(broadcast_device.sent_count_, 1);
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
const uint8_t custom[] = {0x41, 0x01, 0x02};
ASSERT_TRUE(broadcast_device.queue_pdu(custom, {.allow_broadcast_read = true}));
EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read);
EXPECT_TRUE(hub.queued(0).fire_and_forget());
hub.send_next_for_test();
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
ASSERT_TRUE(unicast_device.queue_pdu(read, {.allow_broadcast_read = true}));
EXPECT_FALSE(hub.queued(0).options.allow_broadcast_read);
EXPECT_FALSE(hub.queued(0).fire_and_forget());
}
// expect_broadcast_write_response is the write-side twin: a write to address 0 waits for its reply instead
// of retiring at transmission, and the reply (from address 0) completes it.
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseWaitsAndAcceptsReply) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01};
ASSERT_TRUE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true}));
EXPECT_TRUE(hub.queued(0).options.expect_broadcast_write_response);
EXPECT_FALSE(hub.queued(0).fire_and_forget());
hub.send_next_for_test();
EXPECT_EQ(device.sent_count_, 1);
EXPECT_TRUE(hub.waiting());
EXPECT_EQ(hub.entries(), 1u);
hub.receive_frame_for_test(BROADCAST_ADDRESS, write); // the echo, as address 0
EXPECT_EQ(device.response_count_, 1);
EXPECT_EQ(device.last_response_size_, sizeof(write));
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
// Two requests for the same address-0 write may disagree on expect_broadcast_write_response (a
// broadcastable frame is accepted either way), but a write duplicate is refused at its cap of one in
// flight rather than absorbed, so the queued entry's delivery mode is never changed under it.
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseDuplicateRefusedNotMerged) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
ASSERT_TRUE(device.write_single_register(0x0010, 0x0001)); // fire-and-forget as queued
EXPECT_TRUE(hub.queued(0).fire_and_forget());
EXPECT_FALSE(device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true}));
EXPECT_EQ(hub.entries(), 1u);
EXPECT_TRUE(hub.queued(0).fire_and_forget()); // the refused request left the entry untouched
hub.send_next_for_test();
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
// A custom-code poll at address 0 is a fire-and-forget broadcast that a one-shot duplicate downgrades and
// is absorbed into; if that duplicate wants the reply, the entry waits for it instead of retiring at the
// send, so the absorbed request still gets its terminal callback.
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseMergesIntoDowngradedPoll) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
const uint8_t custom[] = {0x41, 0x01, 0x02};
ASSERT_TRUE(device.queue_pdu(custom, {.continuous = true}));
EXPECT_TRUE(hub.queued(0).fire_and_forget());
ASSERT_TRUE(device.queue_pdu(custom, {.expect_broadcast_write_response = true})); // downgrades, absorbed
EXPECT_EQ(hub.entries(), 1u);
EXPECT_FALSE(hub.queued(0).options.continuous);
EXPECT_FALSE(hub.queued(0).fire_and_forget());
hub.send_next_for_test();
EXPECT_TRUE(hub.waiting());
hub.receive_frame_for_test(BROADCAST_ADDRESS, custom);
EXPECT_EQ(device.response_count_, 1);
}
// A silent device leaves an expected write response to the normal send-wait timeout.
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseTimesOutLikeUnicast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
ASSERT_TRUE(device.write_single_coil(0x0010, true, {.expect_broadcast_write_response = true}));
hub.send_next_for_test();
ASSERT_TRUE(hub.waiting());
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.response_count_, 0);
EXPECT_FALSE(hub.waiting());
EXPECT_EQ(hub.entries(), 0u);
}
// expect_broadcast_write_response is stripped from a read (allow_broadcast_read is the read-side flag, so
// the broadcast guard still refuses it) and from a unicast frame (nothing to expect).
TEST(ModbusClientHubBroadcast, ExpectBroadcastWriteResponseIgnoredForReadsAndUnicast) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
BroadcastProbeDevice broadcast_device(&hub, BROADCAST_ADDRESS);
BroadcastProbeDevice unicast_device(&hub, 0x01);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02};
EXPECT_FALSE(broadcast_device.queue_pdu(read, {.expect_broadcast_write_response = true}));
EXPECT_EQ(hub.entries(), 0u);
ASSERT_TRUE(unicast_device.write_single_register(0x0010, 0x0001, {.expect_broadcast_write_response = true}));
EXPECT_FALSE(hub.queued(0).options.expect_broadcast_write_response);
EXPECT_FALSE(hub.queued(0).fire_and_forget());
}
// The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the
// hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write.
TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) {
+3 -1
View File
@@ -79,7 +79,8 @@ button:
name: "Typed Actions"
on_press:
- modbus_client.write_single_register:
address: 0x01
address: !lambda "return 1;"
expect_broadcast_write_response: true
start_address: 0x0102
value: !lambda "return 42;"
on_response:
@@ -93,6 +94,7 @@ button:
start_address: 0x10
count: 2
continuous: true
allow_broadcast_read: !lambda "return false;"
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
@@ -0,0 +1,36 @@
# Config-only: actions that address the broadcast address (0) and wait for a reply, for a device that
# answers it. Never compiled, so the extra action objects do not inflate the memory-impact baseline.
packages:
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
button:
- platform: template
name: Broadcast probe
on_press:
- modbus_client.read_holding_registers:
address: 0
allow_broadcast_read: true
start_address: 0x10
count: 1
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "broadcast read first=%u", values[0]);'
- modbus_client.write_single_register:
address: 0
expect_broadcast_write_response: true
start_address: 0x0102
value: 42
on_response:
then:
- logger.log: "broadcast write acked"
- modbus_client.read_write_multiple_registers:
address: 0
allow_broadcast_read: true
read_address: 0x10
read_count: 1
write_address: 0x20
values: [1]
- modbus_client.send:
address: 0
expect_broadcast_write_response: true
pdu: [0x41, 0x01]
@@ -6,7 +6,6 @@ modbus_controller:
on_online:
then:
logger.log: "Module Online"
binary_sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller1
@@ -0,0 +1,29 @@
# Config-only: a controller polling the broadcast address (0), for a device that answers it, with a
# writer entity expecting the reply to its broadcast writes. Never compiled, so the extra entities do
# not inflate the memory-impact baseline.
packages:
modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml
modbus_controller:
- id: modbus_controller_broadcast
address: 0
allow_broadcast_read: true
modbus_id: modbus_bus
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_broadcast
id: modbus_broadcast_sensor
name: Broadcast Read Sensor
register_type: holding
address: 0x0010
value_type: U_WORD
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_broadcast
id: modbus_broadcast_switch
name: Broadcast Write Switch
register_type: coil
address: 0x20
expect_broadcast_write_response: true
@@ -1,6 +1,6 @@
# Exercises the provisioning window: api registers as a provisioning source
# (encryption enabled, no key), the on_timeout automation, and the wifi (AP +
# captive portal) and esp32_improv cross-component guards. improv_serial is
# captive portal) and improv_ble cross-component guards. improv_serial is
# intentionally NOT gated.
provisioning:
timeout: 1min
@@ -26,5 +26,5 @@ binary_sensor:
pin: 0
id: io0_button
esp32_improv:
improv_ble:
authorizer: io0_button
+9
View File
@@ -6,6 +6,15 @@ tinyusb:
usb_product_str: ESPHomeTestProduct
usb_serial_str: ESPHomeTestSerialNumber
usb_vendor_id: 0x2345
on_mount:
- logger.log: USB host mounted
- if:
condition:
tinyusb.is_mounted:
then:
- logger.log: USB host is mounted
on_unmount:
- logger.log: USB host unmounted
# tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that.
usb_cdc_acm:
@@ -1 +1,7 @@
<<: !include common.yaml
packages:
tinyusb: !include common.yaml
# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the
# S31 would need the GPIO ISR path and rejects the key.
tinyusb:
vbus_monitor_pin: 4
@@ -1,4 +1,10 @@
<<: !include common.yaml
packages:
tinyusb: !include common.yaml
# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the
# S31 would need the GPIO ISR path and rejects the key.
tinyusb:
vbus_monitor_pin: 4
# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared
# USB OTG peripheral; route the logger to UART0 so the fixture builds.
@@ -1 +1,7 @@
<<: !include common.yaml
packages:
tinyusb: !include common.yaml
# VBUS monitoring is per variant: the OTG hardware watches the pin here, while the
# S31 would need the GPIO ISR path and rejects the key.
tinyusb:
vbus_monitor_pin: 4
+44
View File
@@ -0,0 +1,44 @@
tinyusb:
id: tinyusb_test
on_mount:
- uart_mux.select_bridge: mux_0
on_unmount:
- uart_mux.select_local: mux_0
usb_manufacturer_str: ESPHomeTestManufacturer
usb_product_id: 0x1234
usb_product_str: ESPHomeTestProduct
usb_vendor_id: 0x2345
uart:
- id: uart_0
tx_pin: 14
rx_pin: 13
baud_rate: 115200
usb_cdc_acm:
interfaces:
- id: cdc_acm_1
bridge:
- platform: cdc_acm_uart
id: bridge_0
uart_id: uart_0
usb_cdc_acm_id: cdc_acm_1
uart_mux:
- id: mux_0
bridge_id: bridge_0
initial_route: local
interval:
- interval: 60s
then:
- if:
condition:
uart_mux.is_local: mux_0
then:
- lambda: |-
uint8_t byte;
if (id(mux_0).available() && id(mux_0).read_byte(&byte)) {
id(mux_0).write_byte(byte);
}
@@ -0,0 +1,2 @@
packages:
uart_mux: !include common.yaml
@@ -0,0 +1,7 @@
# ESP32-S2 has no USB_SERIAL_JTAG, so the logger defaults to USB_CDC, which shares
# the USB OTG peripheral with tinyusb. Use a hardware UART for logging instead.
logger:
hardware_uart: UART0
packages:
uart_mux: !include common.yaml
@@ -0,0 +1,2 @@
packages:
uart_mux: !include common.yaml
+1
View File
@@ -6,6 +6,7 @@ usb_uart:
type: cdc_acm
vid: 0x1234
pid: 0x5678
claim_comm_interface: false
channels:
- id: channel_0_1
- id: uart_1
@@ -0,0 +1,16 @@
esphome:
name: wh-template-unknown-test
host:
api:
logger:
water_heater:
- platform: template
id: unknown_boiler
name: Unknown Boiler
# Both temperatures stay unknown, as they do before an upstream component reports a value.
current_temperature: !lambda "return NAN;"
target_temperature: !lambda "return NAN;"
supported_modes:
- "off"
- eco
+153 -152
View File
@@ -1,154 +1,155 @@
{
"tests/integration/test_action_concurrent_reentry.py": 30.48,
"tests/integration/test_addressable_light_transition.py": 42.1,
"tests/integration/test_alarm_control_panel_state_transitions.py": 35.76,
"tests/integration/test_api_action_metadata.py": 22.35,
"tests/integration/test_api_action_responses.py": 30.31,
"tests/integration/test_api_action_timeout.py": 34.73,
"tests/integration/test_api_conditional_memory.py": 18.35,
"tests/integration/test_api_custom_services.py": 15.99,
"tests/integration/test_api_get_time_response_timezone.py": 24.21,
"tests/integration/test_api_homeassistant.py": 33.77,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 20.8,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 23.55,
"tests/integration/test_api_list_entities_backpressure.py": 23.04,
"tests/integration/test_api_message_size_batching.py": 27.31,
"tests/integration/test_api_reboot_timeout.py": 29.32,
"tests/integration/test_api_string_lambda.py": 14.9,
"tests/integration/test_api_vv_logging.py": 26.25,
"tests/integration/test_api_zero_psk_provisioning.py": 38.19,
"tests/integration/test_areas_and_devices.py": 27.52,
"tests/integration/test_automation_wait_actions.py": 24.25,
"tests/integration/test_automations.py": 36.02,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 18.46,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 17.47,
"tests/integration/test_binary_sensor_invalidate_state.py": 14.79,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 21.52,
"tests/integration/test_build_info.py": 21.42,
"tests/integration/test_camera_mock.py": 17.02,
"tests/integration/test_climate_control_action.py": 26.56,
"tests/integration/test_climate_custom_modes.py": 18.82,
"tests/integration/test_continuation_actions.py": 20.39,
"tests/integration/test_cover_control_action.py": 19.91,
"tests/integration/test_crc8_helper.py": 16.73,
"tests/integration/test_device_id_in_state.py": 58.41,
"tests/integration/test_duplicate_entities.py": 30.76,
"tests/integration/test_entity_icon.py": 25.34,
"tests/integration/test_fan_turn_on_action.py": 23.64,
"tests/integration/test_fnv1_hash_object_id.py": 25.44,
"tests/integration/test_fnv1a_hash.py": 20.85,
"tests/integration/test_gpio_expander_cache.py": 14.42,
"tests/integration/test_host_logger_thread_safety.py": 21.31,
"tests/integration/test_host_mode_basic.py": 2.65,
"tests/integration/test_host_mode_batch_delay.py": 22.21,
"tests/integration/test_host_mode_climate_basic_state.py": 27.12,
"tests/integration/test_host_mode_climate_control.py": 21.57,
"tests/integration/test_host_mode_empty_string_options.py": 27.17,
"tests/integration/test_host_mode_entity_fields.py": 30.1,
"tests/integration/test_host_mode_fan_preset.py": 17.55,
"tests/integration/test_host_mode_many_entities.py": 38.98,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.82,
"tests/integration/test_host_mode_noise_encryption.py": 39.84,
"tests/integration/test_host_mode_reconnect.py": 13.1,
"tests/integration/test_host_mode_sensor.py": 22.17,
"tests/integration/test_host_ota.py": 92.05,
"tests/integration/test_host_preferences.py": 20.29,
"tests/integration/test_host_preferences_suspend_resume.py": 15.02,
"tests/integration/test_improv_serial_uart.py": 30.15,
"tests/integration/test_large_message_batching.py": 25.84,
"tests/integration/test_legacy_area.py": 21.24,
"tests/integration/test_legacy_climate_compat.py": 17.34,
"tests/integration/test_legacy_fan_compat.py": 22.6,
"tests/integration/test_light_automations.py": 29.13,
"tests/integration/test_light_binary_effect_off_phase.py": 33.99,
"tests/integration/test_light_calls.py": 26.81,
"tests/integration/test_light_constant_brightness.py": 25.0,
"tests/integration/test_light_control_action.py": 25.57,
"tests/integration/test_light_dim_relative_action.py": 21.4,
"tests/integration/test_light_effect_zero_brightness.py": 19.65,
"tests/integration/test_light_initial_state.py": 17.58,
"tests/integration/test_light_toggle_action.py": 28.28,
"tests/integration/test_lock_automations.py": 23.3,
"tests/integration/test_logger_buffered_recursion_guard.py": 22.96,
"tests/integration/test_loop_disable_enable.py": 16.18,
"tests/integration/test_loop_interval_decoupling.py": 25.19,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 20.59,
"tests/integration/test_lvgl_headless_render.py": 87.78,
"tests/integration/test_micros_to_millis.py": 18.73,
"tests/integration/test_multi_click_trigger.py": 24.2,
"tests/integration/test_multi_device_preferences.py": 20.52,
"tests/integration/test_noise_encryption_key_protection.py": 19.1,
"tests/integration/test_object_id_api_verification.py": 26.24,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 14.88,
"tests/integration/test_object_id_no_friendly_name.py": 61.27,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 82.32,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 46.03,
"tests/integration/test_online_image_bmp.py": 34.21,
"tests/integration/test_oversized_payloads.py": 62.75,
"tests/integration/test_preference_key_stability.py": 26.8,
"tests/integration/test_runtime_stats.py": 28.26,
"tests/integration/test_safe_mode_loop_runs.py": 18.14,
"tests/integration/test_scheduler_blocking_warning.py": 28.7,
"tests/integration/test_scheduler_bulk_cleanup.py": 20.73,
"tests/integration/test_scheduler_defer_cancel.py": 22.99,
"tests/integration/test_scheduler_defer_cancel_regular.py": 21.97,
"tests/integration/test_scheduler_defer_fifo_simple.py": 24.15,
"tests/integration/test_scheduler_defer_stress.py": 23.11,
"tests/integration/test_scheduler_heap_stress.py": 20.2,
"tests/integration/test_scheduler_internal_id_no_collision.py": 23.75,
"tests/integration/test_scheduler_interval_reschedule.py": 15.32,
"tests/integration/test_scheduler_interval_zero_coerced.py": 20.1,
"tests/integration/test_scheduler_null_name.py": 17.43,
"tests/integration/test_scheduler_numeric_id_test.py": 25.51,
"tests/integration/test_scheduler_pool.py": 24.22,
"tests/integration/test_scheduler_rapid_cancellation.py": 24.01,
"tests/integration/test_scheduler_recursive_timeout.py": 22.94,
"tests/integration/test_scheduler_removed_item_race.py": 23.07,
"tests/integration/test_scheduler_self_keyed.py": 18.43,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 21.99,
"tests/integration/test_scheduler_string_test.py": 17.27,
"tests/integration/test_script_array_params.py": 4.59,
"tests/integration/test_script_delay_params.py": 22.46,
"tests/integration/test_script_queued.py": 25.24,
"tests/integration/test_script_queued_idle_loop.py": 5.04,
"tests/integration/test_script_wait_on_boot.py": 21.77,
"tests/integration/test_sdl_headless_screenshot.py": 19.23,
"tests/integration/test_select_stringref_trigger.py": 19.31,
"tests/integration/test_sensor_filters_delta.py": 25.92,
"tests/integration/test_sensor_filters_ring_buffer.py": 22.39,
"tests/integration/test_sensor_filters_sliding_window.py": 57.93,
"tests/integration/test_sensor_filters_value_list.py": 20.32,
"tests/integration/test_sensor_timeout_filter.py": 25.35,
"tests/integration/test_snapshot_display.py": 19.7,
"tests/integration/test_socket_wake_gate_tcp.py": 14.5,
"tests/integration/test_status_flags.py": 33.83,
"tests/integration/test_strftime_to.py": 17.64,
"tests/integration/test_syslog.py": 24.49,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.81,
"tests/integration/test_template_climate_basic.py": 15.28,
"tests/integration/test_template_climate_custom_modes.py": 25.07,
"tests/integration/test_template_climate_nonoptimistic.py": 24.25,
"tests/integration/test_template_climate_on_control_ordering.py": 24.09,
"tests/integration/test_template_climate_publish_all_fields.py": 17.78,
"tests/integration/test_template_climate_sensor_push.py": 17.42,
"tests/integration/test_template_climate_set_actions.py": 23.63,
"tests/integration/test_template_climate_two_point_temperature.py": 25.19,
"tests/integration/test_template_text_save.py": 17.88,
"tests/integration/test_text_command.py": 22.71,
"tests/integration/test_text_sensor_raw_state.py": 25.17,
"tests/integration/test_uart_mock_ld2410.py": 58.15,
"tests/integration/test_uart_mock_ld2412.py": 61.14,
"tests/integration/test_uart_mock_ld2420.py": 33.87,
"tests/integration/test_uart_mock_ld2450.py": 26.06,
"tests/integration/test_uart_mock_modbus.py": 391.79,
"tests/integration/test_udp.py": 7.38,
"tests/integration/test_use_address_runtime.py": 24.09,
"tests/integration/test_valve_control_action.py": 23.22,
"tests/integration/test_varint_five_byte_device_id.py": 17.93,
"tests/integration/test_wait_until_mid_loop_timing.py": 22.26,
"tests/integration/test_wait_until_on_boot.py": 17.46,
"tests/integration/test_wait_until_ordering.py": 11.89,
"tests/integration/test_wait_until_reentrant_restart.py": 22.88,
"tests/integration/test_wake_loop_forces_phase_b.py": 16.6,
"tests/integration/test_water_heater_template.py": 19.66
"tests/integration/test_action_concurrent_reentry.py": 34.72,
"tests/integration/test_addressable_light_transition.py": 33.71,
"tests/integration/test_alarm_control_panel_state_transitions.py": 38.96,
"tests/integration/test_api_action_metadata.py": 33.36,
"tests/integration/test_api_action_responses.py": 26.22,
"tests/integration/test_api_action_timeout.py": 25.41,
"tests/integration/test_api_conditional_memory.py": 20.74,
"tests/integration/test_api_custom_services.py": 23.21,
"tests/integration/test_api_get_time_response_timezone.py": 25.04,
"tests/integration/test_api_homeassistant.py": 24.18,
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 23.47,
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 22.86,
"tests/integration/test_api_list_entities_backpressure.py": 18.8,
"tests/integration/test_api_message_size_batching.py": 28.8,
"tests/integration/test_api_reboot_timeout.py": 9.47,
"tests/integration/test_api_string_lambda.py": 16.88,
"tests/integration/test_api_vv_logging.py": 17.99,
"tests/integration/test_api_zero_psk_provisioning.py": 47.07,
"tests/integration/test_areas_and_devices.py": 20.77,
"tests/integration/test_automation_wait_actions.py": 20.07,
"tests/integration/test_automations.py": 27.41,
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 20.5,
"tests/integration/test_binary_sensor_autorepeat_filter.py": 26.54,
"tests/integration/test_binary_sensor_invalidate_state.py": 16.09,
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.7,
"tests/integration/test_build_info.py": 18.07,
"tests/integration/test_camera_mock.py": 20.44,
"tests/integration/test_climate_control_action.py": 27.97,
"tests/integration/test_climate_custom_modes.py": 26.77,
"tests/integration/test_continuation_actions.py": 12.09,
"tests/integration/test_cover_control_action.py": 19.77,
"tests/integration/test_crc8_helper.py": 12.64,
"tests/integration/test_device_id_in_state.py": 63.19,
"tests/integration/test_duplicate_entities.py": 29.26,
"tests/integration/test_entity_icon.py": 34.95,
"tests/integration/test_fan_turn_on_action.py": 25.98,
"tests/integration/test_fnv1_hash_object_id.py": 4.85,
"tests/integration/test_fnv1a_hash.py": 5.14,
"tests/integration/test_gpio_expander_cache.py": 21.0,
"tests/integration/test_host_logger_thread_safety.py": 17.51,
"tests/integration/test_host_mode_basic.py": 21.2,
"tests/integration/test_host_mode_batch_delay.py": 26.68,
"tests/integration/test_host_mode_climate_basic_state.py": 18.04,
"tests/integration/test_host_mode_climate_control.py": 29.64,
"tests/integration/test_host_mode_empty_string_options.py": 28.8,
"tests/integration/test_host_mode_entity_fields.py": 28.68,
"tests/integration/test_host_mode_fan_preset.py": 16.98,
"tests/integration/test_host_mode_many_entities.py": 39.8,
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 33.12,
"tests/integration/test_host_mode_noise_encryption.py": 52.53,
"tests/integration/test_host_mode_reconnect.py": 14.48,
"tests/integration/test_host_mode_sensor.py": 17.38,
"tests/integration/test_host_ota.py": 94.96,
"tests/integration/test_host_preferences.py": 27.11,
"tests/integration/test_host_preferences_suspend_resume.py": 21.48,
"tests/integration/test_improv_serial_uart.py": 19.43,
"tests/integration/test_large_message_batching.py": 25.67,
"tests/integration/test_legacy_area.py": 15.59,
"tests/integration/test_legacy_climate_compat.py": 18.59,
"tests/integration/test_legacy_fan_compat.py": 18.34,
"tests/integration/test_light_automations.py": 16.74,
"tests/integration/test_light_binary_effect_off_phase.py": 57.7,
"tests/integration/test_light_calls.py": 25.88,
"tests/integration/test_light_constant_brightness.py": 22.32,
"tests/integration/test_light_control_action.py": 18.15,
"tests/integration/test_light_dim_relative_action.py": 30.35,
"tests/integration/test_light_effect_zero_brightness.py": 18.38,
"tests/integration/test_light_initial_state.py": 23.93,
"tests/integration/test_light_toggle_action.py": 26.16,
"tests/integration/test_lock_automations.py": 34.13,
"tests/integration/test_logger_buffered_recursion_guard.py": 25.77,
"tests/integration/test_loop_disable_enable.py": 14.71,
"tests/integration/test_loop_interval_decoupling.py": 26.16,
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 28.47,
"tests/integration/test_lvgl_headless_render.py": 96.36,
"tests/integration/test_micros_to_millis.py": 28.76,
"tests/integration/test_multi_click_trigger.py": 19.8,
"tests/integration/test_multi_device_preferences.py": 38.85,
"tests/integration/test_noise_encryption_key_protection.py": 25.81,
"tests/integration/test_object_id_api_verification.py": 28.46,
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.14,
"tests/integration/test_object_id_no_friendly_name.py": 18.82,
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 30.16,
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.67,
"tests/integration/test_online_image_bmp.py": 7.41,
"tests/integration/test_oversized_payloads.py": 59.52,
"tests/integration/test_preference_key_stability.py": 27.24,
"tests/integration/test_runtime_stats.py": 20.53,
"tests/integration/test_safe_mode_loop_runs.py": 10.17,
"tests/integration/test_scheduler_blocking_warning.py": 51.45,
"tests/integration/test_scheduler_bulk_cleanup.py": 22.59,
"tests/integration/test_scheduler_defer_cancel.py": 17.64,
"tests/integration/test_scheduler_defer_cancel_regular.py": 21.61,
"tests/integration/test_scheduler_defer_fifo_simple.py": 24.73,
"tests/integration/test_scheduler_defer_stress.py": 23.91,
"tests/integration/test_scheduler_heap_stress.py": 25.77,
"tests/integration/test_scheduler_internal_id_no_collision.py": 19.83,
"tests/integration/test_scheduler_interval_reschedule.py": 23.4,
"tests/integration/test_scheduler_interval_zero_coerced.py": 5.11,
"tests/integration/test_scheduler_null_name.py": 17.36,
"tests/integration/test_scheduler_numeric_id_test.py": 20.81,
"tests/integration/test_scheduler_pool.py": 17.42,
"tests/integration/test_scheduler_rapid_cancellation.py": 25.28,
"tests/integration/test_scheduler_recursive_timeout.py": 16.48,
"tests/integration/test_scheduler_removed_item_race.py": 16.14,
"tests/integration/test_scheduler_self_keyed.py": 26.19,
"tests/integration/test_scheduler_simultaneous_callbacks.py": 23.26,
"tests/integration/test_scheduler_string_test.py": 27.09,
"tests/integration/test_script_array_params.py": 3.6,
"tests/integration/test_script_delay_params.py": 24.74,
"tests/integration/test_script_queued.py": 17.21,
"tests/integration/test_script_queued_idle_loop.py": 3.4,
"tests/integration/test_script_wait_on_boot.py": 23.7,
"tests/integration/test_sdl_headless_screenshot.py": 19.53,
"tests/integration/test_select_stringref_trigger.py": 18.93,
"tests/integration/test_sensor_filters_delta.py": 20.85,
"tests/integration/test_sensor_filters_ring_buffer.py": 16.82,
"tests/integration/test_sensor_filters_sliding_window.py": 54.78,
"tests/integration/test_sensor_filters_value_list.py": 19.46,
"tests/integration/test_sensor_timeout_filter.py": 18.39,
"tests/integration/test_set_internal_at_boot.py": 21.69,
"tests/integration/test_snapshot_display.py": 12.64,
"tests/integration/test_socket_wake_gate_tcp.py": 13.08,
"tests/integration/test_status_flags.py": 29.54,
"tests/integration/test_strftime_to.py": 25.62,
"tests/integration/test_syslog.py": 16.25,
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 27.18,
"tests/integration/test_template_climate_basic.py": 20.51,
"tests/integration/test_template_climate_custom_modes.py": 27.73,
"tests/integration/test_template_climate_nonoptimistic.py": 26.35,
"tests/integration/test_template_climate_on_control_ordering.py": 26.55,
"tests/integration/test_template_climate_publish_all_fields.py": 17.59,
"tests/integration/test_template_climate_sensor_push.py": 22.04,
"tests/integration/test_template_climate_set_actions.py": 16.82,
"tests/integration/test_template_climate_two_point_temperature.py": 25.13,
"tests/integration/test_template_text_save.py": 25.36,
"tests/integration/test_text_command.py": 18.79,
"tests/integration/test_text_sensor_raw_state.py": 17.07,
"tests/integration/test_uart_mock_ld2410.py": 59.58,
"tests/integration/test_uart_mock_ld2412.py": 59.4,
"tests/integration/test_uart_mock_ld2420.py": 45.27,
"tests/integration/test_uart_mock_ld2450.py": 27.96,
"tests/integration/test_uart_mock_modbus.py": 562.45,
"tests/integration/test_udp.py": 7.48,
"tests/integration/test_use_address_runtime.py": 17.27,
"tests/integration/test_valve_control_action.py": 18.33,
"tests/integration/test_varint_five_byte_device_id.py": 17.59,
"tests/integration/test_wait_until_mid_loop_timing.py": 16.93,
"tests/integration/test_wait_until_on_boot.py": 19.96,
"tests/integration/test_wait_until_ordering.py": 16.19,
"tests/integration/test_wait_until_reentrant_restart.py": 23.67,
"tests/integration/test_wake_loop_forces_phase_b.py": 17.58,
"tests/integration/test_water_heater_template.py": 21.96
}
@@ -155,3 +155,36 @@ async def test_water_heater_template(
client.water_heater_command(test_water_heater.key, mode=WaterHeaterMode.ECO)
eco_state = await wait_for_state()
assert eco_state.mode == WaterHeaterMode.ECO
@pytest.mark.asyncio
async def test_water_heater_template_unknown_temperature(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test a template water heater whose temperature lambdas stay unknown.
NAN never compares equal to itself, so a lambda that keeps returning NAN must not be
mistaken for a changed value and republish the state on every loop iteration.
"""
async with run_compiled(yaml_config), api_client_connected() as client:
state_count = 0
def on_state(state: aioesphomeapi.EntityState) -> None:
nonlocal state_count
if isinstance(state, WaterHeaterState):
state_count += 1
entities, _ = await client.list_entities_services()
water_heater_infos = [e for e in entities if isinstance(e, WaterHeaterInfo)]
assert len(water_heater_infos) == 1
client.subscribe_states(on_state)
# Let the device run for a while; only the single initial state may arrive.
await asyncio.sleep(1.0)
assert state_count <= 1, (
f"Expected at most 1 state publish, got {state_count} - "
"an unknown (NAN) temperature is republishing every loop"
)
+17 -17
View File
@@ -35,8 +35,8 @@ def _load_script():
def test_spec_key_collapses_destinations() -> None:
"""Two specs delivering one package share a directory and one key."""
mod = _load_script()
assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c"
assert mod.spec_key("esphome/noise-c @ 1.0") == "noise-c"
assert mod.spec_key("esphome/noise-c@1.0") == "noise-c"
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
"esp32async/asynctcp @ 3.5.0"
)
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
"[env:a]\n"
"platform = fake/platform@1\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.26\n"
" esphome/noise-c @ 1.0\n"
" ${common.lib_deps}\n"
" internal_lib\n"
"[env:b]\n"
"lib_deps =\n"
" esphome/noise-c @ 0.1.26\n"
" esphome/noise-c @ 1.0\n"
)
mod = _load_script()
args = Namespace(libraries=True, platforms=True, tools=False)
libs, platforms, tools = mod.parse_specs(str(ini), args)
# exact-string duplicates collapse; distinct version pins survive
assert libs == ["esphome/noise-c @ 0.1.26"]
assert libs == ["esphome/noise-c @ 1.0"]
assert platforms == ["fake/platform@1"]
assert tools == []
assert mod.build_cli_args(libs, platforms, tools) == [
"-l",
"esphome/noise-c @ 0.1.26",
"esphome/noise-c @ 1.0",
"-p",
"fake/platform@1",
]
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
mod.parallel_install(
cls,
[
"esphome/noise-c @ 0.1.26",
"esphome/noise-c @ 0.1.26",
"esphome/noise-c @ 1.0",
"esphome/noise-c @ 1.0",
"esphome/already @ 1.0",
"https://x/framework.tar.xz",
],
)
assert cls.calls == ["esphome/noise-c @ 0.1.26"]
assert cls.calls == ["esphome/noise-c @ 1.0"]
assert cls.lock_events == ["lock", "unlock"]
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.26": [
"esphome/noise-c @ 1.0": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"name": "SPI"},
],
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"])
mod.parallel_install(cls, ["esphome/noise-c @ 1.0", "esphome/wg @ 1.0"])
assert len(cls.calls) == 3 # the shared dep installs exactly once
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
# Wave-1 strings carry no compatibility; the dependency wave does
compats = dict(cls.compat_calls)
assert compats["esphome/noise-c @ 0.1.26"] is None
assert compats["esphome/noise-c @ 1.0"] is None
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
assert dep_compat is not None # mirrors pio's install_dependency
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
mod = _load_script()
cls = _reset_fake(str(tmp_path))
cls.deps = {
"esphome/noise-c @ 0.1.26": [
"esphome/noise-c @ 1.0": [
{"name": "vendored", "version": "https://github.com/x/y.git"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
mod.parallel_install(cls, ["esphome/noise-c @ 1.0"])
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
"""Already-installed top-level packages still feed the dependency
wave; a warm store can be missing a transitive dep."""
mod = _load_script()
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"})
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 1.0"})
cls.deps = {
"esphome/noise-c @ 0.1.26": [
"esphome/noise-c @ 1.0": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
],
}
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"])
mod.parallel_install(cls, ["esphome/noise-c @ 1.0"])
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
+42 -1
View File
@@ -5,8 +5,13 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from esphome import yaml_util
from esphome.components.file import image as file_image
from esphome.external_files import RemoteFile
from esphome.const import CONF_PATH
from esphome.core import CORE
from esphome.external_files import RemoteFile, url_cache_key
from esphome.loader import get_component, get_platform
@@ -55,6 +60,42 @@ def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None:
assert files[1].url == "https://example.com/img.png"
def test_validated_file_values_hash_alike_across_data_dirs(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A CLI and an add-on data dir dump validated image files identically."""
url = "https://example.com/img.png"
(setup_core / "img.png").touch()
dumps: list[str] = []
for data_dir in (
setup_core / ".esphome",
setup_core.parent / f"{setup_core.name}-data",
):
monkeypatch.setenv("ESPHOME_DATA_DIR", str(data_dir))
with patch("esphome.components.file.image.external_files.download_content"):
config = {
"remote": file_image.validate_file_shorthand(url),
"mdi": file_image.validate_file_shorthand("mdi:home"),
"local": file_image.validate_file_shorthand("img.png"),
"local_schema": file_image.LOCAL_SCHEMA({CONF_PATH: "img.png"}),
}
dumps.append(
yaml_util.dump(
config,
sort_keys=True,
relative_to=CORE.config_dir,
data_dir=CORE.data_dir,
)
)
assert dumps[0] == dumps[1]
assert dumps[0].splitlines() == [
"local: img.png",
"local_schema: img.png",
"mdi: .esphome/image/mdi/home.svg",
f"remote: .esphome/image/{url_cache_key(url)}",
]
def test_extractor_matches_validator_path(setup_core: Path) -> None:
"""The path the validator downloads to equals the extractor's path."""
with patch(
+21
View File
@@ -175,6 +175,27 @@ async def test_core_area_recorded_at_config_load(
assert CORE.area == expected_area
@pytest.mark.asyncio
async def test_app_is_default_initialized(
yaml_file: Callable[[str], Path],
) -> None:
"""App is constructed with `new (&App) Application`, no parentheses.
`Application()` would value-initialize and memset the whole object into
storage that is already zero."""
result = load_config_from_fixture(yaml_file, "valid_area_device.yaml", FIXTURES_DIR)
assert result is not None
with patch("esphome.core.config.cg") as mock_cg:
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock()
await config.to_code(result[CONF_ESPHOME])
raw_expressions = [c.args[0] for c in mock_cg.RawExpression.call_args_list]
assert "new (&App) Application" in raw_expressions
assert "new (&App) Application()" not in raw_expressions
def test_config_load_without_area_clears_stale_core_area(
yaml_file: Callable[[str], Path],
) -> None:
+122 -3
View File
@@ -1,7 +1,9 @@
"""Tests for esphome.automation module."""
from collections.abc import Generator
from unittest.mock import AsyncMock, call, patch
from collections.abc import Callable, Generator
from functools import partial
from typing import NamedTuple
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
@@ -12,9 +14,18 @@ from esphome.automation import (
TriggerOnTrueForwarder,
build_callback_automations,
has_non_synchronous_actions,
register_bare_action,
register_bare_condition,
register_parented_action,
register_parented_condition,
register_simple_action,
register_simple_condition,
)
import esphome.codegen as cg
from esphome.const import CONF_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj, RawExpression
from esphome.util import RegistryEntry
from esphome.util import Registry, RegistryEntry
def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]:
@@ -475,3 +486,111 @@ async def test_build_callback_automations_defaults(
mock_build_callback.assert_called_once_with(
parent, "add_on_press_callback", [], conf, forwarder=None
)
PARENT_ID = ID("my_component")
PARENT_OBJ = MockObj("parent", "->")
NEW_OBJ = MockObj("var", "->")
ACTION_TYPE = cg.esphome_ns.class_("MyAction")
CONDITION_TYPE = cg.esphome_ns.class_("MyCondition")
TEMPLATE_ARG = cg.TemplateArguments()
class MockCodegen(NamedTuple):
get_variable: AsyncMock
new_pvariable: MagicMock
register_parented: AsyncMock
@pytest.fixture
def mock_cg() -> Generator[MockCodegen]:
"""Patch the codegen calls the shared builders make."""
with (
patch("esphome.codegen.get_variable", new_callable=AsyncMock) as get_variable,
patch("esphome.codegen.new_Pvariable") as new_pvariable,
patch(
"esphome.codegen.register_parented", new_callable=AsyncMock
) as register_parented,
):
get_variable.return_value = PARENT_OBJ
new_pvariable.return_value = NEW_OBJ
yield MockCodegen(get_variable, new_pvariable, register_parented)
@pytest.fixture
def registries() -> Generator[tuple[Registry, Registry]]:
"""Patch both registries so registrations made by a test do not leak."""
actions = Registry()
conditions = Registry()
with (
patch("esphome.automation.ACTION_REGISTRY", actions),
patch("esphome.automation.CONDITION_REGISTRY", conditions),
):
yield actions, conditions
@pytest.mark.asyncio
@pytest.mark.parametrize(
("register", "is_action", "ctor_parent", "parented"),
[
(partial(register_simple_action, synchronous=True), True, True, False),
(partial(register_bare_action, synchronous=True), True, False, False),
(partial(register_parented_action, synchronous=True), True, False, True),
(register_simple_condition, False, True, False),
(register_bare_condition, False, False, False),
(register_parented_condition, False, False, True),
],
ids=[
"simple_action",
"bare_action",
"parented_action",
"simple_condition",
"bare_condition",
"parented_condition",
],
)
async def test_shared_builders(
registries: tuple[Registry, Registry],
mock_cg: MockCodegen,
register: Callable[..., None],
is_action: bool,
ctor_parent: bool,
parented: bool,
) -> None:
"""Each helper constructs the object and wires the parent the way its C++ shape needs."""
actions, conditions = registries
type_id = ACTION_TYPE if is_action else CONDITION_TYPE
register("my.entry", type_id, {})
entry = (actions if is_action else conditions)["my.entry"]
assert entry.type_id is type_id
config = {CONF_ID: PARENT_ID} if ctor_parent or parented else {}
result = await entry.fun(config, ID("obj_1"), TEMPLATE_ARG, [])
assert result is NEW_OBJ
if ctor_parent:
mock_cg.get_variable.assert_awaited_once_with(PARENT_ID)
mock_cg.new_pvariable.assert_called_once_with(
ID("obj_1"), TEMPLATE_ARG, PARENT_OBJ
)
else:
mock_cg.get_variable.assert_not_called()
mock_cg.new_pvariable.assert_called_once_with(ID("obj_1"), TEMPLATE_ARG)
if parented:
mock_cg.register_parented.assert_awaited_once_with(NEW_OBJ, PARENT_ID)
else:
mock_cg.register_parented.assert_not_called()
@pytest.mark.parametrize("synchronous", [True, False])
def test_shared_builders_keep_synchronous_flag(
registries: tuple[Registry, Registry], synchronous: bool
) -> None:
"""The synchronous flag reaches the registry entry unchanged."""
actions, _ = registries
register_simple_action("my.simple", ACTION_TYPE, {}, synchronous=synchronous)
register_bare_action("my.bare", ACTION_TYPE, {}, synchronous=synchronous)
register_parented_action("my.parented", ACTION_TYPE, {}, synchronous=synchronous)
assert actions["my.simple"].synchronous is synchronous
assert actions["my.bare"].synchronous is synchronous
assert actions["my.parented"].synchronous is synchronous
+119 -1
View File
@@ -1,4 +1,5 @@
import importlib
import io
import json
import logging
from pathlib import Path
@@ -20,6 +21,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32S2,
VARIANT_ESP32S3,
)
from esphome.components.substitutions import do_substitution_pass
from esphome.config_validation import Invalid
from esphome.const import (
CONF_DAY,
@@ -65,7 +67,13 @@ from esphome.core import (
)
from esphome.schema_extractors import SCHEMA_EXTRACT
from esphome.util import Registry
from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base
from esphome.yaml_util import (
ESPHomeDataBase,
SensitiveStr,
load_yaml,
make_data_base,
parse_yaml,
)
def test_check_not_templatable__invalid():
@@ -3174,6 +3182,116 @@ def test_file__existing_relative_path(setup_core: Path) -> None:
assert cv.file_("partitions.csv") == setup_core / "partitions.csv"
def _package_value(setup_core: Path, path: str = "assets/ui.js") -> tuple[Path, str]:
"""Write a package file next to an ``assets/`` dir; return the dir and its loaded *path* value."""
package_dir = setup_core / ".esphome" / "packages" / "abc123" / "vendor"
(package_dir / "assets").mkdir(parents=True)
(package_dir / "assets" / "ui.js").write_text("js\n")
(package_dir / "device.yaml").write_text(f"path: {path}\n")
return package_dir, load_yaml(package_dir / "device.yaml")["path"]
def test_file__resolves_relative_to_the_declaring_document(setup_core: Path) -> None:
"""A package's own asset path resolves against the package file when the config dir lacks it."""
package_dir, value = _package_value(setup_core)
assert cv.file_(value) == package_dir / "assets" / "ui.js"
def test_file__resolves_a_substituted_path_against_the_use_site(
setup_core: Path,
) -> None:
package_dir, _ = _package_value(setup_core)
(package_dir / "device.yaml").write_text(
"substitutions:\n ui: assets/ui.js\npath: ${ui}\n"
)
config = do_substitution_pass(load_yaml(package_dir / "device.yaml"))
assert cv.file_(config["path"]) == package_dir / "assets" / "ui.js"
def test_file__result_is_absolute_for_a_relative_document(
setup_core: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A document loaded by a cwd-relative path still yields an absolute result."""
package_dir, _ = _package_value(setup_core)
monkeypatch.chdir(setup_core)
value = load_yaml(Path(".esphome/packages/abc123/vendor/device.yaml"))["path"]
result = cv.file_(value)
assert result.is_absolute()
assert result == package_dir / "assets" / "ui.js"
def test_file__config_dir_entry_of_the_wrong_kind_does_not_shadow_the_package(
setup_core: Path,
) -> None:
package_dir, value = _package_value(setup_core)
(setup_core / "assets" / "ui.js").mkdir(parents=True)
assert cv.file_(value) == package_dir / "assets" / "ui.js"
def test_file__miss_names_the_declaring_document(setup_core: Path) -> None:
package_dir, value = _package_value(setup_core, "assets/other.js")
with pytest.raises(Invalid, match="Could not find file") as excinfo:
cv.file_(value)
assert f"Also looked next to {package_dir / 'device.yaml'}" in str(excinfo.value)
def test_file__document_spelled_through_dotdot_in_the_config_dir_adds_no_hint(
setup_core: Path,
) -> None:
(setup_core / "sub").mkdir()
(setup_core / "device.yaml").write_text("path: assets/other.js\n")
value = load_yaml(setup_core / "sub" / ".." / "device.yaml")["path"]
with pytest.raises(Invalid) as excinfo:
cv.file_(value)
assert "Also looked" not in str(excinfo.value)
def test_file__wrong_kind_beside_the_document_is_reported(setup_core: Path) -> None:
package_dir, value = _package_value(setup_core, "assets")
with pytest.raises(Invalid, match="is not a file") as excinfo:
cv.file_(value)
assert str(package_dir / "assets") in str(excinfo.value)
def test_file__config_dir_wins_over_the_declaring_document(setup_core: Path) -> None:
_, value = _package_value(setup_core)
(setup_core / "assets").mkdir()
(setup_core / "assets" / "ui.js").write_text("local\n")
assert cv.file_(value) == setup_core / "assets" / "ui.js"
def test_file__declared_in_an_in_memory_document_is_not_resolved(
setup_core: Path,
) -> None:
"""A value whose source document isn't on disk falls through to the config-dir error."""
value = parse_yaml(Path("<unicode string>"), io.StringIO("path: assets/ui.js\n"))[
"path"
]
with pytest.raises(Invalid, match="Could not find file"):
cv.file_(value)
def test_directory_resolves_relative_to_the_declaring_document(
setup_core: Path,
) -> None:
package_dir, value = _package_value(setup_core, "assets")
assert cv.directory(value) == package_dir / "assets"
def test_file__missing_raises(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find file"):
cv.file_("partitions.csv")
+45
View File
@@ -1,5 +1,7 @@
"""Tests for the coroutine module."""
import contextvars
import pytest
from esphome.coroutine import CoroPriority, FakeEventLoop, coroutine_with_priority
@@ -217,3 +219,46 @@ def test_custom_priority_between_enum_values() -> None:
# Check execution order
assert execution_order == ["core", "custom", "diagnostics"]
def test_context_isolated_between_interleaved_tasks() -> None:
"""Test that a contextvar set in one task does not leak into another task that the scheduler interleaves with it."""
my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var")
seen: dict[str, str] = {}
def task_a():
my_var.set("a")
yield # suspend so task_b can run before task_a resumes
seen["a"] = my_var.get()
def task_b():
my_var.set("b")
yield
seen["b"] = my_var.get()
loop = FakeEventLoop()
loop.add_job(task_a)
loop.add_job(task_b)
loop.flush_tasks()
assert seen == {"a": "a", "b": "b"}
def test_context_inherits_ambient_value_at_schedule_time() -> None:
"""Test that a job sees whatever contextvar value was set before it was scheduled."""
my_var: contextvars.ContextVar[str] = contextvars.ContextVar("my_var")
token = my_var.set("ambient")
seen: dict[str, str] = {}
def task():
seen["value"] = my_var.get()
yield
try:
loop = FakeEventLoop()
loop.add_job(task)
loop.flush_tasks()
finally:
my_var.reset(token)
assert seen == {"value": "ambient"}
+2 -2
View File
@@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
{"name": "SPI"},
]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
# The dep wave carries its compatibility so _install searches qualified
dep_call = m._install.call_args_list[-1]
@@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
installed.append(getattr(spec, "name", str(spec)))
)
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))])
pf._preinstall(m, [("noise-c@1.0", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c"]