mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 01:10:22 +00:00
Merge remote-tracking branch 'origin/dev' into jesserockz-2026-503
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ udp:
|
||||
addresses: ["239.0.60.53"]
|
||||
|
||||
time:
|
||||
platform: host
|
||||
- platform: host
|
||||
|
||||
syslog:
|
||||
port: 514
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+66
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user