Merge remote-tracking branch 'origin/dev' into jesserockz-2026-503

This commit is contained in:
Jesse Hills
2026-07-30 15:34:19 +12:00
294 changed files with 20081 additions and 7996 deletions
@@ -0,0 +1,114 @@
"""Tests for bk72xx_ble_tracker scan parameter validation."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.bk72xx_ble_tracker import SCAN_PARAMETERS_SCHEMA, to_ble_units
def _validate(**kwargs: str) -> dict:
"""Run a scan_parameters config through the schema, applying defaults."""
return SCAN_PARAMETERS_SCHEMA(dict(kwargs))
# --- to_ble_units ---
@pytest.mark.parametrize(
("value", "expected"),
[
("2500us", 4), # controller minimum, 2.5 ms
("30ms", 48),
("100ms", 160),
("10240ms", 16384), # controller maximum, 0x4000
],
)
def test_to_ble_units_converts_to_controller_units(value: str, expected: int) -> None:
"""A time is converted to whole 0.625 ms units."""
assert to_ble_units(cv.positive_time_period(value)) == expected
def test_to_ble_units_truncates() -> None:
"""Sub-unit remainders are dropped, which is what makes collapse possible."""
assert to_ble_units(cv.positive_time_period("3000us")) == 4
assert to_ble_units(cv.positive_time_period("2500us")) == 4
# --- accepted configurations ---
def test_defaults_are_valid() -> None:
"""The documented default 100 ms / 30 ms pair validates."""
config = _validate()
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 48
def test_minimum_separation_accepted() -> None:
"""Values one unit apart at the 2.5 ms floor are honest, not collapsed."""
config = _validate(interval="5000us", window="2500us")
assert to_ble_units(config["interval"]) == 8
assert to_ble_units(config["window"]) == 4
def test_maximum_interval_accepted() -> None:
"""The documented 10240 ms ceiling is inclusive, and maps to 0x4000.
Pins the ceiling from the accept side, mirroring the 2.5 ms floor above: the
reject cases alone would let the bound silently become exclusive.
"""
config = _validate(interval="10240ms", window="30ms")
assert to_ble_units(config["interval"]) == 16384
def test_maximum_window_accepted() -> None:
"""The ceiling applies to the window too, and is likewise inclusive."""
config = _validate(interval="10240ms", window="10240ms")
assert to_ble_units(config["window"]) == 16384
def test_window_equal_to_interval_accepted() -> None:
"""A deliberate 100 % duty cycle is allowed; only an accidental one is not."""
config = _validate(interval="100ms", window="100ms")
assert to_ble_units(config["interval"]) == to_ble_units(config["window"])
# --- rejected configurations ---
def test_window_larger_than_interval_rejected() -> None:
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
_validate(interval="30ms", window="100ms")
@pytest.mark.parametrize(
("interval", "window", "offender"),
[
("2ms", "1ms", "interval"), # below the 2.5 ms controller floor
("20s", "1s", "interval"), # above the 10240 ms controller ceiling
("100ms", "1ms", "window"), # window below the floor
],
)
def test_out_of_range_rejected(interval: str, window: str, offender: str) -> None:
"""Values the controller cannot represent are rejected, not silently wrapped."""
with pytest.raises(
cv.Invalid, match=f"Scan {offender} .* must be between 2.5 ms and 10240 ms"
):
_validate(interval=interval, window=window)
def test_unit_collapse_rejected() -> None:
"""Regression: 3000us/2500us both floor to 4 units — a hidden 100 % duty cycle.
This is the configuration that previously validated and programmed the radio
permanently on despite asking for roughly 83 %.
"""
with pytest.raises(cv.Invalid, match="both round to 4 x 0.625 ms"):
_validate(interval="3000us", window="2500us")
def test_duration_shorter_than_three_intervals_rejected() -> None:
with pytest.raises(cv.Invalid, match="must cover at least three scan intervals"):
_validate(duration="1s", interval="500ms", window="100ms")
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
network:
priority:
- ethernet
- wifi
+17
View File
@@ -601,6 +601,23 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
def test_network_wifi_ethernet_priority_keeps_wifi_enabled(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""End-to-end: with both WiFi and Ethernet declared under network: priority:,
the reconciler must NOT disable the WiFi stack or coexistence (the
multi-interface case unlocked by composing network priority with the
sdkconfig reconciler)."""
generate_main(component_config_path("network_wifi_ethernet_priority.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
assert "CONFIG_SW_COEXIST_ENABLE" not in sdkconfig
# WiFi has no AP here, so SoftAP/DHCP server are still dropped.
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
def test_esp32_build_internals_are_yaml_only() -> None:
"""ESP32 raw framework / build inputs are ``YAML_ONLY``.
@@ -0,0 +1,37 @@
"""Tests for the ethernet final-validation coexistence gate."""
import pytest
from voluptuous import Invalid
from esphome.components.ethernet import _final_validate
from esphome.components.network import _validate_priority_list
from esphome.const import CONF_PRIORITY
import esphome.final_validate as fv
@pytest.fixture(autouse=True)
def _reset_full_config():
"""Reset fv.full_config so each test starts with a clean slate."""
token = fv.full_config.set({})
yield
fv.full_config.reset(token)
def test_rejects_wifi_and_ethernet_without_priority() -> None:
"""Wi-Fi + ethernet without a network: priority: list must be rejected."""
fv.full_config.set({"wifi": {}, "ethernet": {}})
with pytest.raises(Invalid, match="cannot be used together with component wifi"):
_final_validate({})
def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None:
"""A priority list missing an interface is rejected and names what's missing."""
fv.full_config.set(
{
"wifi": {},
"ethernet": {},
"network": {CONF_PRIORITY: _validate_priority_list(["ethernet"])},
}
)
with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"):
_final_validate({})
@@ -254,6 +254,31 @@ def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None:
assert CONF_RESET_PIN in result
def test_config_guition_model_applies_defaults(tmp_path: Path) -> None:
"""The GUITION model populates transform and calibration defaults."""
fw = _write_firmware(tmp_path)
result = gsl.CONFIG_SCHEMA(
{
"model": "guition-jc8012p4a1",
"firmware": {"file": str(fw)},
}
)
assert result[CONF_MODEL] == "GUITION-JC8012P4A1"
# Transform defaults from the model.
assert result[CONF_TRANSFORM] == {
"swap_xy": True,
"mirror_x": True,
"mirror_y": False,
}
# Calibration defaults from the model.
assert result[CONF_CALIBRATION]["x_min"] == 20
assert result[CONF_CALIBRATION]["x_max"] == 880
assert result[CONF_CALIBRATION]["y_min"] == 20
assert result[CONF_CALIBRATION]["y_max"] == 1648
assert result[CONF_INTERRUPT_PIN]["number"] == 21
assert result[CONF_RESET_PIN]["number"] == 22
def test_config_rejects_non_dict() -> None:
"""A non-dict configuration is rejected."""
with pytest.raises(cv.Invalid, match="expected a dictionary"):
@@ -0,0 +1,55 @@
"""Tests for LVGL's multi-instance config cross-checks."""
from __future__ import annotations
import pytest
from esphome.components.lvgl import defines as df, multi_conf_validate
from esphome.components.lvgl.schemas import theme_schema
from esphome.config_validation import Invalid
def _config(displays: list[str], theme: dict | None = None) -> dict:
config = {
df.CONF_DISPLAYS: displays,
"log_level": "WARN",
"color_depth": 16,
"byte_order": "big_endian",
df.CONF_TRANSPARENCY_KEY: 0x000400,
}
if theme is not None:
config[df.CONF_THEME] = theme
return config
class TestThemeOnMultipleInstances:
def test_raises_when_two_instances_have_theme(self) -> None:
configs = [
_config(["disp_a"], theme={df.CONF_DARK_MODE: True}),
_config(["disp_b"], theme={df.CONF_DARK_MODE: False}),
]
with pytest.raises(Invalid, match="'theme' may only be set on one"):
multi_conf_validate(configs)
def test_raises_even_with_an_empty_theme_block(self) -> None:
# `theme: {}` still creates a CONF_THEME key (with dark_mode defaulted
# by the schema), so it should be treated the same as a populated one.
# Run it through the real schema rather than hand-building the dict,
# so this actually pins that defaulting behaviour.
configs = [
_config(["disp_a"], theme=theme_schema({})),
_config(["disp_b"], theme=theme_schema({})),
]
with pytest.raises(Invalid, match="'theme' may only be set on one"):
multi_conf_validate(configs)
def test_passes_when_only_one_instance_has_theme(self) -> None:
configs = [
_config(["disp_a"], theme={df.CONF_DARK_MODE: True}),
_config(["disp_b"]),
]
multi_conf_validate(configs)
def test_passes_when_no_instance_has_theme(self) -> None:
configs = [_config(["disp_a"]), _config(["disp_b"])]
multi_conf_validate(configs)
@@ -13,12 +13,7 @@ import pytest
import voluptuous as vol
from esphome import config_validation as cv
import esphome.components.lvgl
from esphome.components.lvgl import (
_theme_schema,
defines as df,
schemas as lvgl_schemas,
)
from esphome.components.lvgl import defines as df, schemas as lvgl_schemas
from esphome.components.lvgl.schemas import (
ALIGN_TO_SCHEMA,
FLAG_SCHEMA,
@@ -31,6 +26,8 @@ from esphome.components.lvgl.schemas import (
obj_schema,
part_dict,
part_schema,
theme_schema,
theme_update_schema,
)
from esphome.components.lvgl.types import LvType
from esphome.components.lvgl.widgets import WidgetType
@@ -43,7 +40,7 @@ def _clear_obj_dict_cache() -> Generator[None]:
cache.clear()
# The lazily-built theme schema is cached on _build_theme_schema; clear it
# too so each test starts from a clean slate.
build_theme = getattr(esphome.components.lvgl, "_build_theme_schema", None)
build_theme = getattr(lvgl_schemas, "_build_theme_schema", None)
if build_theme is not None and hasattr(build_theme, "cache_clear"):
build_theme.cache_clear()
yield
@@ -173,12 +170,12 @@ def test_spread_sources_carry_no_extra_schemas(schema: cv.Schema) -> None:
def test_theme_schema_merges_obj_dict_and_full_style_props() -> None:
# _theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
# theme_schema is the riskiest merge: obj_dict(w) and FULL_STYLE_SCHEMA.schema
# share many STYLE_SCHEMA marker instances. Exercise the merged schema
# end-to-end with one key from each side (a STATE_SCHEMA part from obj_dict
# and a FULL_STYLE-only property) to lock the behaviour against future
# regressions in either source.
out = _theme_schema(
out = theme_schema(
{
df.CONF_DARK_MODE: True,
"obj": {
@@ -202,7 +199,7 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non
# any_widget_schema explicitly supports external components registering
# widgets lazily, and the device builder revalidates in-process, so a
# widget registered after first use must invalidate the cached snapshot.
_theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
theme_schema({df.CONF_DARK_MODE: True}) # populate the cache
name = "test_self_heal_widget"
assert name not in WIDGET_TYPES
@@ -210,18 +207,68 @@ def test_theme_schema_self_heals_when_a_widget_type_is_registered_later() -> Non
# manually so the next theme call sees the new entry.
WIDGET_TYPES[name] = WidgetType(name, LvType("test_fake_t"), (), is_mock=True)
try:
out = _theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
out = theme_schema({df.CONF_DARK_MODE: False, name: {"bg_color": 0x010203}})
assert out[name]["bg_color"] == 0x010203
finally:
WIDGET_TYPES.pop(name, None)
@pytest.mark.parametrize(
("config", "expected_path"),
[
({"button": {"styles": ["foo"]}}, ["button", "styles"]),
(
{"button": {"pressed": {"styles": ["foo"]}}},
["button", "pressed", "styles"],
),
(
{"arc": {"indicator": {"styles": ["foo"]}}},
["arc", "indicator", "styles"],
),
(
{"arc": {"indicator": {"pressed": {"styles": ["foo"]}}}},
["arc", "indicator", "pressed", "styles"],
),
],
)
def test_theme_schema_rejects_styles_key(
config: dict, expected_path: list[str]
) -> None:
# `styles:` (references to named styles) is accepted by FULL_STYLE_SCHEMA
# but silently dropped by style_set when building a theme's hidden style
# -- it only walks ALL_STYLES. Reject it instead of quietly doing nothing,
# at the top level and when nested under a part and/or state.
with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
theme_schema(config)
assert exc_info.value.path == expected_path
def test_theme_update_schema_rejects_styles_key() -> None:
with pytest.raises(vol.Invalid, match="'styles:' is not allowed") as exc_info:
theme_update_schema({"label": {"styles": ["foo"]}})
assert exc_info.value.path == ["label", "styles"]
def test_theme_update_schema_does_not_request_untargeted_main_default() -> None:
# collect_parts() unconditionally seeds a main/default entry even when
# only a specific state (here "pressed") was targeted -- registering a
# request for that spurious entry would make theme_to_code create an
# unused, empty style and attach it to every widget of this type.
theme_update_schema({"label": {"pressed": {"text_color": 0x010203}}})
assert df.get_theme_update_requests()["label"] == {("main", "pressed"): None}
def test_theme_update_schema_requests_explicit_main_default() -> None:
theme_update_schema({"label": {"text_color": 0x010203}})
assert df.get_theme_update_requests()["label"] == {("main", "default"): None}
@pytest.mark.parametrize(
"schema",
[STATE_SCHEMA, FLAG_SCHEMA, STYLE_SCHEMA, FULL_STYLE_SCHEMA],
)
def test_spread_sources_have_no_top_level_marker_defaults(schema: cv.Schema) -> None:
# _theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
# theme_schema merges obj_dict(w) with FULL_STYLE_SCHEMA.schema; on a key
# collision, dict-spread keeps the first source's marker (and its default)
# but the last source's value, whereas .extend() would take both from the
# later source. The two are equivalent today because the overlapping
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
network:
priority:
- ethernet
- wifi
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
network:
priority:
- wifi
- ethernet
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
@@ -0,0 +1,201 @@
"""Tests for the ``network: priority:`` list validator."""
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from voluptuous import Invalid
from esphome.components.network import (
_SETUP_PRIORITY_AFTER_WIFI,
KEY_NETWORK_PRIORITY,
NETWORK_PRIORITY_BASE,
NETWORK_PRIORITY_STEP,
_final_validate,
_validate_priority_list,
get_network_priority,
)
from esphome.const import CONF_PRIORITY
from esphome.core import CORE
import esphome.final_validate as fv
@pytest.fixture(autouse=True)
def _clear_core_data():
"""Wipe CORE.data and reset fv.full_config so each test starts clean."""
CORE.data.clear()
token = fv.full_config.set({})
yield
fv.full_config.reset(token)
CORE.data.clear()
def test_validates_plain_string_list() -> None:
result = _validate_priority_list(["ethernet", "wifi"])
assert result == [{"interface": "ethernet"}, {"interface": "wifi"}]
def test_normalizes_mixed_case_to_lowercase() -> None:
# Regression check: mixed-case input must be lowercased so downstream
# callers like get_network_priority("ethernet") find a match.
result = _validate_priority_list(["Ethernet", "WIFI"])
assert result == [{"interface": "ethernet"}, {"interface": "wifi"}]
def test_accepts_all_supported_interface_types() -> None:
# Only ethernet and wifi are currently accepted. Other interface types
# (openthread, modem) will be added when their setup-priority consumers
# land — see NETWORK_PLAN.md.
result = _validate_priority_list(["ethernet", "wifi"])
assert [e["interface"] for e in result] == ["ethernet", "wifi"]
def test_rejects_not_yet_supported_interface() -> None:
# openthread / modem are in the long-term roadmap but no setup-priority
# consumer is wired yet, so VALID_NETWORK_TYPES excludes them today.
with pytest.raises(Invalid):
_validate_priority_list(["ethernet", "openthread"])
with pytest.raises(Invalid):
_validate_priority_list(["wifi", "modem"])
def test_single_interface_is_valid() -> None:
result = _validate_priority_list(["ethernet"])
assert result == [{"interface": "ethernet"}]
def test_rejects_unknown_interface() -> None:
with pytest.raises(Invalid):
_validate_priority_list(["ethernet", "bluetooth"])
def test_rejects_duplicate_entries() -> None:
with pytest.raises(Invalid, match="Duplicate entries"):
_validate_priority_list(["ethernet", "ethernet"])
def test_rejects_duplicates_regardless_of_case() -> None:
# Same interface in mixed cases should still trip the duplicate check
# after normalization.
with pytest.raises(Invalid, match="Duplicate entries"):
_validate_priority_list(["ethernet", "Ethernet"])
def test_rejects_mapping_form() -> None:
# The mapping form (- ethernet: { timeout: 30s }) was removed when the
# timeout option moved to its consumer PR. Verify we reject it cleanly
# instead of silently accepting a no-op.
with pytest.raises(Invalid):
_validate_priority_list([{"ethernet": {"timeout": "30s"}}])
def test_get_network_priority_returns_none_when_unset() -> None:
assert get_network_priority("ethernet") is None
def test_get_network_priority_assigns_base_to_first_entry() -> None:
CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet", "wifi"])
assert get_network_priority("ethernet") == NETWORK_PRIORITY_BASE
def test_get_network_priority_steps_down_by_step_per_position() -> None:
CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet", "wifi"])
assert get_network_priority("wifi") == NETWORK_PRIORITY_BASE - NETWORK_PRIORITY_STEP
def test_get_network_priority_is_case_insensitive_on_query() -> None:
CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet"])
assert get_network_priority("Ethernet") == NETWORK_PRIORITY_BASE
def test_get_network_priority_returns_none_for_unlisted_interface() -> None:
CORE.data[KEY_NETWORK_PRIORITY] = _validate_priority_list(["ethernet"])
assert get_network_priority("wifi") is None
def test_final_validate_rejects_priority_iface_without_component() -> None:
"""An interface named in 'priority' with no matching component block is rejected."""
# priority lists wifi, but only ethernet is present in the full config.
fv.full_config.set({"ethernet": {}})
config = {CONF_PRIORITY: _validate_priority_list(["ethernet", "wifi"])}
with pytest.raises(
Invalid, match=r"'wifi' is listed in 'network: priority:' but no 'wifi:'"
):
_final_validate(config)
def test_final_validate_accepts_when_all_priority_ifaces_present() -> None:
"""No error when every interface in 'priority' has a matching component block."""
fv.full_config.set({"ethernet": {}, "wifi": {}})
config = {CONF_PRIORITY: _validate_priority_list(["ethernet", "wifi"])}
_final_validate(config) # must not raise
def test_final_validate_noop_without_priority_list() -> None:
"""A network config without a 'priority' list imposes no component requirements."""
fv.full_config.set({})
_final_validate({}) # must not raise
def _cpp_setup_priority(name: str) -> float:
"""Read a setup_priority constant straight from esphome/core/component.h."""
header = Path(__file__).parents[3] / "esphome" / "core" / "component.h"
match = re.search(
rf"inline constexpr float {name} = ([\d.]+)f;", header.read_text()
)
assert match is not None, f"setup_priority::{name} not found in component.h"
return float(match.group(1))
def test_priority_band_constants_match_cpp_setup_priority() -> None:
"""The Python priority-band constants mirror the C++ setup_priority values.
NETWORK_PRIORITY_BASE must equal the historical setup_priority::WIFI /
::ETHERNET default so a single-entry priority list reproduces the legacy
setup order, and the band guard must track setup_priority::AFTER_WIFI.
Reading the values from component.h turns a silent desync into a CI
failure if either side is ever rebalanced.
"""
assert _cpp_setup_priority("WIFI") == NETWORK_PRIORITY_BASE
assert _cpp_setup_priority("ETHERNET") == NETWORK_PRIORITY_BASE
assert _cpp_setup_priority("AFTER_WIFI") == _SETUP_PRIORITY_AFTER_WIFI
# Must stay below AFTER_BLUETOOTH (NetworkComponent's own priority) so
# interfaces never set up before esp_netif_init().
assert _cpp_setup_priority("AFTER_BLUETOOTH") > NETWORK_PRIORITY_BASE
def test_wifi_first_priority_emits_primary_interface_define(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A wifi-first priority list emits USE_NETWORK_PRIMARY_INTERFACE_WIFI."""
generate_main(component_config_path("priority_wifi_first.yaml"))
defines = {d.name for d in CORE.defines}
assert "USE_NETWORK_PRIMARY_INTERFACE_WIFI" in defines
# Emitted by cg.set_setup_priority() at the wifi/ethernet call sites.
assert "USE_SETUP_PRIORITY_OVERRIDE" in defines
def test_ethernet_first_priority_emits_no_primary_interface_define(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Ethernet-first matches the built-in preference order, so no define is emitted."""
generate_main(component_config_path("priority_ethernet_first.yaml"))
assert not any(
d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines
)
# The setup-priority overrides themselves are still emitted.
assert "USE_SETUP_PRIORITY_OVERRIDE" in {d.name for d in CORE.defines}
def test_no_primary_interface_define_without_priority(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without a priority list, no primary-interface define is emitted."""
generate_main(component_config_path("wifi_only.yaml"))
assert not any(
d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines
)
@@ -0,0 +1,45 @@
"""Tests for the safe_mode component."""
from collections.abc import Callable
from esphome.core import CORE
SHUTDOWN_DEFINE = "USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN"
def _has_define(name: str) -> bool:
return any(define.name == name for define in CORE.defines)
def test_boot_is_good_on_shutdown_default(
generate_main: Callable[[str], str],
) -> None:
"""By default, an orderly shutdown confirms the app image."""
main_cpp = generate_main(
"tests/component_tests/safe_mode/test_safe_mode_default.yaml"
)
assert "safe_mode::SafeModeComponent" in main_cpp
assert _has_define(SHUTDOWN_DEFINE)
def test_boot_is_good_on_shutdown_disabled(
generate_main: Callable[[str], str],
) -> None:
"""With boot_is_good_on_shutdown: false, the define is not added."""
main_cpp = generate_main(
"tests/component_tests/safe_mode/test_safe_mode_no_shutdown_confirm.yaml"
)
assert "safe_mode::SafeModeComponent" in main_cpp
assert not _has_define(SHUTDOWN_DEFINE)
def test_safe_mode_disabled(generate_main: Callable[[str], str]) -> None:
"""With safe_mode disabled, no component and no define are generated."""
main_cpp = generate_main(
"tests/component_tests/safe_mode/test_safe_mode_disabled.yaml"
)
assert "safe_mode::SafeModeComponent" not in main_cpp
assert not _has_define(SHUTDOWN_DEFINE)
@@ -0,0 +1,8 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
safe_mode:
@@ -0,0 +1,9 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
safe_mode:
disabled: true
@@ -0,0 +1,9 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
safe_mode:
boot_is_good_on_shutdown: false
@@ -0,0 +1 @@
"""Tests for the wireguard component."""
@@ -0,0 +1,44 @@
"""Tests for the wireguard component schema."""
import pytest
from esphome.components.wireguard import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from esphome.yaml_util import SensitiveStr
from tests.component_tests.types import SetCoreConfigCallable
# Any 42 base64 chars plus a valid terminator satisfies _WG_KEY_REGEX.
PRIVATE_KEY = "a" * 42 + "A="
PEER_PUBLIC_KEY = "b" * 42 + "A="
PEER_PRESHARED_KEY = "c" * 42 + "A="
@pytest.mark.parametrize(
("field", "value", "sensitive"),
[
("private_key", PRIVATE_KEY, True),
("peer_preshared_key", PEER_PRESHARED_KEY, True),
("peer_public_key", PEER_PUBLIC_KEY, False),
],
)
def test_key_sensitivity(
field: str,
value: str,
sensitive: bool,
set_core_config: SetCoreConfigCallable,
) -> None:
"""The private and preshared keys are secrets and must be tagged so dump
tooling redacts them deterministically; the peer's public key is not a
secret and must stay readable in redacted dumps (see issue #17718)."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(
{
"address": "10.0.0.2",
"private_key": PRIVATE_KEY,
"peer_endpoint": "wg.example.com",
"peer_public_key": PEER_PUBLIC_KEY,
"peer_preshared_key": PEER_PRESHARED_KEY,
}
)
assert isinstance(config[field], SensitiveStr) == sensitive
assert config[field] == value
+3
View File
@@ -28,6 +28,7 @@ create an `__init__.py` in your component's test directory and define `override_
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Re-enable the component's own to_code (needed when the component must
# emit C++ setup code that the test binary depends on at link time).
@@ -39,6 +40,7 @@ Or supply a lightweight stub instead of the real `to_code`:
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config):
# Only emit what the C++ tests actually need
@@ -54,6 +56,7 @@ e.g. `tests/components/my_sensor/sensor/__init__.py`):
```python
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
```
+2
View File
@@ -0,0 +1,2 @@
bk72xx_ble:
enable_on_boot: true
@@ -0,0 +1,2 @@
packages:
bk72xx_ble: !include common.yaml
@@ -0,0 +1,11 @@
bk72xx_ble_tracker:
id: ble_tracker
scan_parameters:
# Boundary coverage: the documented 2.5 ms floor on window (expressible only
# via the microsecond-accurate validation), a non-round interval exercising the
# 0.625 ms unit conversion without collapsing onto the window's unit count,
# and the non-continuous config path.
interval: 5000us
window: 2500us
duration: 5min
continuous: false
@@ -0,0 +1,7 @@
bk72xx_ble_tracker:
id: ble_tracker
scan_parameters:
interval: 100ms
window: 30ms
duration: 5min
continuous: true
@@ -0,0 +1,2 @@
packages:
bk72xx_ble_tracker: !include common-boundary.yaml
@@ -0,0 +1,2 @@
packages:
bk72xx_ble_tracker: !include common.yaml
@@ -0,0 +1,12 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# resolve_irk() is compiled only when a sensor configures irk:
# (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has
# no sensors, so emit the define here to put the real IRK path under test.
async def to_code_testing(config):
cg.add_define("USE_BLE_DEVICE_IRK")
manifest.to_code = to_code_testing
@@ -0,0 +1,46 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::ble_device_base::testing {
// from_scan_result() ingests BLE controller order (LSB-first); the public
// accessors must expose the historical esp32 semantics: address() in printable
// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str()
// printed MSB-first.
namespace {
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa};
} // namespace
TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) {
ESPBTDevice device;
device.from_scan_result(MAC_LSB_FIRST, -50, BLE_ADDR_TYPE_PUBLIC, nullptr, 0);
const uint8_t *raw = device.address();
EXPECT_EQ(raw[0], 0xaa); // MSB first, like ESP-IDF's bda
EXPECT_EQ(raw[5], 0xff);
EXPECT_EQ(device.address_uint64(), 0xAABBCCDDEEFFULL);
EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF");
}
// mac_lsb_first_to_uint64() packs the controller-order bytes a raw-advertisement
// callback delivers into the printable-order uint64 the native API speaks — the
// value esp32_ble::ble_addr_to_uint64() has always produced for that address.
TEST(BleDeviceAddress, MacLsbFirstToUint64MatchesWireValue) {
EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), 0xAABBCCDDEEFFULL);
}
// The helper and the parsed-device accessor are two routes to the same wire
// value: byte order must agree no matter which path an advertisement takes.
TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) {
ESPBTDevice device;
device.from_scan_result(MAC_LSB_FIRST, -50, BLE_ADDR_TYPE_PUBLIC, nullptr, 0);
EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64());
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,56 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include "esphome/components/ble_device_base/ble_aes_ccm.h"
namespace esphome::ble_device_base::testing {
// Reference vector generated with Python `cryptography` AESCCM(tag_length=4),
// using the same AES-128-CCM parameters BTHome advertisements use: a 16-byte
// key, a 13-byte nonce, a 4-byte authentication tag and no associated data.
namespace {
const uint8_t KEY[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
const uint8_t NONCE[13] = {0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c};
const uint8_t CIPHERTEXT[7] = {0x68, 0xb4, 0xf6, 0xc5, 0x2b, 0xf8, 0xaf};
const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56};
const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01};
} // namespace
TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) {
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG,
sizeof(TAG)));
EXPECT_EQ(0, memcmp(out, PLAINTEXT, sizeof(PLAINTEXT)));
}
TEST(BleAesCcm, RejectsTamperedTag) {
uint8_t bad_tag[sizeof(TAG)];
memcpy(bad_tag, TAG, sizeof(TAG));
bad_tag[0] ^= 0x01;
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, bad_tag,
sizeof(bad_tag)));
}
TEST(BleAesCcm, RejectsTamperedCiphertext) {
uint8_t bad_ct[sizeof(CIPHERTEXT)];
memcpy(bad_ct, CIPHERTEXT, sizeof(CIPHERTEXT));
bad_ct[0] ^= 0x01;
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_FALSE(
aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, bad_ct, sizeof(bad_ct), out, TAG, sizeof(TAG)));
}
TEST(BleAesCcm, RejectsWrongKey) {
uint8_t bad_key[sizeof(KEY)];
memcpy(bad_key, KEY, sizeof(KEY));
bad_key[0] ^= 0xFF;
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_FALSE(aes_ccm_auth_decrypt(bad_key, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG,
sizeof(TAG)));
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,41 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::ble_device_base::testing {
// A 16- or 32-bit UUID must compare equal to its 128-bit Bluetooth Base UUID form, matching
// esp32_ble_tracker. The 128-bit raw is the base UUID (LSB-first) with the short value at
// bytes 12.. : here 0x1234 -> bytes [12]=0x34, [13]=0x12.
TEST(BleDeviceUuid, ShortFormMatchesEquivalentLongForm) {
const ESPBTUUID u16 = ESPBTUUID::from_uint16(0x1234);
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00};
const ESPBTUUID u128 = ESPBTUUID::from_raw(raw128);
EXPECT_TRUE(u16 == u128);
EXPECT_TRUE(u128 == u16); // symmetric
}
TEST(BleDeviceUuid, ThirtyTwoBitMatchesEquivalentLongForm) {
const ESPBTUUID u32 = ESPBTUUID::from_uint32(0x1122AAFF);
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0xFF, 0xAA, 0x22, 0x11};
const ESPBTUUID u128 = ESPBTUUID::from_raw(raw128);
EXPECT_TRUE(u32 == u128);
}
TEST(BleDeviceUuid, DifferentUuidsDoNotMatch) {
EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_uint16(0x1235));
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00};
// Same low bytes but a non-base prefix is a genuinely different 128-bit UUID.
uint8_t custom[16];
memcpy(custom, raw128, 16);
custom[0] ^= 0x01;
EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_raw(custom));
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,48 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::ble_device_base::testing {
// Reference vector generated with Python `cryptography` AES-128-ECB following
// the RPA resolution procedure (Bluetooth Core, Vol 3 Part H §2.2.2):
// hash = e(IRK, prand), where prand is the top 3 address bytes and the hash
// must equal the low 3 address bytes.
namespace {
const uint8_t IRK[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f};
// 4A:2B:7C:FB:7B:21 — prand 4A:2B:7C (two MSBs = 01, an RPA), hash FB:7B:21.
const uint8_t RPA_LSB_FIRST[6] = {0x21, 0x7b, 0xfb, 0x7c, 0x2b, 0x4a};
ESPBTDevice make_device(const uint8_t mac_lsb_first[6]) {
ESPBTDevice device;
device.from_scan_result(mac_lsb_first, /*rssi=*/-60, /*addr_type=*/BLE_ADDR_TYPE_RPA_RANDOM, nullptr, 0);
return device;
}
} // namespace
TEST(BleIrk, ResolvesMatchingRpa) {
ESPBTDevice device = make_device(RPA_LSB_FIRST);
EXPECT_TRUE(device.resolve_irk(IRK));
}
TEST(BleIrk, RejectsWrongIrk) {
uint8_t wrong_irk[16];
for (int i = 0; i < 16; i++)
wrong_irk[i] = IRK[i] ^ 0xff;
ESPBTDevice device = make_device(RPA_LSB_FIRST);
EXPECT_FALSE(device.resolve_irk(wrong_irk));
}
TEST(BleIrk, RejectsWrongAddress) {
uint8_t other_mac[6];
for (int i = 0; i < 6; i++)
other_mac[i] = RPA_LSB_FIRST[i];
other_mac[0] ^= 0x01; // corrupt one hash byte
ESPBTDevice device = make_device(other_mac);
EXPECT_FALSE(device.resolve_irk(IRK));
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,101 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/ble_device_base/ble_hub.h"
namespace esphome::ble_device_base::testing {
// Exercises the hub contract around RawAdvertisementCallback, not just the
// struct: a hub stores one slot via set_raw_advertisement_callback(), fires it
// only when set ("no subscriber" is the default-constructed slot), and a new
// registration replaces the old ("one consumer at a time").
//
// The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against
// the Beken SDK and cannot run host-side, so the guard-and-fire semantics are
// pinned here through a minimal host BLEHub implementation instead.
namespace {
class FakeHub : public BLEHub {
public:
void register_listener(ESPBTDeviceListener *listener) override {}
void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; }
HubCapabilities get_capabilities() const override { return {false, false, false}; }
void get_adapter_mac(uint8_t out[6]) override {}
bool scan_running() override { return false; }
bool scan_active() override { return false; }
/// The emit path every tracker implements: fire only when a subscriber is set.
void emit(const RawAdvertisement &adv) {
if (this->callback_.is_set())
this->callback_.invoke(adv);
}
protected:
RawAdvertisementCallback callback_; // default-constructed: no subscriber
};
struct CapturingSubscriber {
RawAdvertisement last{};
int calls{0};
static void trampoline(void *self, const RawAdvertisement &adv) {
auto *sub = static_cast<CapturingSubscriber *>(self);
sub->last = adv;
sub->calls++;
}
};
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa};
const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00};
RawAdvertisement make_test_adv() {
return RawAdvertisement{
.mac = MAC_LSB_FIRST, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1};
}
} // namespace
TEST(RawAdvertisementCallback, DefaultConstructedSlotIsNotSet) {
const RawAdvertisementCallback callback{};
EXPECT_FALSE(callback.is_set());
}
TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) {
FakeHub hub;
CapturingSubscriber subscriber;
hub.set_raw_advertisement_callback({&subscriber, CapturingSubscriber::trampoline});
hub.emit(make_test_adv());
ASSERT_EQ(subscriber.calls, 1);
EXPECT_EQ(subscriber.last.mac, MAC_LSB_FIRST);
EXPECT_EQ(subscriber.last.data, ADV_DATA);
EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA));
EXPECT_EQ(subscriber.last.rssi, -63);
EXPECT_EQ(subscriber.last.addr_type, 1);
}
TEST(RawAdvertisementCallback, NoSubscriberDoesNotFire) {
FakeHub hub;
// No set_raw_advertisement_callback(): emitting must be a guarded no-op,
// not a jump through a garbage pointer.
hub.emit(make_test_adv());
}
TEST(RawAdvertisementCallback, NewSubscriberReplacesOld) {
FakeHub hub;
CapturingSubscriber first;
CapturingSubscriber second;
hub.set_raw_advertisement_callback({&first, CapturingSubscriber::trampoline});
hub.set_raw_advertisement_callback({&second, CapturingSubscriber::trampoline});
hub.emit(make_test_adv());
EXPECT_EQ(first.calls, 0); // one consumer at a time
ASSERT_EQ(second.calls, 1);
EXPECT_EQ(second.last.rssi, -63);
}
} // namespace esphome::ble_device_base::testing
+134
View File
@@ -0,0 +1,134 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
namespace esphome::testing {
namespace {
// Large enough that none of the inputs below are ever dropped.
constexpr size_t TEST_BUFFER_SIZE = 64 * JSON_ESCAPE_MAX_EXPANSION + 1;
// Escape into a stack buffer and return the result as a string so the expectations stay readable.
std::string escape(const std::string &value) {
char buf[TEST_BUFFER_SIZE];
return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size()));
}
// Same, but with the short control forms turned off.
std::string escape_long(const std::string &value) {
char buf[TEST_BUFFER_SIZE];
return json_escape_into_buffer(buf, StringRef(value.c_str(), value.size()), false);
}
} // namespace
// Plain ASCII with no special characters is passed through unchanged.
TEST(JsonEscape, PlainStringUnchanged) {
EXPECT_EQ(escape("MyNetwork"), "MyNetwork");
EXPECT_EQ(escape(""), "");
}
// A double quote is escaped so it does not terminate the surrounding JSON string.
TEST(JsonEscape, EscapesDoubleQuote) {
EXPECT_EQ(escape("a\"b"), "a\\\"b");
// A double quote followed by other characters stays inside the JSON string.
EXPECT_EQ(escape("\">end"), "\\\">end");
}
// A backslash is doubled so it does not start an escape sequence in the output.
TEST(JsonEscape, EscapesBackslash) {
EXPECT_EQ(escape("a\\b"), "a\\\\b");
// A trailing backslash must not escape the closing quote of the JSON string.
EXPECT_EQ(escape("net\\"), "net\\\\");
}
// The control characters with short JSON forms use those forms.
TEST(JsonEscape, EscapesShortFormControls) {
EXPECT_EQ(escape("\n"), "\\n");
EXPECT_EQ(escape("\r"), "\\r");
EXPECT_EQ(escape("\t"), "\\t");
EXPECT_EQ(escape("\b"), "\\b");
EXPECT_EQ(escape("\f"), "\\f");
}
// Other control characters (< 0x20) without a short form become \u00XX with lowercase hex.
TEST(JsonEscape, EscapesOtherControlsAsUnicode) {
EXPECT_EQ(escape(std::string("\x00", 1)), "\\u0000");
EXPECT_EQ(escape("\x01"), "\\u0001");
EXPECT_EQ(escape("\x10"), "\\u0010");
EXPECT_EQ(escape("\x1f"), "\\u001f");
// 0x7f (DEL) is >= 0x20, so it is NOT escaped by this helper.
EXPECT_EQ(escape("\x7f"), "\x7f");
}
// With the short forms turned off, every control character is written as \u00XX instead.
TEST(JsonEscape, LongControlEscapes) {
EXPECT_EQ(escape_long("\n"), "\\u000a");
EXPECT_EQ(escape_long("\r"), "\\u000d");
EXPECT_EQ(escape_long("\t"), "\\u0009");
EXPECT_EQ(escape_long("\b"), "\\u0008");
EXPECT_EQ(escape_long("\f"), "\\u000c");
// Controls without a short form are unaffected by the flag.
EXPECT_EQ(escape_long("\x01"), "\\u0001");
}
// The flag only affects control characters. A quote or backslash is never written as \u00XX, because that form is
// no shorter and both modes have always emitted the two character escape.
TEST(JsonEscape, LongModeStillUsesTwoCharQuoteAndBackslash) {
EXPECT_EQ(escape_long("a\"b"), "a\\\"b");
EXPECT_EQ(escape_long("a\\b"), "a\\\\b");
// Ordinary text is untouched in either mode.
EXPECT_EQ(escape_long("MyDevice"), "MyDevice");
}
// Bytes >= 0x20, including multi-byte UTF-8 sequences, are passed through verbatim.
TEST(JsonEscape, PassesThroughUtf8) {
// "café" in UTF-8 (é == 0xC3 0xA9).
EXPECT_EQ(escape("caf\xc3\xa9"), "caf\xc3\xa9");
// Emoji (📶, 4-byte UTF-8) survives unchanged.
EXPECT_EQ(escape("\xf0\x9f\x93\xb6"), "\xf0\x9f\x93\xb6");
}
// A mix of special and normal characters is escaped in place without disturbing the rest.
TEST(JsonEscape, MixedContent) { EXPECT_EQ(escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); }
// A buffer sized at JSON_ESCAPE_MAX_EXPANSION bytes per input byte holds the worst case exactly.
TEST(JsonEscape, WorstCaseInputFitsExactly) {
constexpr size_t input_len = 8;
char buf[input_len * JSON_ESCAPE_MAX_EXPANSION + 1];
const std::string input(input_len, '\x01');
std::string expected;
for (size_t i = 0; i < input_len; i++)
expected += "\\u0001";
EXPECT_EQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), expected);
}
// An escape sequence that would not fit is dropped whole rather than written partially, and the result stays null
// terminated.
TEST(JsonEscape, DropsEscapeThatWouldNotFit) {
// Room for one \u00XX sequence plus the null terminator, but two are requested.
char buf[JSON_ESCAPE_MAX_EXPANSION + 1];
const std::string input(2, '\x01');
const std::string result = json_escape_into_buffer(buf, StringRef(input.c_str(), input.size()));
EXPECT_EQ(result, "\\u0001");
EXPECT_EQ(buf[JSON_ESCAPE_MAX_EXPANSION], '\0');
}
// Plain characters are truncated at the buffer size, leaving room for the null terminator.
TEST(JsonEscape, TruncatesPlainInput) {
char buf[5];
const std::string input(20, 'a');
EXPECT_STREQ(json_escape_into_buffer(buf, StringRef(input.c_str(), input.size())), "aaaa");
}
// A zero length buffer cannot even hold a null terminator, so an empty string is returned instead of writing.
TEST(JsonEscape, EmptyBufferIsSafe) {
const std::string input("test");
EXPECT_STREQ(json_escape_into_buffer(std::span<char>(), StringRef(input.c_str(), input.size())), "");
}
} // namespace esphome::testing
@@ -0,0 +1,96 @@
// Exercises the no-atomics LockFreeQueue implementation (PlainAtomic indices —
// the path used on cores without atomic RMW instructions, currently BK72xx).
// The define is forced before the include so this TU deterministically compiles
// that path regardless of the host's default thread model; no other test TU
// instantiates this template, so the differing definition is confined here.
#define ESPHOME_THREAD_MULTI_NO_ATOMICS
#include "esphome/core/lock_free_queue.h"
#include <gtest/gtest.h>
namespace esphome::core::testing {
TEST(LockFreeQueueNoAtomics, EmptyPopReturnsNull) {
esphome::LockFreeQueue<int, 4> q;
EXPECT_EQ(q.pop(), nullptr);
EXPECT_TRUE(q.empty());
EXPECT_FALSE(q.full());
EXPECT_EQ(q.size(), 0u);
}
TEST(LockFreeQueueNoAtomics, FifoOrder) {
esphome::LockFreeQueue<int, 4> q;
int a = 1, b = 2, c = 3;
EXPECT_TRUE(q.push(&a));
EXPECT_TRUE(q.push(&b));
EXPECT_TRUE(q.push(&c));
EXPECT_EQ(q.size(), 3u);
EXPECT_EQ(q.pop(), &a);
EXPECT_EQ(q.pop(), &b);
EXPECT_EQ(q.pop(), &c);
EXPECT_EQ(q.pop(), nullptr);
}
TEST(LockFreeQueueNoAtomics, CapacityIsSizeMinusOne) {
esphome::LockFreeQueue<int, 4> q;
int v[4] = {0, 1, 2, 3};
EXPECT_TRUE(q.push(&v[0]));
EXPECT_TRUE(q.push(&v[1]));
EXPECT_TRUE(q.push(&v[2]));
EXPECT_TRUE(q.full());
// Ring reserves one slot: the SIZEth push fails and is counted as dropped.
EXPECT_FALSE(q.push(&v[3]));
EXPECT_EQ(q.get_and_reset_dropped_count(), 1u);
EXPECT_EQ(q.get_and_reset_dropped_count(), 0u); // reset is sticky
}
TEST(LockFreeQueueNoAtomics, NullPushRejected) {
esphome::LockFreeQueue<int, 4> q;
EXPECT_FALSE(q.push(nullptr));
EXPECT_TRUE(q.empty());
}
TEST(LockFreeQueueNoAtomics, WrapAround) {
esphome::LockFreeQueue<int, 4> q;
int v[3] = {10, 20, 30};
// Cycle several times the ring size to cross the wrap boundary repeatedly.
for (int cycle = 0; cycle < 10; cycle++) {
for (auto &value : v)
ASSERT_TRUE(q.push(&value));
EXPECT_TRUE(q.full());
for (auto &value : v)
ASSERT_EQ(q.pop(), &value);
EXPECT_TRUE(q.empty());
}
EXPECT_EQ(q.get_and_reset_dropped_count(), 0u);
}
TEST(LockFreeQueueNoAtomics, IncrementDroppedCount) {
esphome::LockFreeQueue<int, 4> q;
// Producer-side external drop accounting (pool exhausted before push).
q.increment_dropped_count();
q.increment_dropped_count();
EXPECT_EQ(q.get_and_reset_dropped_count(), 2u);
}
TEST(LockFreeQueueNoAtomics, InterleavedPushPop) {
esphome::LockFreeQueue<int, 8> q;
int v[64];
int popped = 0;
for (int i = 0; i < 64; i++) {
v[i] = i;
ASSERT_TRUE(q.push(&v[i]));
if (i % 2 == 1) {
int *first = q.pop();
ASSERT_NE(first, nullptr);
EXPECT_EQ(*first, popped++);
int *second = q.pop();
ASSERT_NE(second, nullptr);
EXPECT_EQ(*second, popped++);
}
}
EXPECT_TRUE(q.empty());
EXPECT_EQ(popped, 64);
}
} // namespace esphome::core::testing
+4
View File
@@ -1,14 +1,18 @@
one_wire:
- platform: gpio
id: ow_dallas_temp
pin: ${one_wire_pin}
sensor:
- platform: dallas_temp
one_wire_id: ow_dallas_temp
address: 0x1C0000031EDD2A28
name: Dallas Temperature 1
resolution: 9
- platform: dallas_temp
one_wire_id: ow_dallas_temp
name: Dallas Temperature 2
- platform: dallas_temp
one_wire_id: ow_dallas_temp
name: Dallas Temperature 3
index: 2
@@ -0,0 +1,19 @@
# Deep sleep combined with OTA while bootloader rollback support is enabled
# (the default on ESP-IDF). Entering deep sleep runs the safe shutdown hooks,
# where safe_mode confirms the running app image so the bootloader does not
# roll back a fresh OTA update when the device goes to sleep before
# boot_is_good_after has elapsed.
substitutions:
wakeup_pin: GPIO4
packages:
deep_sleep: !include common.yaml
deep_sleep_esp32: !include common-esp32.yaml
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
password: "superlongpasswordthatnoonewillknow"
@@ -0,0 +1,16 @@
# Deep sleep combined with mcumgr OTA while MCUboot image rollback is enabled
# (the default on nRF52). Entering system-off deep sleep runs the safe
# shutdown hooks, where safe_mode confirms the running image so MCUboot does
# not revert a fresh OTA update on the next wake.
packages:
deep_sleep: !include common.yaml
deep_sleep:
run_duration: 10s
zephyr_ble_server:
ota:
- platform: zephyr_mcumgr
transport:
ble: true
+6 -5
View File
@@ -1,6 +1,7 @@
one_wire:
platform: ds2484
i2c_id: i2c_bus
address: 0x18
active_pullup: true
strong_pullup: false
- platform: ds2484
id: ow_ds2484
i2c_id: i2c_bus
address: 0x18
active_pullup: true
strong_pullup: false
@@ -212,6 +212,29 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Soldered Inkplate 6COLOR 7-color e-paper (600x448, UC8159-family)
- platform: epaper_spi
spi_id: spi_bus
model: inkplate6color
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0));
it.circle(it.get_width() / 2, it.get_height() / 2, 10, Color(255, 165, 0));
# Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2)
- platform: epaper_spi
spi_id: spi_bus
@@ -21,7 +21,7 @@ esp32:
disable_fatfs: true
ota:
platform: esphome
- platform: esphome
wifi:
ssid: MySSID
+1
View File
@@ -74,6 +74,7 @@ sensor:
id: espnow_temp_sensor
- platform: packet_transport
transport_id: transport1
provider: test-provider
remote_id: espnow_temp_sensor
id: remote_temp
@@ -59,6 +59,24 @@ esphome:
id: test_regression_light
brightness: 100%
effect: "None"
- http_request.get:
url: https://esphome.io
capture_response: true
on_response:
then:
# Regression test: http_request.post with json: (dict variant) inside
# on_response of a capture_response: true request puts std::string&
# (body) into the nested action's Ts..., which exposes a
# const-correctness bug in HttpRequestSendAction::play() where
# encode_json_ receives const copies of non-const reference args.
- http_request.post:
url: https://esphome.io
json:
status: "ok"
# Same with json: lambda variant, exercises json_func_ path
- http_request.post:
url: https://esphome.io
json: !lambda "root[\"status\"] = \"ok\";"
http_request:
useragent: esphome/tagreader
@@ -0,0 +1,120 @@
#include <gtest/gtest.h>
#include "esphome/components/light/light_call.h"
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
namespace esphome::light::testing {
namespace {
// A light that only supports ON_OFF, like the `binary` platform and `status_led`.
class OnOffOutput : public LightOutput {
public:
LightTraits get_traits() override {
LightTraits traits;
traits.set_supported_color_modes({ColorMode::ON_OFF});
return traits;
}
void write_state(LightState *state) override {}
};
// A dimmable light, like the `monochromatic` platform.
class BrightnessOutput : public LightOutput {
public:
LightTraits get_traits() override {
LightTraits traits;
traits.set_supported_color_modes({ColorMode::BRIGHTNESS});
return traits;
}
void write_state(LightState *state) override {}
};
// validate_() is where zero brightness is resolved against the light's capabilities.
class TestableLightCall : public LightCall {
public:
using LightCall::LightCall;
using LightCall::validate_;
};
bool as_binary(const LightColorValues &values) {
bool binary;
values.as_binary(&binary);
return binary;
}
} // namespace
// An ON/OFF light has no "on but dark" state, so a zero brightness -- how effects encode
// their dark phase -- must turn the light off. Regression test for
// https://github.com/esphome/esphome/issues/17873.
TEST(LightCallOnOff, ZeroBrightnessTurnsOutputOff) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true).set_brightness(0.0f);
auto values = call.validate_();
EXPECT_FALSE(as_binary(values));
}
// The zero must not be stored, or no later turn-on could clear it: the capability check in
// validate_() drops any brightness an ON/OFF light doesn't support, so a stored zero would
// leave the light permanently off.
TEST(LightCallOnOff, ZeroBrightnessIsNotStored) {
OnOffOutput output;
LightState state(&output);
TestableLightCall dark_call(&state);
dark_call.set_state(true).set_brightness(0.0f);
state.remote_values = dark_call.validate_();
EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 1.0f);
// A plain turn-on afterwards must switch the light back on.
TestableLightCall on_call(&state);
on_call.set_state(true);
auto values = on_call.validate_();
EXPECT_TRUE(as_binary(values));
}
// A plain turn-on with no brightness must still light up.
TEST(LightCallOnOff, PlainTurnOnIsVisible) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true);
auto values = call.validate_();
EXPECT_TRUE(as_binary(values));
}
TEST(LightCallOnOff, TurnOffTurnsOutputOff) {
OnOffOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(false);
auto values = call.validate_();
EXPECT_FALSE(as_binary(values));
}
// A dimmable light can represent "on but dark", so zero brightness must be kept as-is and
// must not be rewritten into a turn-off.
TEST(LightCallBrightness, ZeroBrightnessStaysOnButDark) {
BrightnessOutput output;
LightState state(&output);
TestableLightCall call(&state);
call.set_state(true).set_brightness(0.0f);
auto values = call.validate_();
EXPECT_TRUE(values.is_on());
EXPECT_FLOAT_EQ(values.get_brightness(), 0.0f);
}
} // namespace esphome::light::testing
+16
View File
@@ -295,6 +295,22 @@ lvgl:
id: style_test
bg_color: blue
bg_opa: !lambda return 0.5;
# `obj` is already themed above -- exercises updating an existing hidden style.
- lvgl.theme.update:
obj:
border_width: 2
# `label` is never mentioned under `theme:` -- exercises lazily creating the
# hidden style and getting it attached to already-built label widgets.
- lvgl.theme.update:
label:
text_color: red
# `button` is never mentioned under `theme:`, and only a non-default state is
# targeted here -- exercises that no spurious, empty main/default style is
# created (and attached to every button) alongside the requested one.
- lvgl.theme.update:
button:
pressed:
bg_color: red
- lvgl.image.update:
id: lv_image
src:
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cstdint>
#include "esphome/components/uart/uart_component.h"
namespace esphome::modbus::testing {
// A UART that discards all writes, for tests that never inspect the wire.
class NullUART : public uart::UARTComponent {
public:
NullUART() { this->set_baud_rate(115200); }
void write_array(const uint8_t *data, size_t len) override {}
bool peek_byte(uint8_t *data) override { return false; }
bool read_array(uint8_t *data, size_t len) override { return false; }
size_t available() override { return 0; }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; }
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif
void check_logger_conflict() override {}
};
} // namespace esphome::modbus::testing
@@ -39,6 +39,50 @@ namespace esphome::modbus::testing {
namespace {
// A UART the test can inject received bytes into; sent bytes are discarded.
class InjectableUART : public uart::UARTComponent {
public:
void write_array(const uint8_t *data, size_t len) override {}
bool peek_byte(uint8_t *data) override {
if (this->rx_.empty())
return false;
*data = this->rx_.front();
return true;
}
bool read_array(uint8_t *data, size_t len) override {
if (len > this->rx_.size())
return false;
memcpy(data, this->rx_.data(), len);
this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len);
return true;
}
size_t available() override { return this->rx_.size(); }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; }
void check_logger_conflict() override {}
void inject_frame(uint8_t address, std::span<const uint8_t> pdu) {
// Wire frame: address + PDU + CRC16(low, high)
size_t start = this->rx_.size();
this->rx_.push_back(address);
this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end());
uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start);
this->rx_.push_back(crc & 0xFF);
this->rx_.push_back(crc >> 8);
}
private:
std::vector<uint8_t> rx_;
};
class NullDevice : public ModbusClientDevice {
public:
using ModbusClientDevice::ModbusClientDevice;
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->responses++;
}
int responses{0};
};
struct Sample {
size_t count;
size_t bytes;
@@ -93,14 +137,61 @@ TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) {
EXPECT_EQ(total, 0u);
}
// End to end: bytes injected at the UART travel through receive, frame parsing, response matching and
// device dispatch. The first response may grow the hub's rx buffer once; after that warm-up, handling a
// response performs zero heap allocations all the way to the device callback.
TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) {
InjectableUART uart;
uart.set_baud_rate(115200); // tx timing math divides by the baud rate
ModbusClientHub hub;
hub.set_uart_parent(&uart);
hub.setup(); // computes frame timing from the baud rate
NullDevice device(&hub, 0x02);
StaticVector<uint8_t, MAX_PDU_SIZE> req;
const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02};
req.assign(read_pdu, read_pdu + sizeof(read_pdu));
// Largest possible read response first, so the rx buffer warm-up covers every later size.
uint8_t large_resp[252] = {0x03, 250};
const uint8_t small_resp[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
auto round_trip = [&](std::span<const uint8_t> response_pdu) {
device.send_pdu(req);
hub.loop(); // transmit; the tx queue is empty during the measured receive below
uart.inject_frame(0x02, response_pdu);
return sample([&] { hub.loop(); }); // receive + parse + match + dispatch
};
Sample warmup = round_trip(std::span<const uint8_t>(large_resp, sizeof(large_resp)));
Sample steady_large = round_trip(std::span<const uint8_t>(large_resp, sizeof(large_resp)));
Sample steady_small = round_trip(small_resp);
printf("HEAPPROBE warmup count=%zu bytes=%zu\n", warmup.count, warmup.bytes);
printf("HEAPPROBE steady_large count=%zu bytes=%zu\n", steady_large.count, steady_large.bytes);
printf("HEAPPROBE steady_small count=%zu bytes=%zu\n", steady_small.count, steady_small.bytes);
EXPECT_EQ(device.responses, 3);
EXPECT_LE(warmup.count, 1u); // at most the one-time rx buffer growth
EXPECT_EQ(steady_large.count, 0u);
EXPECT_EQ(steady_small.count, 0u);
}
} // namespace esphome::modbus::testing
#else // !HEAP_PROBE_HAS_ASAN
// Stub every ASan-gated test name, so the suite's test list is identical in every build configuration.
namespace esphome::modbus::testing {
TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) {
GTEST_SKIP() << "allocation counting requires an AddressSanitizer build";
}
TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) {
GTEST_SKIP() << "allocation counting requires an AddressSanitizer build";
}
TEST(HeapProbe, ResponseHandlingIsAllocationFreeAfterWarmup) {
GTEST_SKIP() << "allocation counting requires an AddressSanitizer build";
}
} // namespace esphome::modbus::testing
#endif // HEAP_PROBE_HAS_ASAN
@@ -0,0 +1,344 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <optional>
#include <span>
#include <vector>
#include "esphome/components/modbus/modbus.h"
namespace esphome::modbus::testing {
namespace {
// Records every typed callback so tests can assert on the dispatch performed by the default
// on_response()/on_error() implementations.
class RecordingDevice : public ModbusClientDevice {
public:
struct ReadRegistersCall {
uint16_t start_address;
std::vector<uint16_t> registers;
ResponseStatus status;
};
struct ReadBitsCall {
uint16_t start_address;
uint16_t count;
std::vector<uint8_t> packed;
ResponseStatus status;
};
struct WriteCall {
uint16_t address;
uint16_t value;
ResponseStatus status;
};
void on_read_holding_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) override {
this->holding_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
}
void on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) override {
this->input_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
}
void on_read_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
this->coil_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
}
void on_read_discrete_inputs(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
this->discrete_calls.push_back({start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
}
void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override {
this->write_single_register_calls.push_back({address, value, status});
}
void on_write_single_coil(uint16_t address, bool value, ResponseStatus status) override {
this->write_single_coil_calls.push_back({address, static_cast<uint16_t>(value), status});
}
void on_write_multiple_registers(uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) override {
this->write_multiple_registers_calls.push_back({start_address, {registers.begin(), registers.end()}, status});
}
void on_write_multiple_coils(uint16_t start_address, PackedBits bits, ResponseStatus status) override {
this->write_multiple_coils_calls.push_back(
{start_address, bits.size(), {bits.bytes().begin(), bits.bytes().end()}, status});
}
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) override {
this->custom_requests.emplace_back(request_pdu.begin(), request_pdu.end());
this->custom_responses.emplace_back(response_pdu.begin(), response_pdu.end());
this->custom_statuses.push_back(status);
}
std::vector<ReadRegistersCall> holding_calls;
std::vector<ReadRegistersCall> input_calls;
std::vector<ReadBitsCall> coil_calls;
std::vector<ReadBitsCall> discrete_calls;
std::vector<WriteCall> write_single_register_calls;
std::vector<WriteCall> write_single_coil_calls;
std::vector<ReadRegistersCall> write_multiple_registers_calls;
std::vector<ReadBitsCall> write_multiple_coils_calls;
std::vector<std::vector<uint8_t>> custom_requests;
std::vector<std::vector<uint8_t>> custom_responses;
std::vector<ResponseStatus> custom_statuses;
};
// Overrides only the generic callbacks to verify the typed defaults delegate to them.
class GenericDevice : public ModbusClientDevice {
public:
void on_read_registers(EntityType register_type, uint16_t start_address, std::span<const uint16_t> registers,
ResponseStatus status) override {
this->register_type = register_type;
this->start_address = start_address;
this->registers.assign(registers.begin(), registers.end());
this->calls++;
}
void on_read_bits(EntityType register_type, uint16_t start_address, PackedBits bits, ResponseStatus status) override {
this->register_type = register_type;
this->start_address = start_address;
this->bit_count = bits.size();
this->calls++;
}
EntityType register_type{EntityType::CUSTOM};
uint16_t start_address{0};
uint16_t bit_count{0};
std::vector<uint16_t> registers;
int calls{0};
};
} // namespace
TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) {
RecordingDevice device;
const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 regs at 0x100
const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; // 0x002A, 0x0100
device.on_response(request, response);
ASSERT_EQ(device.holding_calls.size(), 1u);
const auto &call = device.holding_calls.front();
EXPECT_EQ(call.start_address, 0x100);
EXPECT_EQ(call.registers, (std::vector<uint16_t>{0x002A, 0x0100}));
EXPECT_FALSE(call.status.has_value());
}
TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) {
GenericDevice device;
const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01};
const uint8_t response[] = {0x04, 0x02, 0x12, 0x34};
device.on_response(request, response);
EXPECT_EQ(device.calls, 1);
EXPECT_EQ(device.register_type, EntityType::INPUT_REGISTER);
EXPECT_EQ(device.start_address, 0x10);
EXPECT_EQ(device.registers, (std::vector<uint16_t>{0x1234}));
}
TEST(ModbusClientDeviceFanOut, ReadDiscreteInputsDelegateToGenericBits) {
GenericDevice device;
const uint8_t request[] = {0x02, 0x00, 0x20, 0x00, 0x05}; // 5 inputs at 0x20
const uint8_t response[] = {0x02, 0x01, 0x15};
device.on_response(request, response);
EXPECT_EQ(device.calls, 1);
EXPECT_EQ(device.register_type, EntityType::DISCRETE_INPUT);
EXPECT_EQ(device.start_address, 0x20);
EXPECT_EQ(device.bit_count, 5);
}
// A CRC-valid response whose length does not match its request cannot be decoded per the
// function-code contract: it goes to the catch-all with the raw PDUs, not to the typed callback.
TEST(ModbusClientDeviceFanOut, ReadRegistersMismatchedLengthGoesToCatchAll) {
RecordingDevice device;
// Request asks for 4 registers but the response only carries 1.
const uint8_t request[] = {0x03, 0x00, 0x00, 0x00, 0x04};
const uint8_t response[] = {0x03, 0x02, 0xBE, 0xEF};
device.on_response(request, response);
EXPECT_TRUE(device.holding_calls.empty());
ASSERT_EQ(device.custom_responses.size(), 1u);
EXPECT_EQ(device.custom_responses.front(), (std::vector<uint8_t>(response, response + sizeof(response))));
}
// Coil responses are validated the same way: byte count must be ceil(count / 8).
TEST(ModbusClientDeviceFanOut, ReadCoilsMismatchedLengthGoesToCatchAll) {
RecordingDevice device;
const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils -> 3 packed bytes
const uint8_t response[] = {0x01, 0x02, 0xCD, 0x6B}; // only 2
device.on_response(request, response);
EXPECT_TRUE(device.coil_calls.empty());
EXPECT_EQ(device.custom_responses.size(), 1u);
}
TEST(ModbusClientDeviceFanOut, ReadCoilsSuccess) {
RecordingDevice device;
const uint8_t request[] = {0x01, 0x00, 0x13, 0x00, 0x13}; // 19 coils at 0x13
const uint8_t response[] = {0x01, 0x03, 0xCD, 0x6B, 0x05};
device.on_response(request, response);
ASSERT_EQ(device.coil_calls.size(), 1u);
const auto &call = device.coil_calls.front();
EXPECT_EQ(call.start_address, 0x13);
EXPECT_EQ(call.count, 19);
EXPECT_EQ(call.packed, (std::vector<uint8_t>{0xCD, 0x6B, 0x05}));
EXPECT_FALSE(call.status.has_value());
// first coil = bit 0 of byte 0
EXPECT_TRUE(helpers::bit_from_packed(0, call.packed));
EXPECT_FALSE(helpers::bit_from_packed(1, call.packed));
}
TEST(ModbusClientDeviceFanOut, WriteSingleRegisterSuccess) {
RecordingDevice device;
const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03};
device.on_response(request, request); // echo
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
const auto &call = device.write_single_register_calls.front();
EXPECT_EQ(call.address, 1);
EXPECT_EQ(call.value, 3);
EXPECT_FALSE(call.status.has_value());
}
TEST(ModbusClientDeviceFanOut, WriteSingleCoilSuccess) {
RecordingDevice device;
const uint8_t request[] = {0x05, 0x00, 0xAC, 0xFF, 0x00};
device.on_response(request, request);
ASSERT_EQ(device.write_single_coil_calls.size(), 1u);
EXPECT_EQ(device.write_single_coil_calls.front().address, 0xAC);
EXPECT_EQ(device.write_single_coil_calls.front().value, 1u);
}
TEST(ModbusClientDeviceFanOut, WriteErrorReportsRequestArgumentsAndStatus) {
RecordingDevice device;
const uint8_t request[] = {0x06, 0x00, 0x01, 0x00, 0x03};
const uint8_t exception[] = {0x86, 0x02}; // ILLEGAL_DATA_ADDRESS
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
const auto &call = device.write_single_register_calls.front();
EXPECT_EQ(call.address, 1);
EXPECT_EQ(call.value, 3);
EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
TEST(ModbusClientDeviceFanOut, ReadErrorReportsEmptyDataAndStatus) {
RecordingDevice device;
const uint8_t request[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t exception[] = {0x83, 0x02};
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
ASSERT_EQ(device.holding_calls.size(), 1u);
const auto &call = device.holding_calls.front();
EXPECT_EQ(call.start_address, 0x100);
EXPECT_TRUE(call.registers.empty());
EXPECT_EQ(call.status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
TEST(ModbusClientDeviceFanOut, CustomFunctionCodeGoesToCatchAll) {
RecordingDevice device;
const uint8_t request[] = {0x47, 0x01, 0x02, 0x03, 0x04};
const uint8_t response[] = {0x47, 0xAA, 0xBB};
device.on_response(request, response);
ASSERT_EQ(device.custom_requests.size(), 1u);
EXPECT_EQ(device.custom_requests.front(), (std::vector<uint8_t>{0x47, 0x01, 0x02, 0x03, 0x04}));
EXPECT_EQ(device.custom_responses.front(), (std::vector<uint8_t>{0x47, 0xAA, 0xBB}));
EXPECT_FALSE(device.custom_statuses.front().has_value());
EXPECT_TRUE(device.holding_calls.empty());
// On failure the catch-all receives an empty response and the status (the exception code).
const uint8_t exception[] = {0xC7, 0x02};
device.on_error(request, static_cast<ExceptionCode>(exception[1]));
ASSERT_EQ(device.custom_statuses.size(), 2u);
EXPECT_EQ(device.custom_statuses.back(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_TRUE(device.custom_responses.back().empty());
}
// A write ack only echoes the start address and count, so the data that was written is decoded from the
// request PDU: [0] function code, [1..2] start address, [3..4] count, [5] byte count, [6..] data.
TEST(ModbusClientDeviceFanOut, WriteMultipleAcksReportStartAndData) {
RecordingDevice device;
// Write 2 registers (0x0001, 0x0002) at 0x0020: byte count 4, data from offset 6.
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02};
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
device.on_response(reg_request, reg_ack);
// Write 10 coils at 0x0030: byte count 2, packed bits 0xFF 0x03 from offset 6.
const uint8_t coil_request[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03};
const uint8_t coil_ack[] = {0x0F, 0x00, 0x30, 0x00, 0x0A};
device.on_response(coil_request, coil_ack);
ASSERT_EQ(device.write_multiple_registers_calls.size(), 1u);
EXPECT_EQ(device.write_multiple_registers_calls.front().start_address, 0x20);
EXPECT_EQ(device.write_multiple_registers_calls.front().registers, (std::vector<uint16_t>{0x0001, 0x0002}));
ASSERT_EQ(device.write_multiple_coils_calls.size(), 1u);
EXPECT_EQ(device.write_multiple_coils_calls.front().start_address, 0x30);
EXPECT_EQ(device.write_multiple_coils_calls.front().count, 10);
EXPECT_EQ(device.write_multiple_coils_calls.front().packed, (std::vector<uint8_t>{0xFF, 0x03}));
}
// A truncated request (byte-count header promises more data than the PDU carries) is not a standard
// write-multiple, so it is diverted to on_custom_response() - never clamped and delivered as if complete.
TEST(ModbusClientDeviceFanOut, WriteMultipleTruncatedRequestDispatchesAsCustom) {
RecordingDevice device;
// Header claims 2 registers / 4 data bytes, but only one register's worth is present.
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01};
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
device.on_response(reg_request, reg_ack);
EXPECT_TRUE(device.write_multiple_registers_calls.empty());
ASSERT_EQ(device.custom_requests.size(), 1u);
EXPECT_EQ(device.custom_requests.front(), (std::vector<uint8_t>{0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01}));
}
// A request whose byte-count header disagrees with its own quantity field (here: 2 registers but a
// byte count of 2 instead of 4, with matching data) is non-standard and diverted to the catch-all.
TEST(ModbusClientDeviceFanOut, WriteMultipleInconsistentByteCountDispatchesAsCustom) {
RecordingDevice device;
const uint8_t reg_request[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01};
const uint8_t reg_ack[] = {0x10, 0x00, 0x20, 0x00, 0x02};
device.on_response(reg_request, reg_ack);
EXPECT_TRUE(device.write_multiple_registers_calls.empty());
EXPECT_EQ(device.custom_requests.size(), 1u);
}
// An exception on a read still dispatches to the typed callback (empty data, status set): the gate must
// not require a standard response on the failure path, because on_error() delivers an empty response by
// design.
TEST(ModbusClientDeviceFanOut, ReadErrorWithEmptyResponseStillDispatchesTyped) {
RecordingDevice device;
const uint8_t read_request[] = {0x03, 0x01, 0x00, 0x00, 0x02};
device.on_error(read_request, ExceptionCode::ILLEGAL_DATA_ADDRESS);
ASSERT_EQ(device.holding_calls.size(), 1u);
EXPECT_TRUE(device.holding_calls.front().registers.empty());
EXPECT_EQ(device.holding_calls.front().status, ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_TRUE(device.custom_requests.empty());
}
// An error on a coil read must deliver a PackedBits view whose size() is zero - the count must never
// promise bits that have no bytes behind them (operator[] is unchecked).
TEST(ModbusClientDeviceFanOut, ReadCoilsErrorDeliversZeroCountBits) {
RecordingDevice device;
const uint8_t read_request[] = {0x01, 0x01, 0x00, 0x00, 0x0A};
device.on_error(read_request, ExceptionCode::SERVICE_DEVICE_FAILURE);
ASSERT_EQ(device.coil_calls.size(), 1u);
EXPECT_EQ(device.coil_calls.front().count, 0);
EXPECT_TRUE(device.coil_calls.front().packed.empty());
}
// Single-write acks: on success the delivered value is the device's echo (real read-back);
// on an exception it falls back to the request copy.
TEST(ModbusTypedDispatch, SingleWriteAckPrefersTheResponseEcho) {
RecordingDevice device;
const uint8_t request[] = {0x06, 0x00, 0x10, 0x00, 0x2A};
const uint8_t echo_clamped[] = {0x06, 0x00, 0x10, 0x00, 0x28}; // device clamped 42 -> 40
device.on_response(request, echo_clamped);
ASSERT_EQ(device.write_single_register_calls.size(), 1u);
EXPECT_EQ(device.write_single_register_calls.front().value, 0x0028); // the echo, not the request
device.on_error(request, ExceptionCode::ILLEGAL_DATA_VALUE);
ASSERT_EQ(device.write_single_register_calls.size(), 2u);
EXPECT_EQ(device.write_single_register_calls.back().value, 0x002A); // exception: request copy
}
} // namespace esphome::modbus::testing
@@ -1,9 +1,13 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
#include "common.h"
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/hal.h"
namespace esphome::modbus::testing {
@@ -16,19 +20,21 @@ class NoResponseProbeHub : public ModbusClientHub {
public:
size_t queued_frames() const { return this->tx_buffer_.size(); }
const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); }
const ModbusDeviceCommand &queued(size_t i) const { return this->tx_buffer_[i]; }
bool waiting() const { return this->waiting_for_response_.has_value(); }
const ModbusDeviceCommand &waiting_command() const {
EXPECT_TRUE(this->waiting_for_response_.has_value());
return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access)
}
void send_next_for_test() { this->send_next_frame_(); }
void force_send_front() {
this->waiting_for_response_ = std::move(this->tx_buffer_.front());
this->tx_buffer_.pop_front();
}
// Drives the real unexpected-frame branch in process_modbus_server_frame().
void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) {
this->process_modbus_server_frame(address, function_code, data, len);
void receive_frame_for_test(uint8_t address, std::span<const uint8_t> pdu) {
this->process_modbus_server_frame(address, pdu);
}
void timeout_waiting() {
if (this->waiting_for_response_.has_value())
@@ -37,11 +43,11 @@ class NoResponseProbeHub : public ModbusClientHub {
}
};
// A device with a scripted answer to on_modbus_no_response().
// A device with a scripted answer to on_no_response().
class RetryingDevice : public ModbusClientDevice {
public:
RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {}
bool on_modbus_no_response() override {
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
return this->retry_;
}
@@ -55,7 +61,7 @@ class RetryingDevice : public ModbusClientDevice {
class ClearingRetryDevice : public ModbusClientDevice {
public:
ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
bool on_modbus_no_response() override {
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback
return true; // and still requests a retry
@@ -94,8 +100,9 @@ TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) {
EXPECT_EQ(requeued.device, &device);
// address + PDU + CRC
ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3);
EXPECT_EQ(requeued.frame.data.data()[0], 0x02);
EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU)));
EXPECT_EQ(requeued.frame.address(), 0x02);
ASSERT_EQ(requeued.frame.pdu().size(), sizeof(READ_PDU));
EXPECT_EQ(0, memcmp(requeued.frame.pdu().data(), READ_PDU, sizeof(READ_PDU)));
}
// A device that declines the retry has the frame dropped.
@@ -143,8 +150,8 @@ TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) {
hub.force_send_front();
// A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch.
const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload));
const uint8_t stray_pdu[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x07, stray_pdu);
EXPECT_EQ(device.no_response_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue...
@@ -175,4 +182,736 @@ TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) {
EXPECT_FALSE(hub.waiting());
}
// A device whose sent/not-sent callbacks are counted.
namespace {
class SentCountingDevice : public ModbusClientDevice {
public:
SentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_sent(std::span<const uint8_t> request_pdu) override {
this->sent_count_++;
this->last_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
int sent_count_{0};
int not_sent_count_{0};
std::vector<uint8_t> last_sent_pdu_;
std::vector<uint8_t> last_not_sent_pdu_;
};
} // namespace
// on_sent() fires when the frame goes onto the wire, not when it is queued.
TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup(); // frame timing derives from the baud rate
SentCountingDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
EXPECT_EQ(device.sent_count_, 0); // queued only - nothing on the wire yet
hub.send_next_for_test();
EXPECT_EQ(device.sent_count_, 1);
EXPECT_EQ(device.not_sent_count_, 0);
// The callback identifies which command transmitted: it carries the request PDU.
EXPECT_EQ(device.last_sent_pdu_, (std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU))));
EXPECT_TRUE(hub.waiting());
}
// Counts response deliveries so requeue semantics can be pinned end to end.
namespace {
class DataCountingDevice : public ModbusClientDevice {
public:
DataCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override {
this->data_count_++;
}
void on_error(std::span<const uint8_t> request_pdu, ExceptionCode exception_code) override { this->error_count_++; }
bool on_no_response(std::span<const uint8_t> request_pdu) override {
this->no_response_count_++;
this->last_no_response_pdu_.assign(request_pdu.begin(), request_pdu.end());
if (this->retries_ == 0)
return false;
this->retries_--;
return true;
}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->last_not_sent_pdu_.assign(request_pdu.begin(), request_pdu.end());
}
void on_sent(std::span<const uint8_t> request_pdu) override { this->sent_count_++; }
int terminals() const {
return this->data_count_ + this->error_count_ + this->no_response_count_ + this->not_sent_count_;
}
int data_count_{0};
int error_count_{0};
int no_response_count_{0};
int not_sent_count_{0};
int sent_count_{0};
int retries_{0};
std::vector<uint8_t> last_not_sent_pdu_;
std::vector<uint8_t> last_no_response_pdu_;
};
// Runs full send/respond cycles until the queue drains; returns the number of cycles executed.
int drain_with_responses(NoResponseProbeHub &hub, std::span<const uint8_t> response_pdu, int max_cycles = 10) {
int cycles = 0;
while (hub.queued_frames() != 0 && cycles < max_cycles) {
hub.force_send_front();
hub.receive_frame_for_test(0x02, response_pdu);
cycles++;
}
return cycles;
}
} // namespace
constexpr uint8_t OK_RESPONSE[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
// One request produces exactly one data callback.
TEST(ModbusClientHubCallbackCount, SingleReadSingleCallback) {
NoResponseProbeHub hub;
DataCountingDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
drain_with_responses(hub, OK_RESPONSE);
EXPECT_EQ(device.data_count_, 1);
EXPECT_EQ(device.not_sent_count_, 0);
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_FALSE(hub.waiting());
}
// An exception response is a terminal on its own: exactly one on_error(), no others,
// preceded by exactly one on_sent().
TEST(ModbusClientHubCallbackCount, ErrorResponseIsSoleTerminal) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
hub.send_next_for_test();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response);
EXPECT_EQ(device.error_count_, 1);
EXPECT_EQ(device.terminals(), 1);
EXPECT_EQ(device.sent_count_, 1);
}
// A timeout is a terminal on its own: exactly one on_no_response(), preceded by one
// on_sent(); a refused duplicate ends in on_not_sent() with NO on_sent().
TEST(ModbusClientHubCallbackCount, NoResponseIsSoleTerminalAndNotSentHasNoSent) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
hub.send_next_for_test();
hub.timeout_waiting();
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.terminals(), 1);
EXPECT_EQ(device.sent_count_, 1);
// A refused send (empty PDU) is a not_sent terminal, never sent.
const uint8_t write_pdu[] = {0x06, 0x00, 0x10, 0xBE, 0xEF};
device.send_pdu(write_pdu);
device.send_pdu(std::span<const uint8_t>{});
EXPECT_EQ(device.not_sent_count_, 1);
EXPECT_EQ(device.terminals(), 2); // the accepted write is still queued - no terminal for it yet
EXPECT_EQ(device.sent_count_, 1); // and it has not transmitted yet
// Drain it: the write echo response is its data terminal, and the books balance.
hub.send_next_for_test();
hub.receive_frame_for_test(0x02, write_pdu);
EXPECT_EQ(device.data_count_, 1);
EXPECT_EQ(device.terminals(), 3); // 3 accepted lifecycles, 3 terminals
EXPECT_EQ(device.sent_count_, 2); // 2 transmissions (read + write); the refused send never sent
}
// A device-requested retry starts a new lifecycle: each transmission gets its own sent + terminal.
TEST(ModbusClientHubCallbackCount, RetryLifecyclesEachGetSentAndTerminal) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.retries_ = 1; // ask for exactly one retry
device.send_pdu(read_pdu());
hub.send_next_for_test();
hub.timeout_waiting(); // lifecycle 1: sent + no_response (retry requested -> re-queued)
ASSERT_EQ(hub.queued_frames(), 1u);
hub.send_next_for_test();
hub.timeout_waiting(); // lifecycle 2: sent + no_response (retry declined -> done)
EXPECT_EQ(device.no_response_count_, 2);
EXPECT_EQ(device.terminals(), 2);
EXPECT_EQ(device.sent_count_, 2);
EXPECT_EQ(hub.queued_frames(), 0u);
// The retried lifecycle's timeout carries the SAME request PDU as the first attempt.
EXPECT_EQ(device.last_no_response_pdu_, std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU)));
}
// A retry re-queue that finds the buffer full is refused like any other send: the device gets
// on_not_sent() carrying the request PDU (the previously uncovered requeue_waiting_frame_ branch).
TEST(ModbusClientHubCallbackCount, FullQueueRetryRefusalDeliversNotSentWithPdu) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
DataCountingDevice device(&hub, 0x02);
device.retries_ = 1;
SentCountingDevice filler(&hub, 0x05);
device.send_pdu(read_pdu());
hub.force_send_front(); // in flight
// Fill the queue with distinct frames.
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
hub.timeout_waiting(); // retry requested, but the re-queue is refused: not_sent terminal instead
EXPECT_EQ(device.no_response_count_, 1);
EXPECT_EQ(device.not_sent_count_, 1);
EXPECT_EQ(device.last_not_sent_pdu_, std::vector<uint8_t>(READ_PDU, READ_PDU + sizeof(READ_PDU)));
EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
}
// The deprecated device-side send_raw() refusal delivers through the same guard as every other
// path: a handler that reacts to its own refusal with another empty send_raw() stays bounded.
namespace {
class SendRawOnNotSentDevice : public ModbusClientDevice {
public:
SendRawOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
this->send_raw({}); // refused again; the guard must suppress the nested delivery
#pragma GCC diagnostic pop
}
int not_sent_count_{0};
};
} // namespace
TEST(ModbusClientHubQueue, SendRawRefusalIsGuardedAgainstRecursion) {
NoResponseProbeHub hub;
SendRawOnNotSentDevice device(&hub, 0x02);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
device.send_raw({}); // empty payload refused -> on_not_sent -> nested send_raw({}) suppressed
#pragma GCC diagnostic pop
EXPECT_EQ(device.not_sent_count_, 1);
}
namespace {
// A device that chains a follow-up send from inside on_sent().
class ChainOnSentDevice : public ModbusClientDevice {
public:
ChainOnSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_sent(std::span<const uint8_t> request_pdu) override {
if (!this->chained_) {
this->chained_ = true;
const uint8_t follow[] = {0x03, 0x00, 0x09, 0x00, 0x01}; // read holding 0x0009 x1
this->send_pdu(follow);
}
}
bool chained_{false};
};
} // namespace
// clear_tx_queue_for_address() resolves every dropped frame via its owner's on_not_sent(), so a device
// sharing the address with the clearer (e.g. a modbus_client action alongside an offline controller)
// observes the drop; frames for other addresses are untouched.
TEST(ModbusClientHubQueue, ClearAddressQueueNotifiesEveryOwner) {
NoResponseProbeHub hub;
SentCountingDevice controller_like(&hub, 0x02);
SentCountingDevice bystander_same(&hub, 0x02);
SentCountingDevice bystander_other(&hub, 0x03);
const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02};
const uint8_t read_c[] = {0x03, 0x03, 0x00, 0x00, 0x02};
controller_like.send_pdu(read_a);
bystander_same.send_pdu(read_b);
bystander_other.send_pdu(read_c);
ASSERT_EQ(hub.queued_frames(), 3u);
controller_like.clear_tx_queue_for_address(false);
ASSERT_EQ(hub.queued_frames(), 1u); // only the other-address frame remains
EXPECT_EQ(hub.front().frame.address(), 0x03);
EXPECT_EQ(controller_like.not_sent_count_, 1);
EXPECT_EQ(bystander_same.not_sent_count_, 1);
EXPECT_EQ(bystander_other.not_sent_count_, 0);
// each owner saw its own request PDU
EXPECT_EQ(bystander_same.last_not_sent_pdu_, std::vector<uint8_t>(std::begin(read_b), std::end(read_b)));
}
namespace {
// Re-sends its frame once from inside on_not_sent - the re-queued frame must survive the sweep.
class ResendOnNotSentDevice : public ModbusClientDevice {
public:
ResendOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
if (this->not_sent_count_ == 1) {
const uint8_t again[] = {0x06, 0x00, 0x40, 0x00, 0x01};
this->send_pdu(again);
}
}
int not_sent_count_{0};
};
} // namespace
// A handler that re-sends to the same address from inside on_not_sent() neither corrupts the sweep nor
// loops it: only initially-marked frames are swept, so the re-queued frame stays queued.
TEST(ModbusClientHubQueue, ClearAddressReentrantResendSurvives) {
NoResponseProbeHub hub;
ResendOnNotSentDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read);
ASSERT_EQ(hub.queued_frames(), 1u);
hub.clear_tx_queue_for_address(0x02, false);
// The original frame resolved via on_not_sent; the re-send from inside that callback remains queued.
EXPECT_EQ(device.not_sent_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.front().frame.address(), 0x02);
}
namespace {
// Retries from EVERY on_not_sent - against a full queue this recursed without bound before the guard.
class AlwaysRetryDevice : public ModbusClientDevice {
public:
AlwaysRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
const uint8_t again[] = {0x03, 0x00, 0x50, 0x00, 0x01};
this->send_pdu(again);
}
int not_sent_count_{0};
};
// From inside on_not_sent, clears ANOTHER address - those victims must still be notified (the per-device
// guard suppresses deliveries only to a device already inside its own on_not_sent()).
class ClearOtherOnNotSentDevice : public ModbusClientDevice {
public:
ClearOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->parent_->clear_tx_queue_for_address(0x03, false);
}
int not_sent_count_{0};
};
} // namespace
// A handler that retries from every on_not_sent() against a FULL queue must not recurse: the first
// refusal notifies once, the nested refusal is dropped without a callback (the documented guard).
TEST(ModbusClientHubQueue, FullQueueRetryFromNotSentDoesNotRecurse) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
AlwaysRetryDevice retrier(&hub, 0x02);
// Fill the queue with distinct frames.
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
retrier.send_pdu(read); // refused (full) -> on_not_sent -> retry -> refused under the guard, silently
EXPECT_EQ(retrier.not_sent_count_, 1);
EXPECT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
}
namespace {
// From inside on_not_sent, triggers ANOTHER device's send (which will be refused too).
class SendOtherOnNotSentDevice : public ModbusClientDevice {
public:
SendOtherOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
if (this->other_ != nullptr) {
const uint8_t read[] = {0x03, 0x00, 0x60, 0x00, 0x01};
this->other_->send_pdu(read);
}
}
ModbusClientDevice *other_{nullptr};
int not_sent_count_{0};
};
} // namespace
// The refusal recursion guard is per-device: a refusal that lands on a DIFFERENT device while one
// device's notification is on the stack must still deliver - that device did not cause the recursion
// and would otherwise silently lose its terminal callback.
TEST(ModbusClientHubQueue, RefusalForOtherDeviceDeliversDuringNotification) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
SendOtherOnNotSentDevice first(&hub, 0x02);
SentCountingDevice second(&hub, 0x03);
first.other_ = &second;
// Fill the queue with distinct frames.
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
first.send_pdu(read); // refused -> first.on_not_sent -> second's send refused -> second notified
EXPECT_EQ(first.not_sent_count_, 1);
EXPECT_EQ(second.not_sent_count_, 1);
}
// Two devices whose handlers each trigger the other's send cannot recurse without bound: each device
// can be on the notification stack at most once, so the cycle dies as soon as it returns to a device
// whose own on_not_sent() is still running.
TEST(ModbusClientHubQueue, TwoDeviceRefusalCycleTerminates) {
NoResponseProbeHub hub;
SentCountingDevice filler(&hub, 0x05);
SendOtherOnNotSentDevice first(&hub, 0x02);
SendOtherOnNotSentDevice second(&hub, 0x03);
first.other_ = &second;
second.other_ = &first;
// Fill the queue with distinct frames.
for (uint16_t i = 0; i < MODBUS_TX_BUFFER_SIZE; i++) {
const uint8_t fill[] = {0x03, static_cast<uint8_t>(i >> 8), static_cast<uint8_t>(i & 0xFF), 0x00, 0x01};
filler.send_pdu(fill);
}
ASSERT_EQ(hub.queued_frames(), MODBUS_TX_BUFFER_SIZE);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
first.send_pdu(read); // refuse -> first -> second refused -> second -> first suppressed -> unwind
EXPECT_EQ(first.not_sent_count_, 1);
EXPECT_EQ(second.not_sent_count_, 1);
}
namespace {
// From inside on_not_sent, clears its OWN address - its remaining queued frames resolve silently
// (the guard suppresses self-deliveries), while other owners on the address are still notified.
class ClearOwnAddressOnNotSentDevice : public ModbusClientDevice {
public:
ClearOwnAddressOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
this->clear_tx_queue_for_address(/*clear_sent=*/false);
}
int not_sent_count_{0};
};
} // namespace
// The documented cost of the per-device guard: a clear issued from inside your own on_not_sent()
// resolves your remaining frames silently (like clear_tx_queue_for_device() - you cleared them, you
// know), while other owners sharing the address are still notified.
TEST(ModbusClientHubQueue, SelfClearFromNotSentSilentForClearerNotifiesOthers) {
NoResponseProbeHub hub;
ClearOwnAddressOnNotSentDevice clearer(&hub, 0x02);
SentCountingDevice bystander(&hub, 0x02);
const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01};
const uint8_t read_c[] = {0x03, 0x00, 0x30, 0x00, 0x01};
clearer.send_pdu(read_a);
clearer.send_pdu(read_b);
bystander.send_pdu(read_c);
ASSERT_EQ(hub.queued_frames(), 3u);
clearer.send_pdu(std::span<const uint8_t>{}); // refused (empty) -> the handler clears the shared address
EXPECT_EQ(clearer.not_sent_count_, 1); // only the refusal; the two swept frames resolve silently
EXPECT_EQ(bystander.not_sent_count_, 1); // the bystander's swept frame is still notified
EXPECT_EQ(hub.queued_frames(), 0u);
}
// The guard must not over-suppress: a sweep started from inside on_not_sent() still delivers its
// victims' notifications (only nested refusals are silenced).
TEST(ModbusClientHubQueue, NestedClearFromNotSentStillNotifiesVictims) {
NoResponseProbeHub hub;
ClearOtherOnNotSentDevice clearer(&hub, 0x02);
SentCountingDevice victim(&hub, 0x03);
const uint8_t read_a[] = {0x03, 0x00, 0x10, 0x00, 0x01};
const uint8_t read_b[] = {0x03, 0x00, 0x20, 0x00, 0x01};
clearer.send_pdu(read_a);
victim.send_pdu(read_b);
ASSERT_EQ(hub.queued_frames(), 2u);
hub.clear_tx_queue_for_address(0x02, false); // clearer's on_not_sent clears address 0x03 in turn
EXPECT_EQ(clearer.not_sent_count_, 1);
EXPECT_EQ(victim.not_sent_count_, 1); // delivered despite arriving from a nested sweep
EXPECT_EQ(hub.queued_frames(), 0u);
}
namespace {
// tx_blocked() flips to blocked after the first check, so send_next_frame_() passes its own gate but
// send_frame_() refuses - a deterministic transmit failure.
class FlakyBlockHub : public NoResponseProbeHub {
public:
bool tx_blocked() override {
this->tx_blocked_calls_++;
return this->tx_blocked_calls_ > 1;
}
int tx_blocked_calls_{0};
};
// Reacts to a transmit failure by sending another frame from inside the failure callback.
class WriteOnNotSentDevice : public ModbusClientDevice {
public:
WriteOnNotSentDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override {
this->not_sent_count_++;
const uint8_t write[] = {0x06, 0x00, 0x40, 0x01, 0x02};
this->send_pdu(write);
}
int not_sent_count_{0};
};
} // namespace
// A transmit failure must resolve with the failed frame OUT of the queue before its on_not_sent runs: a
// handler that reacts by sending a new frame must not have that frame discarded by the pop that
// follows - the failed frame is popped first, the new frame survives.
TEST(ModbusClientHubQueue, TransmitFailurePopsBeforeNotify) {
FlakyBlockHub hub;
WriteOnNotSentDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read);
ASSERT_EQ(hub.queued_frames(), 1u);
hub.send_next_for_test(); // tx_blocked gate passes, send_frame_ refuses -> failure path
EXPECT_EQ(device.not_sent_count_, 1);
ASSERT_EQ(hub.queued_frames(), 1u); // the handler's write survives...
EXPECT_EQ(hub.front().frame.pdu()[0], 0x06); // ...and it is the write, not the failed read
}
// clear_tx_queue_for_device() drops queued frames SILENTLY - no terminal callback (the documented
// exception to the exactly-one-terminal contract; used during teardown/offline handling).
TEST(ModbusClientHubQueue, ClearDeviceQueueDropsSilently) {
NoResponseProbeHub hub;
SentCountingDevice device(&hub, 0x02);
const uint8_t read_a[] = {0x03, 0x01, 0x00, 0x00, 0x02};
const uint8_t read_b[] = {0x03, 0x02, 0x00, 0x00, 0x02};
device.send_pdu(read_a);
device.send_pdu(read_b);
ASSERT_EQ(hub.queued_frames(), 2u);
device.clear_tx_queue_for_device();
EXPECT_EQ(hub.queued_frames(), 0u);
EXPECT_EQ(device.not_sent_count_, 0); // silent drop: no terminal callback
}
// A send_pdu() from inside on_sent() enqueues behind the in-flight frame rather than sending
// immediately or corrupting the in-flight transaction.
TEST(ModbusClientHubSent, ReentrantSendFromOnSentQueues) {
NullUART uart;
NoResponseProbeHub hub;
hub.set_uart_parent(&uart);
hub.setup();
ChainOnSentDevice device(&hub, 0x02);
device.send_pdu(read_pdu());
hub.send_next_for_test(); // first frame goes on the wire -> on_sent chains a follow-up
EXPECT_TRUE(hub.waiting()); // first frame is in flight
ASSERT_EQ(hub.queued_frames(), 1u); // the follow-up queued behind it, not sent
EXPECT_EQ(hub.queued(0).frame.pdu()[2], 0x09); // it is the chained read (start address 0x0009)
}
namespace {
// Overrides only the DEPRECATED on_modbus_* names: the new-name default implementations must forward, so
// external devices written against the old names keep working through the deprecation window.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
class LegacyNameDevice : public ModbusClientDevice {
public:
LegacyNameDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_modbus_not_sent() override { this->legacy_not_sent_++; }
bool on_modbus_no_response() override {
this->legacy_no_response_++;
return false;
}
int legacy_not_sent_{0};
int legacy_no_response_{0};
};
#pragma GCC diagnostic pop
} // namespace
TEST(ModbusClientHubCompat, LegacyCallbackNamesStillForward) {
NoResponseProbeHub hub;
LegacyNameDevice device(&hub, 0x02);
const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x01};
device.send_pdu(read);
hub.force_send_front();
hub.timeout_waiting(); // no reply -> on_no_response -> forwards to on_modbus_no_response
EXPECT_EQ(device.legacy_no_response_, 1);
device.send_pdu(std::span<const uint8_t>()); // empty PDU refused -> on_not_sent -> forwards
EXPECT_EQ(device.legacy_not_sent_, 1);
}
// The send_pdu() capacity bound: a PDU larger than MAX_PDU_SIZE would build a frame past the RTU
// 256-byte limit, so it is refused up front and signalled like any other failed send.
TEST(ModbusClientHub, OversizedPduIsRefusedWithNotSent) {
NoResponseProbeHub hub;
LegacyNameDevice device(&hub, 0x02);
std::vector<uint8_t> big(MAX_PDU_SIZE + 1, 0x41);
device.send_pdu(big);
EXPECT_EQ(device.legacy_not_sent_, 1); // on_not_sent, observed via the legacy forward
EXPECT_TRUE(hub.tx_buffer_empty());
}
// --- ModbusDevice compatibility shim ------------------------------------------------------------
// External components written against the pre-2026.8 API subclass ModbusDevice and override the
// old callbacks; the shim adapts the span-based hooks back to those signatures.
namespace {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
class LegacyApiDevice : public ModbusDevice {
public:
LegacyApiDevice(ModbusClientHub *hub, uint8_t address) : ModbusDevice(hub, address) {}
void on_modbus_data(const std::vector<uint8_t> &data) override { this->last_data_ = data; }
void on_modbus_error(uint8_t function_code, uint8_t exception_code) override {
this->last_error_fc_ = function_code;
this->last_error_code_ = exception_code;
}
std::vector<uint8_t> last_data_;
int last_error_fc_{-1};
int last_error_code_{-1};
};
#pragma GCC diagnostic pop
} // namespace
TEST(ModbusDeviceShim, LegacyCallbacksReceiveTheOldShapes) {
NoResponseProbeHub hub;
LegacyApiDevice device(&hub, 0x02);
// Read response: on_modbus_data() historically received the payload after the function code and
// the byte-count byte, as an owning vector.
const uint8_t read_req[] = {0x03, 0x00, 0x10, 0x00, 0x02};
device.send_pdu(read_req);
hub.force_send_front();
const uint8_t response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, response);
const std::vector<uint8_t> expected{0x00, 0x2A, 0x01, 0x00};
EXPECT_EQ(device.last_data_, expected);
// Write echo: no byte-count byte, so the payload is everything after the function code.
const uint8_t write_req[] = {0x06, 0x00, 0x10, 0x00, 0x2A};
device.send_pdu(write_req);
hub.force_send_front();
hub.receive_frame_for_test(0x02, write_req); // single-write responses echo the request
const std::vector<uint8_t> expected_echo{0x00, 0x10, 0x00, 0x2A};
EXPECT_EQ(device.last_data_, expected_echo);
// Exception response: on_modbus_error() received the masked function code and the exception code.
device.send_pdu(read_req);
hub.force_send_front();
const uint8_t error[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, error);
EXPECT_EQ(device.last_error_fc_, 0x03);
EXPECT_EQ(device.last_error_code_, 0x02);
}
// --- typed send helpers --------------------------------------------------------------------------
// Each helper is a one-line forward onto a merged builder; these pin the function code and wire
// bytes each one queues, so a swapped code or transposed field cannot survive review silently.
TEST(ModbusTypedSendHelpers, HelpersQueueExpectedPdus) {
NoResponseProbeHub hub;
ModbusClientDevice device(&hub, 0x02);
auto check = [&](const std::vector<uint8_t> &expected) {
ASSERT_EQ(hub.queued_frames(), 1u);
auto pdu = hub.front().frame.pdu();
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
hub.force_send_front();
hub.timeout_waiting(); // default on_no_response() declines the retry, dropping the frame
};
device.read_holding_registers(0x0102, 3);
check({0x03, 0x01, 0x02, 0x00, 0x03});
device.read_input_registers(0x0010, 2);
check({0x04, 0x00, 0x10, 0x00, 0x02});
device.read_coils(0x0020, 10);
check({0x01, 0x00, 0x20, 0x00, 0x0A});
device.read_discrete_inputs(0x0030, 1);
check({0x02, 0x00, 0x30, 0x00, 0x01});
device.write_single_register(0x0040, 0xABCD);
check({0x06, 0x00, 0x40, 0xAB, 0xCD});
device.write_single_coil(0x0041, true);
check({0x05, 0x00, 0x41, 0xFF, 0x00});
device.write_single_coil(0x0041, false);
check({0x05, 0x00, 0x41, 0x00, 0x00});
const uint16_t regs[] = {0x000B, 0x0016};
device.write_multiple_registers(0x0050, regs);
check({0x10, 0x00, 0x50, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16});
const bool coils[] = {true, false, true};
device.write_multiple_coils(0x0060, coils);
check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05});
const uint8_t packed[] = {0x05};
device.write_multiple_coils(0x0060, PackedBits(packed, 3)); // packed overload, same wire bytes
check({0x0F, 0x00, 0x60, 0x00, 0x03, 0x01, 0x05});
}
TEST(ModbusTypedSendHelpers, ReadEntitiesDispatchesByTypeAndRejectsInvalid) {
NoResponseProbeHub hub;
ModbusClientDevice device(&hub, 0x02);
device.read_entities(EntityType::HOLDING, 0x0001, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.front().frame.pdu()[0], 0x03);
hub.force_send_front();
hub.timeout_waiting();
device.read_entities(EntityType::DISCRETE_INPUT, 0x0001, 1);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.front().frame.pdu()[0], 0x02);
hub.force_send_front();
hub.timeout_waiting();
device.read_entities(EntityType::CUSTOM, 0x0001, 1); // no read function: logged and not queued
EXPECT_EQ(hub.queued_frames(), 0u);
}
// A rejected read_entities() signals on_not_sent() like every other refused send.
namespace {
class NotSentCountingDevice : public ModbusClientDevice {
public:
NotSentCountingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {}
void on_not_sent(std::span<const uint8_t> request_pdu) override { this->not_sent_++; }
int not_sent_{0};
};
} // namespace
TEST(ModbusTypedSendHelpers, InvalidReadEntitiesSignalsNotSent) {
NoResponseProbeHub hub;
NotSentCountingDevice device(&hub, 0x02);
device.read_entities(EntityType::CUSTOM, 0x0001, 1);
EXPECT_EQ(device.not_sent_, 1);
EXPECT_EQ(hub.queued_frames(), 0u);
}
} // namespace esphome::modbus::testing
+332 -1
View File
@@ -1,10 +1,12 @@
#include <gtest/gtest.h>
#include <memory>
#include "esphome/components/modbus/modbus_helpers.h"
namespace esphome::modbus::helpers {
using FC = ModbusFunctionCode;
using FC = FunctionCode;
// --- server_frame_length ---------------------------------------------------
// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from
@@ -83,6 +85,13 @@ TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) {
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2);
}
TEST(ModbusClientFrameLength, ReadWriteMultipleByteCountCappedAtSpecLimit) {
// FC 0x17's write byte count caps at the spec 6.17 limit of 121 registers (242 bytes), deliberately
// tighter than FC 0x10's 123, so a corrupt byte count cannot make the parser wait past the real frame.
const uint8_t pdu[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xFF}; // claims 255 bytes
EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 10 + MAX_NUM_OF_REGISTERS_TO_WRITE_RW * 2);
}
TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) {
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9);
@@ -97,6 +106,124 @@ TEST(ModbusClientFrameLength, MiscFixedAndUnknown) {
EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- file-record length cap --------------------------------------------------
// FC 0x14/0x15 are parsed only to keep the frame parser in sync; the byte count caps at 251
// (MAX_PDU_SIZE - 2), reproducing the released frame-relative bound of MAX_FRAME_SIZE - 5.
TEST(ModbusFileRecordCap, PduLengthCapsByteCountAt251) {
const uint8_t pdu[] = {static_cast<uint8_t>(FC::READ_FILE_RECORD), 0xFF}; // claims 255 bytes
EXPECT_EQ(server_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2));
EXPECT_EQ(client_pdu_length(pdu, sizeof(pdu)), 2 + (MAX_PDU_SIZE - 2));
// Frame wrappers: address(1) + PDU + CRC(2) stays within the RTU 256-byte frame limit.
const uint8_t frame[] = {0x01, static_cast<uint8_t>(FC::WRITE_FILE_RECORD), 0xFF};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE);
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), MAX_FRAME_SIZE);
}
TEST(ModbusFileRecordCap, StandardChecksAcceptUpTo251) {
// A full-length PDU at the cap: function(1) + byte count(1) + 251 data bytes = MAX_PDU_SIZE.
std::vector<uint8_t> at_cap(MAX_PDU_SIZE, 0x00);
at_cap[0] = static_cast<uint8_t>(FC::READ_FILE_RECORD);
at_cap[1] = MAX_PDU_SIZE - 2;
EXPECT_TRUE(is_server_pdu_standard(at_cap.data(), at_cap.size()));
EXPECT_TRUE(is_client_pdu_standard(at_cap.data(), at_cap.size()));
// Byte count 252 in the same 253-byte buffer: the parsed length still matches (capped), so this
// exercises the byte-count bound itself rather than the length identity.
at_cap[1] = MAX_PDU_SIZE - 1;
EXPECT_FALSE(is_server_pdu_standard(at_cap.data(), at_cap.size()));
EXPECT_FALSE(is_client_pdu_standard(at_cap.data(), at_cap.size()));
}
// --- is_client_pdu_standard / is_server_pdu_standard -------------------------
// The gatekeepers for the typed client dispatch: a PDU must be exactly its function code's standard
// shape, with byte count, quantity, and address range all consistent.
TEST(ModbusPduStandard, ClientReadAndWriteConformant) {
const uint8_t read_regs[] = {0x03, 0x01, 0x00, 0x00, 0x02};
EXPECT_TRUE(is_client_pdu_standard(read_regs, sizeof(read_regs)));
const uint8_t write_regs[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01, 0x00, 0x02};
EXPECT_TRUE(is_client_pdu_standard(write_regs, sizeof(write_regs)));
// 10 coils pack into 2 data bytes - the coil formula, not the register one.
const uint8_t write_coils[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x02, 0xFF, 0x03};
EXPECT_TRUE(is_client_pdu_standard(write_coils, sizeof(write_coils)));
}
TEST(ModbusPduStandard, ClientRejectsNonConformant) {
// Truncated: header claims 4 data bytes, only 2 present.
const uint8_t truncated[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x04, 0x00, 0x01};
EXPECT_FALSE(is_client_pdu_standard(truncated, sizeof(truncated)));
// Byte count disagrees with quantity (2 registers need 4 bytes, header says 2).
const uint8_t inconsistent[] = {0x10, 0x00, 0x20, 0x00, 0x02, 0x02, 0x00, 0x01};
EXPECT_FALSE(is_client_pdu_standard(inconsistent, sizeof(inconsistent)));
// Coil write using the register byte-count formula (10 coils with 20 data bytes).
const uint8_t coil_as_regs[] = {0x0F, 0x00, 0x30, 0x00, 0x0A, 0x14, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
EXPECT_FALSE(is_client_pdu_standard(coil_as_regs, sizeof(coil_as_regs)));
// Quantity zero and quantity beyond the per-function-code maximum.
const uint8_t zero_qty[] = {0x03, 0x01, 0x00, 0x00, 0x00};
EXPECT_FALSE(is_client_pdu_standard(zero_qty, sizeof(zero_qty)));
const uint8_t too_many[] = {0x03, 0x01, 0x00, 0x00, 0x7E}; // 126 > 125
EXPECT_FALSE(is_client_pdu_standard(too_many, sizeof(too_many)));
// Address range overflow: 0xFFFF + 2 registers exceeds the 16-bit register space.
const uint8_t wraps[] = {0x03, 0xFF, 0xFF, 0x00, 0x02};
EXPECT_FALSE(is_client_pdu_standard(wraps, sizeof(wraps)));
}
TEST(ModbusPduStandard, ServerReadResponses) {
const uint8_t ok[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
EXPECT_TRUE(is_server_pdu_standard(ok, sizeof(ok)));
// Byte-count header disagrees with the actual length.
const uint8_t lying[] = {0x03, 0x06, 0x00, 0x2A, 0x01, 0x00};
EXPECT_FALSE(is_server_pdu_standard(lying, sizeof(lying)));
// An empty PDU (the on_error path) is not a standard response.
EXPECT_FALSE(is_server_pdu_standard(ok, 0));
}
TEST(ModbusPduStandard, ServerResponsesRejectDegenerateShapes) {
// A read response always carries data: byte count zero is non-conformant.
const uint8_t zero_bc[] = {0x03, 0x00};
EXPECT_FALSE(is_server_pdu_standard(zero_bc, sizeof(zero_bc)));
// Registers are 2 bytes each: an odd byte count would silently truncate a register.
const uint8_t odd_bc[] = {0x03, 0x03, 0x00, 0x01, 0x02};
EXPECT_FALSE(is_server_pdu_standard(odd_bc, sizeof(odd_bc)));
// Bit reads have no parity requirement: one packed byte is a fine coil response.
const uint8_t coil_one_byte[] = {0x01, 0x01, 0x05};
EXPECT_TRUE(is_server_pdu_standard(coil_one_byte, sizeof(coil_one_byte)));
// A write-multiple echo claiming 65535 registers written is bounded like the request side.
const uint8_t wild_echo[] = {0x10, 0x00, 0x00, 0xFF, 0xFF};
EXPECT_FALSE(is_server_pdu_standard(wild_echo, sizeof(wild_echo)));
const uint8_t ok_echo[] = {0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_TRUE(is_server_pdu_standard(ok_echo, sizeof(ok_echo)));
}
TEST(ModbusPduStandard, SingleCoilValueMustBeCanonical) {
// FC 0x05's value field allows exactly 0xFF00 (ON) and 0x0000 (OFF); anything else is non-standard.
const uint8_t on[] = {0x05, 0x00, 0x10, 0xFF, 0x00};
const uint8_t off[] = {0x05, 0x00, 0x10, 0x00, 0x00};
const uint8_t junk[] = {0x05, 0x00, 0x10, 0x12, 0x34};
EXPECT_TRUE(is_client_pdu_standard(on, sizeof(on)));
EXPECT_TRUE(is_client_pdu_standard(off, sizeof(off)));
EXPECT_FALSE(is_client_pdu_standard(junk, sizeof(junk)));
EXPECT_TRUE(is_server_pdu_standard(on, sizeof(on))); // the response echoes the request
EXPECT_FALSE(is_server_pdu_standard(junk, sizeof(junk)));
}
TEST(ModbusPduStandard, NonStandardFunctionCodesAcceptedOnLengthAlone) {
// Custom, unimplemented, and exception function codes have no standard shape to check: they are
// accepted whenever the parsed length matches, so a dispatcher can still route them by function
// code instead of having them rejected outright. This is the documented contract - see the header.
const uint8_t custom[] = {0x42}; // user-defined space; 1 byte matches the MIN_PDU_SIZE fallback
EXPECT_TRUE(is_client_pdu_standard(custom, sizeof(custom)));
EXPECT_TRUE(is_server_pdu_standard(custom, sizeof(custom)));
const uint8_t unimplemented[] = {0x07}; // READ_EXCEPTION_STATUS
EXPECT_TRUE(is_server_pdu_standard(unimplemented, sizeof(unimplemented)));
const uint8_t exception[] = {0x83, 0x02}; // exception response; length pinned to 2 bytes
EXPECT_TRUE(is_server_pdu_standard(exception, sizeof(exception)));
// The length identity still gates: extra bytes beyond the parsed fallback are non-conformant.
const uint8_t custom_long[] = {0x42, 0x01};
EXPECT_FALSE(is_client_pdu_standard(custom_long, sizeof(custom_long)));
}
// --- create_client_pdu -----------------------------------------------------
// PDU = function code + data (no address, no CRC).
@@ -179,6 +306,31 @@ TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) {
EXPECT_TRUE(pdu.empty());
}
// The generic write path requires the data length to agree exactly with the entity count
// (registers: 2 bytes each; coils: 8 packed per byte) - the same rule the response dispatch
// enforces via is_client_pdu_standard(), so a frame built here always passes that gate.
TEST(ModbusCreateClientPdu, WriteMultipleRejectsMismatchedDataLength) {
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
// 2 registers need exactly 4 data bytes.
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 3).empty());
EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, 4).empty());
// 10 coils pack into exactly 2 data bytes - the coil formula, not the register one.
EXPECT_FALSE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2).empty());
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 4).empty());
}
TEST(ModbusCreateClientPdu, WriteCoilsUseTheCoilLimitNotTheRegisterLimit) {
// 200 coils: above the 123-register write limit but well within the 1968-coil limit; 25 data bytes.
std::vector<uint8_t> values(25, 0xAA);
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 200, values.data(), values.size());
ASSERT_FALSE(pdu.empty());
EXPECT_EQ(pdu[5], 25); // byte count uses the coil formula
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); // builder output passes the validator
// Builder and validator agree at the top of the range too: 1969 coils rejected.
std::vector<uint8_t> big((1969 + 7) / 8, 0x00);
EXPECT_TRUE(create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 1969, big.data(), big.size()).empty());
}
TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) {
const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_FALSE(payload_to_number(std::span<const uint8_t>(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value());
@@ -229,4 +381,183 @@ TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value());
}
// --- typed builders ----------------------------------------------------------
TEST(ModbusTypedBuilders, ReadPduWireBytes) {
auto pdu = create_read_pdu(FC::READ_HOLDING_REGISTERS, 0x0102, 3);
const std::vector<uint8_t> expected{0x03, 0x01, 0x02, 0x00, 0x03};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size()));
// Reads that run past the 16-bit address space are refused.
EXPECT_TRUE(create_read_pdu(FC::READ_HOLDING_REGISTERS, 0xFFFF, 2).empty());
}
TEST(ModbusTypedBuilders, WriteSinglePduWireBytes) {
auto reg = create_write_single_register_pdu(0x0010, 0xABCD);
const std::vector<uint8_t> expected_reg{0x06, 0x00, 0x10, 0xAB, 0xCD};
EXPECT_EQ(std::vector<uint8_t>(reg.begin(), reg.end()), expected_reg);
EXPECT_TRUE(is_client_pdu_standard(reg.data(), reg.size()));
auto coil_on = create_write_single_coil_pdu(0x0011, true);
auto coil_off = create_write_single_coil_pdu(0x0011, false);
const std::vector<uint8_t> expected_on{0x05, 0x00, 0x11, 0xFF, 0x00};
const std::vector<uint8_t> expected_off{0x05, 0x00, 0x11, 0x00, 0x00};
EXPECT_EQ(std::vector<uint8_t>(coil_on.begin(), coil_on.end()), expected_on);
EXPECT_EQ(std::vector<uint8_t>(coil_off.begin(), coil_off.end()), expected_off);
EXPECT_TRUE(is_client_pdu_standard(coil_on.data(), coil_on.size()));
EXPECT_TRUE(is_client_pdu_standard(coil_off.data(), coil_off.size()));
}
TEST(ModbusTypedBuilders, WriteRegistersPduWireBytes) {
const uint16_t values[] = {0x000B, 0x0016};
auto pdu = create_write_registers_pdu(0x0000, values);
const std::vector<uint8_t> expected{0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size()));
// Writes that run past the 16-bit address space are refused.
EXPECT_TRUE(create_write_registers_pdu(0xFFFF, values).empty());
}
TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) {
std::vector<uint16_t> values(MAX_NUM_OF_REGISTERS_TO_WRITE + 1, 0xAAAA);
EXPECT_TRUE(create_write_registers_pdu(0x0000, values).empty());
values.pop_back();
EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty());
}
TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) {
// The container overload appends - the semantic every migrated caller relies on when a lambda
// has already put words into the buffer.
std::vector<uint16_t> data{0x1234};
float_to_payload(data, 1.0f, SensorValueType::U_WORD);
ASSERT_EQ(data.size(), 2u);
EXPECT_EQ(data[0], 0x1234);
EXPECT_EQ(data[1], 0x0001);
}
TEST(ModbusCreateClientPdu, ExceptionFlaggedWriteCodesRejected) {
// is_function_code_write() masks the exception bit; the builder must not.
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
EXPECT_TRUE(create_client_pdu(FunctionCode(0x90), 0x0000, 2, values, 4).empty());
EXPECT_TRUE(create_client_pdu(FunctionCode(0x85), 0x0000, 1, values, 2).empty());
}
TEST(ModbusTypedBuilders, BoolSpanCoilBuilderRejectsOverLimit) {
// This early guard is what keeps the 246-byte packing buffer from overflowing - the shared core's
// identical check runs after packing, so it cannot protect it.
auto big = std::make_unique<bool[]>(MAX_NUM_OF_COILS_TO_WRITE + 1);
EXPECT_TRUE(create_write_coils_pdu(0, std::span<const bool>(big.get(), MAX_NUM_OF_COILS_TO_WRITE + 1)).empty());
}
TEST(ModbusCreateClientPdu, GenericCoilWriteMasksTrailingPadBits) {
// 10 coils with junk in the pad bits of the last data byte: the generic path masks them like the
// typed builder, so both produce identical wire bytes.
const uint8_t values[] = {0xFF, 0xFF};
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_COILS, 0x0000, 10, values, 2);
ASSERT_FALSE(pdu.empty());
EXPECT_EQ(pdu[pdu.size() - 1], 0x03); // bits 8-9 kept, pad bits 10-15 zeroed
}
TEST(ModbusCreateClientPdu, SingleCoilValueValidated) {
const uint8_t on[] = {0xFF, 0x00};
const uint8_t junk[] = {0x01, 0x00};
EXPECT_FALSE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, on, 2).empty());
EXPECT_TRUE(create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, junk, 2).empty());
}
// --- create_write_coils_pdu (packed) ---------------------------------------
TEST(ModbusWriteCoilsPacked, MatchesBoolBuilder) {
const bool coils[] = {true, false, true, true, false, false, true, false, true, true};
uint8_t packed[] = {0b01001101, 0b00000011};
auto from_bools = create_write_coils_pdu(0x13, coils);
auto from_packed = create_write_coils_pdu(0x13, PackedBits(packed, 10));
ASSERT_EQ(from_packed.size(), from_bools.size());
EXPECT_EQ(0, memcmp(from_packed.data(), from_bools.data(), from_bools.size()));
}
TEST(ModbusWriteCoilsPacked, MasksUnusedTrailingBits) {
uint8_t packed[] = {0xFF};
auto pdu = create_write_coils_pdu(0, PackedBits(packed, 3));
ASSERT_EQ(pdu.size(), 7u);
EXPECT_EQ(pdu[6], 0x07);
}
TEST(ModbusWriteCoilsPacked, RejectsShortBufferAndZeroCount) {
uint8_t packed[] = {0xFF};
EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 9)).empty()); // needs 2 bytes
EXPECT_TRUE(create_write_coils_pdu(0, PackedBits(packed, 0)).empty());
}
TEST(ModbusHelpersTest, PackedBitsReadsLsbFirst) {
const uint8_t packed[] = {0x0D, 0x03}; // bits 0,2,3 and 8,9
PackedBits bits(packed, 11);
EXPECT_EQ(bits.size(), 11u);
EXPECT_TRUE(bits[0]);
EXPECT_FALSE(bits[1]);
EXPECT_TRUE(bits[2]);
EXPECT_TRUE(bits[3]);
EXPECT_FALSE(bits[7]);
EXPECT_TRUE(bits[8]);
EXPECT_TRUE(bits[9]);
EXPECT_FALSE(bits[10]);
EXPECT_EQ(bits.bytes().size(), 2u);
}
TEST(ModbusHelpersTest, MutablePackedBitsSetsAndClears) {
uint8_t packed[2] = {0x00, 0xFF};
MutablePackedBits bits(packed, 16);
bits.set(0, true);
bits.set(3, true);
bits.set(9, false);
EXPECT_EQ(packed[0], 0x09); // bits 0 and 3
EXPECT_EQ(packed[1], 0xFD); // bit 9 (bit 1 of byte 1) cleared
}
TEST(ModbusHelpersTest, MutablePackedBitsRoundTripAndConversion) {
const bool original[] = {true, true, false, true, false, false, false, false, true, false, true};
constexpr uint16_t count = sizeof(original);
uint8_t packed[(count + 7) / 8] = {};
MutablePackedBits out(packed, count);
for (uint16_t i = 0; i != count; i++)
out.set(i, original[i]);
PackedBits view = out; // implicit conversion to the read-only view
ASSERT_EQ(view.size(), count);
for (uint16_t i = 0; i != count; i++)
EXPECT_EQ(view[i], original[i]) << "bit " << i;
}
TEST(ModbusHelpersTest, PackedBitsViewContractsEnforced) {
uint8_t buf[8] = {};
PackedBits view(buf, 10); // 10 bits -> 2 bytes, over an 8-byte buffer
EXPECT_EQ(view.bytes().size(), 2u);
MutablePackedBits bits(std::span<uint8_t>(buf, 2), 10);
bits.set(9, true); // in range: lands in byte 1
bits.set(10, true); // out of range: dropped
bits.set(300, true); // far out of range: dropped, no write past the span
MutablePackedBits short_bits(std::span<uint8_t>(buf, 1), 10); // contract-violating: 10 bits over 1 byte
short_bits.set(9, false); // within count_ but past the span: dropped (would clear bit 9 set above)
EXPECT_EQ(buf[1], 0x02);
for (size_t i = 2; i < sizeof(buf); i++)
EXPECT_EQ(buf[i], 0) << "byte " << i;
}
// server_pdu_payload() must never classify an exception PDU as a read: [fc|0x80, code] is 2 bytes, and a
// read-offset of 2 would return an empty span, losing the exception code. The payload of an exception PDU
// is the exception code byte, for reads and writes alike.
TEST(ModbusServerPduPayload, ExceptionOfReadYieldsExceptionCode) {
const uint8_t pdu[] = {0x83, 0x02}; // exception response to READ_HOLDING_REGISTERS
auto payload = server_pdu_payload(pdu);
ASSERT_EQ(payload.size(), 1u);
EXPECT_EQ(payload[0], 0x02);
}
TEST(ModbusServerPduPayload, ExceptionOfWriteYieldsExceptionCode) {
const uint8_t pdu[] = {0x86, 0x03}; // exception response to WRITE_SINGLE_REGISTER
auto payload = server_pdu_payload(pdu);
ASSERT_EQ(payload.size(), 1u);
EXPECT_EQ(payload[0], 0x03);
}
} // namespace esphome::modbus::helpers
@@ -18,7 +18,7 @@ binary_sensor:
modbus_controller_id: modbus_controller1
id: modbus_binary_sensor2
name: Test Binary Sensor with Lambda
register_type: read
register_type: input
address: 0x3201
lambda: |-
return x;
@@ -4,7 +4,7 @@
namespace esphome::modbus_server {
using modbus::ModbusExceptionCode;
using modbus::ExceptionCode;
using modbus::RegisterValues;
namespace {
@@ -73,7 +73,7 @@ TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) {
auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_VALUE);
EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied
EXPECT_FALSE(dword_written);
}
@@ -87,7 +87,7 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) {
auto status = server.on_write_registers(0x0000, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// An address with no registered register yields ILLEGAL_DATA_ADDRESS.
@@ -96,7 +96,7 @@ TEST(ModbusServerWrite, UnmatchedAddressRejected) {
auto status = server.on_write_registers(0x0005, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already
@@ -117,7 +117,7 @@ TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) {
auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE);
EXPECT_EQ(status.value(), ExceptionCode::SERVICE_DEVICE_FAILURE);
EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure
}
@@ -168,7 +168,7 @@ TEST(ModbusServerRead, StartInsideValueRejected) {
auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_FALSE(read_called);
}
@@ -187,7 +187,7 @@ TEST(ModbusServerRead, ClippedTailRejected) {
auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_FALSE(read_called);
}
@@ -203,7 +203,7 @@ TEST(ModbusServerRead, WriteOnlyRegisterRejected) {
auto status = server.on_read_registers(0x0000, 1, out);
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// An unregistered address with courtesy enabled returns the default value for each cell.
@@ -227,7 +227,7 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
auto status = server.on_read_registers(0x0005, 1, out);
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// --- partial reads (opt-in) ----------------------------------------------------
+13
View File
@@ -0,0 +1,13 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.enable_codegen()
real_to_code = manifest.to_code
async def to_code_testing(config):
await real_to_code(config)
cg.add_define("USE_NETWORK_IPV6", True)
manifest.to_code = to_code_testing
@@ -0,0 +1,23 @@
# Compiled dual-stack test: wifi + ethernet coexisting via network: priority:.
# This is the first build path that keeps both radios' stacks compiled in, so
# it must actually compile (not just validate) to guard the reconciler wiring.
# WiFi is listed first so the build also exercises the wifi-primary branch in
# network/util.cpp (the ethernet-primary branch matches the legacy order).
wifi:
ssid: MySSID
password: password1
ethernet:
type: W5500
clk_pin: GPIO19
mosi_pin: GPIO21
miso_pin: GPIO23
cs_pin: GPIO18
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 10Mhz
network:
priority:
- wifi
- ethernet
@@ -0,0 +1,208 @@
#include <gtest/gtest.h>
#include "esphome/components/network/ip_address.h"
#ifdef USE_HOST
#if USE_NETWORK_IPV6
namespace esphome::network::testing {
// =========================================================================
// IPv4
// =========================================================================
TEST(IPAddressHost, IPv4DefaultNotSet) {
IPAddress addr;
EXPECT_FALSE(addr.is_set());
}
TEST(IPAddressHost, IPv4DefaultIsIPv4) {
IPAddress addr;
EXPECT_TRUE(addr.is_ip4());
EXPECT_FALSE(addr.is_ip6());
}
TEST(IPAddressHost, IPv4ParseAndSerialize) {
IPAddress addr("192.168.1.1");
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "192.168.1.1");
}
TEST(IPAddressHost, IPv4FromOctets) {
IPAddress addr(192, 168, 1, 1);
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "192.168.1.1");
}
TEST(IPAddressHost, IPv4IsSet) {
IPAddress addr("192.168.1.1");
EXPECT_TRUE(addr.is_set());
}
TEST(IPAddressHost, IPv4IsIp4) {
IPAddress addr("192.168.1.1");
EXPECT_TRUE(addr.is_ip4());
EXPECT_FALSE(addr.is_ip6());
}
TEST(IPAddressHost, IPv4MulticastDetected) {
IPAddress addr("239.0.60.53");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv4MulticastBoundaryLow) {
IPAddress addr("224.0.0.0");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv4MulticastBoundaryHigh) {
IPAddress addr("239.255.255.255");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv4UnicastNotMulticast) {
IPAddress addr("192.168.1.1");
EXPECT_FALSE(addr.is_multicast());
}
TEST(IPAddressHost, IPv4EqualityMatch) {
IPAddress a("192.168.1.1");
IPAddress b("192.168.1.1");
EXPECT_EQ(a, b);
}
TEST(IPAddressHost, IPv4EqualityMismatch) {
IPAddress a("192.168.1.1");
IPAddress b("192.168.1.2");
EXPECT_NE(a, b);
}
TEST(IPAddressHost, IPv4FromOctetsMatchesParse) {
IPAddress from_octets(192, 168, 1, 1);
IPAddress from_string("192.168.1.1");
EXPECT_EQ(from_octets, from_string);
}
TEST(IPAddressHost, IPv4FromIPAddrT) {
ip_addr_t raw;
memset(&raw, 0, sizeof(raw));
raw.u_addr.ip4.s_addr = htonl((192u << 24) | (168u << 16) | (1u << 8) | 1u);
raw.type = IPADDR_TYPE_V4;
IPAddress addr(&raw);
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "192.168.1.1");
EXPECT_TRUE(addr.is_ip4());
EXPECT_FALSE(addr.is_ip6());
}
// =========================================================================
// IPv6
// =========================================================================
TEST(IPAddressHost, IPv6ParseAndSerialize) {
IPAddress addr("ff12::cafe");
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "ff12::cafe");
}
TEST(IPAddressHost, IPv6Loopback) {
IPAddress addr("::1");
char buf[IP_ADDRESS_BUFFER_SIZE];
EXPECT_STREQ(addr.str_to(buf), "::1");
}
TEST(IPAddressHost, IPv6IsIp6) {
IPAddress addr("ff12::cafe");
EXPECT_TRUE(addr.is_ip6());
EXPECT_FALSE(addr.is_ip4());
}
TEST(IPAddressHost, IPv6AllZerosNotSet) {
IPAddress addr("::");
EXPECT_FALSE(addr.is_set());
}
TEST(IPAddressHost, IPv6LoopbackIsSet) {
IPAddress addr("::1");
EXPECT_TRUE(addr.is_set());
}
TEST(IPAddressHost, IPv6MulticastDetected) {
IPAddress addr("ff12::cafe");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv6MulticastLinkLocal) {
IPAddress addr("ff02::1");
EXPECT_TRUE(addr.is_multicast());
}
TEST(IPAddressHost, IPv6UnicastNotMulticast) {
IPAddress addr("::1");
EXPECT_FALSE(addr.is_multicast());
}
TEST(IPAddressHost, IPv6EqualityMatch) {
IPAddress a("ff12::cafe");
IPAddress b("ff12::cafe");
EXPECT_EQ(a, b);
}
TEST(IPAddressHost, IPv6EqualityMismatch) {
IPAddress a("ff12::cafe");
IPAddress b("ff02::1");
EXPECT_NE(a, b);
}
TEST(IPAddressHost, IPv6OutputIsLowercase) {
// inet_pton is case-insensitive; str_to must lowercase the output
IPAddress addr("FF12::CAFE");
char buf[IP_ADDRESS_BUFFER_SIZE];
addr.str_to(buf);
for (const char *p = buf; *p; ++p) {
EXPECT_FALSE(*p >= 'A' && *p <= 'F') << "uppercase letter in: " << buf;
}
}
TEST(IPAddressHost, IPv6FullAddressRoundTrip) {
// A full 128-bit address with no compression opportunity
const char *input = "fde0:983a:d0d3:a65e:725a:0fff:fe36:9916";
IPAddress addr(input);
char buf[IP_ADDRESS_BUFFER_SIZE];
addr.str_to(buf);
EXPECT_NE(buf[0], '\0');
EXPECT_NE(std::string(buf).find("fde0"), std::string::npos);
}
// =========================================================================
// Malformed input
// =========================================================================
TEST(IPAddressHost, MalformedIPv4YieldsEmptyAddress) {
IPAddress addr("not-an-ip");
EXPECT_FALSE(addr.is_set());
EXPECT_TRUE(addr.is_ip4());
}
TEST(IPAddressHost, MalformedIPv6YieldsEmptyAddress) {
// "gg::1" looks like IPv6 (contains ':') but fails inet_pton; addr stays
// zeroed (type=V4 from memset) so is_set() is false and is_ip4() is true.
IPAddress addr("gg::1");
EXPECT_FALSE(addr.is_set());
EXPECT_TRUE(addr.is_ip4());
}
// =========================================================================
// Cross-family
// =========================================================================
TEST(IPAddressHost, IPv4AndIPv6NotEqual) {
IPAddress v4("192.168.1.1");
IPAddress v6("::1");
EXPECT_NE(v4, v6);
}
} // namespace esphome::network::testing
#endif // USE_NETWORK_IPV6
#endif // USE_HOST
@@ -0,0 +1,4 @@
zephyr_ble_server:
ota:
- platform: zephyr_mcumgr
+20 -16
View File
@@ -7,36 +7,40 @@ udp:
addresses: ["239.0.60.53"]
packet_transport:
platform: udp
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
ping_pong_enable: true
binary_sensors:
- binary_sensor_id1
- id: binary_sensor_id1
broadcast_id: other_id
sensors:
- sensor_id1
- id: sensor_id1
broadcast_id: other_id
providers:
- name: some-device-name
encryption: "their key goes here"
- platform: udp
id: transport_udp
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
ping_pong_enable: true
binary_sensors:
- binary_sensor_id1
- id: binary_sensor_id1
broadcast_id: other_id
sensors:
- sensor_id1
- id: sensor_id1
broadcast_id: other_id
providers:
- name: some-device-name
encryption: "their key goes here"
sensor:
- platform: template
id: sensor_id1
- platform: packet_transport
transport_id: transport_udp
provider: some-device-name
id: our_id
remote_id: some_sensor_id
binary_sensor:
- platform: packet_transport
transport_id: transport_udp
provider: unencrypted-device
id: other_binary_sensor_id
- platform: packet_transport
transport_id: transport_udp
provider: some-device-name
type: status
name: Some-Device Status
@@ -3,36 +3,40 @@ udp:
addresses: ["239.0.60.53"]
packet_transport:
platform: udp
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
ping_pong_enable: true
binary_sensors:
- binary_sensor_id1
- id: binary_sensor_id1
broadcast_id: other_id
sensors:
- sensor_id1
- id: sensor_id1
broadcast_id: other_id
providers:
- name: some-device-name
encryption: "their key goes here"
- platform: udp
id: transport_udp
update_interval: 5s
encryption: "our key goes here"
rolling_code_enable: true
ping_pong_enable: true
binary_sensors:
- binary_sensor_id1
- id: binary_sensor_id1
broadcast_id: other_id
sensors:
- sensor_id1
- id: sensor_id1
broadcast_id: other_id
providers:
- name: some-device-name
encryption: "their key goes here"
sensor:
- platform: template
id: sensor_id1
- platform: packet_transport
transport_id: transport_udp
provider: some-device-name
id: our_id
remote_id: some_sensor_id
binary_sensor:
- platform: packet_transport
transport_id: transport_udp
provider: unencrypted-device
id: other_binary_sensor_id
- platform: packet_transport
transport_id: transport_udp
provider: some-device-name
type: status
name: Some-Device Status
@@ -198,7 +198,7 @@ button:
0xFF,
]
- platform: template
name: Haier
name: Haier Long
on_press:
remote_transmitter.transmit_haier:
code:
@@ -217,6 +217,21 @@ button:
0x00,
0x05,
]
- platform: template
name: Haier Short
on_press:
remote_transmitter.transmit_haier:
code:
[
0xA6,
0xDA,
0x00,
0x00,
0x40,
0x40,
0x00,
0x80,
]
- platform: template
name: Mirage
on_press:
@@ -0,0 +1,11 @@
# Compile with OTA rollback support active (ota + safe_mode on ESP-IDF, the
# default) but boot_is_good_on_shutdown disabled, so an orderly shutdown does
# not confirm the app image; only boot_is_good_after / mark_successful do.
packages:
safe_mode: !include common-enabled.yaml
safe_mode:
boot_is_good_on_shutdown: false
ota:
- platform: esphome
+5 -4
View File
@@ -24,10 +24,10 @@ sensor:
name: PM <10µm Weight concentration
id: pm_10_0
accuracy_decimals: 1
nox:
name: NOx
voc:
name: VOC
nox_index:
name: NOx Index
voc_index:
name: VOC Index
algorithm_tuning:
index_offset: 100
learning_time_offset_hours: 12
@@ -42,4 +42,5 @@ sensor:
auto_cleaning_interval: 604800s
acceleration_mode: low
store_baseline: true
model: sen55
address: 0x69
+4 -4
View File
@@ -26,10 +26,10 @@ sensor:
name: PM <10µm Weight concentration
id: sen6x_pm_10_0
accuracy_decimals: 1
nox:
name: NOx
voc:
name: VOC
nox_index:
name: NOx Index
voc_index:
name: VOC Index
co2:
name: Carbon Dioxide
formaldehyde:
+3 -3
View File
@@ -1,7 +1,7 @@
sensor:
- platform: sgp4x
i2c_id: i2c_bus
voc:
voc_index:
name: VOC Index
id: sgp40_voc_index
algorithm_tuning:
@@ -11,8 +11,8 @@ sensor:
gating_max_duration_minutes: 180
std_initial: 50
gain_factor: 230
nox:
name: NOx
nox_index:
name: NOx Index
algorithm_tuning:
index_offset: 100
learning_time_offset_hours: 12
+1 -1
View File
@@ -2,7 +2,7 @@ udp:
addresses: ["239.0.60.53"]
time:
platform: host
- platform: host
syslog:
port: 514
+11
View File
@@ -4,6 +4,17 @@ wifi:
binary_sensor:
cover:
- platform: template
name: "Template Cover Assumed"
# assumed_state must be reflected in the web_server JSON (detail=all)
assumed_state: true
lambda: 'return COVER_OPEN;'
open_action:
- logger.log: open_action
close_action:
- logger.log: close_action
stop_action:
- logger.log: stop_action
fan:
light:
sensor:
+8
View File
@@ -187,6 +187,7 @@ loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
state_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
"""This callback only receives NEW state changes, not initial states."""
states[state.key] = state
@@ -195,6 +196,7 @@ def on_state(state: EntityState) -> None:
if not state_future.done():
state_future.set_result(state)
# Get entities and set up state synchronization
entities, services = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
@@ -228,6 +230,7 @@ loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
state_future: asyncio.Future[EntityState] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
# Check for specific condition using isinstance
@@ -235,6 +238,7 @@ def on_state(state: EntityState) -> None:
if not state_future.done():
state_future.set_result(state)
client.subscribe_states(on_state)
# Wait for state with timeout
@@ -263,11 +267,13 @@ entity_count = 50
received_states: set[int] = set()
all_states_future: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None:
received_states.add(state.key)
if len(received_states) >= entity_count and not all_states_future.done():
all_states_future.set_result(True)
client.subscribe_states(on_state)
await asyncio.wait_for(all_states_future, timeout=10.0)
```
@@ -367,6 +373,7 @@ service_future = loop.create_future()
connected_pattern = re.compile(r"Client .* connected from")
service_pattern = re.compile(r"Service called")
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not connected_future.done() and connected_pattern.search(line):
@@ -374,6 +381,7 @@ def check_output(line: str) -> None:
elif not service_future.done() and service_pattern.search(line):
service_future.set_result(True)
async with run_compiled(yaml_config, line_callback=check_output):
async with api_client_connected() as client:
# Wait for specific log message
@@ -0,0 +1,66 @@
esphome:
name: blocking-warning-cascade
on_boot:
then:
- script.execute: blocking_60
- script.execute: blocking_90
- script.execute: blocking_120
host:
api:
logger:
level: DEBUG
on_message:
level: WARN
then:
- lambda: |-
uint32_t injected_delay = 0;
if (strstr(message, "blocking_60 took a long time") != nullptr) {
injected_delay = 60;
} else if (strstr(message, "blocking_90 took a long time") != nullptr) {
injected_delay = 90;
} else if (strstr(message, "blocking_120 took a long time") != nullptr) {
injected_delay = 120;
}
if (injected_delay != 0) {
id(injected_delay_total) += injected_delay;
const uint32_t start = millis();
while (millis() - start < injected_delay) {
}
}
globals:
- id: injected_delay_total
type: uint32_t
initial_value: "0"
script:
- id: blocking_60
then:
- delay: 20ms
- lambda: |-
const uint32_t start = millis();
while (millis() - start < 80) {
}
- id: blocking_90
then:
- delay: 300ms
- lambda: |-
const uint32_t start = millis();
while (millis() - start < 80) {
}
- id: blocking_120
then:
- delay: 600ms
- lambda: |-
const uint32_t start = millis();
while (millis() - start < 80) {
}
- delay: 200ms
- logger.log:
format: "BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=%u"
args: [id(injected_delay_total)]
@@ -0,0 +1,29 @@
esphome:
name: light-binary-effect-off
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: binary_output
type: binary
write_action:
- logger.log:
format: "BINARY_OUTPUT:%s"
args: [YESNO(state)]
light:
- platform: binary
name: "Test Binary Light"
id: test_binary_light
output: binary_output
effects:
- strobe:
name: "Fast Strobe"
colors:
- state: true
duration: 50ms
- state: false
duration: 50ms
@@ -0,0 +1,21 @@
esphome:
name: light-binary-zero-bright
host:
api: # Port will be automatically injected
logger:
level: DEBUG
output:
- platform: template
id: binary_output
type: binary
write_action:
- logger.log:
format: "BINARY_OUTPUT:%s"
args: [YESNO(state)]
light:
- platform: binary
name: "Test Binary Light"
id: test_binary_light
output: binary_output
@@ -0,0 +1,63 @@
"""Regression test for blocking-warning log time attribution."""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
WARN_PATTERN = re.compile(
r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms"
)
COMPLETE_PATTERN = re.compile(r"BLOCKING_WARNING_CASCADE_TEST_COMPLETE total=(\d+)")
PRIMARY_SOURCES = {"blocking_60", "blocking_90", "blocking_120"}
@pytest.mark.asyncio
async def test_blocking_warning_log_time_not_charged_to_next_operation(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Synchronous warning-log delays must not be charged to the next operation."""
loop = asyncio.get_running_loop()
complete = asyncio.Event()
warnings: list[tuple[str, int, int]] = []
injected_delay_total = 0
def check_output(line: str) -> None:
nonlocal injected_delay_total
if match := WARN_PATTERN.search(line):
warnings.append((match.group(1), int(match.group(2)), int(match.group(3))))
if match := COMPLETE_PATTERN.search(line):
injected_delay_total = int(match.group(1))
loop.call_soon_threadsafe(complete.set)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
assert await client.device_info() is not None
await asyncio.wait_for(complete.wait(), timeout=10.0)
assert injected_delay_total == 270, (
f"Expected 270 ms of injected warning-log delay, got {injected_delay_total} ms"
)
primary_warnings = [
warning for warning in warnings if warning[0] in PRIMARY_SOURCES
]
assert {warning[0] for warning in primary_warnings} == PRIMARY_SOURCES, (
f"Expected one real blocking warning from each test script, got: {warnings}"
)
secondary_warnings = [
warning for warning in warnings if warning[0] not in PRIMARY_SOURCES
]
assert not secondary_warnings, (
"Warning-handler time was incorrectly charged to the next operation: "
f"{secondary_warnings}"
)
@@ -0,0 +1,207 @@
"""Integration test verifying the off phase of an effect reaches an ON/OFF-only light.
Regression test for https://github.com/esphome/esphome/issues/17873. A strobe effect
encodes its dark phase as `brightness = 0` while keeping `state = true`, so that the
effect keeps running instead of being stopped by an explicit turn-off. On a dimmable
light that works, because the output is driven by `state * brightness`. On a binary
light the dark phase used to be dropped, so the output stayed on forever.
Effect ticks are published with `publish: false` (so Home Assistant isn't spammed with
every frame), so the effect's actual output can't be observed via API state broadcasts.
Instead, this test reads the output component's log lines, which are written on every
update regardless of the publish flag.
The output log line is emitted strictly after the API state response: `perform()`
publishes inline, but the write is deferred to the next `LightState::loop()` iteration
and then has to cross the subprocess stdout pipe. So a future is armed *before* each
command and awaited afterwards, rather than reading the last observed value.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from aioesphomeapi import EntityState, LightState
import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction
OUTPUT_PATTERN = re.compile(r"BINARY_OUTPUT:(YES|NO)")
@pytest.mark.asyncio
async def test_light_binary_effect_off_phase(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A strobe effect must drive a binary light's output both on and off."""
loop = asyncio.get_running_loop()
observed: list[bool] = []
pending: list[asyncio.Future[bool]] = []
def on_log_line(line: str) -> None:
if match := OUTPUT_PATTERN.search(line):
value = match.group(1) == "YES"
observed.append(value)
while pending:
future = pending.pop(0)
if not future.done():
future.set_result(value)
break
def arm_output() -> asyncio.Future[bool]:
"""Arm a future for the next output write, before sending the command."""
future: asyncio.Future[bool] = loop.create_future()
pending.append(future)
return future
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light = next(e for e in entities if e.object_id == "test_binary_light")
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
# ESPHome sends the current state of every entity right after connecting; drain
# that initial burst so it can't be mistaken for the response to a command below.
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
"""Send a light command and wait for the matching state response."""
state_futures[light.key] = loop.create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
# A plain turn-on must drive the output on -- brightness defaults to 100% and
# must not be mistaken for a dark phase.
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Plain turn-on did not switch the output on"
)
# Run the strobe effect; both phases must reach the output.
observed.clear()
state = await send_and_wait(effect="Fast Strobe")
assert state.effect == "Fast Strobe"
# Let several effect cycles run (each phase is 50ms in the fixture).
await asyncio.sleep(1.0)
assert True in observed, (
f"Strobe effect never switched the output on -- got {observed}"
)
assert False in observed, (
f"Strobe effect never switched the output off; its dark phase was lost -- "
f"got {observed}"
)
# Stopping the effect must leave the light usable.
state = await send_and_wait(effect="None")
assert state.effect == "None"
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Light stayed off after the effect stopped"
)
# An explicit turn-off still switches the output off.
output = arm_output()
state = await send_and_wait(state=False)
assert state.state is False
assert await asyncio.wait_for(output, timeout=5.0) is False, (
"Turn-off did not switch the output off"
)
@pytest.mark.asyncio
async def test_light_binary_zero_brightness_is_recoverable(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Zero brightness on an ON/OFF light must not leave it permanently stuck off.
An ON/OFF light has no brightness capability, so `turn_on` with 0% brightness has
no representable "on but dark" state. It must switch the output off and report the
light as off, and a later plain turn-on must bring it back.
"""
loop = asyncio.get_running_loop()
pending: list[asyncio.Future[bool]] = []
def on_log_line(line: str) -> None:
if match := OUTPUT_PATTERN.search(line):
value = match.group(1) == "YES"
while pending:
future = pending.pop(0)
if not future.done():
future.set_result(value)
break
def arm_output() -> asyncio.Future[bool]:
future: asyncio.Future[bool] = loop.create_future()
pending.append(future)
return future
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
light = next(e for e in entities if e.object_id == "test_binary_light")
state_futures: dict[int, asyncio.Future[LightState]] = {}
def on_state(state: EntityState) -> None:
if isinstance(state, LightState) and state.key in state_futures:
future = state_futures[state.key]
if not future.done():
future.set_result(state)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(timeout: float = 5.0, **kwargs: Any) -> LightState:
state_futures[light.key] = loop.create_future()
client.light_command(key=light.key, **kwargs)
return await asyncio.wait_for(state_futures[light.key], timeout=timeout)
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True
# Turning on at 0% brightness has no representable "on but dark" state here,
# so the light must switch off and report itself as off.
output = arm_output()
state = await send_and_wait(state=True, brightness=0.0)
assert await asyncio.wait_for(output, timeout=5.0) is False, (
"Zero brightness did not switch the output off"
)
assert state.state is False, (
"Light reported itself as on while its output was off"
)
# A plain turn-on must recover -- the stored zero brightness must not persist.
output = arm_output()
state = await send_and_wait(state=True)
assert state.state is True
assert await asyncio.wait_for(output, timeout=5.0) is True, (
"Light was left permanently off by a zero-brightness turn-on"
)
+6 -4
View File
@@ -71,10 +71,12 @@ def test_branch_manifest_targets_ghcr_only(
)
assert commands == [
"docker buildx imagetools create "
"--tag ghcr.io/esphome/esphome-hassio:my-branch "
"ghcr.io/esphome/esphome-hassio-amd64:my-branch "
"ghcr.io/esphome/esphome-hassio-aarch64:my-branch"
(
"docker buildx imagetools create "
"--tag ghcr.io/esphome/esphome-hassio:my-branch "
"ghcr.io/esphome/esphome-hassio-amd64:my-branch "
"ghcr.io/esphome/esphome-hassio-aarch64:my-branch"
)
]
+16
View File
@@ -79,6 +79,22 @@ def test_get_pr_number_from_github_env_event_file(
assert result == "5678"
def test_get_github_event_data_decodes_utf8_regardless_of_locale(
monkeypatch: MonkeyPatch, tmp_path: Path
) -> None:
"""The event payload is UTF-8; parsing must not depend on the platform
default encoding. On Windows the default is cp1252, which raised
UnicodeDecodeError as soon as a commit title carried non ASCII text."""
event_file = tmp_path / "event.json"
event_data = {"head_commit": {"message": "Answer UNPAIR with Response… é"}}
event_file.write_bytes(json.dumps(event_data, ensure_ascii=False).encode("utf-8"))
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file))
result = helpers._get_github_event_data()
assert result == event_data
def test_get_pr_number_from_github_env_no_pr(
monkeypatch: MonkeyPatch, tmp_path: Path
) -> None:
@@ -22,7 +22,7 @@ def _make_build_dir(tmp_path: Path, name: str = "mydevice") -> Path:
def _touch(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("")
path.write_text("", encoding="utf-8")
return path
+12
View File
@@ -184,6 +184,18 @@ def test_get_component_cmakelists_compile_flags_excluded_from_link_opts() -> Non
assert "-Wl,--gc-sections" in content
def test_get_component_cmakelists_globs_alternate_cpp_extensions() -> None:
"""Both app_sources glob variants include .cc/.cxx/.c++ so vendored sources
are compiled, matching the extensions PlatformIO's builder globs by default."""
CORE.build_flags = set()
from esphome.build_gen.espidf import get_component_cmakelists
content = get_component_cmakelists()
for ext in ("cc", "cxx", "c++"):
assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/*.{ext}"') == 2
assert content.count(f'"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.{ext}"') == 2
def test_get_project_cmakelists_emits_managed_components_property(
tmp_path: Path,
) -> None:
+59 -2
View File
@@ -1,9 +1,15 @@
"""Tests for ESP8266 component."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.esp8266 import lambdas_use_scanf_float
from esphome.core import Lambda
from esphome.components import esp8266
from esphome.components.esp8266 import check_rosetta, lambdas_use_scanf_float
from esphome.core import EsphomeError, Lambda
from esphome.types import ConfigType
@@ -60,3 +66,54 @@ def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True
@pytest.fixture
def apple_silicon_run(monkeypatch: pytest.MonkeyPatch) -> Generator[MagicMock]:
"""Simulate an Apple Silicon Mac and yield the mocked subprocess.run."""
monkeypatch.setattr(esp8266, "IS_MACOS", True)
with (
patch("esphome.components.esp8266.platform.machine", return_value="arm64"),
patch("esphome.components.esp8266.subprocess.run") as mock_run,
):
yield mock_run
@pytest.mark.parametrize(
("is_macos", "machine"),
[
(False, "arm64"),
(True, "x86_64"),
],
)
def test_check_rosetta_skips_other_systems(
monkeypatch: pytest.MonkeyPatch, is_macos: bool, machine: str
) -> None:
"""The check only probes on Apple Silicon Macs."""
monkeypatch.setattr(esp8266, "IS_MACOS", is_macos)
with (
patch("esphome.components.esp8266.platform.machine", return_value=machine),
patch("esphome.components.esp8266.subprocess.run") as mock_run,
):
check_rosetta()
mock_run.assert_not_called()
def test_check_rosetta_installed(apple_silicon_run: MagicMock) -> None:
"""No error when the x86_64 probe succeeds (Rosetta present)."""
apple_silicon_run.return_value = MagicMock(returncode=0)
check_rosetta()
apple_silicon_run.assert_called_once()
def test_check_rosetta_missing(apple_silicon_run: MagicMock) -> None:
"""A failing x86_64 probe raises an actionable error."""
apple_silicon_run.return_value = MagicMock(returncode=1)
with pytest.raises(EsphomeError, match="softwareupdate --install-rosetta"):
check_rosetta()
def test_check_rosetta_arch_unavailable(apple_silicon_run: MagicMock) -> None:
"""The build proceeds when arch(1) cannot be executed."""
apple_silicon_run.side_effect = OSError("no such file")
check_rosetta()
@@ -137,3 +137,45 @@ def test_process_stacktrace_esp32_crash_handler(
state = process_stacktrace(config, line_bt1, False)
mock_esp32_decode_pc.assert_called_once_with(config, "42005ABC")
assert state is False
mock_esp32_decode_pc.reset_mock()
# Reason line carries no address, must not trigger a decode
line_reason = "[E][esp32.crash:079]: Reason: Fault - LoadProhibited (cause 28)"
state = process_stacktrace(config, line_reason, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
mock_esp32_decode_pc.reset_mock()
# EXCVADDR pointing at code (e.g. jumping through a corrupted pointer) decodes
line_excvaddr = "[E][esp32.crash:081]: EXCVADDR: 0x400D9ABC (faulting address)"
state = process_stacktrace(config, line_excvaddr, False)
mock_esp32_decode_pc.assert_called_once_with(config, "400D9ABC")
assert state is False
mock_esp32_decode_pc.reset_mock()
# EXCVADDR pointing at data (heap/null) is not a code address, must be ignored
line_excvaddr_data = (
"[E][esp32.crash:081]: EXCVADDR: 0x0000001C (faulting address)"
)
state = process_stacktrace(config, line_excvaddr_data, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
mock_esp32_decode_pc.reset_mock()
# RISC-V MTVAL pointing at code decodes
line_mtval = "[E][esp32.crash:081]: MTVAL: 0x42001234 (faulting address)"
state = process_stacktrace(config, line_mtval, False)
mock_esp32_decode_pc.assert_called_once_with(config, "42001234")
assert state is False
mock_esp32_decode_pc.reset_mock()
# RISC-V MTVAL pointing at data must be ignored
line_mtval_data = "[E][esp32.crash:081]: MTVAL: 0x3FC80123 (faulting address)"
state = process_stacktrace(config, line_mtval_data, False)
mock_esp32_decode_pc.assert_not_called()
assert state is False
@@ -0,0 +1,120 @@
"""Minimal idf_tools stand-in for get_tool_downloads.py tests."""
from collections.abc import Iterable
import os
CURRENT_PLATFORM = "linux-amd64"
TOOLS_FILE = "tools/tools.json"
class ToolBinaryError(RuntimeError):
pass
class _G:
idf_path: str | None = None
idf_tools_path: str | None = None
tools_json: str | None = None
g = _G()
class IDFEnv:
@classmethod
def get_idf_env(cls) -> "IDFEnv":
return cls()
def add_and_check_targets(idf_env_obj: IDFEnv, targets_str: str) -> list[str]:
return targets_str.split(",")
class _Download:
def __init__(self, url: str, size: int, sha256: str, rename_dist: str = "") -> None:
self.url = url
self.size = size
self.sha256 = sha256
self.rename_dist = rename_dist
class _Version:
def __init__(self, download: _Download | None) -> None:
self._download = download
def get_download_for_platform(self, platform_name: str) -> _Download | None:
return self._download
class _Tool:
def __init__(
self,
versions: dict[str, _Version],
recommended: str | None,
installed: Iterable[str] = (),
broken: bool = False,
) -> None:
self.versions = versions
self._recommended = recommended
self.versions_installed = list(installed)
self._broken = broken
def compatible_with_platform(self) -> bool:
return True
def get_recommended_version(self) -> str | None:
return self._recommended
def find_installed_versions(self) -> None:
if self._broken:
raise ToolBinaryError("broken binary")
_TOOLS = {
"cmake": _Tool(
{"3.30.2": _Version(_Download("https://gh.test/cmake.tar.gz", 11, "aa"))},
"3.30.2",
),
"ninja": _Tool(
{
"1.12.1": _Version(
_Download("https://gh.test/ninja-mac.zip", 22, "bb", "ninja-v1.zip")
)
},
"1.12.1",
),
"installed-tool": _Tool(
{"1.0": _Version(_Download("https://gh.test/x.tar.gz", 33, "cc"))},
"1.0",
installed=["1.0"],
),
"broken-tool": _Tool(
{"2.0": _Version(_Download("https://gh.test/y.tar.gz", 44, "dd"))},
"2.0",
broken=True,
),
"no-recommended-tool": _Tool({"3.0": _Version(None)}, None),
"no-download-tool": _Tool({"4.0": _Version(None)}, "4.0"),
}
def load_tools_info() -> dict[str, _Tool]:
return _TOOLS
def expand_tools_arg(
tools_spec: list[str], overall_tools: dict[str, _Tool], targets: list[str]
) -> list[str]:
if "required" in tools_spec:
return list(overall_tools)
return [t for t in tools_spec if "@" not in t] + [t for t in tools_spec if "@" in t]
def get_idf_download_url_apply_mirrors(
args: object = None, download_url: str = ""
) -> str:
print(f"Changed download URL: {download_url}") # noise on stdout, like idf_tools
prefix = os.environ.get("TEST_MIRROR_PREFIX")
if prefix:
return prefix + download_url
return download_url
+268 -2
View File
@@ -27,6 +27,7 @@ from esphome.bundle import (
is_bundle_path,
prepare_bundle_for_compile,
read_bundle_manifest,
remap_bundle_path,
)
from esphome.core import CORE, EsphomeError
from esphome.yaml_util import force_load_include_files
@@ -478,7 +479,10 @@ def test_read_bundle_manifest_corrupted_tar(tmp_path: Path) -> None:
def test_read_bundle_manifest(tmp_path: Path) -> None:
bundle_path = _make_bundle(
tmp_path,
manifest_overrides={ManifestKey.HAS_SECRETS: True},
manifest_overrides={
ManifestKey.HAS_SECRETS: True,
ManifestKey.CONFIG_DIR: "/original/config",
},
extra_files={"secrets.yaml": b"wifi: test\n"},
)
@@ -489,6 +493,7 @@ def test_read_bundle_manifest(tmp_path: Path) -> None:
assert manifest.esphome_version == "2026.2.0-test"
assert manifest.config_filename == "test.yaml"
assert manifest.has_secrets is True
assert manifest.config_dir == "/original/config"
def test_read_bundle_manifest_minimal(tmp_path: Path) -> None:
@@ -508,6 +513,266 @@ def test_read_bundle_manifest_minimal(tmp_path: Path) -> None:
assert result.esphome_version == "unknown"
assert not result.files
assert result.has_secrets is False
assert result.config_dir is None
def test_read_bundle_manifest_non_string_config_dir(tmp_path: Path) -> None:
"""A malformed config_dir value is dropped rather than propagated."""
bundle_path = _make_bundle(
tmp_path, manifest_overrides={ManifestKey.CONFIG_DIR: 42}
)
assert read_bundle_manifest(bundle_path).config_dir is None
# ---------------------------------------------------------------------------
# remap_bundle_path
# ---------------------------------------------------------------------------
ORIGINAL_CONFIG_DIR = "/original/config"
def _bundle_manifest_dict(**overrides: Any) -> dict[str, Any]:
"""Manifest content an extracted bundle would contain."""
manifest: dict[str, Any] = {
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.CONFIG_FILENAME: "test.yaml",
ManifestKey.CONFIG_DIR: ORIGINAL_CONFIG_DIR,
}
manifest.update(overrides)
return manifest
def _setup_extracted_dir(
tmp_path: Path,
manifest: dict[str, Any] | str | None,
files: dict[str, str] | None = None,
) -> Path:
"""Create a directory shaped like an extracted bundle and point CORE at it."""
extract_dir = _setup_config_dir(tmp_path, files)
if manifest is not None:
content = manifest if isinstance(manifest, str) else json.dumps(manifest)
(extract_dir / MANIFEST_FILENAME).write_text(content)
return extract_dir
def test_remap_bundle_path_success(tmp_path: Path) -> None:
"""A stale absolute path resolves to the bundled copy next to the config."""
extract_dir = _setup_extracted_dir(
tmp_path, _bundle_manifest_dict(), files={"boards/partitions.csv": "csv\n"}
)
remapped = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/boards/partitions.csv")
assert remapped == extract_dir / "boards" / "partitions.csv"
assert remapped.is_file()
@pytest.mark.parametrize(
"value",
[
pytest.param(r"C:\Users\nick\esphome\boards\partitions.csv", id="backslashes"),
pytest.param("C:/Users/nick/esphome/boards/partitions.csv", id="forward"),
pytest.param(r"c:\users\NICK\esphome\boards\partitions.csv", id="case"),
],
)
def test_remap_bundle_path_windows_bundle_on_posix(tmp_path: Path, value: str) -> None:
"""A bundle created on Windows remaps on a build server with another layout."""
extract_dir = _setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}),
files={"boards/partitions.csv": "csv\n"},
)
remapped = remap_bundle_path(value)
assert remapped == extract_dir / "boards" / "partitions.csv"
assert remapped.is_file()
def test_remap_bundle_path_windows_bundle_path_not_under_config_dir(
tmp_path: Path,
) -> None:
"""A Windows path outside the original config dir is left alone."""
_setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}),
files={"partitions.csv": "csv\n"},
)
assert remap_bundle_path(r"D:\other\partitions.csv") is None
def test_remap_bundle_path_windows_profile_with_spaces(tmp_path: Path) -> None:
r"""A Windows profile like C:\Users\First Last remaps like any other dir."""
extract_dir = _setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(
**{ManifestKey.CONFIG_DIR: r"C:\Users\First Last\esphome"}
),
files={"boards/my partitions.csv": "csv\n"},
)
remapped = remap_bundle_path(
r"C:\Users\First Last\esphome\boards\my partitions.csv"
)
assert remapped == extract_dir / "boards" / "my partitions.csv"
assert remapped.is_file()
def test_remap_bundle_path_unc_config_dir(tmp_path: Path) -> None:
"""A bundle created from a UNC share remaps like any other Windows path."""
extract_dir = _setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"\\server\share\esphome"}),
files={"partitions.csv": "csv\n"},
)
remapped = remap_bundle_path(r"\\server\share\esphome\partitions.csv")
assert remapped == extract_dir / "partitions.csv"
def test_remap_bundle_path_flavor_mismatch(tmp_path: Path) -> None:
"""A POSIX style value cannot come from a Windows config dir; no remap."""
_setup_extracted_dir(
tmp_path,
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: r"C:\Users\nick\esphome"}),
files={"partitions.csv": "csv\n"},
)
assert remap_bundle_path("/original/config/partitions.csv") is None
def test_remap_bundle_path_rejects_traversal(tmp_path: Path) -> None:
"""A remap may never escape the extracted config tree."""
extract_dir = _setup_extracted_dir(tmp_path, _bundle_manifest_dict())
(tmp_path / "outside.csv").write_text("csv\n")
assert (extract_dir / ".." / "outside.csv").resolve().is_file()
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/../outside.csv") is None
def test_remap_bundle_path_relative_value(tmp_path: Path) -> None:
"""Relative references resolve normally and are never remapped."""
_setup_extracted_dir(tmp_path, _bundle_manifest_dict())
assert remap_bundle_path("missing.csv") is None
def test_remap_bundle_path_no_manifest(tmp_path: Path) -> None:
"""A config dir without a manifest is not an extracted bundle."""
_setup_extracted_dir(tmp_path, None, files={"partitions.csv": "csv\n"})
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None
@pytest.mark.parametrize(
"manifest",
[
pytest.param("{not json", id="malformed_json"),
pytest.param("[]", id="not_a_dict"),
pytest.param(
_bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: "x"}),
id="version_not_int",
),
pytest.param(
_bundle_manifest_dict(**{ManifestKey.MANIFEST_VERSION: 0}),
id="version_zero",
),
pytest.param(
_bundle_manifest_dict(**{ManifestKey.CONFIG_FILENAME: "other.yaml"}),
id="config_filename_mismatch",
),
pytest.param(
{
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.CONFIG_FILENAME: "test.yaml",
},
id="config_dir_missing",
),
pytest.param(
_bundle_manifest_dict(**{ManifestKey.CONFIG_DIR: ""}),
id="config_dir_empty",
),
],
)
def test_remap_bundle_path_untrusted_manifest(
tmp_path: Path, manifest: dict[str, Any] | str
) -> None:
"""Manifests that do not look like this bundle's manifest are ignored."""
_setup_extracted_dir(tmp_path, manifest, files={"partitions.csv": "csv\n"})
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None
def test_remap_bundle_path_unreadable_manifest_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A present but broken manifest is reported, not silently ignored."""
_setup_extracted_dir(tmp_path, "{not json", files={"partitions.csv": "csv\n"})
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None
assert "ignoring unreadable" in caplog.text
def test_remap_bundle_path_outside_original_config_dir(tmp_path: Path) -> None:
"""Paths that were not under the original config dir are left alone."""
_setup_extracted_dir(tmp_path, _bundle_manifest_dict())
assert remap_bundle_path("/elsewhere/partitions.csv") is None
def test_remap_bundle_path_bundled_copy_missing(tmp_path: Path) -> None:
"""No remap when the bundle does not contain the file."""
_setup_extracted_dir(tmp_path, _bundle_manifest_dict())
assert remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv") is None
def test_remap_bundle_path_manifest_read_once(tmp_path: Path) -> None:
"""The manifest lookup result is cached for the rest of the run."""
extract_dir = _setup_extracted_dir(
tmp_path, _bundle_manifest_dict(), files={"partitions.csv": "csv\n"}
)
first = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv")
assert first == extract_dir / "partitions.csv"
(extract_dir / MANIFEST_FILENAME).unlink()
second = remap_bundle_path(f"{ORIGINAL_CONFIG_DIR}/partitions.csv")
assert second == first
def test_remap_bundle_path_round_trip(tmp_path: Path) -> None:
"""A file referenced by absolute path survives bundle create and extract.
Reproduces https://github.com/esphome/esphome/issues/17755: the config
names its partitions csv by absolute path, the bundle is extracted on a
machine where that path does not exist, and the reference must resolve
to the bundled copy.
"""
config_dir = _setup_config_dir(tmp_path, files={"partitions.csv": "nvs,data\n"})
abs_path = (config_dir / "partitions.csv").resolve()
creator = ConfigBundleCreator({"esp32": {"partitions": abs_path}})
result = creator.create_bundle()
bundle_path = tmp_path / f"device{BUNDLE_EXTENSION}"
bundle_path.write_bytes(result.data)
target = tmp_path / "build_server"
config_path = extract_bundle(bundle_path, target)
# Simulate the build server: fresh run, original config dir gone
CORE.reset()
CORE.config_path = config_path
shutil.rmtree(config_dir)
remapped = remap_bundle_path(str(abs_path))
assert remapped == target.resolve() / "partitions.csv"
assert remapped.is_file()
# ---------------------------------------------------------------------------
@@ -1261,7 +1526,7 @@ def test_create_bundle_produces_valid_archive(tmp_path: Path) -> None:
def test_create_bundle_manifest_content(tmp_path: Path) -> None:
_setup_config_dir(tmp_path)
config_dir = _setup_config_dir(tmp_path)
creator = ConfigBundleCreator({})
result = creator.create_bundle()
@@ -1269,6 +1534,7 @@ def test_create_bundle_manifest_content(tmp_path: Path) -> None:
manifest = result.manifest
assert manifest[ManifestKey.MANIFEST_VERSION] == CURRENT_MANIFEST_VERSION
assert manifest[ManifestKey.CONFIG_FILENAME] == "test.yaml"
assert manifest[ManifestKey.CONFIG_DIR] == str(config_dir.resolve())
assert "test.yaml" in manifest[ManifestKey.FILES]
+10 -10
View File
@@ -74,13 +74,13 @@ def _write_storage(
"framework": "arduino",
"core_platform": core_platform,
}
storage_path.write_text(json.dumps(data))
storage_path.write_text(json.dumps(data), encoding="utf-8")
def _write_cache(cache_path: Path, body: str = _VALIDATED_CONFIG_YAML) -> Path:
"""Write the cache file and return it."""
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(body)
cache_path.write_text(body, encoding="utf-8")
return cache_path
@@ -220,7 +220,7 @@ def test_run_esphome_upload_and_logs_use_cache_when_fresh(
with (
caplog.at_level("INFO", logger="esphome.__main__"),
patch("esphome.__main__.read_config") as mock_read,
patch("esphome.config.read_config") as mock_read,
patch.dict("esphome.__main__.POST_CONFIG_ACTIONS", {command: _stub}),
):
assert run_esphome(["esphome", command, str(fresh_cache_files)]) == 0
@@ -242,7 +242,7 @@ def test_run_esphome_upload_and_logs_fall_back_when_no_cache(
yaml_path.write_text("esphome:\n name: lite_test\n")
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{command: lambda args, config: 0},
@@ -266,7 +266,7 @@ def test_run_esphome_upload_does_not_refresh_cache_without_sidecar(
with (
patch(
"esphome.__main__.read_config",
"esphome.config.read_config",
return_value={"esphome": {"name": "lite_test"}},
),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
@@ -299,7 +299,7 @@ def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
fresh_config = {"esphome": {"name": "lite_test"}, "logger": {}}
with (
patch("esphome.__main__.read_config", return_value=fresh_config),
patch("esphome.config.read_config", return_value=fresh_config),
patch(
"esphome.compiled_config.save_compiled_config", wraps=save_compiled_config
) as mock_save,
@@ -322,7 +322,7 @@ def test_run_esphome_upload_with_substitution_does_not_refresh_cache(
"""`-s` substitutions skip the cache on both read and write -- saving
here would clobber the cache with a substitution-specific config."""
with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
patch("esphome.config.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
@@ -341,7 +341,7 @@ def test_run_esphome_compile_does_not_refresh_cache_via_fallback(
upload/logs fallback path -- the fallback save would skip the
storage_should_clean check."""
with (
patch("esphome.__main__.read_config", return_value={"esphome": {}}),
patch("esphome.config.read_config", return_value={"esphome": {}}),
patch("esphome.compiled_config.save_compiled_config") as mock_save,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
@@ -360,7 +360,7 @@ def test_run_esphome_upload_with_substitution_skips_cache(
against the prior substitution set, so reusing it would silently
ignore the override."""
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"upload": lambda args, config: 0},
@@ -374,7 +374,7 @@ def test_run_esphome_upload_with_substitution_skips_cache(
def test_run_esphome_compile_does_not_use_cache(fresh_cache_files: Path) -> None:
"""The compile subcommand always re-validates -- it's what writes the cache."""
with (
patch("esphome.__main__.read_config", return_value=None) as mock_read,
patch("esphome.config.read_config", return_value=None) as mock_read,
patch.dict(
"esphome.__main__.POST_CONFIG_ACTIONS",
{"compile": lambda args, config: 0},
+134
View File
@@ -1,3 +1,5 @@
import json
import logging
from pathlib import Path
import string
@@ -2912,3 +2914,135 @@ def test_rename_key_present() -> None:
def test_rename_key_absent() -> None:
assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5}
def test_rename_key_no_removed_in_is_silent(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5}
assert not caplog.records
def test_rename_key_removed_in_renames_and_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
result = cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5})
assert result == {"new": 5}
assert "'old' is deprecated, use 'new'. Will be removed in 2026.8.0" in caplog.text
def test_rename_key_removed_in_absent_key_no_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
result = cv.rename_key("old", "new", removed_in="2026.8.0")({"other": 5})
assert result == {"other": 5}
assert not caplog.records
def test_rename_key_removed_in_with_component_prefixes_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.WARNING, logger="esphome.config_validation"):
result = cv.rename_key(
"old", "new", removed_in="2026.8.0", component="my_component"
)({"old": 5})
assert result == {"new": 5}
assert (
"[my_component] 'old' is deprecated, use 'new'. Will be removed in 2026.8.0"
in caplog.text
)
def test_rename_key_both_keys_rejected() -> None:
with pytest.raises(Invalid, match="Cannot specify more than one of"):
cv.rename_key("old", "new")({"old": 5, "new": 6})
def test_rename_key_both_keys_rejected_with_removed_in(
caplog: pytest.LogCaptureFixture,
) -> None:
with (
caplog.at_level(logging.WARNING, logger="esphome.config_validation"),
pytest.raises(Invalid, match="Cannot specify more than one of"),
):
cv.rename_key("old", "new", removed_in="2026.8.0")({"old": 5, "new": 6})
assert not caplog.records
def test_file__existing_relative_path(setup_core: Path) -> None:
(setup_core / "partitions.csv").write_text("csv\n")
assert cv.file_("partitions.csv") == setup_core / "partitions.csv"
def test_file__missing_raises(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find file"):
cv.file_("partitions.csv")
def test_file__remaps_bundle_absolute_path(setup_core: Path) -> None:
"""A stale absolute path in an extracted bundle resolves to the bundled copy."""
manifest = {
"manifest_version": 1,
"config_filename": "test.yaml",
"config_dir": "/original/config",
}
(setup_core / "manifest.json").write_text(json.dumps(manifest))
(setup_core / "partitions.csv").write_text("csv\n")
assert cv.file_("/original/config/partitions.csv") == setup_core / "partitions.csv"
def test_file__missing_absolute_path_without_bundle(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find file"):
cv.file_("/original/config/partitions.csv")
def test_file__remaps_windows_bundle_absolute_path(setup_core: Path) -> None:
"""A bundle created on Windows resolves on a host with another layout."""
manifest = {
"manifest_version": 1,
"config_filename": "test.yaml",
"config_dir": "C:\\Users\\nick\\esphome",
}
(setup_core / "manifest.json").write_text(json.dumps(manifest))
(setup_core / "partitions.csv").write_text("csv\n")
result = cv.file_("C:\\Users\\nick\\esphome\\partitions.csv")
assert result == setup_core / "partitions.csv"
def test_directory_remaps_bundle_absolute_path(setup_core: Path) -> None:
"""A stale absolute directory in an extracted bundle resolves to the bundled copy."""
manifest = {
"manifest_version": 1,
"config_filename": "test.yaml",
"config_dir": "/original/config",
}
(setup_core / "manifest.json").write_text(json.dumps(manifest))
(setup_core / "headers").mkdir()
assert cv.directory("/original/config/headers") == setup_core / "headers"
def test_directory_missing_raises(setup_core: Path) -> None:
with pytest.raises(Invalid, match="Could not find directory"):
cv.directory("/original/config/headers")
def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None:
"""A remapped path that is a directory still fails file validation."""
manifest = {
"manifest_version": 1,
"config_filename": "test.yaml",
"config_dir": "/original/config",
}
(setup_core / "manifest.json").write_text(json.dumps(manifest))
(setup_core / "headers").mkdir()
with pytest.raises(Invalid, match="is not a file"):
cv.file_("/original/config/headers")
+646 -22
View File
@@ -3,10 +3,14 @@
# pylint: disable=protected-access
from contextlib import contextmanager
import importlib.util
import io
import json
import logging
import os
from pathlib import Path
import runpy
import subprocess
import sys
import tarfile
from types import SimpleNamespace
@@ -15,7 +19,10 @@ from unittest.mock import patch
import pytest
from esphome.espidf.framework import (
ESPHOME_STAMP_FILE,
STAMP_SCHEMA_VERSION,
_ccache_env,
_check_esphome_idf_framework_install,
_check_stamp,
_check_windows_path_length,
_clone_idf_with_submodules,
@@ -25,8 +32,11 @@ from esphome.espidf.framework import (
_get_python_env_path,
_get_python_version,
_parse_git_source,
_patch_tools_json_demote_openocd,
_patch_tools_json_demote_unused_tools,
_patch_tools_json_for_linux_arm64,
_prefetch_idf_tool_archives,
_read_stamp,
_stamp_covers,
_windows_long_paths_enabled,
_write_idf_version_txt,
_write_stamp,
@@ -132,10 +142,17 @@ def test_parse_git_source_rejected(source: str) -> None:
assert _parse_git_source(source) is None
def _make_idf_tree(framework_path: Path) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for."""
def _make_idf_tree(framework_path: Path, *, gitmodules: bool = True) -> None:
"""Create the minimum tree _clone_idf_with_submodules sanity-checks for.
``gitmodules=False`` simulates a fork that vendors components in-tree
instead of declaring submodules; update_submodules skips the git call
when that file is missing.
"""
(framework_path / "tools").mkdir(parents=True)
(framework_path / "tools" / "idf_tools.py").write_text("# stub\n")
if gitmodules:
(framework_path / ".gitmodules").write_text("# stub\n")
def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None:
@@ -209,6 +226,28 @@ def test_clone_idf_with_submodules_raises_when_tree_missing(
)
def test_clone_idf_accepts_flattened_fork_without_gitmodules(
tmp_path: Path,
) -> None:
"""A fork that vendors components in-tree instead of as submodules is valid.
No .gitmodules means the submodule step is skipped entirely.
"""
framework_path = tmp_path / "idf"
framework_path.mkdir()
_make_idf_tree(framework_path, gitmodules=False)
with patch("esphome.git.run_git_command", return_value="") as run_git_command_mock:
_clone_idf_with_submodules(
framework_path,
"https://github.com/example/flattened-esp-idf.git",
None,
)
calls = [c.args[0] for c in run_git_command_mock.call_args_list]
assert not any(c[1] == "submodule" for c in calls)
# ---------------------------------------------------------------------------
# Helpers for _tar_extract_all hard-link prefix-stripping tests
# ---------------------------------------------------------------------------
@@ -311,6 +350,21 @@ class TestTarExtractHardLinkPrefixStripping:
_IDF_VERSION = "5.1.2"
def _fake_download_from_mirrors(
mirrors: list[str],
substitutions: dict[str, str],
target: object,
**kwargs: object,
) -> str:
"""Stand-in for download_from_mirrors that creates path targets, since
the framework code opens the downloaded tarball afterwards."""
if isinstance(target, (str, os.PathLike)):
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
return "https://example.com/idf.tar.xz"
@pytest.fixture
def espidf_mocks(setup_core: Path):
"""Patch the heavy I/O of check_esp_idf_install and pre-create the framework dir."""
@@ -321,7 +375,7 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework.rmdir") as rmdir_mock,
patch(
"esphome.espidf.framework.download_from_mirrors",
return_value="https://example.com/idf.tar.xz",
side_effect=_fake_download_from_mirrors,
) as download,
patch("esphome.espidf.framework.archive_extract_all") as extract,
patch("esphome.espidf.framework.create_venv") as venv,
@@ -332,9 +386,11 @@ def espidf_mocks(setup_core: Path):
patch("esphome.espidf.framework._clone_idf_with_submodules") as clone,
patch("esphome.espidf.framework._write_idf_version_txt"),
patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"),
patch("esphome.espidf.framework._patch_tools_json_demote_openocd"),
patch("esphome.espidf.framework._patch_tools_json_demote_unused_tools"),
patch("esphome.espidf.framework._prefetch_idf_tool_archives"),
patch("esphome.espidf.framework._write_stamp"),
patch("esphome.espidf.framework._check_stamp", return_value=True),
patch("esphome.espidf.framework._stamp_covers", return_value=True),
patch("esphome.espidf.framework._get_idf_version", return_value=_IDF_VERSION),
patch("esphome.espidf.framework._get_python_version", return_value="3.11.0"),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
@@ -413,6 +469,20 @@ def test_check_esp_idf_install_already_installed(espidf_mocks: SimpleNamespace)
espidf_mocks.venv.assert_not_called()
def test_corrupt_tarball_removed_when_extraction_fails(
espidf_mocks: SimpleNamespace,
) -> None:
"""A tarball that fails to extract (e.g. torn by an unclean shutdown) is
deleted so the next run re-downloads instead of failing forever."""
espidf_mocks.extract.side_effect = RuntimeError("xz: unexpected end of input")
tarball = get_idf_tools_path() / "dist" / f"esp-idf-{_IDF_VERSION}.tar.xz"
with pytest.raises(RuntimeError, match="unexpected end of input"):
check_esp_idf_install(_IDF_VERSION, force=True)
assert not tarball.exists()
def test_check_esp_idf_install_framework_failure(espidf_mocks: SimpleNamespace) -> None:
"""A failing idf_tools install raises."""
espidf_mocks.run_ok.side_effect = [False]
@@ -450,13 +520,17 @@ def _mark_installed() -> None:
def test_check_esp_idf_install_stamp_mismatch_reinstalls(
espidf_mocks: SimpleNamespace,
) -> None:
"""A stamp mismatch reinstalls tools (marker present, so no re-extract)."""
"""A stamp mismatch reinstalls tools (marker present, so no re-extract).
The python env is left alone: it depends on the framework version and
features, not on which toolchains are installed.
"""
_mark_installed()
with patch("esphome.espidf.framework._check_stamp", return_value=False):
with patch("esphome.espidf.framework._stamp_covers", return_value=False):
check_esp_idf_install(_IDF_VERSION)
espidf_mocks.extract.assert_not_called() # marker present -> no re-extract
espidf_mocks.venv.assert_called_once() # tools reinstall -> venv rebuilt
espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept
def test_check_esp_idf_install_check_command_failure_reinstalls(
@@ -469,7 +543,7 @@ def test_check_esp_idf_install_check_command_failure_reinstalls(
check_esp_idf_install(_IDF_VERSION, features=["fb"])
espidf_mocks.extract.assert_not_called()
espidf_mocks.venv.assert_called_once()
espidf_mocks.venv.assert_not_called() # tools-only install -> venv kept
def test_check_esp_idf_install_unknown_python_version_reinstalls(
@@ -489,8 +563,8 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
) -> None:
"""Framework stamp matches but the python-env stamp does not -> venv rebuilt."""
# _check_stamp passes for the framework (no python_version key) and fails
# for the python env (carries python_version), so only the venv rebuilds.
# _check_stamp only guards the python env now (the framework uses
# _stamp_covers, patched True by the fixture); failing it rebuilds the venv.
def stamp_ok(_stamp_file, info: dict) -> bool:
return "python_version" not in info
@@ -502,6 +576,146 @@ def test_check_esp_idf_install_python_stamp_mismatch_rebuilds_venv(
espidf_mocks.venv.assert_called_once()
def _requested_stamp(targets: list[str], tools: list[str] | None = None) -> dict:
return {
"schema_version": STAMP_SCHEMA_VERSION,
"targets": targets,
"tools": tools or ["required"],
}
@pytest.mark.parametrize(
("stored", "targets", "expected"),
[
# a stored "all" covers any target
(_requested_stamp(["all"]), ["esp32"], True),
# exact match and superset both cover
(_requested_stamp(["esp32"]), ["esp32"], True),
(_requested_stamp(["esp32", "esp32c3"]), ["esp32"], True),
# a new target is not covered
(_requested_stamp(["esp32"]), ["esp32c3"], False),
# tools and schema_version must match exactly
(_requested_stamp(["all"], tools=["cmake", "required"]), ["esp32"], False),
(_requested_stamp(["all"]) | {"schema_version": "no"}, ["esp32"], False),
# an unknown extra field participates in invalidation by default
(_requested_stamp(["all"]) | {"module_version": 1}, ["esp32"], False),
# missing/corrupt stamps never cover
(None, ["esp32"], False),
(
{"schema_version": STAMP_SCHEMA_VERSION, "tools": ["required"]},
["esp32"],
False,
),
],
)
def test_stamp_covers(stored: dict | None, targets: list[str], expected: bool) -> None:
assert _stamp_covers(stored, _requested_stamp(targets)) is expected
@contextmanager
def _framework_install_patches():
"""Patches for calling _check_esphome_idf_framework_install directly with
real stamp files (unlike espidf_mocks, which stubs the stamp layer)."""
with (
patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok,
patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
patch("esphome.espidf.framework.rmdir"),
):
yield run_ok
def _extracted_framework_with_stamp(stamp: dict) -> Path:
framework_path = _get_framework_path(_IDF_VERSION)
framework_path.mkdir(parents=True, exist_ok=True)
(framework_path / ".esphome_extracted").touch()
_write_stamp(framework_path / ESPHOME_STAMP_FILE, stamp)
return framework_path
def test_framework_install_target_subset_skips_install() -> None:
"""A stamp holding a superset of the requested targets skips the installer."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["all"]))
with _framework_install_patches() as run_ok:
_, fresh_extract = _check_esphome_idf_framework_install(
_IDF_VERSION, ["esp32"], ["required"]
)
run_ok.assert_not_called()
assert fresh_extract is False
# the stamp is untouched
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["all"]
def test_framework_install_new_target_installs_and_merges_stamp() -> None:
"""A new target runs the installer for just that target and the stamp
records the union of everything installed so far."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"]))
with _framework_install_patches() as run_ok:
_, fresh_extract = _check_esphome_idf_framework_install(
_IDF_VERSION, ["esp32c3"], ["required"]
)
assert fresh_extract is False
assert "--targets=esp32c3" in run_ok.call_args[0][0]
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["esp32", "esp32c3"]
def test_check_esp_idf_install_env_targets_override_wins(
espidf_mocks: SimpleNamespace,
) -> None:
"""An explicitly set ESPHOME_IDF_DEFAULT_TARGETS overrides per-variant targets."""
with patch("esphome.espidf.framework._IDF_DEFAULT_TARGETS_EXPLICIT", True):
check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"])
install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0]
assert "--targets=all" in install_cmd
def test_check_esp_idf_install_uses_requested_targets(
espidf_mocks: SimpleNamespace,
) -> None:
"""Without the env override, the caller's per-variant targets are installed."""
check_esp_idf_install(_IDF_VERSION, force=True, targets=["esp32"])
install_cmd = espidf_mocks.run_ok.call_args_list[0][0][0]
assert "--targets=esp32" in install_cmd
def test_framework_install_all_request_collapses_merged_stamp_to_all() -> None:
"""Requesting "all" over a per-variant stamp merges and collapses to
["all"], not ["all", "esp32"], so the stamp shape stays canonical."""
framework_path = _extracted_framework_with_stamp(_requested_stamp(["esp32"]))
with _framework_install_patches() as run_ok:
_check_esphome_idf_framework_install(_IDF_VERSION, ["all"], ["required"])
run_ok.assert_called_once()
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["all"]
def test_framework_install_tools_change_resets_stamp_targets() -> None:
"""A reinstall triggered by a tools change must not carry the old stamp's
targets forward: the installer only ran for this build's targets, so a
merged stamp would let other variants skip the reinstall they need."""
framework_path = _extracted_framework_with_stamp(
_requested_stamp(["all"], tools=["cmake", "required"])
)
with _framework_install_patches() as run_ok:
_check_esphome_idf_framework_install(_IDF_VERSION, ["esp32"], ["required"])
run_ok.assert_called_once()
stamp = json.loads((framework_path / ESPHOME_STAMP_FILE).read_text())
assert stamp["targets"] == ["esp32"]
assert stamp["tools"] == ["required"]
@pytest.mark.parametrize(
("lib", "expect_hint"),
[
@@ -637,28 +851,377 @@ def test_patch_tools_json_already_patched_is_noop(tmp_path: Path) -> None:
# ---------------------------------------------------------------------------
# _patch_tools_json_demote_openocd (openocd-esp32 made optional)
# _prefetch_idf_tool_archives
# ---------------------------------------------------------------------------
def test_demote_openocd_patches_install_type(tmp_path: Path) -> None:
_PREFETCH_JSON = json.dumps(
[
{
"name": "cmake@3.30.2",
"url": "https://example.com/cmake.tar.gz",
"size": 123,
"sha256": "ab" * 32,
"dest": "cmake-3.30.2.tar.gz",
},
{
"name": "ninja@1.12.1",
"url": "https://example.com/ninja.zip",
"size": 45,
"sha256": "cd" * 32,
"dest": "ninja.zip",
},
]
)
def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
dist = get_idf_tools_path() / "dist"
assert download.call_count == 2
assert download.call_args_list[0][0] == (
"https://example.com/cmake.tar.gz",
dist / "cmake-3.30.2.tar.gz",
)
assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123}
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
dist = get_idf_tools_path() / "dist"
dist.mkdir(parents=True)
(dist / "cmake-3.30.2.tar.gz").write_bytes(b"cached")
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch("esphome.espidf.framework.download_with_resume") as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
# only the missing archive is downloaded
assert download.call_count == 1
assert download.call_args[0][1] == dist / "ninja.zip"
@pytest.mark.parametrize(
("run_result", "download_error", "expected_log"),
[
((False, "", "script exploded"), None, "tool downloads"), # script failure
((True, "{ not json", ""), None, "prefetch failed"), # unparsable output
(
(True, _PREFETCH_JSON, ""),
OSError("network down"),
"Could not prefetch",
), # download failure
],
)
def test_prefetch_failures_never_raise(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
run_result: tuple[bool, str, str],
download_error: Exception | None,
expected_log: str,
) -> None:
"""The prefetch is best-effort; idf_tools downloads whatever is missing."""
with (
patch("esphome.espidf.framework.run_command", return_value=run_result),
patch(
"esphome.espidf.framework.download_with_resume",
side_effect=download_error,
),
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert expected_log in caplog.text
def test_prefetch_one_failed_archive_does_not_stop_the_rest(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A single archive failing its download must not abort the prefetch of
the remaining archives."""
with (
patch(
"esphome.espidf.framework.run_command",
return_value=(True, _PREFETCH_JSON, ""),
),
patch(
"esphome.espidf.framework.download_with_resume",
side_effect=[OSError("network down"), None],
) as download,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
assert download.call_count == 2
assert "Could not prefetch cmake@3.30.2" in caplog.text
def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
with (
patch(
"esphome.espidf.framework.run_command", return_value=(True, "[]", "")
) as run,
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
):
_prefetch_idf_tool_archives(
tmp_path, "esp32,esp32c3", ["required", "cmake"], {"IDF_TOOLS_PATH": "/x"}
)
cmd = run.call_args[0][0]
assert cmd[-3:] == ["esp32,esp32c3", "required", "cmake"]
assert cmd[1].endswith("get_tool_downloads.py")
# the script inherits the caller's env plus the framework tools PYTHONPATH
env = run.call_args[1]["env"]
assert env["IDF_TOOLS_PATH"] == "/x"
assert env["PYTHONPATH"] == str(tmp_path / "tools")
def test_framework_install_prefetches_before_installer(
espidf_mocks: SimpleNamespace,
) -> None:
"""The prefetch runs before idf_tools.py install so the installer finds
the archives already in dist/."""
calls: list[str] = []
with (
patch(
"esphome.espidf.framework._prefetch_idf_tool_archives",
side_effect=lambda *a, **k: calls.append("prefetch"),
),
):
espidf_mocks.run_ok.side_effect = lambda *a, **k: (
calls.append("install") or True
)
check_esp_idf_install(_IDF_VERSION, force=True)
assert calls.index("prefetch") < calls.index("install")
# ---------------------------------------------------------------------------
# get_tool_downloads.py (against the stub idf_tools module in fixtures/)
# ---------------------------------------------------------------------------
_IDF_TOOLS_STUB_DIR = Path(__file__).parent / "fixtures" / "idf_tools_stub"
def _run_downloads_script(
tmp_path: Path, *args: str, env_extra: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
"""Run the real get_tool_downloads.py against the stub idf_tools module."""
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
env = os.environ | {
"PYTHONPATH": str(_IDF_TOOLS_STUB_DIR),
"IDF_TOOLS_PATH": str(tmp_path / "tp"),
}
if env_extra:
env |= env_extra
return subprocess.run(
[sys.executable, str(script), str(tmp_path / "fw"), *args],
capture_output=True,
text=True,
env=env,
check=False,
)
def test_get_tool_downloads_lists_missing_tools(tmp_path: Path) -> None:
"""Installed versions are skipped, tools that fail their binary check are
still listed, rename_dist decides the dist filename, and idf_tools' stdout
chatter stays off the JSON channel."""
result = _run_downloads_script(tmp_path, "esp32", "required")
assert result.returncode == 0, result.stderr
downloads = {d["name"]: d for d in json.loads(result.stdout)}
# installed-tool@1.0 is already installed and must not be listed
assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"}
assert downloads["cmake@3.30.2"]["dest"] == "cmake.tar.gz"
assert downloads["cmake@3.30.2"]["size"] == 11
assert downloads["cmake@3.30.2"]["sha256"] == "aa"
# rename_dist overrides the URL basename
assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip"
# the stub prints informational lines; they must be on stderr
assert "Changed download URL" in result.stderr
def test_get_tool_downloads_applies_mirror_rewrite(tmp_path: Path) -> None:
result = _run_downloads_script(
tmp_path,
"esp32",
"required",
env_extra={"TEST_MIRROR_PREFIX": "https://mirror.test/"},
)
assert result.returncode == 0, result.stderr
downloads = json.loads(result.stdout)
assert all(d["url"].startswith("https://mirror.test/") for d in downloads)
def _run_downloads_inprocess(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
*args: str,
) -> list[dict]:
"""Execute get_tool_downloads.py in-process against the stub idf_tools.
Unlike the subprocess variant this runs under coverage, exercising the
script's own lines.
"""
spec = importlib.util.spec_from_file_location(
"idf_tools", _IDF_TOOLS_STUB_DIR / "idf_tools.py"
)
stub = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stub)
monkeypatch.setitem(sys.modules, "idf_tools", stub)
monkeypatch.setenv("IDF_TOOLS_PATH", str(tmp_path / "tp"))
script = Path(__file__).parents[2] / "esphome" / "espidf" / "get_tool_downloads.py"
monkeypatch.setattr(sys, "argv", [str(script), str(tmp_path / "fw"), *args])
runpy.run_path(str(script))
return json.loads(capsys.readouterr().out)
def test_get_tool_downloads_inprocess_full_flow(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""In-process run covering the whole script: required expansion,
installed/broken tools, rename_dist, and version pinning via tool@version."""
downloads = {
d["name"]: d
for d in _run_downloads_inprocess(
tmp_path, monkeypatch, capsys, "esp32", "required"
)
}
assert set(downloads) == {"cmake@3.30.2", "ninja@1.12.1", "broken-tool@2.0"}
assert downloads["ninja@1.12.1"]["dest"] == "ninja-v1.zip"
assert downloads["cmake@3.30.2"]["url"] == "https://gh.test/cmake.tar.gz"
def test_get_tool_downloads_inprocess_explicit_tool_specs(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Explicit tool names and tool@version specs resolve; unknown tools and
unknown versions are skipped."""
downloads = _run_downloads_inprocess(
tmp_path,
monkeypatch,
capsys,
"esp32",
"cmake@3.30.2",
"no-such-tool",
"cmake@9.9.9",
)
assert [d["name"] for d in downloads] == ["cmake@3.30.2"]
# ---------------------------------------------------------------------------
# _patch_tools_json_demote_unused_tools (openocd, gdb, ULP toolchain optional)
# ---------------------------------------------------------------------------
def test_demote_unused_tools_patches_install_type(tmp_path: Path) -> None:
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{"name": "openocd-esp32", "install": "always"},
{"name": "cmake", "install": "always"},
{"name": "xtensa-esp-elf-gdb", "install": "always"},
{"name": "riscv32-esp-elf-gdb", "install": "always"},
{"name": "esp32ulp-elf", "install": "always"},
{"name": "xtensa-esp-elf", "install": "always"},
{"name": "esp-rom-elfs", "install": "always"},
]
},
)
_patch_tools_json_demote_openocd(tmp_path)
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
install_types = {t["name"]: t["install"] for t in data["tools"]}
assert install_types == {
"openocd-esp32": "on_request",
"xtensa-esp-elf-gdb": "on_request",
"riscv32-esp-elf-gdb": "on_request",
"esp32ulp-elf": "on_request",
# the compiler toolchain and ROM ELFs stay required
"xtensa-esp-elf": "always",
"esp-rom-elfs": "always",
}
def test_demote_unused_tools_drops_xtensa_from_riscv_targets(tmp_path: Path) -> None:
"""riscv32-esp-elf loses the xtensa chips (ULP-RISC-V only, which ESPHome
never builds) but keeps its RISC-V targets; other tools are untouched."""
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": ["esp32s2", "esp32s3", "esp32c3", "esp32p4"],
},
{
"name": "xtensa-esp-elf",
"install": "always",
"supported_targets": ["esp32", "esp32s2", "esp32s3"],
},
]
},
)
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf")
xtensa = next(t for t in data["tools"] if t["name"] == "xtensa-esp-elf")
assert riscv["supported_targets"] == ["esp32c3", "esp32p4"]
assert riscv["install"] == "always"
assert xtensa["supported_targets"] == ["esp32", "esp32s2", "esp32s3"]
def test_demote_unused_tools_bad_supported_targets_type_still_demotes(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-list supported_targets on riscv32-esp-elf must not abort the
other demotions; the targets patch is best-effort and logs the skip so a
silently resumed riscv download is diagnosable."""
tools_json = _write_tools_json(
tmp_path,
{
"tools": [
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": None,
},
{"name": "openocd-esp32", "install": "always"},
]
},
)
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
_patch_tools_json_demote_unused_tools(tmp_path)
data = json.loads(tools_json.read_text(encoding="utf-8"))
openocd = next(t for t in data["tools"] if t["name"] == "openocd-esp32")
cmake = next(t for t in data["tools"] if t["name"] == "cmake")
riscv = next(t for t in data["tools"] if t["name"] == "riscv32-esp-elf")
assert openocd["install"] == "on_request"
# other tools are left untouched
assert cmake["install"] == "always"
assert riscv["supported_targets"] is None
assert "Unexpected supported_targets" in caplog.text
def test_patch_tools_json_unexpected_structure_warns_and_skips(
@@ -670,16 +1233,29 @@ def test_patch_tools_json_unexpected_structure_warns_and_skips(
tools_json = tools_dir / "tools.json"
tools_json.write_text('["not", "a", "dict"]', encoding="utf-8")
before = tools_json.read_text(encoding="utf-8")
_patch_tools_json_demote_openocd(tmp_path) # AttributeError -> skip
_patch_tools_json_demote_unused_tools(tmp_path) # AttributeError -> skip
assert tools_json.read_text(encoding="utf-8") == before
def test_demote_openocd_already_patched_is_noop(tmp_path: Path) -> None:
def test_demote_unused_tools_already_patched_is_noop(tmp_path: Path) -> None:
tools_json = _write_tools_json(
tmp_path, {"tools": [{"name": "openocd-esp32", "install": "on_request"}]}
tmp_path,
{
"tools": [
{"name": "openocd-esp32", "install": "on_request"},
{"name": "xtensa-esp-elf-gdb", "install": "on_request"},
{"name": "riscv32-esp-elf-gdb", "install": "on_request"},
{"name": "esp32ulp-elf", "install": "on_request"},
{
"name": "riscv32-esp-elf",
"install": "always",
"supported_targets": ["esp32c3", "esp32p4"],
},
]
},
)
before = tools_json.read_text(encoding="utf-8")
_patch_tools_json_demote_openocd(tmp_path)
_patch_tools_json_demote_unused_tools(tmp_path)
assert tools_json.read_text(encoding="utf-8") == before
@@ -909,6 +1485,54 @@ def test_check_stamp_corrupt_file(tmp_path: Path) -> None:
assert _check_stamp(f, {"a": "1"}) is False
def test_read_stamp_corrupt_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# A corrupt stamp forces a full reinstall on every build, so it warns
# where the normal missing-file case stays silent.
f = tmp_path / "s.json"
f.write_text("{ not json", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
assert _read_stamp(f) is None
assert "Ignoring corrupt stamp file" in caplog.text
def test_read_stamp_unreadable_file_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# An I/O fault (permissions, disk error) is distinguished from a simply
# missing stamp with a warning before falling back to reinstall.
f = tmp_path / "s.json"
f.write_text(json.dumps({"a": "1"}), encoding="utf-8")
with (
patch.object(Path, "open", side_effect=PermissionError("denied")),
caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"),
):
assert _read_stamp(f) is None
assert "Could not read stamp file" in caplog.text
def test_read_stamp_non_dict_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Well-formed JSON that is not an object is a fault, not a first install;
# it must leave a trace before forcing reinstalls.
f = tmp_path / "s.json"
f.write_text("null", encoding="utf-8")
with caplog.at_level(logging.WARNING, logger="esphome.espidf.framework"):
assert _read_stamp(f) is None
assert "unexpected type NoneType" in caplog.text
def test_read_stamp_missing_file_is_silent(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
# Missing stamps are the normal first-install case and must not log.
with caplog.at_level(logging.DEBUG, logger="esphome.espidf.framework"):
assert _read_stamp(tmp_path / "nope.json") is None
assert "stamp file" not in caplog.text
def test_write_idf_version_txt_writes_when_missing(tmp_path: Path) -> None:
_write_idf_version_txt(tmp_path, "5.1.2")
assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "v5.1.2\n"
+82 -3
View File
@@ -10,7 +10,13 @@ from unittest.mock import patch
import pytest
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
CONF_FRAMEWORK,
CONF_SOURCE,
)
from esphome.core import CORE, EsphomeError
from esphome.espidf import toolchain
@@ -50,7 +56,7 @@ def test_get_esphome_esp_idf_paths_forwards_source_override():
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=url)
mock_install.assert_called_once_with("5.5.4", targets=None, source_url=url)
def test_get_esphome_esp_idf_paths_no_override():
@@ -61,7 +67,28 @@ def test_get_esphome_esp_idf_paths_no_override():
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
) as mock_install:
toolchain._get_esphome_esp_idf_paths("5.5.4")
mock_install.assert_called_once_with("5.5.4", source_url=None)
mock_install.assert_called_once_with("5.5.4", targets=None, source_url=None)
def test_get_configured_targets_from_variant(monkeypatch: pytest.MonkeyPatch):
"""The configured variant restricts the toolchain install to its target."""
monkeypatch.delenv("CI", raising=False)
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
assert toolchain._get_configured_targets() == ["esp32s3"]
def test_get_configured_targets_without_variant(monkeypatch: pytest.MonkeyPatch):
"""No stored variant (e.g. tooling outside a build) keeps the default."""
monkeypatch.delenv("CI", raising=False)
CORE.data.pop(KEY_ESP32, None)
assert toolchain._get_configured_targets() is None
def test_get_configured_targets_ci_installs_all(monkeypatch: pytest.MonkeyPatch):
"""CI installs every target so the shared cache covers all variants."""
monkeypatch.setenv("CI", "true")
CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"}
assert toolchain._get_configured_targets() is None
def _setup_build(setup_core: Path) -> tuple[Path, Path]:
@@ -309,6 +336,58 @@ def test_get_cmake_output_missing_build_does_not_resolve_idf_env(
mock_run.assert_not_called()
def test_run_idf_py_jobs_sets_build_jobs_env(setup_core: Path) -> None:
"""The jobs argument is exported to idf.py as IDF_PY_BUILD_JOBS."""
_setup_build(setup_core)
with (
patch.object(toolchain, "_get_idf_path", return_value=Path("/idf")),
patch.object(toolchain, "_get_idf_env", return_value={"PATH": "/bin"}),
patch.object(toolchain, "_get_idf_tool", return_value="python"),
patch.object(toolchain.subprocess, "run") as mock_run,
):
mock_run.return_value.returncode = 0
toolchain.run_idf_py("build", jobs=2)
env = mock_run.call_args.kwargs["env"]
assert env["IDF_PY_BUILD_JOBS"] == "2"
assert env["PATH"] == "/bin"
toolchain.run_idf_py("build")
env = mock_run.call_args.kwargs["env"]
assert "IDF_PY_BUILD_JOBS" not in env
def test_run_compile_passes_compile_process_limit(setup_core: Path) -> None:
"""compile_process_limit is forwarded to run_idf_py as the job limit."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 1}}
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0) as mock_run,
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_run.assert_called_once_with("build", "size", jobs=1)
def test_run_compile_without_compile_process_limit(setup_core: Path) -> None:
"""When no compile_process_limit is set, no job limit is passed to idf.py."""
_setup_build(setup_core)
config = {CONF_ESPHOME: {}}
with (
patch.object(toolchain, "need_reconfigure", return_value=False),
patch.object(toolchain, "run_idf_py", return_value=0) as mock_run,
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile(config, verbose=False) == 0
mock_run.assert_called_once_with("build", "size", jobs=None)
def test_get_core_framework_version_from_core_data():
"""The version is read from CORE.data when validation populated it."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
+714 -10
View File
@@ -2,8 +2,10 @@
# pylint: disable=protected-access
import hashlib
import importlib.util
import io
import json
import logging
import os
from pathlib import Path
@@ -16,6 +18,7 @@ import zipfile
import pytest
import requests as req
from esphome import framework_helpers
from esphome.core import EsphomeError
from esphome.framework_helpers import (
_7z_extract_all,
@@ -26,6 +29,7 @@ from esphome.framework_helpers import (
archive_extract_all,
create_venv,
download_from_mirrors,
download_with_resume,
get_project_compile_flags,
get_project_cxx_compile_flags,
get_project_link_flags,
@@ -507,7 +511,7 @@ class TestArchiveExtractAll:
# ---------------------------------------------------------------------------
# download_from_mirrors
# download_from_mirrors / download_with_resume
# ---------------------------------------------------------------------------
@@ -515,6 +519,8 @@ def _mock_response(content: bytes, ok: bool = True) -> MagicMock:
r = MagicMock()
r.__enter__.return_value = r
r.__exit__.return_value = False
r.status_code = 200
r.ok = ok
if ok:
r.raise_for_status.return_value = None
else:
@@ -524,6 +530,563 @@ def _mock_response(content: bytes, ok: bool = True) -> MagicMock:
return r
def _interrupted_response(content: bytes, etag: str | None = None) -> MagicMock:
"""A response whose body yields ``content`` and then drops mid-stream.
``etag`` makes the response resumable: without a validator the retry
logic restarts from zero rather than stitching unverified bytes.
"""
def body(chunk_size):
yield content
raise req.exceptions.ChunkedEncodingError("connection dropped")
r = _mock_response(b"")
if etag is not None:
r.headers = {**r.headers, "ETag": etag}
r.iter_content.side_effect = body
return r
def _resumed_response(content: bytes) -> MagicMock:
"""An HTTP 206 response continuing an interrupted download."""
r = _mock_response(content)
r.status_code = 206
return r
class TestOpenRanged:
def test_fresh_download_sends_no_range(self) -> None:
with patch("requests.get", return_value=_mock_response(b"x")) as mock_get:
resp, offset = framework_helpers._open_ranged("https://e.com/f", 0, 30)
assert offset == 0
assert mock_get.call_args[1]["headers"] == {}
assert resp is mock_get.return_value
def test_resume_kept_on_206(self) -> None:
with patch("requests.get", return_value=_resumed_response(b"x")):
_, offset = framework_helpers._open_ranged("https://e.com/f", 7, 30)
assert offset == 7
def test_resume_downgraded_on_200(self) -> None:
"""A server that ignores the Range header forces a restart."""
with patch("requests.get", return_value=_mock_response(b"x")):
_, offset = framework_helpers._open_ranged("https://e.com/f", 7, 30)
assert offset == 0
def test_http_error_closes_response_and_raises(self) -> None:
r = _mock_response(b"", ok=False)
with (
patch("requests.get", return_value=r),
pytest.raises(req.HTTPError),
):
framework_helpers._open_ranged("https://e.com/f", 0, 30)
r.close.assert_called_once()
def test_connect_error_propagates(self) -> None:
with (
patch("requests.get", side_effect=req.ConnectionError("refused")),
pytest.raises(req.ConnectionError),
):
framework_helpers._open_ranged("https://e.com/f", 0, 30)
class TestDownloadWithResume:
def test_downloads_and_renames(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
with patch("requests.get", return_value=_mock_response(b"data")) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"data"
assert not (tmp_path / "tool.tar.gz.part").exists()
# a fresh download must not send a Range header
assert "Range" not in mock_get.call_args[1]["headers"]
def test_mid_stream_drop_resumes_with_range(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
with patch(
"requests.get",
side_effect=[first, _resumed_response(b"5678")],
) as mock_get:
download_with_resume("https://example.com/t", dest)
# earlier bytes were kept, remainder appended conditionally
assert dest.read_bytes() == b"12345678"
assert mock_get.call_args_list[1][1]["headers"] == {
"Range": "bytes=4-",
"If-Range": '"v1"',
}
def test_unverifiable_drop_without_length_restarts(self, tmp_path: Path) -> None:
"""A validator alone is not enough to stitch when nothing can prove
the stitched file complete (no sha/size and no content-length)."""
dest = tmp_path / "tool.tar.gz"
with patch(
"requests.get",
side_effect=[
_interrupted_response(b"1234", etag='"v1"'),
_mock_response(b"full"),
],
) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_resumed_clean_but_short_body_discarded(self, tmp_path: Path) -> None:
"""A resumed stream that ends cleanly but short of the advertised
total is rejected and re-downloaded, not promoted."""
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"abcd", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
# resume ends cleanly after only 2 of the 4 missing bytes
short = _resumed_response(b"ef")
full = _mock_response(b"abcdefgh")
full.headers = {**full.headers, "content-length": "8"}
with patch("requests.get", side_effect=[first, short, full]) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"abcdefgh"
# the short stitch was discarded; the final attempt started fresh
assert "Range" not in mock_get.call_args_list[2][1]["headers"]
def test_unverifiable_drop_without_validator_restarts(self, tmp_path: Path) -> None:
"""No sha/size and no server validator: the retry must not stitch."""
dest = tmp_path / "tool.tar.gz"
with patch(
"requests.get",
side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")],
) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_resume_across_invocations_from_part_file(self, tmp_path: Path) -> None:
"""A .part file left by a previous run is resumed, not restarted,
when sha/size verification will vouch for the stitched result."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
good = hashlib.sha256(b"12345678").hexdigest()
with patch("requests.get", return_value=_resumed_response(b"678")) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=8)
assert dest.read_bytes() == b"12345678"
assert mock_get.call_args[1]["headers"] == {"Range": "bytes=5-"}
def test_unverifiable_leftover_part_file_ignored(self, tmp_path: Path) -> None:
"""Without sha/size there is no way to vouch for a cross-run stitch,
so a leftover part file starts over."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
with patch("requests.get", return_value=_mock_response(b"fresh")) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"fresh"
assert "Range" not in mock_get.call_args[1]["headers"]
def test_server_without_range_support_restarts(self, tmp_path: Path) -> None:
"""HTTP 200 in response to a Range request truncates and restarts."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"sta")
good = hashlib.sha256(b"fresh").hexdigest()
with patch("requests.get", return_value=_mock_response(b"fresh")) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=5)
# the Range request was sent (verifiable resume) and downgraded
assert mock_get.call_args[1]["headers"] == {"Range": "bytes=3-"}
assert dest.read_bytes() == b"fresh"
def test_size_only_leftover_part_restarts(self, tmp_path: Path) -> None:
"""A size alone cannot detect a same-length content change on the
server, so a cross-run part without sha256 restarts from zero."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"12")
with patch("requests.get", return_value=_mock_response(b"1234")) as mock_get:
download_with_resume("https://example.com/t", dest, size=4)
assert "Range" not in mock_get.call_args[1]["headers"]
assert dest.read_bytes() == b"1234"
def test_size_only_in_run_drop_resumes_with_validator(self, tmp_path: Path) -> None:
"""Within a run the If-Range validator proves identity, so size-only
callers still resume mid-stream drops."""
dest = tmp_path / "tool.tar.gz"
with patch(
"requests.get",
side_effect=[
_interrupted_response(b"12", etag='"v1"'),
_resumed_response(b"34"),
],
) as mock_get:
download_with_resume("https://example.com/t", dest, size=4)
assert dest.read_bytes() == b"1234"
assert mock_get.call_args_list[1][1]["headers"] == {
"Range": "bytes=2-",
"If-Range": '"v1"',
}
def test_unverifiable_download_logged(
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""No sha, no size, no content-length: the download is promoted with
a debug note (routine for e.g. the constraints host, so not a
warning) that completeness could not be verified."""
dest = tmp_path / "tool.tar.gz"
with (
caplog.at_level(logging.DEBUG, logger="esphome.framework_helpers"),
patch("requests.get", return_value=_mock_response(b"data")),
):
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"data"
assert "without any way to verify completeness" in caplog.text
def test_416_promotes_complete_part_when_size_unknown(self, tmp_path: Path) -> None:
"""sha256-only caller with a byte-complete part file: the server's
416 confirms nothing is missing, verification promotes in place, and
the 416 must not loop as a retryable error."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"data")
good = hashlib.sha256(b"data").hexdigest()
r416 = _mock_response(b"", ok=False)
r416.status_code = 416
with patch("requests.get", return_value=r416) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good)
assert mock_get.call_count == 1
r416.close.assert_called_once()
assert dest.read_bytes() == b"data"
def test_416_with_corrupt_part_discards_and_redownloads(
self, tmp_path: Path
) -> None:
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"bad!")
good = hashlib.sha256(b"data").hexdigest()
r416 = _mock_response(b"", ok=False)
r416.status_code = 416
with patch(
"requests.get", side_effect=[r416, _mock_response(b"data")]
) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good)
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
assert dest.read_bytes() == b"data"
def test_hash_mismatch_discards_and_retries(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
good = hashlib.sha256(b"good").hexdigest()
with patch(
"requests.get",
side_effect=[_mock_response(b"bad!"), _mock_response(b"good")],
) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert dest.read_bytes() == b"good"
# the corrupt part file was discarded, so the retry starts fresh
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_size_mismatch_discards_part(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
with (
patch("requests.get", return_value=_mock_response(b"xx")),
pytest.raises(EsphomeError, match="after 2 attempts"),
):
download_with_resume("https://example.com/t", dest, size=99, attempts=2)
assert not (tmp_path / "tool.tar.gz.part").exists()
assert not dest.exists()
def test_attempts_exhausted_keeps_part_file(self, tmp_path: Path) -> None:
"""Mid-stream failures keep the partial file so a later run resumes."""
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"12", etag='"v1"')
first.headers = {**first.headers, "content-length": "4"}
second = _interrupted_response(b"34")
second.status_code = 206
with (
patch("requests.get", side_effect=[first, second]),
pytest.raises(EsphomeError, match="after 2 attempts"),
):
download_with_resume("https://example.com/t", dest, attempts=2)
assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"1234"
def test_multiple_drops_accumulate_across_attempts(self, tmp_path: Path) -> None:
"""Each attempt appends its bytes; three partial responses complete
the file."""
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"ab", etag='"v1"')
first.headers = {**first.headers, "content-length": "6"}
second = _interrupted_response(b"cd")
second.status_code = 206
third = _resumed_response(b"ef")
with patch(
"requests.get",
side_effect=[first, second, third],
) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"abcdef"
expected = {"Range": "bytes=2-", "If-Range": '"v1"'}
assert mock_get.call_args_list[1][1]["headers"] == expected
expected = {"Range": "bytes=4-", "If-Range": '"v1"'}
assert mock_get.call_args_list[2][1]["headers"] == expected
def test_connect_error_then_success(self, tmp_path: Path) -> None:
"""A connect error (no response at all) consumes an attempt and the
next attempt succeeds."""
dest = tmp_path / "tool.tar.gz"
with patch(
"requests.get",
side_effect=[req.ConnectionError("refused"), _mock_response(b"data")],
):
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"data"
def test_http_error_keeps_part_file(self, tmp_path: Path) -> None:
"""A transient HTTP error (e.g. 503) must not discard resume state."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"keep")
error = _mock_response(b"", ok=False)
error.status_code = 503
with (
patch("requests.get", return_value=error),
pytest.raises(EsphomeError, match="after 1 attempts"),
):
download_with_resume("https://example.com/t", dest, attempts=1)
assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"keep"
def test_creates_missing_parent_directories(self, tmp_path: Path) -> None:
dest = tmp_path / "dist" / "nested" / "tool.tar.gz"
with patch("requests.get", return_value=_mock_response(b"data")):
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"data"
def test_verifies_both_size_and_sha(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get", return_value=_mock_response(b"data")):
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert dest.read_bytes() == b"data"
def test_corrupt_partial_resumed_then_discarded_then_redownloaded(
self, tmp_path: Path
) -> None:
"""The full recovery cycle for a corrupted partial download: the
resume completes it, verification fails, the poisoned part file is
discarded, and the next attempt re-downloads from scratch."""
dest = tmp_path / "tool.tar.gz"
# a previous run left a corrupted 4-byte prefix behind
(tmp_path / "tool.tar.gz.part").write_bytes(b"BAD!")
good = hashlib.sha256(b"data66").hexdigest()
with patch(
"requests.get",
side_effect=[
_resumed_response(b"66"), # resume "completes" the bad part
_mock_response(b"data66"), # clean retry from zero
],
) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=6)
# first attempt resumed at the corrupt offset, failed verification;
# second attempt started fresh (no Range header) and succeeded
assert mock_get.call_args_list[0][1]["headers"] == {"Range": "bytes=4-"}
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
assert dest.read_bytes() == b"data66"
assert not (tmp_path / "tool.tar.gz.part").exists()
def test_existing_dest_passing_verification_kept(self, tmp_path: Path) -> None:
"""A dest completed by an earlier run is reused without any request."""
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"data")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get") as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
mock_get.assert_not_called()
assert dest.read_bytes() == b"data"
@pytest.mark.parametrize(
"stale",
[
pytest.param(b"corrupt!", id="wrong-size"),
pytest.param(b"bad!", id="right-size-wrong-hash"),
],
)
def test_existing_dest_failing_verification_redownloaded(
self, tmp_path: Path, stale: bytes
) -> None:
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(stale)
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get", return_value=_mock_response(b"data")):
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert dest.read_bytes() == b"data"
def test_existing_dest_with_size_only_kept(self, tmp_path: Path) -> None:
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"data")
with patch("requests.get") as mock_get:
download_with_resume("https://example.com/t", dest, size=4)
mock_get.assert_not_called()
def test_existing_dest_with_sha_only_kept(self, tmp_path: Path) -> None:
"""sha-only verification also authorizes reusing a completed dest."""
dest = tmp_path / "tool.tar.gz"
dest.write_bytes(b"data")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get") as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good)
mock_get.assert_not_called()
def test_meta_write_failure_is_best_effort(self, tmp_path: Path) -> None:
"""A failure to persist the resume sidecar must not fail the
download itself."""
dest = tmp_path / "f.tar.xz"
first = _mock_response(b"data")
first.headers = {**first.headers, "ETag": '"v1"', "content-length": "4"}
with (
patch("requests.get", return_value=first),
patch.object(Path, "write_text", side_effect=OSError("read-only")),
):
download_with_resume("https://example.com/f", dest)
assert dest.read_bytes() == b"data"
def test_meta_sidecar_written_and_removed(self, tmp_path: Path) -> None:
"""The validator sidecar appears while downloading and is cleaned up
with the promotion."""
dest = tmp_path / "f.tar.xz"
meta = tmp_path / "f.tar.xz.part.meta"
seen: list[bool] = []
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
responses = [first]
def get(*args: object, **kwargs: object) -> MagicMock:
if responses:
return responses.pop(0)
# the resume request: the sidecar written by the first response
# must already be on disk at this point
seen.append(meta.is_file())
return _resumed_response(b"5678")
with patch("requests.get", side_effect=get):
download_with_resume("https://example.com/f", dest)
assert dest.read_bytes() == b"12345678"
assert seen == [True] # sidecar existed during the resume attempt
assert not meta.exists() # cleaned up on success
def test_locked_promotion_keeps_verified_part(self, tmp_path: Path) -> None:
"""A rename that stays blocked (e.g. a long-lived Windows file lock)
must not delete the verified download; the next attempt retries just
the rename without touching the network."""
dest = tmp_path / "tool.tar.gz"
good = hashlib.sha256(b"data").hexdigest()
with (
patch("requests.get", return_value=_mock_response(b"data")) as mock_get,
patch(
"esphome.framework_helpers._rename_with_retry",
side_effect=[PermissionError("locked"), None],
) as rename,
):
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
# one download; the second attempt only redid the rename
assert mock_get.call_count == 1
assert rename.call_count == 2
def test_locked_promotion_exhausted_keeps_part_for_next_run(
self, tmp_path: Path
) -> None:
dest = tmp_path / "tool.tar.gz"
good = hashlib.sha256(b"data").hexdigest()
with (
patch("requests.get", return_value=_mock_response(b"data")),
patch(
"esphome.framework_helpers._rename_with_retry",
side_effect=PermissionError("locked"),
),
pytest.raises(EsphomeError, match="after 1 attempts"),
):
download_with_resume(
"https://example.com/t", dest, sha256=good, size=4, attempts=1
)
# the verified bytes survive for the next run
assert (tmp_path / "tool.tar.gz.part").read_bytes() == b"data"
def test_meta_sidecar_resumes_across_runs_without_sha(self, tmp_path: Path) -> None:
"""A later run resumes an unfinished download using the validator the
first run stored — the cross-run fix for the framework tarball."""
dest = tmp_path / "f.tar.xz"
(tmp_path / "f.tar.xz.part").write_bytes(b"1234")
(tmp_path / "f.tar.xz.part.meta").write_text(
json.dumps(
{"url": "https://example.com/f", "validator": '"v1"', "total": 8}
)
)
with patch("requests.get", return_value=_resumed_response(b"5678")) as mock_get:
download_with_resume("https://example.com/f", dest)
assert dest.read_bytes() == b"12345678"
assert mock_get.call_args[1]["headers"] == {
"Range": "bytes=4-",
"If-Range": '"v1"',
}
def test_meta_sidecar_for_other_url_ignored(self, tmp_path: Path) -> None:
"""Metadata from a different mirror URL must not authorize a stitch."""
dest = tmp_path / "f.tar.xz"
(tmp_path / "f.tar.xz.part").write_bytes(b"1234")
(tmp_path / "f.tar.xz.part.meta").write_text(
json.dumps({"url": "https://other.com/f", "validator": '"v1"', "total": 8})
)
full = _mock_response(b"12345678")
with patch("requests.get", return_value=full) as mock_get:
download_with_resume("https://example.com/f", dest)
assert "Range" not in mock_get.call_args[1]["headers"]
assert dest.read_bytes() == b"12345678"
def test_complete_part_file_promoted_without_network(self, tmp_path: Path) -> None:
"""A .part holding every byte (killed between write and rename) is
verified in place and promoted; no request is made, so no 416 loop."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"data")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get") as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
mock_get.assert_not_called()
assert dest.read_bytes() == b"data"
def test_complete_but_corrupt_part_file_redownloaded(self, tmp_path: Path) -> None:
"""A full-size .part with a wrong hash is discarded and re-downloaded
from scratch."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"bad!")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get", return_value=_mock_response(b"data")) as mock_get:
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert "Range" not in mock_get.call_args[1]["headers"]
assert dest.read_bytes() == b"data"
def test_oversized_part_file_discarded(self, tmp_path: Path) -> None:
"""A .part larger than the expected size fails verification and is
replaced by a fresh download."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"toolong")
good = hashlib.sha256(b"data").hexdigest()
with patch("requests.get", return_value=_mock_response(b"data")):
download_with_resume("https://example.com/t", dest, sha256=good, size=4)
assert dest.read_bytes() == b"data"
def test_malformed_content_length_degrades_gracefully(self, tmp_path: Path) -> None:
"""A garbage Content-Length must not crash the attempt; it means
"unknown", so a drop restarts instead of stitching and a clean
download still succeeds."""
dest = tmp_path / "tool.tar.gz"
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "explode"}
retry = _mock_response(b"full")
retry.headers = {**retry.headers, "content-length": "explode"}
with patch("requests.get", side_effect=[first, retry]) as mock_get:
download_with_resume("https://example.com/t", dest)
assert dest.read_bytes() == b"full"
# unknown length -> completeness unprovable -> no resume attempted
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_zero_byte_part_file_sends_no_range(self, tmp_path: Path) -> None:
"""An empty leftover part file is a fresh download, not a resume."""
dest = tmp_path / "tool.tar.gz"
(tmp_path / "tool.tar.gz.part").write_bytes(b"")
with patch("requests.get", return_value=_mock_response(b"data")) as mock_get:
download_with_resume("https://example.com/t", dest)
assert mock_get.call_args[1]["headers"] == {}
assert dest.read_bytes() == b"data"
class TestDownloadFromMirrors:
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
target = tmp_path / "out.bin"
@@ -640,7 +1203,8 @@ class TestDownloadFromMirrors:
ei.value
)
def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None:
def test_falls_back_to_second_mirror(self) -> None:
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")],
@@ -648,14 +1212,152 @@ class TestDownloadFromMirrors:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
tmp_path / "out.bin",
buf,
)
assert url == "https://mirror2.com/f"
assert (tmp_path / "out.bin").read_bytes() == b"second"
assert buf.getvalue() == b"second"
def test_all_mirrors_fail_raises_error_listing_every_attempt(
self, tmp_path: Path
) -> None:
def test_mid_stream_drop_resumes_same_mirror(self) -> None:
"""A mid-stream failure retries the same mirror with Range and
If-Range headers, keeping the bytes already received, before falling
to the next."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[first, _resumed_response(b"5678")],
) as mock_get:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
buf,
)
assert url == "https://mirror1.com/f"
assert buf.getvalue() == b"12345678"
assert mock_get.call_count == 2
assert mock_get.call_args_list[1][0][0] == "https://mirror1.com/f"
# the resume is conditional on the content being unchanged
assert mock_get.call_args_list[1][1]["headers"] == {
"Range": "bytes=4-",
"If-Range": '"v1"',
}
def test_mid_stream_drop_without_validator_restarts(self) -> None:
"""A server offering no ETag/Last-Modified cannot be resumed safely;
the retry restarts from zero instead of stitching unverified bytes."""
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[_interrupted_response(b"1234"), _mock_response(b"full")],
) as mock_get:
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert buf.getvalue() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_drop_after_last_byte_recovers_via_416(self) -> None:
"""A connection drop after the final body byte leaves a complete file;
the retry's 416 answer plus the length check turn it into success
instead of a wasted refetch."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "4"}
r416 = _mock_response(b"", ok=False)
r416.status_code = 416
buf = io.BytesIO()
with patch("requests.get", side_effect=[first, r416]) as mock_get:
url = download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert url == "https://mirror1.com/f"
assert buf.getvalue() == b"1234"
assert mock_get.call_count == 2
def test_mirror_drop_without_length_restarts(self) -> None:
"""With no content-length there is no way to prove a stitched file
complete, so the retry restarts even though a validator exists."""
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=[
_interrupted_response(b"1234", etag='"v1"'),
_mock_response(b"full"),
],
) as mock_get:
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
assert buf.getvalue() == b"full"
assert "Range" not in mock_get.call_args_list[1][1]["headers"]
def test_path_target_resumes_across_runs(self, tmp_path: Path) -> None:
"""A path target routes through download_with_resume: a part file and
metadata from a previous run resume instead of restarting."""
dest = tmp_path / "idf.tar.xz"
(tmp_path / "idf.tar.xz.part").write_bytes(b"1234")
(tmp_path / "idf.tar.xz.part.meta").write_text(
json.dumps(
{"url": "https://mirror1.com/f", "validator": '"v1"', "total": 8}
)
)
with patch("requests.get", return_value=_resumed_response(b"5678")) as mock_get:
url = download_from_mirrors(["https://mirror1.com/f"], {}, dest)
assert url == "https://mirror1.com/f"
assert dest.read_bytes() == b"12345678"
assert mock_get.call_args[1]["headers"] == {
"Range": "bytes=4-",
"If-Range": '"v1"',
}
def test_path_target_falls_back_to_next_mirror(self, tmp_path: Path) -> None:
dest = tmp_path / "idf.tar.xz"
with patch(
"requests.get",
side_effect=[req.ConnectionError("down"), _mock_response(b"data")],
):
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"], {}, dest
)
assert url == "https://mirror2.com/f"
assert dest.read_bytes() == b"data"
def test_resumed_short_body_fails_length_check(self) -> None:
"""A stitched file whose final length disagrees with the advertised
total is rejected instead of reported as success."""
first = _interrupted_response(b"1234", etag='"v1"')
first.headers = {**first.headers, "content-length": "8"}
# the resume ends early (5 of 8 bytes); the poisoned part is then
# discarded and the fresh retry also delivers a short body
short_resume = _resumed_response(b"5")
short_fresh = _mock_response(b"56")
short_fresh.headers = {**short_fresh.headers, "content-length": "8"}
buf = io.BytesIO()
with (
patch("requests.get", side_effect=[first, short_resume, short_fresh]),
pytest.raises(EsphomeError, match="all mirrors"),
):
download_from_mirrors(["https://mirror1.com/f"], {}, buf)
def test_failed_mirror_leftovers_not_kept_for_next_mirror(self) -> None:
"""Bytes from a mirror that failed all attempts must not leak into the
next mirror's download (no bogus Range request, fresh content)."""
exhausted = [_interrupted_response(b"AAAA", etag='"a1"')]
for _ in range(2):
r = _interrupted_response(b"BB")
r.status_code = 206
exhausted.append(r)
buf = io.BytesIO()
with patch(
"requests.get",
side_effect=exhausted + [_mock_response(b"clean")],
) as mock_get:
url = download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
buf,
)
assert url == "https://mirror2.com/f"
assert buf.getvalue() == b"clean"
# the second mirror starts fresh, without a Range header
assert mock_get.call_args_list[3][0][0] == "https://mirror2.com/f"
assert "Range" not in mock_get.call_args_list[3][1]["headers"]
def test_all_mirrors_fail_raises_error_listing_every_attempt(self) -> None:
with (
patch(
"requests.get",
@@ -666,7 +1368,7 @@ class TestDownloadFromMirrors:
download_from_mirrors(
["https://mirror1.com/f", "https://mirror2.com/f"],
{},
tmp_path / "out.bin",
io.BytesIO(),
)
# Every attempted URL appears in the message, and the first mirror's
# exception (the primary URL, usually the one that matters) is chained.
@@ -731,8 +1433,10 @@ def test_importing_framework_helpers_does_not_import_requests() -> None:
[
sys.executable,
"-c",
"import sys\nimport esphome.framework_helpers\n"
"print('\\n'.join(sys.modules))",
(
"import sys\nimport esphome.framework_helpers\n"
"print('\\n'.join(sys.modules))"
),
],
capture_output=True,
text=True,
+442 -58
View File
@@ -1,8 +1,10 @@
"""Tests for git.py module."""
from collections.abc import Callable
import logging
import os
from pathlib import Path
import subprocess
import time
from typing import Any
from unittest.mock import Mock, patch
@@ -71,19 +73,40 @@ def _simulate_cloned_repo(repo_dir: Path) -> None:
(repo_dir / ".git").mkdir(exist_ok=True)
def _make_clone_side_effect(repo_dir: Path) -> Callable[..., str]:
"""Return a run_git_command side effect whose clone creates the repo dir."""
def _make_clone_side_effect(
repo_dir: Path, gitmodules: bool = False
) -> Callable[..., str]:
"""Return a run_git_command side effect whose clone creates the repo dir.
With ``gitmodules`` the cloned repo also declares submodules.
"""
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
if _get_git_command_type(cmd) == "clone":
_simulate_cloned_repo(repo_dir)
if gitmodules:
(repo_dir / ".gitmodules").write_text("test")
return ""
return git_command_side_effect
def _submodule_calls(mock: Mock) -> list[Any]:
"""Return the mock's `git submodule` calls."""
return [
c for c in mock.call_args_list if _get_git_command_type(c[0][0]) == "submodule"
]
def _assert_submodule_runs_without_isolation(call: Any, repo_dir: Path) -> None:
"""Assert a git submodule call ran with plain cwd, not GIT_DIR/GIT_WORK_TREE
isolation, which breaks the submodule porcelain on some installations."""
assert call.kwargs.get("git_dir") is None
assert call.kwargs.get("cwd") == repo_dir
def test_run_git_command_success(tmp_path: Path) -> None:
"""Test that run_git_command returns output on success."""
# Create a simple git repo to test with
@@ -100,6 +123,22 @@ def test_run_git_command_success(tmp_path: Path) -> None:
assert isinstance(result, str)
def test_run_git_command_debug_log_redacts_credentials(
tmp_path: Path, mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture
) -> None:
"""Embedded URL credentials never reach the debug log; -v output is
routinely pasted into public issues. subprocess is mocked so no real
git ever sees the URL (the path is not creatable on Windows)."""
mock_subprocess_run.return_value = Mock(returncode=0, stdout=b"", stderr=b"")
with caplog.at_level(logging.DEBUG, logger="esphome.git"):
git.run_git_command(
["git", "clone", "https://user:hunter2@github.com/test/repo"],
cwd=tmp_path,
)
assert "hunter2" not in caplog.text
assert "://***@github.com/test/repo" in caplog.text
def test_run_git_command_with_git_dir_isolation(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
@@ -116,10 +155,17 @@ def test_run_git_command_with_git_dir_isolation(
stderr=b"",
)
result = git.run_git_command(
["git", "rev-parse", "HEAD"],
git_dir=repo_dir,
)
# Ambient repo-scoping vars simulate a git hook invoking ESPHome; an
# ambient GIT_INDEX_FILE surviving into a git_dir invocation fails
# silently (git operates on the caller's index and exits 0).
with patch.dict(
os.environ,
{"GIT_INDEX_FILE": "/caller/index", "GIT_OBJECT_DIRECTORY": "/caller/objects"},
):
result = git.run_git_command(
["git", "rev-parse", "HEAD"],
git_dir=repo_dir,
)
# Verify subprocess.run was called
assert mock_subprocess_run.called
@@ -131,6 +177,9 @@ def test_run_git_command_with_git_dir_isolation(
assert "GIT_WORK_TREE" in env
assert env["GIT_DIR"] == str(repo_dir / ".git")
assert env["GIT_WORK_TREE"] == str(repo_dir)
# The ambient scoping vars must be stripped, not passed through.
assert "GIT_INDEX_FILE" not in env
assert "GIT_OBJECT_DIRECTORY" not in env
assert result == "test output"
@@ -216,6 +265,89 @@ def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None:
assert result == "Cloning into 'test_repo'..."
@pytest.mark.parametrize("relative", [False, True], ids=["absolute", "relative"])
def test_run_git_command_with_cwd_runs_in_dir_without_isolation(
tmp_path: Path,
mock_subprocess_run: Mock,
monkeypatch: pytest.MonkeyPatch,
relative: bool,
) -> None:
"""The cwd parameter sets the working directory without GIT_DIR/GIT_WORK_TREE.
Ambient GIT_DIR/GIT_WORK_TREE (e.g. from a git hook or CI wrapper) must be
stripped too, and GIT_CEILING_DIRECTORIES must stop git from walking up to
an enclosing repository if the target repo's .git is missing or corrupt.
Git silently ignores a relative ceiling entry, so the variable must come
out absolute even when the given cwd is relative.
"""
repo_dir = tmp_path / "test_repo"
repo_dir.mkdir()
if relative:
monkeypatch.chdir(tmp_path)
cwd_arg = Path("test_repo")
else:
cwd_arg = repo_dir
mock_subprocess_run.return_value = Mock(
returncode=0,
stdout=b"test output",
stderr=b"",
)
with patch.dict(
os.environ,
{
"GIT_DIR": "/ambient/.git",
"GIT_WORK_TREE": "/ambient",
"GIT_INDEX_FILE": "/ambient/.git/index",
},
):
result = git.run_git_command(["git", "submodule", "update"], cwd=cwd_arg)
call_args = mock_subprocess_run.call_args
env = call_args[1]["env"]
assert "GIT_DIR" not in env
assert "GIT_WORK_TREE" not in env
assert "GIT_INDEX_FILE" not in env
ceiling = Path(env["GIT_CEILING_DIRECTORIES"])
assert ceiling.is_absolute()
assert ceiling.samefile(tmp_path)
assert call_args[1]["cwd"] == cwd_arg
assert result == "test output"
def test_run_git_command_raises_on_nonfatal_stderr(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
"""Nonzero exit with stderr lacking a fatal: prefix raises with full stderr."""
mock_subprocess_run.return_value = Mock(
returncode=1,
stdout=b"",
stderr=b"error: pathspec 'nope' did not match any file(s)\n",
)
with pytest.raises(GitCommandError, match="did not match"):
git.run_git_command(["git", "checkout", "nope"], git_dir=tmp_path)
def test_run_git_command_raises_on_nonzero_exit_without_stderr(
tmp_path: Path, mock_subprocess_run: Mock
) -> None:
"""A nonzero exit must raise even when git printed nothing to stderr.
Silent nonzero exits were previously treated as success, which is how
broken checkouts could be cached as complete.
"""
mock_subprocess_run.return_value = Mock(
returncode=1,
stdout=b"",
stderr=b"",
)
with pytest.raises(GitCommandError, match="exited with code 1"):
git.run_git_command(["git", "submodule", "update"], cwd=tmp_path)
def test_run_git_command_without_git_dir_raises_error(
mock_subprocess_run: Mock,
) -> None:
@@ -1156,46 +1288,6 @@ def test_clone_with_ref_uses_shallow_fetch(
assert ref in fetch_calls[0][0][0]
def test_clone_with_submodules_uses_shallow_submodule_update(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""Submodule init on a fresh clone should use --depth=1."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
if _get_git_command_type(cmd) == "clone":
repo_dir.mkdir(parents=True, exist_ok=True)
(repo_dir / ".git").mkdir(exist_ok=True)
return ""
mock_run_git_command.side_effect = git_command_side_effect
git.clone_or_update(
url=url,
ref=None,
refresh=None,
domain=domain,
submodules=["components/foo"],
)
submodule_calls = [
c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0]
]
assert len(submodule_calls) == 1
cmd = submodule_calls[0][0][0]
assert "--depth=1" in cmd
assert "components/foo" in cmd
# The `--` terminator must precede the submodule paths so a path
# beginning with `-` cannot be parsed as an option.
assert cmd.index("--") < cmd.index("components/foo")
def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None:
"""The refresh-path fetch should use --depth=1."""
CORE.config_path = tmp_path / "test.yaml"
@@ -1220,10 +1312,91 @@ def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) ->
assert cmd[-1] == ref
def test_refresh_submodule_update_is_shallow(
@pytest.mark.parametrize(
"refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"]
)
def test_all_submodules_skipped_without_gitmodules(
tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None
) -> None:
"""init_submodules is a no-op for repos with no .gitmodules.
This is the esp-idf toolchain library scenario from issue #17860: the
PlatformIO library converter requests "all submodules" for every git
library, and most libraries declare none. The git submodule porcelain
must not run at all in that case — it fails outright on some git
installations.
"""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
if refresh is None:
mock_run_git_command.side_effect = _make_clone_side_effect(repo_dir)
else:
_setup_old_repo(repo_dir)
mock_run_git_command.return_value = "abc123"
git.clone_or_update(
url=url,
ref=None,
refresh=refresh,
domain=domain,
init_submodules=True,
)
assert not _submodule_calls(mock_run_git_command)
@pytest.mark.parametrize(
"refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"]
)
def test_all_submodules_updated_with_gitmodules(
tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None
) -> None:
"""init_submodules initializes all submodules when .gitmodules exists."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
if refresh is None:
mock_run_git_command.side_effect = _make_clone_side_effect(
repo_dir, gitmodules=True
)
else:
_setup_old_repo(repo_dir)
(repo_dir / ".gitmodules").write_text("test")
mock_run_git_command.return_value = "abc123"
git.clone_or_update(
url=url,
ref=None,
refresh=refresh,
domain=domain,
init_submodules=True,
)
submodule_calls = _submodule_calls(mock_run_git_command)
# Which submodules get populated is git's own policy, so no status
# verification follows the update.
assert len(submodule_calls) == 1
cmd = submodule_calls[0][0][0]
assert cmd[2] == "update"
assert "--depth=1" in cmd
# Recursive, mirroring PlatformIO's recursive library clones.
assert "--recursive" in cmd
_assert_submodule_runs_without_isolation(submodule_calls[0], repo_dir)
def test_recovery_reclone_keeps_credentials_and_cache_key(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""The refresh-path submodule update should use --depth=1."""
"""The recovery re-clone must not re-apply credentials to the already
rewritten URL (no doubled userinfo) and must land in the same cache
directory, or a credentialed private repo re-clones on every run."""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
@@ -1231,24 +1404,235 @@ def test_refresh_submodule_update_is_shallow(
repo_dir = _compute_repo_dir(url, None, domain)
_setup_old_repo(repo_dir)
mock_run_git_command.return_value = "abc123"
(repo_dir / ".gitmodules").write_text("test")
git.clone_or_update(
calls = {"submodule": 0}
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
if _get_git_command_type(cmd) == "clone":
_simulate_cloned_repo(repo_dir)
if _get_git_command_type(cmd) == "submodule":
calls["submodule"] += 1
if calls["submodule"] == 1:
raise git.GitCommandError("git submodule update exited with code 1")
return ""
mock_run_git_command.side_effect = git_command_side_effect
recovered_dir, _ = git.clone_or_update(
url=url,
ref=None,
refresh=TimePeriodSeconds(days=1),
domain=domain,
submodules=["components/foo"],
username="user",
password="hunter2",
init_submodules=True,
)
submodule_calls = [
c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0]
assert recovered_dir == repo_dir
clone_cmds = [
c[0][0]
for c in mock_run_git_command.call_args_list
if _get_git_command_type(c[0][0]) == "clone"
]
assert len(submodule_calls) == 1
cmd = submodule_calls[0][0][0]
assert "--depth=1" in cmd
assert "components/foo" in cmd
assert cmd.index("--") < cmd.index("components/foo")
assert clone_cmds
clone_url = clone_cmds[0][-2]
assert clone_url == "https://user:hunter2@github.com/test/repo"
assert clone_url.count("@") == 1
def test_refresh_submodule_failure_recovers_then_raises(
tmp_path: Path, mock_run_git_command: Mock
) -> None:
"""A refresh-path submodule failure routes through the recovery re-clone.
The broken repo is removed and re-cloned; when the submodule update fails
again on the fresh clone the cache entry is removed and the error
propagates, instead of leaving behind a repo the refresh window would
silently accept on the next run.
"""
CORE.config_path = tmp_path / "test.yaml"
url = "https://github.com/test/repo"
domain = "test"
repo_dir = _compute_repo_dir(url, None, domain)
_setup_old_repo(repo_dir)
(repo_dir / ".gitmodules").write_text("test")
def git_command_side_effect(
cmd: list[str], cwd: str | None = None, **kwargs: Any
) -> str:
if _get_git_command_type(cmd) == "clone":
_simulate_cloned_repo(repo_dir)
(repo_dir / ".gitmodules").write_text("test")
if _get_git_command_type(cmd) == "submodule":
raise git.GitCommandError("git submodule update exited with code 1")
return ""
mock_run_git_command.side_effect = git_command_side_effect
with pytest.raises(git.GitCommandError, match="exited with code 1"):
git.clone_or_update(
url=url,
ref=None,
refresh=TimePeriodSeconds(days=1),
domain=domain,
init_submodules=True,
)
assert not repo_dir.is_dir()
# Recovery removed the repo and re-cloned before failing again.
assert any(
_get_git_command_type(c[0][0]) == "clone"
for c in mock_run_git_command.call_args_list
)
def _real_git(*args: str, cwd: Path) -> None:
"""Run real git to build a test fixture repository."""
subprocess.run(
[
"git",
"-c",
"user.email=test@test.invalid",
"-c",
"user.name=test",
"-c",
"commit.gpgsign=false",
"-c",
"protocol.file.allow=always",
*args,
],
cwd=cwd,
check=True,
capture_output=True,
)
# Git blocks file-protocol submodules by default (CVE-2022-39253); the e2e
# tests allow them via GIT_CONFIG_* environment variables, which reach the
# child git processes through run_git_command's filtered environment.
_ALLOW_FILE_PROTOCOL_ENV = {
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "protocol.file.allow",
"GIT_CONFIG_VALUE_0": "always",
}
def _make_real_repo(path: Path, filename: str) -> None:
"""Create a real git repository containing one committed file."""
path.mkdir()
_real_git("init", "-q", cwd=path)
(path / filename).write_text("content")
_real_git("add", filename, cwd=path)
_real_git("commit", "-q", "-m", "init", cwd=path)
def _add_submodule(
repo: Path, url: Path, path: str, *, update_none: bool = False
) -> None:
"""Add ``url`` as a submodule of ``repo`` at ``path`` and commit it."""
_real_git("submodule", "add", str(url), path, cwd=repo)
if update_none:
_real_git(
"config", "-f", ".gitmodules", f"submodule.{path}.update", "none", cwd=repo
)
_real_git("add", ".gitmodules", cwd=repo)
_real_git("commit", "-q", "-m", f"add submodule {path}", cwd=repo)
def test_clone_or_update_real_git_without_submodules(tmp_path: Path) -> None:
"""End-to-end with real git: a repo with no .gitmodules clones cleanly.
This is the issue #17860 scenario: requesting "all submodules" on a
submodule-less repository must not invoke the git submodule porcelain
and must produce a usable checkout.
"""
CORE.config_path = tmp_path / "test.yaml"
upstream = tmp_path / "upstream"
_make_real_repo(upstream, "README.md")
repo_dir, _ = git.clone_or_update(
url=str(upstream),
ref=None,
refresh=None,
domain="test_e2e",
init_submodules=True,
)
assert (repo_dir / "README.md").is_file()
def test_clone_or_update_real_git_initializes_submodules(tmp_path: Path) -> None:
"""End-to-end with real git: submodules are actually checked out.
Exercises the real `git submodule update` invocation, including the
env handling in run_git_command that the mocked tests cannot cover.
"""
CORE.config_path = tmp_path / "test.yaml"
sub_repo = tmp_path / "sub"
_make_real_repo(sub_repo, "sub_file.txt")
upstream = tmp_path / "upstream"
_make_real_repo(upstream, "README.md")
_add_submodule(upstream, sub_repo, "vendor/sub")
with patch.dict(os.environ, _ALLOW_FILE_PROTOCOL_ENV):
repo_dir, _ = git.clone_or_update(
url=str(upstream),
ref=None,
refresh=None,
domain="test_e2e",
init_submodules=True,
)
assert (repo_dir / "vendor" / "sub" / "sub_file.txt").is_file()
def test_clone_or_update_real_git_honors_update_none_submodule(
tmp_path: Path,
) -> None:
"""End-to-end with real git: submodules declared `update = none` stay skipped.
Shows git itself skipping the declared paths at both nesting levels
(and exiting 0) while the regular submodules check out.
"""
CORE.config_path = tmp_path / "test.yaml"
sub_repo = tmp_path / "sub"
_make_real_repo(sub_repo, "sub_file.txt")
# Intermediate submodule that itself declares a skipped nested submodule.
mid_repo = tmp_path / "mid"
_make_real_repo(mid_repo, "mid_file.txt")
_add_submodule(mid_repo, sub_repo, "vendor/leaf", update_none=True)
upstream = tmp_path / "upstream"
_make_real_repo(upstream, "README.md")
_add_submodule(upstream, sub_repo, "vendor/sub")
_add_submodule(upstream, sub_repo, "vendor/skipped", update_none=True)
_add_submodule(upstream, mid_repo, "vendor/mid")
with patch.dict(os.environ, _ALLOW_FILE_PROTOCOL_ENV):
repo_dir, _ = git.clone_or_update(
url=str(upstream),
ref=None,
refresh=None,
domain="test_e2e",
init_submodules=True,
)
assert (repo_dir / "vendor" / "sub" / "sub_file.txt").is_file()
assert not (repo_dir / "vendor" / "skipped" / "sub_file.txt").exists()
assert (repo_dir / "vendor" / "mid" / "mid_file.txt").is_file()
assert not (
repo_dir / "vendor" / "mid" / "vendor" / "leaf" / "sub_file.txt"
).exists()
def test_refresh_picks_up_new_remote_commits(
+53
View File
@@ -0,0 +1,53 @@
"""Guard the lazy-import contract of ``esphome.__main__``.
Every ``esphome`` invocation pays for whatever ``esphome.__main__``
imports at module level before the requested command runs. The
dashboard and device-builder spawn one ``esphome upload`` subprocess
per device, so keeping validation/codegen machinery out of the
top-level import directly lowers the RAM cost of each concurrent
upload (the upload/logs fast path in ``esphome.compiled_config``
never needs them).
``script/check_import_time.py`` budgets import *time* in CI; this
test pins down *which* heavy modules must stay out entirely.
"""
from __future__ import annotations
import subprocess
import sys
# Modules that must only load for the commands that actually use them
# (compile/config validation, shell completion), never from a bare
# ``import esphome.__main__``.
HEAVY_MODULES = (
"argcomplete",
"esphome.codegen",
"esphome.config",
"esphome.config_validation",
"esphome.cpp_generator",
"esphome.loader",
"voluptuous",
)
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen."""
check = (
"import sys; import esphome.__main__; "
f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; "
"print(','.join(leaked))"
)
result = subprocess.run(
[sys.executable, "-c", check],
capture_output=True,
text=True,
check=True,
)
leaked = result.stdout.strip()
assert not leaked, (
f"esphome.__main__ imports heavy modules at top level: {leaked}. "
"Import them lazily inside the command that needs them instead; "
"every esphome invocation (including each parallel dashboard "
"upload subprocess) pays for top-level imports."
)
+98 -4
View File
@@ -442,6 +442,46 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix(
assert not any("legacy substring" in rec.message for rec in caplog.records)
@pytest.mark.parametrize("field", ["public_key", "peer_public_key"])
def test_redact_with_legacy_fallback__skips_public_key_fields(
field: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Public keys are not secret; fields with a ``public`` name segment
must pass through unredacted and without the migration warning
(see issue #17718)."""
text = f"{field}: c29tZXB1YmxpY2tleQ==\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert out == text
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__public_substitution_still_redacted(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Substitution keys are user-named with no schema behind them, so the
public-key exemption does not apply there; a ``public``-named substitution
keeps the conservative silent redaction."""
text = "substitutions:\n public_key: something\nesphome:\n name: x\n"
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback(text)
assert "public_key: \\033[8msomething\\033[28m" in out
assert not any("legacy substring" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__public_must_be_a_whole_segment(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The exemption matches ``public`` as an underscore-separated segment,
not a substring; an unrelated name like ``republic_key`` keeps the
conservative redaction."""
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
out = _redact_with_legacy_fallback("republic_key: abc\n")
assert "republic_key: \\033[8mabc\\033[28m" in out
assert any("'republic_key'" in rec.message for rec in caplog.records)
def test_redact_with_legacy_fallback__substitutions_redacted_without_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
@@ -578,7 +618,7 @@ def test_command_config__no_defaults_skips_strip_default_ids(
validated.user_config = {"sensor": [{"name": "x"}]}
with patch(
"esphome.__main__.strip_default_ids", side_effect=AssertionError
"esphome.config.strip_default_ids", side_effect=AssertionError
) as mock_strip:
result = command_config(args, validated)
@@ -5410,6 +5450,43 @@ def _setup_build_info_test(
return build_info_path, firmware_path
def test_compile_program_esp8266_runs_rosetta_check(tmp_path: Path) -> None:
"""Test that compile_program runs the Rosetta preflight for ESP8266 targets."""
setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device")
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
with (
patch(
"esphome.components.esp8266.check_rosetta",
side_effect=EsphomeError("Rosetta 2 is not installed"),
) as mock_check,
pytest.raises(EsphomeError, match="Rosetta 2 is not installed"),
):
compile_program(args, config)
mock_check.assert_called_once()
def test_compile_program_skips_rosetta_check_on_other_platforms(
tmp_path: Path,
mock_compile_build_info_run_compile: Mock,
mock_compile_build_info_get_idedata: Mock,
) -> None:
"""Test that the Rosetta preflight does not run for non-ESP8266 targets."""
_setup_build_info_test(tmp_path, firmware_first=True)
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
args = MockArgs()
with patch("esphome.components.esp8266.check_rosetta") as mock_check:
result = compile_program(args, config)
assert result == 0
mock_check.assert_not_called()
def test_compile_program_emits_build_info_when_firmware_rebuilt(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
@@ -6124,7 +6201,7 @@ def test_run_esphome_bundle_detection(tmp_path: Path) -> None:
"esphome.bundle.prepare_bundle_for_compile",
return_value=extracted_yaml,
) as mock_prepare,
patch("esphome.__main__.read_config", return_value=None),
patch("esphome.config.read_config", return_value=None),
):
result = run_esphome(["esphome", "compile", str(bundle_path)])
@@ -6142,7 +6219,7 @@ def test_run_esphome_non_bundle_skips_extraction(tmp_path: Path) -> None:
with (
patch("esphome.bundle.is_bundle_path", return_value=False) as mock_is_bundle,
patch("esphome.bundle.prepare_bundle_for_compile") as mock_prepare,
patch("esphome.__main__.read_config", return_value=None),
patch("esphome.config.read_config", return_value=None),
):
result = run_esphome(["esphome", "compile", str(yaml_file)])
@@ -6170,7 +6247,7 @@ def test_run_esphome_skip_external_update_per_command(
yaml_file = tmp_path / "device.yaml"
yaml_file.write_text("esphome:\n name: test\n")
with patch("esphome.__main__.read_config", return_value=None) as mock_read:
with patch("esphome.config.read_config", return_value=None) as mock_read:
run_esphome(["esphome", command, str(yaml_file)])
mock_read.assert_called_once()
@@ -6328,6 +6405,23 @@ def test_parse_args_logs_states() -> None:
assert args.states is True
def test_parse_args_argcomplete_only_runs_when_completing() -> None:
"""Only import and invoke argcomplete when _ARGCOMPLETE is set.
The shell-completion machinery sets _ARGCOMPLETE when it invokes the
CLI; a normal invocation must skip the import entirely so every
esphome subprocess (e.g. parallel dashboard uploads) avoids paying
for it.
"""
fake_argcomplete = MagicMock()
with (
patch.dict(os.environ, {"_ARGCOMPLETE": "1"}),
patch.dict(sys.modules, {"argcomplete": fake_argcomplete}),
):
parse_args(["esphome", "version"])
fake_argcomplete.autocomplete.assert_called_once()
def test_should_subscribe_states_default() -> None:
"""Test that states are shown by default when nothing is set."""
from esphome.__main__ import _should_subscribe_states
+18
View File
@@ -201,6 +201,24 @@ class TestCheckAndInstall:
assert mock_nrf52_ops.download_from_mirrors.call_count == 2
assert mock_nrf52_ops.archive_extract_all.call_count == 2
def test_framework_clone_is_shallow(
self,
nrf52_dirs: SimpleNamespace,
mock_nrf52_ops: SimpleNamespace,
) -> None:
"""Both the manifest repository and every project are fetched at depth 1."""
_mark_venv_ready(nrf52_dirs.python_env)
check_and_install()
init_cmd, update_cmd = (
call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2]
)
assert "init" in init_cmd
assert "-o=--depth=1" in init_cmd
assert "update" in update_cmd
assert "--fetch-opt=--depth=1" in update_cmd
def test_requirements_install_failure_raises(
self,
nrf52_dirs: SimpleNamespace,
+5 -2
View File
@@ -261,15 +261,18 @@ def test_convert_libraries_raises_when_manifest_missing_after_retry(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# If the forced re-download still yields no manifest, the error is raised
# after exactly one retry (no retry loop).
# after exactly one retry (no retry loop). The error must name the cache
# directory so users can find the broken entry instead of guessing where
# the library was unpacked.
calls = _patch_download_without_manifest(
monkeypatch, tmp_path, manifest_on_force=False
)
with pytest.raises(RuntimeError, match="Invalid PIO library"):
with pytest.raises(RuntimeError, match="Invalid PIO library") as excinfo:
convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
assert calls == [False, True]
assert str(tmp_path / "esphome__A") in str(excinfo.value)
@pytest.mark.parametrize(
+147 -1
View File
@@ -322,6 +322,149 @@ def test_run_platformio_cli_sets_environment_variables(
assert "arg" in args
def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
"""Ccache is enabled when the binary is on PATH and no override is set."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
assert env["CCACHE_DIR"].endswith("platformio-ccache")
assert env["CCACHE_NOHASHDIR"] == "true"
# Nothing may leak into os.environ: a later ESP-IDF build in the same
# process would otherwise skip its own ccache defaults.
assert "CCACHE_BASEDIR" not in os.environ
assert "ESPHOME_CCACHE_ENABLE" not in os.environ
def test_ccache_env_disabled_without_binary(setup_core: Path) -> None:
"""Ccache stays off when the binary is not on PATH."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value=None),
):
env = toolchain._ccache_env()
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
def test_ccache_env_opt_out(setup_core: Path) -> None:
"""ESPHOME_CCACHE_ENABLE=0 disables ccache even with the binary present."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
):
env = toolchain._ccache_env()
assert env == {"ESPHOME_CCACHE_ENABLE": "0"}
def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None:
"""A truthy override value is normalized to "1" for the build scripts."""
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True),
patch.object(toolchain.shutil, "which", return_value=None),
):
env = toolchain._ccache_env()
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
def test_ccache_env_respects_user_values_and_refreshes_basedir(
setup_core: Path,
) -> None:
"""User CCACHE_* values win, but CCACHE_BASEDIR follows the build dir."""
user_env = {
"CCACHE_DIR": "/custom/cache",
"CCACHE_BASEDIR": "/stale/other-device",
}
CORE.build_path = setup_core / "build" / "test"
with (
patch.dict(os.environ, user_env, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
):
env = toolchain._ccache_env()
# CCACHE_DIR is not returned, so the user's os.environ value applies in
# the subprocess; CCACHE_BASEDIR is always refreshed to the build dir.
assert "CCACHE_DIR" not in env
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
def test_run_platformio_cli_passes_ccache_env_to_subprocess_only(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""The ccache settings reach the subprocess env without touching os.environ."""
CORE.build_path = str(setup_core / "build" / "test")
with (
patch.dict(os.environ, {}, clear=False),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
):
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli("test", "arg")
env = mock_run_external_process.call_args[1]["env"]
assert env["ESPHOME_CCACHE_ENABLE"] == "1"
assert env["CCACHE_BASEDIR"] == str((setup_core / "build" / "test").resolve())
assert "ESPHOME_CCACHE_ENABLE" not in os.environ
assert "CCACHE_BASEDIR" not in os.environ
def test_ccache_env_requires_build_path(setup_core: Path) -> None:
"""Enabling ccache without a build path fails loudly."""
CORE.build_path = None
with (
patch.dict(os.environ, {}, clear=True),
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
pytest.raises(ValueError, match="CORE.build_path must be set"),
):
toolchain._ccache_env()
def test_run_platformio_cli_merges_caller_env(
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""A caller-supplied env is the base and gains the ccache settings."""
CORE.build_path = str(setup_core / "build" / "test")
with patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"):
mock_run_external_process.return_value = 0
toolchain.run_platformio_cli(
"test", env={"CUSTOM_VAR": "1", "ESPHOME_CCACHE_ENABLE": "0"}
)
env = mock_run_external_process.call_args[1]["env"]
assert env["CUSTOM_VAR"] == "1"
# The normalized enable flag still lands in the subprocess env.
assert "ESPHOME_CCACHE_ENABLE" in env
def test_copy_ccache_script(setup_core: Path) -> None:
"""The shared ccache pre-script is copied into the build dir."""
CORE.build_path = setup_core / "build" / "test"
toolchain.copy_ccache_script()
dest = setup_core / "build" / "test" / "ccache.py"
source = Path(toolchain.__file__).parent / "ccache.py.script"
assert dest.read_text() == source.read_text()
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
@@ -375,7 +518,10 @@ def test_run_platformio_cli_strips_win_long_path_prefix(
)
with (
patch.dict(os.environ, {}, clear=False),
# Pin ccache off: patching sys.platform to win32 (sys is a singleton,
# so the stdlib sees it too) would send shutil.which down the Windows
# code path, which crashes on a POSIX host.
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=False),
patch("esphome.platformio.toolchain.sys.platform", "win32"),
patch("esphome.platformio.toolchain.sys.executable", prefixed_exe),
):