Merge branch 'analyze-memory-dedup-aliased-ram-symbols' into integration

This commit is contained in:
J. Nick Koston
2026-07-05 23:00:31 -05:00
1116 changed files with 21021 additions and 14895 deletions
@@ -0,0 +1,28 @@
"""Tests for arg-type selection of api user-defined services with homeassistant.action."""
CONFIG = "tests/component_tests/api/test_homeassistant_action.yaml"
def test_synchronous_chain_keeps_zero_copy_args(generate_main):
"""A chain of synchronous actions keeps the non-owning StringRef arg type."""
main_cpp = generate_main(CONFIG)
assert (
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, StringRef>"
'("zero_copy_args", {"message"})' in main_cpp
)
def test_response_callback_args_are_owning(generate_main):
"""homeassistant.action with on_success/on_error stores the trigger args
until the HomeassistantActionResponse arrives, so string args must fall
back to owning std::string; StringRef would point into the connection's
receive buffer, which is reused before the response arrives."""
main_cpp = generate_main(CONFIG)
assert (
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, std::string>"
'("response_args", {"message"})' in main_cpp
)
assert "api::HomeAssistantServiceCallAction<std::string>" in main_cpp
assert "api::HomeAssistantServiceCallAction<StringRef>" not in main_cpp
@@ -0,0 +1,43 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: SomeNetwork
password: SomePassword
logger:
api:
actions:
# Chain of synchronous actions that never store the args:
# keeps the zero-copy StringRef arg type.
- action: zero_copy_args
variables:
message: string
then:
- logger.log:
format: "%s"
args: [message.c_str()]
# homeassistant.action with on_success/on_error stores the trigger args
# until the action response arrives, so the codegen must fall back to
# owning std::string args (StringRef would dangle once the receive
# buffer is reused).
- action: response_args
variables:
message: string
then:
- homeassistant.action:
action: notify.notify
data:
message: !lambda return message;
on_success:
- logger.log:
format: "sent %s"
args: [message.c_str()]
on_error:
- logger.log:
format: "failed (%s): %s"
args: [error.c_str(), message.c_str()]
@@ -154,6 +154,10 @@ def test_all_predefined_models(
if not model.get_default(CONF_CS_PIN):
config[CONF_CS_PIN] = 5
# Dual-CS models (e.g. T133A01) require a second chip-select pin
if model.manages_cs and not model.get_default("cs1_pin"):
config["cs1_pin"] = 4
# Select an ESP32 variant on which all of this model's pins are valid
# (some models default to high-numbered pins only present on the S3).
choose_variant_with_pins(_pins_for(model, config))
@@ -204,6 +208,10 @@ def test_individual_models(
if not model.get_default(CONF_CS_PIN):
config[CONF_CS_PIN] = 5
# Dual-CS models (e.g. T133A01) require a second chip-select pin
if model.manages_cs and not model.get_default("cs1_pin"):
config["cs1_pin"] = 4
# Select an ESP32 variant on which all of this model's pins are valid
# (some models default to high-numbered pins only present on the S3).
choose_variant_with_pins(_pins_for(model, config))
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
disabled: true
binary_sensor:
- platform: gpio
pin: GPIO34
name: test
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
binary_sensor:
- platform: gpio
pin: GPIO34
name: test
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
psram:
mode: quad
binary_sensor:
- platform: gpio
pin: GPIO34
name: test
+26
View File
@@ -213,6 +213,32 @@ def test_execute_from_psram_p4_sdkconfig(
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
@pytest.mark.parametrize(
("fixture", "expect_warning"),
[
("psram_quad_gpio34.yaml", False),
("psram_octal_gpio34.yaml", True),
("psram_octal_disabled_gpio34.yaml", False),
],
)
def test_s3_psram_pin_warning_only_for_octal(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
fixture: str,
expect_warning: bool,
) -> None:
"""GPIO33-37 are only used by the PSRAM interface in octal mode.
Using such a pin must only warn when octal PSRAM is configured; on quad
PSRAM the pins are free and warning would be a false positive (#16857).
"""
with caplog.at_level("WARNING"):
generate_main(component_config_path(fixture))
warned = "GPIO34 is used by the PSRAM interface in octal mode" in caplog.text
assert warned == expect_warning
def test_ignore_pin_validation_error_on_clean_pin_warns(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
+45 -1
View File
@@ -306,6 +306,50 @@ def test_all_predefined_models(
run_schema_validation(config)
def test_single_bus_no_cs_no_mode_warns(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
run_schema_validation({"model": "ili9488", "dc_pin": 14})
assert "defaulting to MODE3 due to lack of CS pin" in caplog.text
@pytest.mark.parametrize(
"config",
[
pytest.param(
{"model": "ili9488", "dc_pin": 14, "cs_pin": 0},
id="cs_pin_provided",
),
pytest.param(
{"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"},
id="spi_mode_provided",
),
],
)
def test_single_bus_no_mode_warning_suppressed(
config: ConfigType,
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""No MODE3 warning when a CS pin or an explicit SPI mode is provided."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
run_schema_validation(config)
assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text
def test_native_generation(
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
@@ -333,6 +377,6 @@ def test_lvgl_generation(
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
in main_cpp
)
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp
assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp
assert "show_test_card();" not in main_cpp
assert "set_auto_clear(false);" in main_cpp
@@ -13,6 +13,16 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.mipi import (
CONF_DIMENSIONS,
CONF_HEIGHT,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_OFFSET_HEIGHT,
CONF_OFFSET_WIDTH,
CONF_SWAP_XY,
CONF_WIDTH,
)
from esphome.components.mipi_spi.display import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
@@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import (
get_instance,
)
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework
from esphome.const import (
CONF_CS_PIN,
CONF_DC_PIN,
CONF_DISABLED,
CONF_TRANSFORM,
PlatformFramework,
)
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
@@ -432,3 +448,152 @@ class TestUserConfiguredPadding:
assert config["dimensions"]["width"] == 240
assert config["dimensions"]["height"] == 240
assert config["dimensions"]["pad_height"] == 16
class TestHasHardwareTransform:
"""Test DriverChip.has_hardware_transform()."""
def test_full_transform_model_without_transform_key(self) -> None:
"""A model supporting swap_xy uses a hardware transform by default."""
model = MODELS["ST7789V"]
assert model.has_hardware_transform({}) is True
def test_full_transform_model_with_transform_dict(self) -> None:
"""A configured (non-disabled) transform still uses the hardware path."""
model = MODELS["ST7789V"]
assert (
model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True
)
def test_full_transform_model_with_transform_disabled(self) -> None:
"""Disabling the transform falls back to software transforms."""
model = MODELS["ST7789V"]
assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False
def test_model_without_swap_xy_support(self) -> None:
"""Models that cannot swap axes never use a hardware transform."""
# AXS15231 only supports mirror_x/mirror_y, not swap_xy.
model = MODELS["AXS15231"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
assert model.has_hardware_transform({}) is False
class TestSwapXYNativeDimensions:
"""Test that native dimensions are swapped when a swap_xy transform is active.
When explicit dimensions are given in the swapped (rotated) orientation and the
model applies a hardware swap_xy transform, the model's native_width/native_height
defaults must be swapped to match, otherwise padding is computed against the wrong
axis and validation fails.
"""
def test_explicit_swapped_dimensions_with_swap_xy_transform(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Explicit landscape dimensions on a portrait-native model with swap_xy."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# ST7789V is natively 240x320 (portrait). Provide landscape dimensions
# together with a swap_xy transform.
model = MODELS["ST7789V"]
assert model.get_default("native_width") == 240
assert model.get_default("native_height") == 320
config = {
"model": "ST7789V",
CONF_DIMENSIONS: {
CONF_WIDTH: 320,
CONF_HEIGHT: 240,
CONF_OFFSET_WIDTH: 0,
CONF_OFFSET_HEIGHT: 0,
},
CONF_TRANSFORM: {
CONF_SWAP_XY: True,
CONF_MIRROR_X: False,
CONF_MIRROR_Y: False,
},
}
# swap=False because the buffer is laid out in the requested orientation.
width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions(
config, swap=False
)
# Native dims are swapped to 320x240, so padding works out to zero rather
# than going negative (which previously raised "Invalid offsets").
assert (width, height) == (320, 240)
assert (offset_w, offset_h) == (0, 0)
assert (pad_w, pad_h) == (0, 0)
def test_explicit_dimensions_without_swap_keeps_native_orientation(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Without swap_xy the native dimensions keep their original orientation."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
model = MODELS["ST7789V"]
config = {
"model": "ST7789V",
CONF_DIMENSIONS: {
CONF_WIDTH: 240,
CONF_HEIGHT: 320,
CONF_OFFSET_WIDTH: 0,
CONF_OFFSET_HEIGHT: 0,
},
CONF_TRANSFORM: {
CONF_SWAP_XY: False,
CONF_MIRROR_X: False,
CONF_MIRROR_Y: False,
},
}
width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions(
config, swap=False
)
assert (width, height) == (240, 320)
assert (offset_w, offset_h) == (0, 0)
assert (pad_w, pad_h) == (0, 0)
def test_swapped_native_dimensions_compute_padding(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Padding is derived from the swapped native size when swap_xy is active."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# ILI9341 is natively 240x320. Request a 300x240 area in landscape; the
# swapped native size is 320x240, leaving 20px of horizontal padding.
model = MODELS["ILI9341"]
assert model.get_default("native_width") == 240
assert model.get_default("native_height") == 320
config = {
"model": "ILI9341",
CONF_DIMENSIONS: {
CONF_WIDTH: 300,
CONF_HEIGHT: 240,
CONF_OFFSET_WIDTH: 0,
CONF_OFFSET_HEIGHT: 0,
},
CONF_TRANSFORM: {
CONF_SWAP_XY: True,
CONF_MIRROR_X: False,
CONF_MIRROR_Y: False,
},
}
width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False)
assert (width, height) == (300, 240)
# native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20
assert pad_w == 20
assert pad_h == 0
@@ -0,0 +1,113 @@
"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT.
Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present)
and the error behaviour when neither page-selection command is present.
"""
from __future__ import annotations
from typing import Any
import pytest
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def validated_config(config: dict[str, Any]) -> dict[str, Any]:
"""Run schema + final validation and return the validated config."""
cfg = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(cfg)
return cfg
def test_madctl_error_suppressed_when_pagesel_present(
set_core_config: SetCoreConfigCallable,
) -> None:
"""If PAGESEL is present in init_sequence, MADCTL presence must not raise an error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
cfg = {
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
"init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]],
}
# Should not raise
validated = validated_config(cfg)
assert validated is not None
def test_pixfmt_error_suppressed_when_pagesel1_present(
set_core_config: SetCoreConfigCallable,
) -> None:
"""If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
cfg = {
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]],
}
# Should not raise
validated = validated_config(cfg)
assert validated is not None
def test_madctl_raises_without_pagesel(
set_core_config: SetCoreConfigCallable,
) -> None:
"""MADCTL in the init_sequence should raise when a transform is configured and
no PAGESEL/PAGESEL1 is present.
"""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
cfg: dict[str, Any] = {
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
"init_sequence": [[MADCTL, 0x01]],
}
with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"):
CONFIG_SCHEMA(cfg)
def test_pixfmt_raises_without_pagesel1(
set_core_config: SetCoreConfigCallable,
) -> None:
"""PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
cfg: dict[str, Any] = {
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[PIXFMT, 0x01]],
}
with pytest.raises(
cv.Invalid, match=r"PIXFMT .* should not be in the init sequence"
):
CONFIG_SCHEMA(cfg)
@@ -0,0 +1,84 @@
"""Tests for modbus_server configuration validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.modbus_server import (
SERVER_SENSOR_VALUE_TYPE,
_validate_no_overlapping_registers,
_validate_register_ranges,
)
from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE
from esphome.const import CONF_ADDRESS
def _config(registers: list[tuple[int, str]]) -> dict:
return {
CONF_REGISTERS: [
{CONF_ADDRESS: address, CONF_VALUE_TYPE: value_type}
for address, value_type in registers
]
}
def test_non_overlapping_registers_pass() -> None:
# Values that tile the address space without gaps or overlaps are accepted.
config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")])
assert _validate_no_overlapping_registers(config) is config
def test_registers_with_gaps_pass() -> None:
config = _config([(0x00, "U_WORD"), (0x05, "U_QWORD"), (0x20, "U_WORD")])
assert _validate_no_overlapping_registers(config) is config
def test_no_registers_pass() -> None:
assert _validate_no_overlapping_registers({}) == {}
def test_duplicate_address_rejected() -> None:
config = _config([(0x10, "U_WORD"), (0x10, "U_WORD")])
with pytest.raises(cv.Invalid, match="overlaps"):
_validate_no_overlapping_registers(config)
def test_multi_register_value_overlapping_neighbour_rejected() -> None:
# U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word.
config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")])
with pytest.raises(cv.Invalid, match="overlaps"):
_validate_no_overlapping_registers(config)
def test_overlap_detected_regardless_of_order() -> None:
# The U_DWORD at 0x10 covers 0x10-0x11 and overlaps the U_WORD at 0x11 even when declared after it.
config = _config([(0x11, "U_WORD"), (0x10, "U_DWORD")])
with pytest.raises(cv.Invalid, match="overlaps"):
_validate_no_overlapping_registers(config)
def test_register_span_within_address_space_pass() -> None:
# A value whose span ends exactly at 0xFFFF is fine (U_QWORD at 0xFFFC covers 0xFFFC-0xFFFF).
config = _config([(0xFFFF, "U_WORD"), (0xFFFC, "U_QWORD")])
assert _validate_register_ranges(config) is config
def test_register_span_past_end_rejected() -> None:
# U_QWORD at 0xFFFE would need 0xFFFE-0x10001, running off the 16-bit address space.
config = _config([(0xFFFE, "U_QWORD")])
with pytest.raises(cv.Invalid, match="past the end"):
_validate_register_ranges(config)
def test_multi_register_value_at_last_address_rejected() -> None:
# A U_DWORD at 0xFFFF needs a second register at 0x10000, which does not exist.
config = _config([(0xFFFF, "U_DWORD")])
with pytest.raises(cv.Invalid, match="past the end"):
_validate_register_ranges(config)
def test_raw_value_type_rejected() -> None:
# RAW has no numeric encoding, so it is not offered as a server register type.
validator = cv.enum(SERVER_SENSOR_VALUE_TYPE)
with pytest.raises(cv.Invalid):
validator("RAW")
assert validator("U_WORD") == "U_WORD"
+15 -125
View File
@@ -12,7 +12,6 @@ from esphome.components.packages import (
_substitute_package_definition,
_walk_packages,
do_packages_pass,
is_package_definition,
merge_packages,
resolve_packages,
)
@@ -89,44 +88,6 @@ def packages_pass(config):
return config
_INCLUDE_FILE = "INCLUDE_FILE"
@pytest.mark.parametrize(
("value", "expected"),
[
# IncludeFile objects are package definitions
(_INCLUDE_FILE, True),
# Git URL shorthand strings are package definitions
("github://esphome/firmware/base.yaml@main", True),
# Remote package dicts (with url key) are package definitions
({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True),
# Plain config dicts are NOT package definitions (they are config fragments)
({"wifi": {"ssid": "test"}}, False),
# None is not a package definition
(None, False),
# Lists are not package definitions
([{"wifi": {"ssid": "test"}}], False),
# Empty dicts are not package definitions
({}, False),
],
ids=[
"include_file",
"git_shorthand",
"remote_package",
"config_fragment",
"none",
"list",
"empty_dict",
],
)
def test_is_package_definition(value: object, expected: bool) -> None:
"""Test that is_package_definition correctly identifies package definitions."""
if value is _INCLUDE_FILE:
value = MagicMock(spec=IncludeFile)
assert is_package_definition(value) is expected
def test_package_unused(basic_esphome, basic_wifi) -> None:
"""
Ensures do_package_pass does not change a config if packages aren't used.
@@ -210,30 +171,6 @@ def test_package_include(basic_wifi, basic_esphome) -> None:
assert actual == expected
def test_single_package(
basic_esphome,
basic_wifi,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Tests the simple case where a single package is added to the top-level config as is.
In this test, the CONF_WIFI config is expected to be simply added to the top-level config.
This tests the case where the user just put packages: !include package.yaml, not
part of a list or mapping of packages.
This behavior is deprecated, the test also checks if a warning is issued.
"""
config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}}
expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi}
with caplog.at_level("WARNING"):
actual = packages_pass(config)
assert actual == expected
assert "This method for including packages will go away in 2026.7.0" in caplog.text
def test_package_append(basic_wifi, basic_esphome) -> None:
"""
Tests the case where a key is present in both a package and top-level config.
@@ -1154,6 +1091,10 @@ def test_packages_include_file_resolves_to_invalid_type_raises(
6,
"some string",
True,
None,
["some string"],
{"some_component": 8},
{3: 2},
],
)
def test_invalid_package_contents_rejected(invalid_package: object) -> None:
@@ -1167,28 +1108,15 @@ def test_invalid_package_contents_rejected(invalid_package: object) -> None:
do_packages_pass(config)
@pytest.mark.xfail(
reason="Deprecated single-package fallback swallows these errors. "
"Remove xfail when single-package deprecation is removed (2026.7.0).",
strict=True,
)
@pytest.mark.parametrize(
"invalid_package",
[
None,
["some string"],
{"some_component": 8},
{3: 2},
],
)
def test_invalid_package_contents_masked_by_deprecation(
invalid_package: object,
) -> None:
"""These invalid packages are swallowed by the deprecated single-package fallback."""
def test_single_package_fragment_form_rejected() -> None:
"""The deprecated single-package form is removed and now raises.
Previously ``packages: !include some_package.yaml`` resolving to a bare config
fragment dict was silently wrapped and merged via the single-package fallback.
That form must now raise instead of being accepted.
"""
config = {
CONF_PACKAGES: {
"some_package": invalid_package,
},
CONF_PACKAGES: {CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}},
}
with pytest.raises(cv.Invalid):
do_packages_pass(config)
@@ -1231,14 +1159,10 @@ def test_named_dict_with_include_files_no_false_deprecation_warning(
assert "deprecated" not in caplog.text.lower()
def test_validate_deprecated_false_raises_directly(
def test_named_package_errors_raise_directly(
caplog: pytest.LogCaptureFixture,
) -> None:
"""With validate_deprecated=False, errors raise directly without fallback.
This is the codepath used for remote packages where _process_remote_package
returns already-resolved dicts that is_package_definition cannot detect.
"""
"""Errors processing a named-dict package raise directly, with no deprecation warning."""
config = {
CONF_PACKAGES: {
"pkg_a": {CONF_WIFI: {CONF_SSID: "test"}},
@@ -1261,7 +1185,7 @@ def test_validate_deprecated_false_raises_directly(
caplog.at_level(logging.WARNING),
pytest.raises(cv.Invalid, match="nested error"),
):
_walk_packages(config, failing_callback, validate_deprecated=False)
_walk_packages(config, failing_callback)
assert "deprecated" not in caplog.text.lower()
@@ -1296,40 +1220,6 @@ def test_error_on_first_declared_package_still_detected() -> None:
_walk_packages(config, fail_on_last)
def test_deprecated_single_package_fallback_still_works(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecated single-package form still falls back at the top level.
When a dict's values are plain config fragments (not package definitions)
and the callback fails, the deprecated fallback wraps the dict in a list
and retries with a deprecation warning.
"""
config = {
CONF_PACKAGES: {
CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"},
},
}
attempt = 0
def fail_then_succeed(
package_config: dict, context: object, path: DocumentPath | None = None
) -> dict:
nonlocal attempt
attempt += 1
if attempt == 1:
# First attempt: treating as named dict fails
raise cv.Invalid("not a valid package")
# Second attempt: after fallback wraps as list, succeeds
return package_config
with caplog.at_level(logging.WARNING):
_walk_packages(config, fail_then_succeed)
assert "deprecated" in caplog.text.lower()
def test_merge_packages_invalid_nested_type_raises() -> None:
"""Invalid nested packages type during merge raises cv.Invalid."""
config = {
+9 -1
View File
@@ -12,9 +12,12 @@ from esphome.components.esp32 import (
VARIANT_ESP32C5,
VARIANT_ESP32C6,
VARIANT_ESP32H2,
VARIANT_ESP32H4,
VARIANT_ESP32H21,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
)
import esphome.config_validation as cv
from esphome.const import CONF_ESPHOME, PlatformFramework
@@ -25,21 +28,26 @@ UNSUPPORTED_PSRAM_VARIANTS = [
VARIANT_ESP32C3,
VARIANT_ESP32C6,
VARIANT_ESP32H2,
VARIANT_ESP32H21,
]
SUPPORTED_PSRAM_VARIANTS = [
VARIANT_ESP32,
VARIANT_ESP32C5,
VARIANT_ESP32H4,
VARIANT_ESP32P4,
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
]
SUPPORTED_PSRAM_MODES = {
VARIANT_ESP32: ["quad"],
VARIANT_ESP32C5: ["quad"],
VARIANT_ESP32H4: ["quad"],
VARIANT_ESP32P4: ["hex"],
VARIANT_ESP32S2: ["quad"],
VARIANT_ESP32S3: ["quad", "octal"],
VARIANT_ESP32S31: ["octal"],
}
@@ -187,7 +195,7 @@ def _setup_psram_final_validation_test(
{"mode": "octal"},
{"variant": "ESP32"},
True,
r"Octal PSRAM is only supported on ESP32-S3",
r"Octal PSRAM is not supported on ESP32",
id="octal_mode_only_esp32s3",
),
pytest.param(
+28
View File
@@ -109,6 +109,34 @@ api:
- name.c_str()
- int_arr.size()
- string_arr.size()
# Test string + array args used by homeassistant.action's deferred
# on_success/on_error response callback. homeassistant.action registers
# synchronous=False, so the api codegen must fall back to owning
# std::string / std::vector args here: the non-owning defaults would
# dangle once rx_buf_ is reused before the response arrives, and the
# non-copyable FixedVector would fail to compile when captured into
# the response callback.
- action: action_response_args
variables:
name: string
int_arr: int[]
then:
- homeassistant.action:
action: notify.notify
data:
message: !lambda 'return name;'
on_success:
- logger.log:
format: "Notified %s (%u ints)"
args:
- name.c_str()
- int_arr.size()
on_error:
- logger.log:
format: "Notify failed (%s): %s"
args:
- error.c_str()
- name.c_str()
# Test ContinuationAction (IfAction with then/else branches)
- action: test_if_action
variables:
@@ -0,0 +1,7 @@
<<: !include common.yaml
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
+1 -1
View File
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO14
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
uart_4800: !include ../../test_build_components/common/uart_4800/esp32-idf.yaml
<<: !include common.yaml
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO3
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
uart_4800: !include ../../test_build_components/common/uart_4800/esp8266-ard.yaml
<<: !include common.yaml
+1 -1
View File
@@ -3,6 +3,6 @@ substitutions:
rx_pin: GPIO5
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
uart_4800: !include ../../test_build_components/common/uart_4800/rp2040-ard.yaml
<<: !include common.yaml
+23 -11
View File
@@ -3,52 +3,64 @@ sensor:
name: "BMI270 Temperature"
- platform: motion
motion_id: bmi270_motion
type: acceleration_x
name: "Accel X"
name: "BMI270 Accel X"
accuracy_decimals: 4
filters:
- sliding_window_moving_average:
window_size: 4
send_every: 1
- platform: motion
motion_id: bmi270_motion
type: acceleration_y
name: "Accel Y"
name: "BMI270 Accel Y"
accuracy_decimals: 4
- platform: motion
motion_id: bmi270_motion
type: acceleration_z
name: "Accel Z"
name: "BMI270 Accel Z"
accuracy_decimals: 4
# Gyroscope axes (unit: °/s)
- platform: motion
motion_id: bmi270_motion
type: gyroscope_x
name: "Gyro X"
name: "BMI270 Gyro X"
- platform: motion
motion_id: bmi270_motion
type: gyroscope_y
name: "Gyro Y"
name: "BMI270 Gyro Y"
- platform: motion
motion_id: bmi270_motion
type: gyroscope_z
name: "Gyro Z"
name: "BMI270 Gyro Z"
- platform: motion
motion_id: bmi270_motion
type: angular_rate_x
name: "Angular Rate X"
name: "BMI270 Angular Rate X"
- platform: motion
motion_id: bmi270_motion
type: angular_rate_y
name: "Angular Rate Y"
name: "BMI270 Angular Rate Y"
- platform: motion
motion_id: bmi270_motion
type: angular_rate_z
name: "Angular Rate Z"
name: "BMI270 Angular Rate Z"
- platform: motion
motion_id: bmi270_motion
type: pitch
name: "Pitch"
name: "BMI270 Pitch"
- platform: motion
motion_id: bmi270_motion
type: roll
name: "Roll"
name: "BMI270 Roll"
motion:
- platform: bmi270
id: bmi270_motion
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
+22
View File
@@ -0,0 +1,22 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: cst328_ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
- id: cst328_page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
touchscreen:
- platform: cst328
i2c_id: i2c_bus
id: cst328_touchscreen
display: cst328_ssd1306_i2c_display
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
binary_sensor:
- platform: cst328
id: touch_key_cst328
@@ -0,0 +1,8 @@
substitutions:
display_reset_pin: "4"
interrupt_pin: "20"
reset_pin: "21"
packages:
- !include ../../test_build_components/common/i2c/esp32-idf.yaml
- !include common.yaml
+16
View File
@@ -0,0 +1,16 @@
display:
- id: cst9220_display
platform: ili9xxx
model: ili9342
cs_pin: ${cs_pin}
dc_pin: ${dc_pin}
reset_pin: ${disp_reset_pin}
invert_colors: false
touchscreen:
- id: ts_cst9220
i2c_id: i2c_bus
platform: cst9220
display: cst9220_display
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
@@ -0,0 +1,12 @@
substitutions:
cs_pin: GPIO4
dc_pin: GPIO5
disp_reset_pin: GPIO12
interrupt_pin: GPIO15
reset_pin: GPIO25
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,5 @@
substitutions:
wakeup_pin: GPIO4
<<: !include common.yaml
<<: !include common-esp32-ext1.yaml
@@ -0,0 +1,5 @@
substitutions:
wakeup_pin: GPIO4
<<: !include common.yaml
<<: !include common-esp32-ext1.yaml
@@ -76,6 +76,14 @@ display:
- platform: epaper_spi
model: seeed-reterminal-e1002
- platform: epaper_spi
model: seeed-reterminal-e1004
cs_pin: 33
cs1_pin: 34
dc_pin: 35
reset_pin: 36
busy_pin: 37
enable_pin: 39
- platform: epaper_spi
model: seeed-ee04-mono-4.26
full_update_every: 10
@@ -161,3 +169,66 @@ display:
busy_pin:
allow_other_uses: true
number: GPIO4
# Waveshare 2.13" V4 B series 3-color e-paper (122x250, BWR, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.13in-bv4
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
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
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 2 3-color e-paper (104x212, BWR)
- platform: epaper_spi
spi_id: spi_bus
model: inkplate2
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
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
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));
# Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2)
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-7.5in-bv2-bwr
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
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0));
@@ -0,0 +1,38 @@
esphome:
name: e1004-test
friendly_name: E1004 Test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
psram:
mode: octal
spi:
- id: epaper_spi_bus
clk_pin: GPIO7
mosi_pin: GPIO9
display:
- platform: epaper_spi
spi_id: epaper_spi_bus
model: seeed-reterminal-e1004
update_interval: never
lambda: |-
it.fill(Color::WHITE);
it.rectangle(10, 10, it.get_width() - 20, it.get_height() - 20, Color::BLACK);
it.print(it.get_width() / 2, it.get_height() / 2, id(my_font), Color::BLACK, TextAlign::CENTER, "E1004 Test");
it.circle(100, 100, 30, Color(255, 0, 0));
it.circle(200, 100, 30, Color(0, 255, 0));
it.circle(300, 100, 30, Color(0, 0, 255));
it.circle(400, 100, 30, Color(255, 255, 0));
font:
- file: "gfonts://Roboto"
id: my_font
size: 20
logger:
@@ -77,3 +77,34 @@ esp32_ble_server:
id: test_change_descriptor
value:
data: [0x01, 0x02, 0x03]
# Regression test for #17142: the set_value action used from a trigger that passes
# its argument by reference (climate on_control supplies ClimateCall&) previously
# failed to compile.
sensor:
- platform: template
id: ble_test_temp
lambda: "return 20.0;"
output:
- platform: template
id: ble_test_output
type: float
write_action:
- logger.log: "out"
climate:
- platform: pid
name: "BLE Test Climate"
id: ble_test_climate
sensor: ble_test_temp
default_target_temperature: 20
heat_output: ble_test_output
control_parameters:
kp: 0.1
ki: 0.001
kd: 0.1
on_control:
- ble_server.characteristic.set_value:
id: test_notify_characteristic
value: !lambda "return std::vector<uint8_t>{0, 1, 2};"
@@ -1,7 +1,31 @@
# P0.2, P0.4 and P0.5 all live on the same Zephyr port device (gpio0) and each
# attaches its own interrupt. This locks in shared-port behavior: every pin owns
# a separate gpio_callback initialized with its own BIT(pin) mask, so Zephyr
# dispatches to each pin independently even though the port device is shared.
binary_sensor:
- platform: gpio
pin: 2
id: gpio_binary_sensor
use_interrupt: true
interrupt_type: ANY
# Inverted pin with an edge-specific interrupt: exercises the inversion-aware
# interrupt-arming path (logical RISING must arm on the physical falling edge).
- platform: gpio
pin:
number: P0.4
inverted: true
id: gpio_binary_sensor_inverted
use_interrupt: true
interrupt_type: RISING
# Second non-inverted interrupt on the same port (gpio0) as P0.2 above: verifies
# multiple pins sharing one port device each get their own callback/pin_mask.
- platform: gpio
pin: P0.5
id: gpio_binary_sensor_shared_port
use_interrupt: true
interrupt_type: FALLING
output:
- platform: gpio
+174
View File
@@ -0,0 +1,174 @@
#ifdef USE_HOST
#include <gtest/gtest.h>
#include <cstdlib>
#include <filesystem>
#include "esphome/components/host/preferences.h"
#include "esphome/core/application.h"
namespace esphome::host::testing {
namespace fs = std::filesystem;
/// RAII helper to save and restore an environment variable.
class ScopedEnvVar {
public:
explicit ScopedEnvVar(const char *name) : name_(name) {
const char *val = getenv(name);
if (val != nullptr) {
saved_value_ = val;
was_set_ = true;
}
}
~ScopedEnvVar() {
if (this->was_set_) {
setenv(this->name_.c_str(), this->saved_value_.c_str(), 1);
} else {
unsetenv(this->name_.c_str());
}
}
ScopedEnvVar(const ScopedEnvVar &) = delete;
ScopedEnvVar &operator=(const ScopedEnvVar &) = delete;
private:
std::string name_;
std::string saved_value_;
bool was_set_{false};
};
class HostPreferencesTest : public ::testing::Test {
protected:
void SetUp() override {
// Create a unique temp directory for this test
this->temp_dir_ = fs::temp_directory_path() / "esphome_prefs_test";
fs::create_directories(this->temp_dir_);
// Set up App name — string literal has static storage so StringRef is safe
App.pre_setup("test_prefs", 10, "", 0);
}
void TearDown() override {
std::error_code ec;
fs::remove_all(this->temp_dir_, ec);
}
fs::path temp_dir_;
};
TEST_F(HostPreferencesTest, BothVarsUnset_SyncReturnsFalse) {
ScopedEnvVar home_guard("HOME");
ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR");
unsetenv("HOME");
unsetenv("ESPHOME_PREFDIR");
HostPreferences prefs;
EXPECT_FALSE(prefs.sync());
}
TEST_F(HostPreferencesTest, BothVarsUnset_SaveSucceedsInMemory) {
ScopedEnvVar home_guard("HOME");
ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR");
unsetenv("HOME");
unsetenv("ESPHOME_PREFDIR");
HostPreferences prefs;
uint32_t value = 42;
// save() stores in memory even without a valid file path
EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast<const uint8_t *>(&value), sizeof(value)));
// But sync to disk should fail
EXPECT_FALSE(prefs.sync());
}
TEST_F(HostPreferencesTest, PrefDirSet_SaveAndSync) {
ScopedEnvVar home_guard("HOME");
ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR");
auto prefdir = this->temp_dir_ / "prefdir";
setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1);
unsetenv("HOME");
HostPreferences prefs;
uint32_t value = 42;
EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast<const uint8_t *>(&value), sizeof(value)));
EXPECT_TRUE(prefs.sync());
// Verify file was created in ESPHOME_PREFDIR
auto expected_file = prefdir / "test_prefs.prefs";
EXPECT_TRUE(fs::exists(expected_file));
}
TEST_F(HostPreferencesTest, HomeSet_SaveAndSync) {
ScopedEnvVar home_guard("HOME");
ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR");
auto home = this->temp_dir_ / "home";
setenv("HOME", home.c_str(), 1);
unsetenv("ESPHOME_PREFDIR");
HostPreferences prefs;
uint32_t value = 42;
EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast<const uint8_t *>(&value), sizeof(value)));
EXPECT_TRUE(prefs.sync());
// Verify file was created in HOME/.esphome/prefs
auto expected_file = home / ".esphome" / "prefs" / "test_prefs.prefs";
EXPECT_TRUE(fs::exists(expected_file));
}
TEST_F(HostPreferencesTest, PrefDirTakesPrecedenceOverHome) {
ScopedEnvVar home_guard("HOME");
ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR");
auto prefdir = this->temp_dir_ / "prefdir";
auto home = this->temp_dir_ / "home";
setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1);
setenv("HOME", home.c_str(), 1);
HostPreferences prefs;
uint32_t value = 42;
EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast<const uint8_t *>(&value), sizeof(value)));
EXPECT_TRUE(prefs.sync());
// File should be in ESPHOME_PREFDIR, not HOME
auto prefdir_file = prefdir / "test_prefs.prefs";
auto home_file = home / ".esphome" / "prefs" / "test_prefs.prefs";
EXPECT_TRUE(fs::exists(prefdir_file));
EXPECT_FALSE(fs::exists(home_file));
}
TEST_F(HostPreferencesTest, SaveAndLoadRoundTrip) {
ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR");
auto prefdir = this->temp_dir_ / "roundtrip";
setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1);
// Save data with one instance
{
HostPreferences prefs;
uint32_t value = 0xDEADBEEF;
EXPECT_TRUE(prefs.save(0xABCD, reinterpret_cast<const uint8_t *>(&value), sizeof(value)));
EXPECT_TRUE(prefs.sync());
}
// Load with a fresh instance (reads from file)
{
HostPreferences prefs;
uint32_t loaded = 0;
EXPECT_TRUE(prefs.load(0xABCD, reinterpret_cast<uint8_t *>(&loaded), sizeof(loaded)));
EXPECT_EQ(loaded, 0xDEADBEEFu);
}
}
TEST_F(HostPreferencesTest, LoadNonExistentKeyReturnsFalse) {
ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR");
auto prefdir = this->temp_dir_ / "nokey";
setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1);
HostPreferences prefs;
uint32_t loaded = 0;
EXPECT_FALSE(prefs.load(0x9999, reinterpret_cast<uint8_t *>(&loaded), sizeof(loaded)));
}
} // namespace esphome::host::testing
#endif
@@ -0,0 +1,109 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
display:
# Generic IT8951 with explicit dimensions
- platform: it8951
spi_id: spi_bus
model: it8951
dimensions:
width: 1872
height: 1404
cs_pin:
allow_other_uses: true
number: GPIO5
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
enable_pin:
- GPIO17
- GPIO18
vcom: 1500
update_interval: 60s
# Exercise an alias for the update_mode config option.
update_mode: fast
lambda: |-
it.circle(64, 64, 50, Color::BLACK);
# m5stack-m5paper (960x540) — model supplies pin defaults
- platform: it8951
id: m5epd_display
spi_id: spi_bus
model: m5stack-m5paper
cs_pin:
allow_other_uses: true
number: GPIO5
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
full_update_every: 30
invert_colors: false
sleep_when_done: true
grayscale: true
update_mode: GC16
rotation: 270
transform:
mirror_x: false
mirror_y: false
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);
# seeed-reterminal-e1003 (1872x1404)
- platform: it8951
spi_id: spi_bus
model: seeed-reterminal-e1003
cs_pin:
allow_other_uses: true
number: GPIO5
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
vcom: 1400
sleep_when_done: false
lambda: |-
it.filled_rectangle(0, 0, 128, 128, Color::BLACK);
# seeed-ee03 (1872x1404), monochrome fast path
- platform: it8951
spi_id: spi_bus
model: seeed-ee03
cs_pin:
allow_other_uses: true
number: GPIO5
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
grayscale: false
dithering: false
update_mode: DU
lambda: |-
it.circle(128, 128, 64, Color::BLACK);
# Exercise the it8951.update automation: alias modes, a direct enum-name mode,
# and the bare (default-mode) form.
interval:
- interval: 30s
then:
- it8951.update:
id: m5epd_display
mode: fast
- it8951.update:
id: m5epd_display
mode: full
- it8951.update:
id: m5epd_display
mode: A2
- it8951.update: m5epd_display
+3
View File
@@ -18,6 +18,9 @@ class MockUARTComponent : public uart::UARTComponent {
MOCK_METHOD(size_t, available, (), (override));
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif // USE_ESP8266 || USE_ESP32
};
// Expose protected members for testing.
+24 -12
View File
@@ -1,54 +1,66 @@
sensor:
- platform: lsm6ds
name: "lsm6ds Temperature"
name: "LSM6DS Temperature"
- platform: motion
motion_id: lsm6ds_motion
type: acceleration_x
name: "Accel X"
name: "LSM6DS Accel X"
accuracy_decimals: 4
filters:
- sliding_window_moving_average:
window_size: 4
send_every: 1
- platform: motion
motion_id: lsm6ds_motion
type: acceleration_y
name: "Accel Y"
name: "LSM6DS Accel Y"
accuracy_decimals: 4
- platform: motion
motion_id: lsm6ds_motion
type: acceleration_z
name: "Accel Z"
name: "LSM6DS Accel Z"
accuracy_decimals: 4
# Gyroscope axes (unit: °/s)
- platform: motion
motion_id: lsm6ds_motion
type: gyroscope_x
name: "Gyro X"
name: "LSM6DS Gyro X"
- platform: motion
motion_id: lsm6ds_motion
type: gyroscope_y
name: "Gyro Y"
name: "LSM6DS Gyro Y"
- platform: motion
motion_id: lsm6ds_motion
type: gyroscope_z
name: "Gyro Z"
name: "LSM6DS Gyro Z"
- platform: motion
motion_id: lsm6ds_motion
type: angular_rate_x
name: "Angular Rate X"
name: "LSM6DS Angular Rate X"
- platform: motion
motion_id: lsm6ds_motion
type: angular_rate_y
name: "Angular Rate Y"
name: "LSM6DS Angular Rate Y"
- platform: motion
motion_id: lsm6ds_motion
type: angular_rate_z
name: "Angular Rate Z"
name: "LSM6DS Angular Rate Z"
- platform: motion
motion_id: lsm6ds_motion
type: pitch
name: "Pitch"
name: "LSM6DS Pitch"
- platform: motion
motion_id: lsm6ds_motion
type: roll
name: "Roll"
name: "LSM6DS Roll"
motion:
- platform: lsm6ds
id: lsm6ds_motion
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
@@ -0,0 +1,7 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
mdns:
@@ -1,7 +1,11 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
- !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml
psram:
mode: octal
<<: !include common.yaml
ch422g:
display:
- platform: mipi_rgb
model: WAVESHARE-5-1024X600
@@ -4,6 +4,181 @@
namespace esphome::modbus::helpers {
using FC = ModbusFunctionCode;
// --- server_frame_length ---------------------------------------------------
// Frame layout: address(1) + function(1) + ... + CRC(2). Fixtures borrowed from
// tests/integration/fixtures/uart_mock_modbus.yaml.
TEST(ModbusServerFrameLength, TooShortReturnsMinimum) {
const uint8_t frame[] = {0x01};
EXPECT_EQ(server_frame_length(frame, 1), MIN_FRAME_SIZE);
}
TEST(ModbusServerFrameLength, ReadHoldingUsesByteCount) {
// inject_rx for basic_register: 2 data bytes -> 5 + 2 = 7
const uint8_t frame[] = {0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 7);
}
TEST(ModbusServerFrameLength, ReadByteCountCappedAtMax) {
const uint8_t frame[] = {0x01, 0x03, 0xFF}; // claim 255 bytes
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5 + MAX_NUM_OF_REGISTERS_TO_READ * 2);
}
TEST(ModbusServerFrameLength, ReadMissingByteCountReturnsHeaderOnly) {
const uint8_t frame[] = {0x01, 0x03};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5);
}
TEST(ModbusServerFrameLength, ExceptionResponse) {
// exception_response fixture: function code 0x83 has the exception bit set
const uint8_t frame[] = {0x01, 0x83, 0x02, 0xC0, 0xF1};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 5);
}
TEST(ModbusServerFrameLength, WriteResponsesAreFixed) {
for (FC fc :
{FC::WRITE_SINGLE_COIL, FC::WRITE_SINGLE_REGISTER, FC::WRITE_MULTIPLE_COILS, FC::WRITE_MULTIPLE_REGISTERS}) {
const uint8_t frame[] = {0x01, static_cast<uint8_t>(fc)};
EXPECT_EQ(server_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast<int>(fc);
}
}
TEST(ModbusServerFrameLength, MiscFixedAndUnknown) {
const uint8_t mask[] = {0x01, static_cast<uint8_t>(FC::MASK_WRITE_REGISTER)};
const uint8_t fifo[] = {0x01, static_cast<uint8_t>(FC::READ_FIFO_QUEUE)};
const uint8_t unknown[] = {0x01, 0x42};
EXPECT_EQ(server_frame_length(mask, sizeof(mask)), 10);
EXPECT_EQ(server_frame_length(fifo, sizeof(fifo)), 6);
EXPECT_EQ(server_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- client_frame_length ---------------------------------------------------
TEST(ModbusClientFrameLength, TooShortReturnsMinimum) {
const uint8_t frame[] = {0x01};
EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE);
}
TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) {
// basic_register request fixture is a read-holding request -> 8 bytes
const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A};
EXPECT_EQ(client_frame_length(read, sizeof(read)), 8);
for (FC fc : {FC::READ_COILS, FC::READ_DISCRETE_INPUTS, FC::READ_INPUT_REGISTERS, FC::WRITE_SINGLE_COIL,
FC::WRITE_SINGLE_REGISTER}) {
const uint8_t frame[] = {0x01, static_cast<uint8_t>(fc)};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 8) << "fc=" << static_cast<int>(fc);
}
}
TEST(ModbusClientFrameLength, WriteMultipleUsesByteCount) {
// write 2 registers (4 data bytes): addr(2)+qty(2)+count(1) then data; count is frame[6]
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + 4);
}
TEST(ModbusClientFrameLength, WriteMultipleByteCountCapped) {
const uint8_t frame[] = {0x01, 0x0F, 0x00, 0x00, 0x00, 0x02, 0xFF};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9 + MAX_NUM_OF_REGISTERS_TO_WRITE * 2);
}
TEST(ModbusClientFrameLength, WriteMultipleMissingByteCount) {
const uint8_t frame[] = {0x01, 0x10, 0x00, 0x00, 0x00, 0x02};
EXPECT_EQ(client_frame_length(frame, sizeof(frame)), 9);
}
TEST(ModbusClientFrameLength, MiscFixedAndUnknown) {
const uint8_t mask[] = {0x01, static_cast<uint8_t>(FC::MASK_WRITE_REGISTER)};
const uint8_t fifo[] = {0x01, static_cast<uint8_t>(FC::READ_FIFO_QUEUE)};
const uint8_t unknown[] = {0x01, 0x42};
EXPECT_EQ(client_frame_length(mask, sizeof(mask)), 10);
EXPECT_EQ(client_frame_length(fifo, sizeof(fifo)), 6);
EXPECT_EQ(client_frame_length(unknown, sizeof(unknown)), MIN_FRAME_SIZE);
}
// --- create_client_pdu -----------------------------------------------------
// PDU = function code + data (no address, no CRC).
TEST(ModbusCreateClientPdu, ReadHolding) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0003, 1);
const std::vector<uint8_t> expected{0x03, 0x00, 0x03, 0x00, 0x01};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, WriteSingleOmitsQuantity) {
const uint8_t values[] = {0x00, 0x0B};
auto pdu = create_client_pdu(FC::WRITE_SINGLE_REGISTER, 0x0003, 1, values, sizeof(values));
const std::vector<uint8_t> expected{0x06, 0x00, 0x03, 0x00, 0x0B};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, WriteSingleTooFewValuesReturnsEmpty) {
const uint8_t values[] = {0x00};
auto pdu = create_client_pdu(FC::WRITE_SINGLE_COIL, 0x0003, 1, values, sizeof(values));
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteMultipleIncludesByteCount) {
const uint8_t values[] = {0x00, 0x0B, 0x00, 0x16};
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 2, values, sizeof(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);
}
TEST(ModbusCreateClientPdu, WriteMultipleOverCapacityReturnsEmpty) {
std::vector<uint8_t> values(MAX_PDU_SIZE - 6 + 1, 0xAA);
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, values.data(), values.size());
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, UnsupportedFunctionCodeReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_FIFO_QUEUE, 0x0000, 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ZeroEntitiesReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, 0);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteWithoutValuesReturnsEmpty) {
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, 1, nullptr, 0);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ReadHoldingOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_HOLDING_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
// Regression: coils allow up to 2000 entities, well above the 125 register limit.
// A switch fall-through previously subjected coil/discrete reads to the register limit.
TEST(ModbusCreateClientPdu, ReadCoilsAboveRegisterLimitIsValid) {
const uint16_t quantity = MAX_NUM_OF_REGISTERS_TO_READ + 1; // 126: valid for coils, too many for registers
auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, quantity);
const std::vector<uint8_t> expected{0x01, 0x00, 0x00, static_cast<uint8_t>(quantity >> 8),
static_cast<uint8_t>(quantity & 0xFF)};
EXPECT_EQ(std::vector<uint8_t>(pdu.begin(), pdu.end()), expected);
}
TEST(ModbusCreateClientPdu, ReadCoilsOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_COILS, 0x0000, MAX_NUM_OF_COILS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, ReadDiscreteInputsOverMaxReturnsEmpty) {
auto pdu = create_client_pdu(FC::READ_DISCRETE_INPUTS, 0x0000, MAX_NUM_OF_DISCRETE_INPUTS_TO_READ + 1);
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusCreateClientPdu, WriteMultipleOverEntityLimitReturnsEmpty) {
const uint8_t values[] = {0x00, 0x0B};
auto pdu = create_client_pdu(FC::WRITE_MULTIPLE_REGISTERS, 0x0000, MAX_NUM_OF_REGISTERS_TO_WRITE + 1, values,
sizeof(values));
EXPECT_TRUE(pdu.empty());
}
TEST(ModbusHelpersTest, PayloadToNumberRejectsOffsetAtEndOfBuffer) {
const std::vector<uint8_t> data{0x12, 0x34};
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 2, 0xFFFFFFFF), 0);
@@ -19,4 +194,40 @@ TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) {
EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234);
}
// --- registers_to_number ---------------------------------------------------
// Register words are host byte order; results must match the byte-based payload_to_number.
TEST(ModbusHelpersTest, RegistersToNumberDecodesWord) {
const uint16_t registers[] = {0x1234};
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD), 0x1234);
}
TEST(ModbusHelpersTest, RegistersToNumberDecodesDwordHighWordFirst) {
const uint16_t registers[] = {0x1234, 0x5678};
EXPECT_EQ(registers_to_number(registers, 2, SensorValueType::U_DWORD), 0x12345678);
}
TEST(ModbusHelpersTest, RegistersToNumberDecodesAtSpanStart) {
// The function decodes the value at the start of the span; the caller advances the pointer.
const uint16_t registers[] = {0xAAAA, 0x1234};
EXPECT_EQ(registers_to_number(registers + 1, 1, SensorValueType::U_WORD), 0x1234);
}
TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) {
// Same value via both decoders: registers (host order) vs big-endian bytes.
const uint16_t registers[] = {0x8001, 0x0002};
const std::vector<uint8_t> bytes{0x80, 0x01, 0x00, 0x02};
for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) {
EXPECT_EQ(registers_to_number(registers, 2, value_type), payload_to_number(bytes, value_type, 0, 0xFFFFFFFF))
<< "value_type=" << static_cast<int>(value_type);
}
}
TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
const uint16_t registers[] = {0x1234};
bool error = false;
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_DWORD, &error), 0);
EXPECT_TRUE(error);
}
} // namespace esphome::modbus::helpers
-59
View File
@@ -1,59 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/modbus/modbus.h"
#include "esphome/core/helpers.h"
namespace esphome::modbus {
// Exposes protected methods for testing.
class TestModbus : public Modbus {
public:
bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); }
void test_clear_rx_buffer() { this->rx_buffer_.clear(); }
void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; }
};
class MockDevice : public ModbusDevice {
public:
void on_modbus_data(const std::vector<uint8_t> &data) override { this->data_received = true; }
bool data_received{false};
};
TEST(ModbusTest, TwoByteRegressionTest) {
TestModbus modbus;
modbus.set_role(ModbusRole::CLIENT);
// First byte (at=0)
EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01));
// Second byte (at=1)
// This used to reach raw[2] because it skipped the if(at==2) check, causing a
// buffer overflow.
EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03));
}
TEST(ModbusTest, TestValidFrame) {
TestModbus modbus;
modbus.set_role(ModbusRole::CLIENT);
MockDevice device;
device.set_parent(&modbus);
device.set_address(0x01);
modbus.register_device(&device);
modbus.set_waiting(0x01);
// Address 1, Function 3, Length 2, Data 0x1234
uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34};
uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data));
std::vector<uint8_t> frame;
for (uint8_t b : frame_data)
frame.push_back(b);
frame.push_back(crc & 0xFF);
frame.push_back((crc >> 8) & 0xFF);
for (size_t i = 0; i < frame.size(); i++) {
bool result = modbus.test_parse_modbus_byte(frame[i]);
EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")";
}
EXPECT_TRUE(device.data_received);
}
} // namespace esphome::modbus
@@ -18,6 +18,7 @@ modbus_server:
registers:
- address: 0x9
value_type: S_DWORD
allow_partial_read: true
read_lambda: |-
return 31;
write_lambda: |-
@@ -0,0 +1,285 @@
#include <gtest/gtest.h>
#include "esphome/components/modbus_server/modbus_server.h"
namespace esphome::modbus_server {
using modbus::ModbusExceptionCode;
using modbus::RegisterValues;
namespace {
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
} // namespace
// A single writable WORD register is applied and the handler reports success (nullopt).
TEST(ModbusServerWrite, SingleWordSucceeds) {
ModbusServer server;
int64_t written = -1;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
reg.write_lambda = [&written](int64_t value) {
written = value;
return true;
};
server.add_server_register(&reg);
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234}));
EXPECT_FALSE(status.has_value()); // nullopt == success
EXPECT_EQ(written, 0x1234);
}
// A multi-register value is decoded high word first and applied as a single number.
TEST(ModbusServerWrite, DwordSucceeds) {
ModbusServer server;
int64_t written = -1;
ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2);
reg.write_lambda = [&written](int64_t value) {
written = value;
return true;
};
server.add_server_register(&reg);
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234, 0x5678}));
EXPECT_FALSE(status.has_value());
EXPECT_EQ(written, 0x12345678);
}
// Regression: a request that under-supplies a multi-register value is rejected before any
// write_lambda runs, so no register is partially written.
TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) {
ModbusServer server;
bool word_written = false;
ServerRegister word_reg(0x0000, SensorValueType::U_WORD, 1);
word_reg.write_lambda = [&word_written](int64_t) {
word_written = true;
return true;
};
bool dword_written = false;
ServerRegister dword_reg(0x0001, SensorValueType::U_DWORD, 2); // needs two registers
dword_reg.write_lambda = [&dword_written](int64_t) {
dword_written = true;
return true;
};
server.add_server_register(&word_reg);
server.add_server_register(&dword_reg);
// Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs.
auto status = server.on_modbus_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_FALSE(word_written); // the writable WORD must NOT have been applied
EXPECT_FALSE(dword_written);
}
// A read-only register (no write_lambda) yields ILLEGAL_DATA_ADDRESS and applies nothing.
TEST(ModbusServerWrite, UnwritableRegisterRejected) {
ModbusServer server;
ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set
server.add_server_register(&read_only);
auto status = server.on_modbus_write_registers(0x0000, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// An address with no registered register yields ILLEGAL_DATA_ADDRESS.
TEST(ModbusServerWrite, UnmatchedAddressRejected) {
ModbusServer server;
auto status = server.on_modbus_write_registers(0x0005, make_registers({0x1234}));
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already
// applied, and the handler reports SERVICE_DEVICE_FAILURE.
TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) {
ModbusServer server;
bool first_written = false;
ServerRegister first(0x0000, SensorValueType::U_WORD, 1);
first.write_lambda = [&first_written](int64_t) {
first_written = true;
return true;
};
ServerRegister second(0x0001, SensorValueType::U_WORD, 1);
second.write_lambda = [](int64_t) { return false; }; // rejects at runtime
server.add_server_register(&first);
server.add_server_register(&second);
auto status = server.on_modbus_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_TRUE(first_written); // pre-validation passed, so the first write applied before the failure
}
// --- on_modbus_read_registers --------------------------------------------------
TEST(ModbusServerRead, SingleWordSucceeds) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1);
reg.read_lambda = []() -> int64_t { return 0x1234; };
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0000, 1, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0], 0x1234);
}
TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2);
reg.read_lambda = []() -> int64_t { return 0x12345678; };
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0000, 2, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0], 0x1234);
EXPECT_EQ(out[1], 0x5678);
}
// Starting inside a multi-register value is rejected with ILLEGAL_DATA_ADDRESS -- not masked by the courtesy
// default -- and the read_lambda is never invoked.
TEST(ModbusServerRead, StartInsideValueRejected) {
ModbusServer server;
bool read_called = false;
ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); // occupies 0x0010 and 0x0011
reg.read_lambda = [&read_called]() -> int64_t {
read_called = true;
return 0;
};
server.set_server_courtesy_response(
ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD});
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_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_FALSE(read_called);
}
// A read that stops short of a value's end clips it -> ILLEGAL_DATA_ADDRESS, and the read_lambda is not invoked.
TEST(ModbusServerRead, ClippedTailRejected) {
ModbusServer server;
bool read_called = false;
ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2);
reg.read_lambda = [&read_called]() -> int64_t {
read_called = true;
return 0;
};
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_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_FALSE(read_called);
}
// A write-only register (no read_lambda) is not readable -> ILLEGAL_DATA_ADDRESS, not a courtesy default.
TEST(ModbusServerRead, WriteOnlyRegisterRejected) {
ModbusServer server;
ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); // no read_lambda set
server.set_server_courtesy_response(
ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD});
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0000, 1, out);
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// An unregistered address with courtesy enabled returns the default value for each cell.
TEST(ModbusServerRead, CourtesyDefaultForUnregistered) {
ModbusServer server;
server.set_server_courtesy_response(
ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD});
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0005, 2, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 2u);
EXPECT_EQ(out[0], 0xABCD);
EXPECT_EQ(out[1], 0xABCD);
}
// An unregistered address with courtesy disabled is rejected.
TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) {
ModbusServer server;
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0005, 1, out);
ASSERT_TRUE(status.has_value());
if (status.has_value())
EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// --- partial reads (opt-in) ----------------------------------------------------
// With allow_partial_read, reading only the first register of a DWORD returns its high word.
TEST(ModbusServerRead, PartialReadHighWord) {
ModbusServer server;
ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2);
reg.allow_partial_read = true;
reg.read_lambda = []() -> int64_t { return 0x12345678; };
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0010, 1, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0], 0x1234);
}
// With allow_partial_read, starting at the interior cell returns the low word.
TEST(ModbusServerRead, PartialReadLowWordFromInterior) {
ModbusServer server;
ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2);
reg.allow_partial_read = true;
reg.read_lambda = []() -> int64_t { return 0x12345678; };
server.add_server_register(&reg);
RegisterValues out;
auto status = server.on_modbus_read_registers(0x0011, 1, out);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(out.size(), 1u);
EXPECT_EQ(out[0], 0x5678);
}
// Slicing is in wire order, so a reversed value type partials correctly: U_DWORD_R emits the low word
// first, so 0x0010 holds 0x5678 and 0x0011 holds 0x1234.
TEST(ModbusServerRead, PartialReadReversedType) {
ModbusServer server;
ServerRegister reg(0x0010, SensorValueType::U_DWORD_R, 2);
reg.allow_partial_read = true;
reg.read_lambda = []() -> int64_t { return 0x12345678; };
server.add_server_register(&reg);
RegisterValues first;
ASSERT_FALSE(server.on_modbus_read_registers(0x0010, 1, first).has_value());
ASSERT_EQ(first.size(), 1u);
EXPECT_EQ(first[0], 0x5678);
RegisterValues second;
ASSERT_FALSE(server.on_modbus_read_registers(0x0011, 1, second).has_value());
ASSERT_EQ(second.size(), 1u);
EXPECT_EQ(second[0], 0x1234);
}
} // namespace esphome::modbus_server
@@ -0,0 +1,2 @@
packages:
common: !include common.yaml
@@ -1 +1,5 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
@@ -1 +1,5 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
@@ -1 +1,5 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
@@ -19,5 +19,3 @@ nrf52:
reg0:
voltage: 2.1V
uicr_erase: true
framework:
version: "2.6.1-b"
+2
View File
@@ -0,0 +1,2 @@
network:
enable_ipv6: true
@@ -0,0 +1,20 @@
<<: !include common.yaml
openthread:
device_type: MTD
force_dataset: false
use_address: open-thread-test.local
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
poll_period: 5s
switch:
- platform: template
name: "Radio Always On"
optimistic: true
restore_mode: ALWAYS_OFF
turn_on_action:
then:
- openthread.set_poll_period: 0s
turn_off_action:
then:
- openthread.set_poll_period: 5s
@@ -1,14 +1,6 @@
esp32:
board: esp32-c6-devkitc-1
framework:
type: esp-idf
log_level: DEBUG
network:
enable_ipv6: true
<<: !include common.yaml
openthread:
device_type: MTD
channel: 13
network_name: OpenThread-8f28
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
@@ -16,7 +8,4 @@ openthread:
ext_pan_id: 0xd63e8e3e495ebbc3
pskc: 0xc23a76e98f1a6483639b1ac1271e2e27
mesh_local_prefix: fd53:145f:ed22:ad81::/64
force_dataset: true
use_address: open-thread-test.local
poll_period: 20sec
output_power: 1dBm
+26
View File
@@ -0,0 +1,26 @@
display:
- platform: pixoo
id: pixoo_display
model: 64x64
cs_pin: GPIO5
data_rate: 10MHz
update_interval: 1s
lambda: |-
it.fill(Color(0, 0, 0));
it.filled_rectangle(0, 0, 16, 16, Color(255, 0, 0));
it.line(0, 0, 63, 63, Color(0, 255, 0));
- platform: pixoo
id: pixoo_display_pages
model: 64x64
cs_pin: GPIO21
rotation: 90
pages:
- id: pixoo_page
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height(), Color(0, 0, 255));
light:
- platform: pixoo
pixoo_id: pixoo_display
name: Pixoo Brightness
@@ -0,0 +1,4 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,47 @@
#include <gtest/gtest.h>
#include "esphome/components/power_supply/power_supply.h"
#include "esphome/core/gpio.h"
#include "esphome/core/component.h"
namespace esphome::power_supply::testing {
// Minimal dummy internal GPIO pin implementation for testing
class DummyInternalPin : public InternalGPIOPin {
public:
DummyInternalPin() = default;
void setup() override {}
void pin_mode(esphome::gpio::Flags) override {}
esphome::gpio::Flags get_flags() const override { return esphome::gpio::FLAG_NONE; }
bool digital_read() override { return false; }
void digital_write(bool) override {}
void detach_interrupt() const override {}
ISRInternalGPIOPin to_isr() const override { return ISRInternalGPIOPin(); }
uint8_t get_pin() const override { return 0; }
bool is_inverted() const override { return false; }
protected:
// Implement protected attach_interrupt required by InternalGPIOPin
void attach_interrupt(void (*func)(void *), void *arg, esphome::gpio::InterruptType type) const override {}
};
TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) {
power_supply::PowerSupply ps;
DummyInternalPin pin;
ps.set_pin(&pin);
ps.set_enable_on_boot(true);
// POWER priority should be greater than BUS priority
EXPECT_GT(ps.get_setup_priority(), setup_priority::BUS);
}
TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) {
power_supply::PowerSupply ps;
DummyInternalPin pin;
ps.set_pin(&pin);
ps.set_enable_on_boot(false);
EXPECT_EQ(ps.get_setup_priority(), setup_priority::IO);
}
} // namespace esphome::power_supply::testing
+70
View File
@@ -0,0 +1,70 @@
sensor:
- platform: qmi8658
name: "QMI8658 Temperature"
- platform: motion
type: acceleration_x
name: "Accel X"
accuracy_decimals: 4
filters:
- sliding_window_moving_average:
window_size: 4
send_every: 1
- platform: motion
type: acceleration_y
name: "Accel Y"
accuracy_decimals: 4
- platform: motion
type: acceleration_z
name: "Accel Z"
accuracy_decimals: 4
# Gyroscope axes (unit: °/s)
- platform: motion
type: gyroscope_x
name: "Gyro X"
- platform: motion
type: gyroscope_y
name: "Gyro Y"
- platform: motion
type: gyroscope_z
name: "Gyro Z"
- platform: motion
type: angular_rate_x
name: "Angular Rate X"
- platform: motion
type: angular_rate_y
name: "Angular Rate Y"
- platform: motion
type: angular_rate_z
name: "Angular Rate Z"
- platform: motion
type: pitch
name: "Pitch"
- platform: motion
type: roll
name: "Roll"
motion:
- platform: qmi8658
i2c_id: i2c_bus
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
# Accelerometer output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ |
# 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ
accelerometer_odr: 1000HZ
# Gyroscope full-scale range: 16DPS | 32DPS | 64DPS | 128DPS |
# 256DPS | 512DPS | 1024DPS | 2048DPS
gyroscope_range: 2048DPS
# Gyroscope output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ |
# 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ
gyroscope_odr: 1000HZ
axis_map:
x: y
y: x
z: -z
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
<<: !include common.yaml
@@ -1,4 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
uart_19200: !include ../../test_build_components/common/uart_19200/esp32-idf.yaml
<<: !include common.yaml
@@ -1,4 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
uart_19200: !include ../../test_build_components/common/uart_19200/esp8266-ard.yaml
<<: !include common.yaml
@@ -1,4 +1,4 @@
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
uart_19200: !include ../../test_build_components/common/uart_19200/rp2040-ard.yaml
<<: !include common.yaml
@@ -0,0 +1 @@
socket:
@@ -0,0 +1 @@
socket:
@@ -0,0 +1 @@
socket:
+18
View File
@@ -0,0 +1,18 @@
display:
- platform: ssd1306_i2c
i2c_id: i2c_bus
id: st7123_ssd1306_i2c_display
model: SSD1306_128X64
reset_pin: ${display_reset_pin}
pages:
- id: st7123_page1
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
touchscreen:
- platform: st7123
i2c_id: i2c_bus
id: st7123_touchscreen
display: st7123_ssd1306_i2c_display
interrupt_pin: ${interrupt_pin}
reset_pin: ${reset_pin}
@@ -0,0 +1,9 @@
substitutions:
display_reset_pin: "10"
interrupt_pin: "20"
reset_pin: "21"
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
+2
View File
@@ -21,6 +21,8 @@ sx126x:
coding_rate: CR_4_6
tcxo_voltage: 1_8V
tcxo_delay: 5ms
whitening_enable: false
whitening_initial: 0x1FF
on_packet:
then:
- lambda: |-
@@ -0,0 +1,34 @@
waveshare_io_ch32v003:
- id: wave_io
i2c_id: i2c_bus
address: 0x24
binary_sensor:
- platform: gpio
id: wave_io_binary_sensor
pin:
waveshare_io_ch32v003: wave_io
number: 3
mode: INPUT
inverted: false
output:
- platform: gpio
id: wave_io_output
pin:
waveshare_io_ch32v003: wave_io
number: 0
mode: OUTPUT
inverted: false
- platform: waveshare_io_ch32v003
id: wave_io_pwm_output
inverted: true
zero_means_zero: true
safe_pwm_levels:
min_value: 0
max_value: 247
sensor:
- platform: waveshare_io_ch32v003
id: wave_io_adc
@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
<<: !include common.yaml
+1 -1
View File
@@ -4,7 +4,7 @@ packages:
binary_sensor:
- platform: template
name: "Garage Door Open 10"
report: "enable"
report: "default"
- platform: template
name: "Garage Door Open 12"
report: "force"
-6
View File
@@ -1,6 +0,0 @@
import pathlib
def get_fixture_path(filename: str) -> pathlib.Path:
"""Get path of fixture."""
return pathlib.Path(__file__).parent.joinpath("fixtures", filename)
-43
View File
@@ -1,43 +0,0 @@
"""Common fixtures for dashboard tests."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, Mock
import pytest
import pytest_asyncio
from esphome.dashboard.core import ESPHomeDashboard
from esphome.dashboard.entries import DashboardEntries
@pytest.fixture
def mock_settings(tmp_path: Path) -> MagicMock:
"""Create mock dashboard settings."""
settings = MagicMock()
settings.config_dir = str(tmp_path)
settings.absolute_config_dir = tmp_path
return settings
@pytest.fixture
def mock_dashboard(mock_settings: MagicMock) -> Mock:
"""Create a mock dashboard."""
dashboard = Mock(spec=ESPHomeDashboard)
dashboard.settings = mock_settings
dashboard.entries = Mock()
dashboard.entries.async_all.return_value = []
dashboard.stop_event = Mock()
dashboard.stop_event.is_set.return_value = True
dashboard.ping_request = Mock()
dashboard.ignored_devices = set()
dashboard.bus = Mock()
dashboard.bus.async_fire = Mock()
return dashboard
@pytest_asyncio.fixture
async def dashboard_entries(mock_dashboard: Mock) -> DashboardEntries:
"""Create a DashboardEntries instance for testing."""
return DashboardEntries(mock_dashboard)
-47
View File
@@ -1,47 +0,0 @@
substitutions:
name: picoproxy
friendly_name: Pico Proxy
esphome:
name: ${name}
friendly_name: ${friendly_name}
project:
name: esphome.bluetooth-proxy
version: "1.0"
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
api:
logger:
ota:
improv_serial:
dashboard_import:
package_import_url: github://esphome/firmware/bluetooth-proxy/esp32-generic.yaml@main
button:
- platform: factory_reset
id: resetf
- platform: safe_mode
name: Safe Mode Boot
entity_category: diagnostic
sensor:
- platform: template
id: pm11
name: "pm 1.0µm"
lambda: return 1.0;
- platform: template
id: pm251
name: "pm 2.5µm"
lambda: return 2.5;
- platform: template
id: pm101
name: "pm 10µm"
lambda: return 10;
View File
-199
View File
@@ -1,199 +0,0 @@
"""Unit tests for esphome.dashboard.dns module."""
from __future__ import annotations
import time
from unittest.mock import AsyncMock, patch
from icmplib import NameLookupError
import pytest
from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper
@pytest.fixture
def dns_cache_fixture() -> DNSCache:
"""Create a DNSCache instance."""
return DNSCache()
def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses when hostname is not in cache."""
now = time.monotonic()
result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now)
assert result is None
def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses when cache entry is expired."""
now = time.monotonic()
# Add entry that's already expired
dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"])
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result is None
# Expired entry should still be in cache (not removed by get_cached_addresses)
assert "example.com" in dns_cache_fixture._cache
def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses with valid cache entry."""
now = time.monotonic()
# Add entry that expires in 60 seconds
dns_cache_fixture._cache["example.com"] = (
now + 60,
["192.168.1.10", "192.168.1.11"],
)
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result == ["192.168.1.10", "192.168.1.11"]
# Entry should still be in cache
assert "example.com" in dns_cache_fixture._cache
def test_get_cached_addresses_hostname_normalization(
dns_cache_fixture: DNSCache,
) -> None:
"""Test get_cached_addresses normalizes hostname."""
now = time.monotonic()
# Add entry with lowercase hostname
dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"])
# Test with various forms
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [
"192.168.1.10"
]
assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [
"192.168.1.10"
]
assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [
"192.168.1.10"
]
def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses with IPv6 addresses."""
now = time.monotonic()
dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"])
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result == ["2001:db8::1", "fe80::1"]
def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses with empty address list."""
now = time.monotonic()
dns_cache_fixture._cache["example.com"] = (now + 60, [])
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result == []
def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None:
"""Test get_cached_addresses when cache contains an exception."""
now = time.monotonic()
# Store an exception (from failed resolution)
dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed"))
result = dns_cache_fixture.get_cached_addresses("example.com", now)
assert result is None # Should return None for exceptions
def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None:
"""Test that get_cached_addresses never calls async_resolve."""
now = time.monotonic()
with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve:
# Test non-cached
result = dns_cache_fixture.get_cached_addresses("uncached.com", now)
assert result is None
mock_resolve.assert_not_called()
# Test expired
dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"])
result = dns_cache_fixture.get_cached_addresses("expired.com", now)
assert result is None
mock_resolve.assert_not_called()
# Test valid
dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"])
result = dns_cache_fixture.get_cached_addresses("valid.com", now)
assert result == ["192.168.1.10"]
mock_resolve.assert_not_called()
@pytest.mark.asyncio
async def test_async_resolve_wrapper_ip_address() -> None:
"""Test _async_resolve_wrapper returns IP address directly."""
result = await _async_resolve_wrapper("192.168.1.10")
assert result == ["192.168.1.10"]
result = await _async_resolve_wrapper("2001:db8::1")
assert result == ["2001:db8::1"]
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_fallback_success() -> None:
"""Test _async_resolve_wrapper falls back to bare hostname for .local."""
mock_resolve = AsyncMock()
# First call (device.local) fails, second call (device) succeeds
mock_resolve.side_effect = [
NameLookupError("device.local"),
["192.168.1.50"],
]
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
assert result == ["192.168.1.50"]
assert mock_resolve.call_count == 2
mock_resolve.assert_any_call("device.local")
mock_resolve.assert_any_call("device")
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_fallback_both_fail() -> None:
"""Test _async_resolve_wrapper returns exception when both fail."""
mock_resolve = AsyncMock()
original_exception = NameLookupError("device.local")
mock_resolve.side_effect = [
original_exception,
NameLookupError("device"),
]
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
# Should return the original exception, not the fallback exception
assert result is original_exception
assert mock_resolve.call_count == 2
@pytest.mark.asyncio
async def test_async_resolve_wrapper_non_local_no_fallback() -> None:
"""Test _async_resolve_wrapper doesn't fallback for non-.local hostnames."""
mock_resolve = AsyncMock()
original_exception = NameLookupError("device.example.com")
mock_resolve.side_effect = original_exception
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.example.com")
assert result is original_exception
# Should only try the original hostname, no fallback
assert mock_resolve.call_count == 1
mock_resolve.assert_called_once_with("device.example.com")
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_success_no_fallback() -> None:
"""Test _async_resolve_wrapper doesn't fallback when .local succeeds."""
mock_resolve = AsyncMock(return_value=["192.168.1.50"])
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
assert result == ["192.168.1.50"]
# Should only try once since it succeeded
assert mock_resolve.call_count == 1
mock_resolve.assert_called_once_with("device.local")
-240
View File
@@ -1,240 +0,0 @@
"""Unit tests for esphome.dashboard.status.mdns module."""
from __future__ import annotations
from unittest.mock import Mock, patch
import pytest
import pytest_asyncio
from zeroconf import AddressResolver, IPVersion
from esphome.dashboard.const import DashboardEvent
from esphome.dashboard.status.mdns import MDNSStatus
from esphome.zeroconf import DiscoveredImport
@pytest_asyncio.fixture
async def mdns_status(mock_dashboard: Mock) -> MDNSStatus:
"""Create an MDNSStatus instance in async context."""
# We're in an async context so get_running_loop will work
return MDNSStatus(mock_dashboard)
@pytest.mark.asyncio
async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses when no zeroconf instance is available."""
mdns_status.aiozc = None
result = mdns_status.get_cached_addresses("device.local")
assert result is None
@pytest.mark.asyncio
async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses when address is not in cache."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = False
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local")
assert result is None
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
@pytest.mark.asyncio
async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses when address is found in cache."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local")
assert result == ["192.168.1.10", "fe80::1"]
mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf)
mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All)
@pytest.mark.asyncio
async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses with hostname having trailing dot."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local.")
assert result == ["192.168.1.10"]
# Should normalize to device.local. for zeroconf
mock_resolver.assert_called_once_with("device.local.")
@pytest.mark.asyncio
async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses with uppercase hostname."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("DEVICE.LOCAL")
assert result == ["192.168.1.10"]
# Should normalize to device.local. for zeroconf
mock_resolver.assert_called_once_with("device.local.")
@pytest.mark.asyncio
async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses with simple hostname (no domain)."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device")
assert result == ["192.168.1.10"]
# Should append .local. for zeroconf
mock_resolver.assert_called_once_with("device.local.")
@pytest.mark.asyncio
async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses returning only IPv6 addresses."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"]
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local")
assert result == ["fe80::1", "2001:db8::1"]
@pytest.mark.asyncio
async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None:
"""Test get_cached_addresses returning empty list from cache."""
mdns_status.aiozc = Mock()
mdns_status.aiozc.zeroconf = Mock()
with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver:
mock_info = Mock(spec=AddressResolver)
mock_info.load_from_cache.return_value = True
mock_info.parsed_scoped_addresses.return_value = []
mock_resolver.return_value = mock_info
result = mdns_status.get_cached_addresses("device.local")
assert result == []
@pytest.mark.asyncio
async def test_async_setup_success(mock_dashboard: Mock) -> None:
"""Test successful async_setup."""
mdns_status = MDNSStatus(mock_dashboard)
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
mock_zc.return_value = Mock()
result = mdns_status.async_setup()
assert result is True
assert mdns_status.aiozc is not None
@pytest.mark.asyncio
async def test_async_setup_failure(mock_dashboard: Mock) -> None:
"""Test async_setup with OSError."""
mdns_status = MDNSStatus(mock_dashboard)
with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc:
mock_zc.side_effect = OSError("Network error")
result = mdns_status.async_setup()
assert result is False
assert mdns_status.aiozc is None
@pytest.mark.asyncio
async def test_on_import_update_device_added(mdns_status: MDNSStatus) -> None:
"""Test _on_import_update when a device is added."""
# Create a DiscoveredImport object
discovered = DiscoveredImport(
device_name="test_device",
friendly_name="Test Device",
package_import_url="https://example.com/package",
project_name="test_project",
project_version="1.0.0",
network="wifi",
)
# Call _on_import_update with a device
mdns_status._on_import_update("test_device", discovered)
# Should fire IMPORTABLE_DEVICE_ADDED event
mock_dashboard = mdns_status.dashboard
mock_dashboard.bus.async_fire.assert_called_once()
call_args = mock_dashboard.bus.async_fire.call_args
assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED
assert "device" in call_args[0][1]
device_data = call_args[0][1]["device"]
assert device_data["name"] == "test_device"
assert device_data["friendly_name"] == "Test Device"
assert device_data["project_name"] == "test_project"
assert device_data["ignored"] is False
@pytest.mark.asyncio
async def test_on_import_update_device_ignored(mdns_status: MDNSStatus) -> None:
"""Test _on_import_update when a device is ignored."""
# Add device to ignored list
mdns_status.dashboard.ignored_devices.add("ignored_device")
# Create a DiscoveredImport object for ignored device
discovered = DiscoveredImport(
device_name="ignored_device",
friendly_name="Ignored Device",
package_import_url="https://example.com/package",
project_name="test_project",
project_version="1.0.0",
network="ethernet",
)
# Call _on_import_update with an ignored device
mdns_status._on_import_update("ignored_device", discovered)
# Should fire IMPORTABLE_DEVICE_ADDED event with ignored=True
mock_dashboard = mdns_status.dashboard
mock_dashboard.bus.async_fire.assert_called_once()
call_args = mock_dashboard.bus.async_fire.call_args
assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED
device_data = call_args[0][1]["device"]
assert device_data["name"] == "ignored_device"
assert device_data["ignored"] is True
@pytest.mark.asyncio
async def test_on_import_update_device_removed(mdns_status: MDNSStatus) -> None:
"""Test _on_import_update when a device is removed."""
# Call _on_import_update with None (device removed)
mdns_status._on_import_update("removed_device", None)
# Should fire IMPORTABLE_DEVICE_REMOVED event
mdns_status.dashboard.bus.async_fire.assert_called_once_with(
DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": "removed_device"}
)
-288
View File
@@ -1,288 +0,0 @@
"""Tests for dashboard entries Path-related functionality."""
from __future__ import annotations
import os
from pathlib import Path
import tempfile
from unittest.mock import Mock
import pytest
from esphome.core import CORE
from esphome.dashboard.const import DashboardEvent
from esphome.dashboard.entries import DashboardEntries, DashboardEntry
def create_cache_key() -> tuple[int, int, float, int]:
"""Helper to create a valid DashboardCacheKeyType."""
return (0, 0, 0.0, 0)
@pytest.fixture(autouse=True)
def setup_core():
"""Set up CORE for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
CORE.config_path = Path(tmpdir) / "test.yaml"
yield
CORE.reset()
def test_dashboard_entry_path_initialization() -> None:
"""Test DashboardEntry initializes with path correctly."""
test_path = Path("/test/config/device.yaml")
cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path
assert entry.cache_key == cache_key
def test_dashboard_entry_path_with_absolute_path() -> None:
"""Test DashboardEntry handles absolute paths."""
# Use a truly absolute path for the platform
test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml"
cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path
assert entry.path.is_absolute()
def test_dashboard_entry_path_with_relative_path() -> None:
"""Test DashboardEntry handles relative paths."""
test_path = Path("configs/device.yaml")
cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path
assert not entry.path.is_absolute()
@pytest.mark.asyncio
async def test_dashboard_entries_get_by_path(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test getting entry by path."""
# Create a test file
test_file = tmp_path / "device.yaml"
test_file.write_text("test config")
# Update entries to load the file
await dashboard_entries.async_update_entries()
# Verify the entry was loaded
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 1
entry = all_entries[0]
assert entry.path == test_file
# Also verify get() works with Path
result = dashboard_entries.get(test_file)
assert result == entry
@pytest.mark.asyncio
async def test_dashboard_entries_get_nonexistent_path(
dashboard_entries: DashboardEntries,
) -> None:
"""Test getting non-existent entry returns None."""
result = dashboard_entries.get("/nonexistent/path.yaml")
assert result is None
@pytest.mark.asyncio
async def test_dashboard_entries_path_normalization(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test that paths are handled consistently."""
# Create a test file
test_file = tmp_path / "device.yaml"
test_file.write_text("test config")
# Update entries to load the file
await dashboard_entries.async_update_entries()
# Get the entry by path
result = dashboard_entries.get(test_file)
assert result is not None
@pytest.mark.asyncio
async def test_dashboard_entries_path_with_spaces(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test handling paths with spaces."""
# Create a test file with spaces in name
test_file = tmp_path / "my device.yaml"
test_file.write_text("test config")
# Update entries to load the file
await dashboard_entries.async_update_entries()
# Get the entry by path
result = dashboard_entries.get(test_file)
assert result is not None
assert result.path == test_file
@pytest.mark.asyncio
async def test_dashboard_entries_path_with_special_chars(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test handling paths with special characters."""
# Create a test file with special characters
test_file = tmp_path / "device-01_test.yaml"
test_file.write_text("test config")
# Update entries to load the file
await dashboard_entries.async_update_entries()
# Get the entry by path
result = dashboard_entries.get(test_file)
assert result is not None
def test_dashboard_entries_windows_path() -> None:
"""Test handling Windows-style paths."""
test_path = Path(r"C:\Users\test\esphome\device.yaml")
cache_key = create_cache_key()
entry = DashboardEntry(test_path, cache_key)
assert entry.path == test_path
@pytest.mark.asyncio
async def test_dashboard_entries_path_to_cache_key_mapping(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test internal entries storage with paths and cache keys."""
# Create test files
file1 = tmp_path / "device1.yaml"
file2 = tmp_path / "device2.yaml"
file1.write_text("test config 1")
file2.write_text("test config 2")
# Update entries to load the files
await dashboard_entries.async_update_entries()
# Get entries and verify they have different cache keys
entry1 = dashboard_entries.get(file1)
entry2 = dashboard_entries.get(file2)
assert entry1 is not None
assert entry2 is not None
assert entry1.cache_key != entry2.cache_key
def test_dashboard_entry_path_property() -> None:
"""Test that path property returns expected value."""
test_path = Path("/test/config/device.yaml")
entry = DashboardEntry(test_path, create_cache_key())
assert entry.path == test_path
assert isinstance(entry.path, Path)
@pytest.mark.asyncio
async def test_dashboard_entries_all_returns_entries_with_paths(
dashboard_entries: DashboardEntries, tmp_path: Path
) -> None:
"""Test that all() returns entries with their paths intact."""
# Create test files
files = [
tmp_path / "device1.yaml",
tmp_path / "device2.yaml",
tmp_path / "device3.yaml",
]
for file in files:
file.write_text("test config")
# Update entries to load the files
await dashboard_entries.async_update_entries()
all_entries = dashboard_entries.async_all()
assert len(all_entries) == len(files)
retrieved_paths = [entry.path for entry in all_entries]
assert set(retrieved_paths) == set(files)
@pytest.mark.asyncio
async def test_async_update_entries_removed_path(
dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path
) -> None:
"""Test that removed files trigger ENTRY_REMOVED event."""
# Create a test file
test_file = tmp_path / "device.yaml"
test_file.write_text("test config")
# First update to add the entry
await dashboard_entries.async_update_entries()
# Verify entry was added
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 1
entry = all_entries[0]
# Delete the file
test_file.unlink()
# Second update to detect removal
await dashboard_entries.async_update_entries()
# Verify entry was removed
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 0
# Verify ENTRY_REMOVED event was fired
mock_dashboard.bus.async_fire.assert_any_call(
DashboardEvent.ENTRY_REMOVED, {"entry": entry}
)
@pytest.mark.asyncio
async def test_async_update_entries_updated_path(
dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path
) -> None:
"""Test that modified files trigger ENTRY_UPDATED event."""
# Create a test file
test_file = tmp_path / "device.yaml"
test_file.write_text("test config")
# First update to add the entry
await dashboard_entries.async_update_entries()
# Verify entry was added
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 1
entry = all_entries[0]
original_cache_key = entry.cache_key
# Modify the file to change its mtime
test_file.write_text("updated config")
# Explicitly change the mtime to ensure it's different
stat = test_file.stat()
os.utime(test_file, (stat.st_atime, stat.st_mtime + 1))
# Second update to detect modification
await dashboard_entries.async_update_entries()
# Verify entry is still there with updated cache key
all_entries = dashboard_entries.async_all()
assert len(all_entries) == 1
updated_entry = all_entries[0]
assert updated_entry == entry # Same entry object
assert updated_entry.cache_key != original_cache_key # But cache key updated
# Verify ENTRY_UPDATED event was fired
mock_dashboard.bus.async_fire.assert_any_call(
DashboardEvent.ENTRY_UPDATED, {"entry": entry}
)
-287
View File
@@ -1,287 +0,0 @@
"""Tests for DashboardSettings (path resolution and authentication)."""
from __future__ import annotations
from argparse import Namespace
from pathlib import Path
import tempfile
import pytest
from esphome.core import CORE
from esphome.dashboard.settings import DashboardSettings
from esphome.dashboard.util.password import password_hash
@pytest.fixture
def dashboard_settings(tmp_path: Path) -> DashboardSettings:
"""Create DashboardSettings instance with temp directory."""
settings = DashboardSettings()
# Resolve symlinks to ensure paths match
resolved_dir = tmp_path.resolve()
settings.config_dir = resolved_dir
settings.absolute_config_dir = resolved_dir
return settings
def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with simple relative path."""
result = dashboard_settings.rel_path("config.yaml")
expected = dashboard_settings.config_dir / "config.yaml"
assert result == expected
def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with multiple path components."""
result = dashboard_settings.rel_path("subfolder", "device", "config.yaml")
expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml"
assert result == expected
def test_rel_path_with_dots(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path prevents directory traversal."""
# This should raise ValueError as it tries to go outside config_dir
with pytest.raises(ValueError):
dashboard_settings.rel_path("..", "outside.yaml")
def test_rel_path_absolute_path_within_config(
dashboard_settings: DashboardSettings,
) -> None:
"""Test rel_path with absolute path that's within config dir."""
internal_path = dashboard_settings.absolute_config_dir / "internal.yaml"
internal_path.touch()
result = dashboard_settings.rel_path("internal.yaml")
expected = dashboard_settings.config_dir / "internal.yaml"
assert result == expected
def test_rel_path_absolute_path_outside_config(
dashboard_settings: DashboardSettings,
) -> None:
"""Test rel_path with absolute path outside config dir raises error."""
outside_path = "/tmp/outside/config.yaml"
with pytest.raises(ValueError):
dashboard_settings.rel_path(outside_path)
def test_rel_path_empty_args(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with no arguments returns config_dir."""
result = dashboard_settings.rel_path()
assert result == dashboard_settings.config_dir
def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path works with Path objects as arguments."""
path_obj = Path("subfolder") / "config.yaml"
result = dashboard_settings.rel_path(path_obj)
expected = dashboard_settings.config_dir / "subfolder" / "config.yaml"
assert result == expected
def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path normalizes path separators."""
# os.path.join normalizes slashes on Windows but preserves them on Unix
# Test that providing components separately gives same result
result1 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml")
result2 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml")
assert result1 == result2
# Also test that the result is as expected
expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml"
assert result1 == expected
def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles paths with spaces."""
result = dashboard_settings.rel_path("my folder", "my config.yaml")
expected = dashboard_settings.config_dir / "my folder" / "my config.yaml"
assert result == expected
def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles paths with special characters."""
result = dashboard_settings.rel_path("device-01_test", "config.yaml")
expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml"
assert result == expected
def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None:
"""Test that config_dir can be accessed and used with Path operations."""
config_path = dashboard_settings.config_dir
assert config_path.exists()
assert config_path.is_dir()
assert config_path.is_absolute()
def test_absolute_config_dir_property(dashboard_settings: DashboardSettings) -> None:
"""Test absolute_config_dir is a Path object."""
assert isinstance(dashboard_settings.absolute_config_dir, Path)
assert dashboard_settings.absolute_config_dir.exists()
assert dashboard_settings.absolute_config_dir.is_dir()
assert dashboard_settings.absolute_config_dir.is_absolute()
def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with symlink that points inside config dir."""
target = dashboard_settings.absolute_config_dir / "target.yaml"
target.touch()
symlink = dashboard_settings.absolute_config_dir / "link.yaml"
symlink.symlink_to(target)
result = dashboard_settings.rel_path("link.yaml")
expected = dashboard_settings.config_dir / "link.yaml"
assert result == expected
def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path with symlink that points outside config dir."""
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp:
symlink = dashboard_settings.absolute_config_dir / "external_link.yaml"
symlink.symlink_to(tmp.name)
with pytest.raises(ValueError):
dashboard_settings.rel_path("external_link.yaml")
def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles None arguments gracefully."""
result = dashboard_settings.rel_path("None")
expected = dashboard_settings.config_dir / "None"
assert result == expected
def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None:
"""Test rel_path handles numeric arguments."""
result = dashboard_settings.rel_path("123", "456.789")
expected = dashboard_settings.config_dir / "123" / "456.789"
assert result == expected
def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None:
"""Test that CORE.config_path.parent resolves to config_dir after parse_args.
This is a regression test for issue #11280 where binary download failed
when using packages with secrets after the Path migration in 2025.10.0.
The issue was that after switching from os.path to Path:
- Before: os.path.dirname("/config/.") → "/config"
- After: Path("/config/.").parent → Path("/") (normalized first!)
The fix uses a sentinel file so .parent returns the correct directory:
- Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config")
"""
# Create test directory structure with secrets and packages
config_dir = tmp_path / "config"
config_dir.mkdir()
# Create secrets.yaml with obviously fake test values
secrets_file = config_dir / "secrets.yaml"
secrets_file.write_text(
"wifi_ssid: TEST-DUMMY-SSID\n"
"wifi_password: not-a-real-password-just-for-testing\n"
)
# Create package file that uses secrets
package_file = config_dir / "common.yaml"
package_file.write_text(
"wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n"
)
# Create main device config that includes the package
device_config = config_dir / "test-device.yaml"
device_config.write_text(
"esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n"
)
# Set up dashboard settings with our test config directory
settings = DashboardSettings()
args = Namespace(
configuration=str(config_dir),
password=None,
username=None,
ha_addon=False,
verbose=False,
)
settings.parse_args(args)
# Verify that CORE.config_path.parent correctly points to the config directory
# This is critical for secret resolution in yaml_util.py which does:
# main_config_dir = CORE.config_path.parent
# main_secret_yml = main_config_dir / "secrets.yaml"
assert CORE.config_path.parent == config_dir.resolve()
assert (CORE.config_path.parent / "secrets.yaml").exists()
assert (CORE.config_path.parent / "common.yaml").exists()
# Verify that CORE.config_path itself uses the sentinel file
assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml"
assert not CORE.config_path.exists() # Sentinel file doesn't actually exist
@pytest.fixture
def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings:
"""Create DashboardSettings with auth configured, based on dashboard_settings."""
dashboard_settings.username = "admin"
dashboard_settings.using_password = True
dashboard_settings.password_hash = password_hash("correctpassword")
return dashboard_settings
def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None:
"""Test check_password returns True for correct username and password."""
assert auth_settings.check_password("admin", "correctpassword") is True
def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False for wrong password."""
assert auth_settings.check_password("admin", "wrongpassword") is False
def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False for wrong username."""
assert auth_settings.check_password("notadmin", "correctpassword") is False
def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None:
"""Test check_password returns False when both are wrong."""
assert auth_settings.check_password("notadmin", "wrongpassword") is False
def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None:
"""Test check_password returns True when auth is not configured."""
assert dashboard_settings.check_password("anyone", "anything") is True
def test_check_password_non_ascii_username(
dashboard_settings: DashboardSettings,
) -> None:
"""Test check_password handles non-ASCII usernames without TypeError."""
dashboard_settings.username = "\u00e9l\u00e8ve"
dashboard_settings.using_password = True
dashboard_settings.password_hash = password_hash("pass")
assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True
assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False
assert dashboard_settings.check_password("other", "pass") is False
def test_check_password_ha_addon_no_password(
dashboard_settings: DashboardSettings,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test check_password doesn't crash in HA add-on mode without a password.
In HA add-on mode, using_ha_addon_auth can be True while using_password
is False, leaving password_hash as b"". This must not raise TypeError
in hmac.compare_digest.
"""
monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False)
dashboard_settings.on_ha_addon = True
dashboard_settings.using_password = False
# password_hash stays as default b""
assert dashboard_settings.check_password("anyone", "anything") is False
File diff suppressed because it is too large Load Diff
-219
View File
@@ -1,219 +0,0 @@
"""Tests for dashboard web_server Path-related functionality."""
from __future__ import annotations
import gzip
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
from esphome.dashboard import web_server
def test_get_base_frontend_path_production() -> None:
"""Test get_base_frontend_path in production mode."""
mock_module = MagicMock()
mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard")
with (
patch.dict(os.environ, {}, clear=True),
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
):
result = web_server.get_base_frontend_path()
assert result == Path("/usr/local/lib/esphome_dashboard")
mock_module.where.assert_called_once()
def test_get_base_frontend_path_dev_mode() -> None:
"""Test get_base_frontend_path in development mode."""
test_path = "/home/user/esphome/dashboard"
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path()
# The function uses Path.resolve() which resolves symlinks
# The actual function adds "/" to the path, so we simulate that
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
assert result == expected
def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None:
"""Test get_base_frontend_path in dev mode with trailing slash."""
test_path = "/home/user/esphome/dashboard/"
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path()
# The function uses Path.resolve() which resolves symlinks
expected = (Path.cwd() / test_path / "esphome_dashboard").resolve()
assert result == expected
def test_get_base_frontend_path_dev_mode_relative_path() -> None:
"""Test get_base_frontend_path with relative dev path."""
test_path = "./dashboard"
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}):
result = web_server.get_base_frontend_path()
# The function uses Path.resolve() which resolves symlinks
# The actual function adds "/" to the path, so we simulate that
test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/"
expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve()
assert result == expected
assert result.is_absolute()
def test_get_static_path_single_component() -> None:
"""Test get_static_path with single path component."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("file.js")
assert result == Path("/base/frontend") / "static" / "file.js"
def test_get_static_path_multiple_components() -> None:
"""Test get_static_path with multiple path components."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("js", "esphome", "index.js")
assert (
result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js"
)
def test_get_static_path_empty_args() -> None:
"""Test get_static_path with no arguments."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path()
assert result == Path("/base/frontend") / "static"
def test_get_static_path_with_pathlib_path() -> None:
"""Test get_static_path with Path objects."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
path_obj = Path("js") / "app.js"
result = web_server.get_static_path(str(path_obj))
assert result == Path("/base/frontend") / "static" / "js" / "app.js"
def test_get_static_file_url_production() -> None:
"""Test get_static_file_url in production mode."""
web_server.get_static_file_url.cache_clear()
mock_module = MagicMock()
mock_path = MagicMock(spec=Path)
mock_path.read_bytes.return_value = b"test content"
with (
patch.dict(os.environ, {}, clear=True),
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
patch("esphome.dashboard.web_server.get_static_path") as mock_get_path,
):
mock_get_path.return_value = mock_path
result = web_server.get_static_file_url("js/app.js")
assert result.startswith("./static/js/app.js?hash=")
def test_get_static_file_url_dev_mode() -> None:
"""Test get_static_file_url in development mode."""
with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": "/dev/path"}):
web_server.get_static_file_url.cache_clear()
result = web_server.get_static_file_url("js/app.js")
assert result == "./static/js/app.js"
def test_get_static_file_url_index_js_special_case() -> None:
"""Test get_static_file_url replaces index.js with entrypoint."""
web_server.get_static_file_url.cache_clear()
mock_module = MagicMock()
mock_module.entrypoint.return_value = "main.js"
with (
patch.dict(os.environ, {}, clear=True),
patch.dict("sys.modules", {"esphome_dashboard": mock_module}),
):
result = web_server.get_static_file_url("js/esphome/index.js")
assert result == "./static/js/esphome/main.js"
def test_load_file_path(tmp_path: Path) -> None:
"""Test loading a file."""
test_file = tmp_path / "test.txt"
test_file.write_bytes(b"test content")
with test_file.open("rb") as f:
content = f.read()
assert content == b"test content"
def test_load_file_compressed_path(tmp_path: Path) -> None:
"""Test loading a compressed file."""
test_file = tmp_path / "test.txt.gz"
with gzip.open(test_file, "wb") as gz:
gz.write(b"compressed content")
with gzip.open(test_file, "rb") as gz:
content = gz.read()
assert content == b"compressed content"
def test_path_normalization_in_static_path() -> None:
"""Test that paths are normalized correctly."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
# Test with separate components
result1 = web_server.get_static_path("js", "app.js")
result2 = web_server.get_static_path("js", "app.js")
assert result1 == result2
assert result1 == Path("/base/frontend") / "static" / "js" / "app.js"
def test_windows_path_handling() -> None:
"""Test handling of Windows-style paths."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path(r"C:\Program Files\esphome\frontend")
result = web_server.get_static_path("js", "app.js")
# Path should handle this correctly on the platform
expected = (
Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js"
)
assert result == expected
def test_path_with_special_characters() -> None:
"""Test paths with special characters."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/frontend")
result = web_server.get_static_path("js-modules", "app_v1.0.js")
assert (
result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js"
)
def test_path_with_spaces() -> None:
"""Test paths with spaces."""
with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base:
mock_base.return_value = Path("/base/my frontend")
result = web_server.get_static_path("my js", "my app.js")
assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js"
View File
@@ -8,14 +8,23 @@ static const char *const TAG = "bulk_cleanup";
void SchedulerBulkCleanupComponent::setup() { ESP_LOGI(TAG, "Scheduler bulk cleanup test component loaded"); }
// Static name tables keep the const char* pointers valid for the lifetime of the scheduled tasks.
static const char *const BULK_TIMEOUT_NAMES[25] = {
"bulk_timeout_0", "bulk_timeout_1", "bulk_timeout_2", "bulk_timeout_3", "bulk_timeout_4",
"bulk_timeout_5", "bulk_timeout_6", "bulk_timeout_7", "bulk_timeout_8", "bulk_timeout_9",
"bulk_timeout_10", "bulk_timeout_11", "bulk_timeout_12", "bulk_timeout_13", "bulk_timeout_14",
"bulk_timeout_15", "bulk_timeout_16", "bulk_timeout_17", "bulk_timeout_18", "bulk_timeout_19",
"bulk_timeout_20", "bulk_timeout_21", "bulk_timeout_22", "bulk_timeout_23", "bulk_timeout_24"};
static const char *const POST_CLEANUP_NAMES[5] = {"post_cleanup_0", "post_cleanup_1", "post_cleanup_2",
"post_cleanup_3", "post_cleanup_4"};
void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() {
ESP_LOGI(TAG, "Starting bulk cleanup test...");
// Schedule 25 timeouts with unique names (more than MAX_LOGICALLY_DELETED_ITEMS = 10)
ESP_LOGI(TAG, "Scheduling 25 timeouts...");
for (int i = 0; i < 25; i++) {
std::string name = "bulk_timeout_" + std::to_string(i);
App.scheduler.set_timeout(this, name, 2500, [i]() {
App.scheduler.set_timeout(this, BULK_TIMEOUT_NAMES[i], 2500, [i]() {
// These should never execute as we'll cancel them
ESP_LOGW(TAG, "Timeout %d executed - this should not happen!", i);
});
@@ -24,8 +33,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() {
// Cancel all of them to mark for removal
ESP_LOGI(TAG, "Cancelling all 25 timeouts to trigger bulk cleanup...");
int cancelled_count = 0;
for (int i = 0; i < 25; i++) {
std::string name = "bulk_timeout_" + std::to_string(i);
for (const char *name : BULK_TIMEOUT_NAMES) {
if (App.scheduler.cancel_timeout(this, name)) {
cancelled_count++;
}
@@ -56,8 +64,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() {
// Also schedule some normal timeouts to ensure scheduler keeps working after cleanup
static int post_cleanup_count = 0;
for (int i = 0; i < 5; i++) {
std::string name = "post_cleanup_" + std::to_string(i);
App.scheduler.set_timeout(this, name, 50 + i * 25, [i]() {
App.scheduler.set_timeout(this, POST_CLEANUP_NAMES[i], 50 + i * 25, [i]() {
ESP_LOGI(TAG, "Post-cleanup timeout %d executed correctly", i);
post_cleanup_count++;
if (post_cleanup_count >= 5) {
@@ -4,12 +4,18 @@
#include <vector>
#include <chrono>
#include <random>
#include <sstream>
namespace esphome::scheduler_rapid_cancellation_component {
static const char *const TAG = "scheduler_rapid_cancellation";
// Static name table keeps the const char* pointers valid for the lifetime of the scheduled tasks.
// Threads race over this fixed set of names; STATIC_STRING names match by content, so scheduling
// the same name replaces (implicitly cancels) the previous timeout, exactly as before.
static const char *const SHARED_TIMEOUT_NAMES[10] = {
"shared_timeout_0", "shared_timeout_1", "shared_timeout_2", "shared_timeout_3", "shared_timeout_4",
"shared_timeout_5", "shared_timeout_6", "shared_timeout_7", "shared_timeout_8", "shared_timeout_9"};
void SchedulerRapidCancellationComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerRapidCancellationComponent setup"); }
void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() {
@@ -32,14 +38,12 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() {
for (int i = 0; i < OPERATIONS_PER_THREAD; i++) {
// Use modulo to ensure multiple threads use the same names
int name_index = i % NUM_NAMES;
std::stringstream ss;
ss << "shared_timeout_" << name_index;
std::string name = ss.str();
const char *name = SHARED_TIMEOUT_NAMES[name_index];
// All threads schedule timeouts - this will implicitly cancel existing ones
this->set_timeout(name, 150, [this, name]() {
this->total_executed_.fetch_add(1);
ESP_LOGI(TAG, "Executed callback '%s'", name.c_str());
ESP_LOGI(TAG, "Executed callback '%s'", name);
});
this->total_scheduled_.fetch_add(1);
@@ -1,9 +1,9 @@
#include "simultaneous_callbacks_component.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <thread>
#include <vector>
#include <chrono>
#include <sstream>
namespace esphome::scheduler_simultaneous_callbacks_component {
@@ -41,13 +41,11 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test()
std::this_thread::sleep_until(start_time + std::chrono::microseconds(100));
for (int i = 0; i < CALLBACKS_PER_THREAD; i++) {
// Create unique name for each callback
std::stringstream ss;
ss << "thread_" << thread_id << "_cb_" << i;
std::string name = ss.str();
// Unique numeric ID for each callback (zero heap allocation, no name collisions)
uint32_t callback_id = static_cast<uint32_t>(thread_id) * CALLBACKS_PER_THREAD + i;
// Schedule callback for exactly DELAY_MS from now
this->set_timeout(name, DELAY_MS, [this, name]() {
this->set_timeout(callback_id, DELAY_MS, [this, callback_id]() {
// Increment concurrent counter atomically
int current = this->callbacks_at_once_.fetch_add(1) + 1;
@@ -57,7 +55,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test()
// Loop until we successfully update or someone else set a higher value
}
ESP_LOGV(TAG, "Callback executed: %s (concurrent: %d)", name.c_str(), current);
ESP_LOGV(TAG, "Callback executed: id=%" PRIu32 " (concurrent: %d)", callback_id, current);
// Simulate some minimal work
std::atomic<int> work{0};
@@ -73,7 +71,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test()
});
this->total_scheduled_.fetch_add(1);
ESP_LOGV(TAG, "Scheduled callback %s", name.c_str());
ESP_LOGV(TAG, "Scheduled callback id=%" PRIu32, callback_id);
}
ESP_LOGD(TAG, "Thread %d completed scheduling", thread_id);
@@ -1,21 +0,0 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
scheduler_string_lifetime_component_ns = cg.esphome_ns.namespace(
"scheduler_string_lifetime_component"
)
SchedulerStringLifetimeComponent = scheduler_string_lifetime_component_ns.class_(
"SchedulerStringLifetimeComponent", cg.Component
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(SchedulerStringLifetimeComponent),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -1,260 +0,0 @@
#include "string_lifetime_component.h"
#include "esphome/core/log.h"
#include <memory>
#include <thread>
#include <chrono>
namespace esphome::scheduler_string_lifetime_component {
static const char *const TAG = "scheduler_string_lifetime";
void SchedulerStringLifetimeComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringLifetimeComponent setup"); }
void SchedulerStringLifetimeComponent::run_string_lifetime_test() {
ESP_LOGI(TAG, "Starting string lifetime tests");
this->tests_passed_ = 0;
this->tests_failed_ = 0;
// Run each test
test_temporary_string_lifetime();
test_scope_exit_string();
test_vector_reallocation();
test_string_move_semantics();
test_lambda_capture_lifetime();
}
void SchedulerStringLifetimeComponent::run_test1() {
test_temporary_string_lifetime();
// Wait for all callbacks to execute
this->set_timeout("test1_complete", 10, []() { ESP_LOGI(TAG, "Test 1 complete"); });
}
void SchedulerStringLifetimeComponent::run_test2() {
test_scope_exit_string();
// Wait for all callbacks to execute
this->set_timeout("test2_complete", 20, []() { ESP_LOGI(TAG, "Test 2 complete"); });
}
void SchedulerStringLifetimeComponent::run_test3() {
test_vector_reallocation();
// Wait for all callbacks to execute
this->set_timeout("test3_complete", 60, []() { ESP_LOGI(TAG, "Test 3 complete"); });
}
void SchedulerStringLifetimeComponent::run_test4() {
test_string_move_semantics();
// Wait for all callbacks to execute
this->set_timeout("test4_complete", 35, []() { ESP_LOGI(TAG, "Test 4 complete"); });
}
void SchedulerStringLifetimeComponent::run_test5() {
test_lambda_capture_lifetime();
// Wait for all callbacks to execute
this->set_timeout("test5_complete", 50, []() { ESP_LOGI(TAG, "Test 5 complete"); });
}
void SchedulerStringLifetimeComponent::run_final_check() {
ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_);
ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_);
if (this->tests_failed_ == 0) {
ESP_LOGI(TAG, "SUCCESS: All string lifetime tests passed!");
} else {
ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_);
}
ESP_LOGI(TAG, "String lifetime tests complete");
}
void SchedulerStringLifetimeComponent::test_temporary_string_lifetime() {
ESP_LOGI(TAG, "Test 1: Temporary string lifetime for timeout names");
// Test with a temporary string that goes out of scope immediately
{
std::string temp_name = "temp_callback_" + std::to_string(12345);
// Schedule with temporary string name - scheduler must copy/store this
this->set_timeout(temp_name, 1, [this]() {
ESP_LOGD(TAG, "Callback for temp string name executed");
this->tests_passed_++;
});
// String goes out of scope here, but scheduler should have made a copy
}
// Test with rvalue string as name
this->set_timeout(std::string("rvalue_test"), 2, [this]() {
ESP_LOGD(TAG, "Rvalue string name callback executed");
this->tests_passed_++;
});
// Test cancelling with reconstructed string
{
std::string cancel_name = "cancel_test_" + std::to_string(999);
this->set_timeout(cancel_name, 100, [this]() {
ESP_LOGE(TAG, "This should have been cancelled!");
this->tests_failed_++;
});
} // cancel_name goes out of scope
// Reconstruct the same string to cancel
std::string cancel_name_2 = "cancel_test_" + std::to_string(999);
bool cancelled = this->cancel_timeout(cancel_name_2);
if (cancelled) {
ESP_LOGD(TAG, "Successfully cancelled with reconstructed string");
this->tests_passed_++;
} else {
ESP_LOGE(TAG, "Failed to cancel with reconstructed string");
this->tests_failed_++;
}
}
void SchedulerStringLifetimeComponent::test_scope_exit_string() {
ESP_LOGI(TAG, "Test 2: Scope exit string names");
// Create string names in a limited scope
{
std::string scoped_name = "scoped_timeout_" + std::to_string(555);
// Schedule with scoped string name
this->set_timeout(scoped_name, 3, [this]() {
ESP_LOGD(TAG, "Scoped name callback executed");
this->tests_passed_++;
});
// scoped_name goes out of scope here
}
// Test with dynamically allocated string name
{
auto *dynamic_name = new std::string("dynamic_timeout_" + std::to_string(777));
this->set_timeout(*dynamic_name, 4, [this, dynamic_name]() {
ESP_LOGD(TAG, "Dynamic string name callback executed");
this->tests_passed_++;
delete dynamic_name; // Clean up in callback
});
// Pointer goes out of scope but string object remains until callback
}
// Test multiple timeouts with same dynamically created name
for (int i = 0; i < 3; i++) {
std::string loop_name = "loop_timeout_" + std::to_string(i);
this->set_timeout(loop_name, 5 + i * 1, [this, i]() {
ESP_LOGD(TAG, "Loop timeout %d executed", i);
this->tests_passed_++;
});
// loop_name destroyed and recreated each iteration
}
}
void SchedulerStringLifetimeComponent::test_vector_reallocation() {
ESP_LOGI(TAG, "Test 3: Vector reallocation stress on timeout names");
// Create a vector that will reallocate
std::vector<std::string> names;
names.reserve(2); // Small initial capacity to force reallocation
// Schedule callbacks with string names from vector
for (int i = 0; i < 10; i++) {
names.push_back("vector_cb_" + std::to_string(i));
// Use the string from vector as timeout name
this->set_timeout(names.back(), 8 + i * 1, [this, i]() {
ESP_LOGV(TAG, "Vector name callback %d executed", i);
this->tests_passed_++;
});
}
// Force reallocation by adding more elements
// This will move all strings to new memory locations
for (int i = 10; i < 50; i++) {
names.push_back("realloc_trigger_" + std::to_string(i));
}
// Add more timeouts after reallocation to ensure old names still work
for (int i = 50; i < 55; i++) {
names.push_back("post_realloc_" + std::to_string(i));
this->set_timeout(names.back(), 20 + (i - 50), [this]() {
ESP_LOGV(TAG, "Post-reallocation callback executed");
this->tests_passed_++;
});
}
// Clear the vector while timeouts are still pending
names.clear();
ESP_LOGD(TAG, "Vector cleared - all string names destroyed");
}
void SchedulerStringLifetimeComponent::test_string_move_semantics() {
ESP_LOGI(TAG, "Test 4: String move semantics for timeout names");
// Test moving string names
std::string original = "move_test_original";
std::string moved = std::move(original);
// Schedule with moved string as name
this->set_timeout(moved, 30, [this]() {
ESP_LOGD(TAG, "Moved string name callback executed");
this->tests_passed_++;
});
// original is now empty, try to use it as a different timeout name
original = "reused_after_move";
this->set_timeout(original, 32, [this]() {
ESP_LOGD(TAG, "Reused string name callback executed");
this->tests_passed_++;
});
}
void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() {
ESP_LOGI(TAG, "Test 5: Complex timeout name scenarios");
// Test scheduling with name built in lambda
[this]() {
std::string lambda_name = "lambda_built_name_" + std::to_string(888);
this->set_timeout(lambda_name, 38, [this]() {
ESP_LOGD(TAG, "Lambda-built name callback executed");
this->tests_passed_++;
});
}(); // Lambda executes and lambda_name is destroyed
// Test with shared_ptr name
auto shared_name = std::make_shared<std::string>("shared_ptr_timeout");
this->set_timeout(*shared_name, 40, [this, shared_name]() {
ESP_LOGD(TAG, "Shared_ptr name callback executed");
this->tests_passed_++;
});
shared_name.reset(); // Release the shared_ptr
// Test overwriting timeout with same name
std::string overwrite_name = "overwrite_test";
this->set_timeout(overwrite_name, 1000, [this]() {
ESP_LOGE(TAG, "This should have been overwritten!");
this->tests_failed_++;
});
// Overwrite with shorter timeout
this->set_timeout(overwrite_name, 42, [this]() {
ESP_LOGD(TAG, "Overwritten timeout executed");
this->tests_passed_++;
});
// Test very long string name
std::string long_name;
for (int i = 0; i < 100; i++) {
long_name += "very_long_timeout_name_segment_" + std::to_string(i) + "_";
}
this->set_timeout(long_name, 44, [this]() {
ESP_LOGD(TAG, "Very long name timeout executed");
this->tests_passed_++;
});
// Test empty string as name
this->set_timeout("", 46, [this]() {
ESP_LOGD(TAG, "Empty string name timeout executed");
this->tests_passed_++;
});
}
} // namespace esphome::scheduler_string_lifetime_component
@@ -1,35 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include <vector>
#include <string>
namespace esphome::scheduler_string_lifetime_component {
class SchedulerStringLifetimeComponent : public Component {
public:
void setup() override;
float get_setup_priority() const override { return setup_priority::LATE; }
void run_string_lifetime_test();
// Individual test methods exposed as services
void run_test1();
void run_test2();
void run_test3();
void run_test4();
void run_test5();
void run_final_check();
private:
void test_temporary_string_lifetime();
void test_scope_exit_string();
void test_vector_reallocation();
void test_string_move_semantics();
void test_lambda_capture_lifetime();
int tests_passed_{0};
int tests_failed_{0};
};
} // namespace esphome::scheduler_string_lifetime_component
@@ -1,21 +0,0 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
scheduler_string_name_stress_component_ns = cg.esphome_ns.namespace(
"scheduler_string_name_stress_component"
)
SchedulerStringNameStressComponent = scheduler_string_name_stress_component_ns.class_(
"SchedulerStringNameStressComponent", cg.Component
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(SchedulerStringNameStressComponent),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -1,108 +0,0 @@
#include "string_name_stress_component.h"
#include "esphome/core/log.h"
#include <thread>
#include <atomic>
#include <vector>
#include <chrono>
#include <string>
#include <sstream>
namespace esphome::scheduler_string_name_stress_component {
static const char *const TAG = "scheduler_string_name_stress";
void SchedulerStringNameStressComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringNameStressComponent setup"); }
void SchedulerStringNameStressComponent::run_string_name_stress_test() {
// Use member variables to reset state
this->total_callbacks_ = 0;
this->executed_callbacks_ = 0;
static constexpr int NUM_THREADS = 10;
static constexpr int CALLBACKS_PER_THREAD = 100;
ESP_LOGI(TAG, "Starting string name stress test - multi-threaded set_timeout with std::string names");
ESP_LOGI(TAG, "This test specifically uses dynamic string names to test memory management");
// Track start time
auto start_time = std::chrono::steady_clock::now();
// Create threads
std::vector<std::thread> threads;
ESP_LOGI(TAG, "Creating %d threads, each will schedule %d callbacks with dynamic names", NUM_THREADS,
CALLBACKS_PER_THREAD);
threads.reserve(NUM_THREADS);
for (int i = 0; i < NUM_THREADS; i++) {
threads.emplace_back([this, i]() {
ESP_LOGV(TAG, "Thread %d starting", i);
// Each thread schedules callbacks with dynamically created string names
for (int j = 0; j < CALLBACKS_PER_THREAD; j++) {
int callback_id = this->total_callbacks_.fetch_add(1);
// Create a dynamic string name - this will test memory management
std::stringstream ss;
ss << "thread_" << i << "_callback_" << j << "_id_" << callback_id;
std::string dynamic_name = ss.str();
ESP_LOGV(TAG, "Thread %d scheduling timeout with dynamic name: %s", i, dynamic_name.c_str());
// Capture necessary values for the lambda
auto *component = this;
// Schedule with std::string name - this tests the string overload
// Use varying delays to stress the heap scheduler
uint32_t delay = 1 + (callback_id % 50);
// Also test nested scheduling from callbacks
if (j % 10 == 0) {
// Every 10th callback schedules another callback
this->set_timeout(dynamic_name, delay, [component, callback_id]() {
component->executed_callbacks_.fetch_add(1);
ESP_LOGV(TAG, "Executed string-named callback %d (nested scheduler)", callback_id);
// Schedule another timeout from within this callback with a new dynamic name
std::string nested_name = "nested_from_" + std::to_string(callback_id);
component->set_timeout(nested_name, 1, [callback_id]() {
ESP_LOGV(TAG, "Executed nested string-named callback from %d", callback_id);
});
});
} else {
// Regular callback
this->set_timeout(dynamic_name, delay, [component, callback_id]() {
component->executed_callbacks_.fetch_add(1);
ESP_LOGV(TAG, "Executed string-named callback %d", callback_id);
});
}
// Add some timing variations to increase race conditions
if (j % 5 == 0) {
std::this_thread::sleep_for(std::chrono::microseconds(100));
}
}
ESP_LOGV(TAG, "Thread %d finished scheduling", i);
});
}
// Wait for all threads to complete scheduling
for (auto &t : threads) {
t.join();
}
auto end_time = std::chrono::steady_clock::now();
auto thread_time = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time).count();
ESP_LOGI(TAG, "All threads finished scheduling in %lldms. Created %d callbacks with dynamic names", thread_time,
this->total_callbacks_.load());
// Give some time for callbacks to execute
ESP_LOGI(TAG, "Waiting for callbacks to execute...");
// Schedule a final callback to signal completion
this->set_timeout("test_complete", 2000, [this]() {
ESP_LOGI(TAG, "String name stress test complete. Executed %d of %d callbacks", this->executed_callbacks_.load(),
this->total_callbacks_.load());
});
}
} // namespace esphome::scheduler_string_name_stress_component
@@ -1,20 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include <atomic>
namespace esphome::scheduler_string_name_stress_component {
class SchedulerStringNameStressComponent : public Component {
public:
void setup() override;
float get_setup_priority() const override { return setup_priority::LATE; }
void run_string_name_stress_test();
private:
std::atomic<int> total_callbacks_{0};
std::atomic<int> executed_callbacks_{0};
};
} // namespace esphome::scheduler_string_name_stress_component
@@ -109,7 +109,7 @@ select:
set_action:
- lambda: |-
ESP_LOGI("test", "Device A Mode set to %s", x.c_str());
id(mode_device_a).state = x;
id(mode_device_a).publish_state(x);
- platform: template
name: Mode
@@ -124,7 +124,7 @@ select:
set_action:
- lambda: |-
ESP_LOGI("test", "Device B Mode set to %s", x.c_str());
id(mode_device_b).state = x;
id(mode_device_b).publish_state(x);
- platform: template
name: Mode
@@ -138,7 +138,7 @@ select:
set_action:
- lambda: |-
ESP_LOGI("test", "Main Mode set to %s", x.c_str());
id(mode_main).state = x;
id(mode_main).publish_state(x);
# Button to trigger preference logging test
button:
@@ -153,9 +153,9 @@ button:
ESP_LOGI("test", "Device A Setpoint: %.1f", id(setpoint_device_a).state);
ESP_LOGI("test", "Device B Setpoint: %.1f", id(setpoint_device_b).state);
ESP_LOGI("test", "Main Setpoint: %.1f", id(setpoint_main).state);
ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).state.c_str());
ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).state.c_str());
ESP_LOGI("test", "Main Mode: %s", id(mode_main).state.c_str());
ESP_LOGI("test", "Device A Mode: %s", id(mode_device_a).current_option().c_str());
ESP_LOGI("test", "Device B Mode: %s", id(mode_device_b).current_option().c_str());
ESP_LOGI("test", "Main Mode: %s", id(mode_main).current_option().c_str());
// Log preference hashes for entities that actually store preferences
ESP_LOGI("test", "Device A Switch Pref Hash: %u", id(light_device_a).get_preference_hash());
ESP_LOGI("test", "Device B Switch Pref Hash: %u", id(light_device_b).get_preference_hash());
@@ -156,9 +156,9 @@ script:
// Simulate a burst of defer operations like ratgdo does with state updates
// These should execute immediately and recycle quickly to the pool
// Phase-specific id range (0..9) so ids never collide with later phases
for (int i = 0; i < 10; i++) {
std::string defer_name = "defer_" + std::to_string(i);
App.scheduler.set_timeout(component, defer_name, 0, [i]() {
App.scheduler.set_timeout(component, static_cast<uint32_t>(i), 0, [i]() {
ESP_LOGD("test", "Defer %d executed", i);
// Force a small delay between defer executions to see recycling
if (i == 5) {
@@ -207,9 +207,9 @@ script:
// Now create 8 new timeouts - they should reuse from pool when available
int reuse_test_count = 8;
// Phase-specific id range (100..107) so ids never collide with other phases
for (int i = 0; i < reuse_test_count; i++) {
std::string name = "reuse_test_" + std::to_string(i);
App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() {
App.scheduler.set_timeout(component, static_cast<uint32_t>(100 + i), 10 + i * 5, [i]() {
ESP_LOGD("test", "Reuse test %d completed", i);
});
}
@@ -229,9 +229,9 @@ script:
auto *component = id(test_sensor);
int full_reuse_count = 10;
// Phase-specific id range (200..209) so ids never collide with other phases
for (int i = 0; i < full_reuse_count; i++) {
std::string name = "full_reuse_" + std::to_string(i);
App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() {
App.scheduler.set_timeout(component, static_cast<uint32_t>(200 + i), 10 + i * 5, [i]() {
ESP_LOGD("test", "Full reuse test %d completed", i);
});
}
@@ -1,48 +0,0 @@
esphome:
debug_scheduler: true # Enable scheduler leak detection
name: scheduler-string-lifetime-test
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [scheduler_string_lifetime_component]
host:
logger:
level: DEBUG
scheduler_string_lifetime_component:
id: string_lifetime
api:
services:
- service: run_string_lifetime_test
then:
- lambda: |-
id(string_lifetime)->run_string_lifetime_test();
- service: run_test1
then:
- lambda: |-
id(string_lifetime)->run_test1();
- service: run_test2
then:
- lambda: |-
id(string_lifetime)->run_test2();
- service: run_test3
then:
- lambda: |-
id(string_lifetime)->run_test3();
- service: run_test4
then:
- lambda: |-
id(string_lifetime)->run_test4();
- service: run_test5
then:
- lambda: |-
id(string_lifetime)->run_test5();
- service: run_final_check
then:
- lambda: |-
id(string_lifetime)->run_final_check();
@@ -1,39 +0,0 @@
esphome:
debug_scheduler: true # Enable scheduler leak detection
name: sched-string-name-stress
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
components: [scheduler_string_name_stress_component]
host:
logger:
level: VERBOSE
scheduler_string_name_stress_component:
id: string_stress
api:
services:
- service: run_string_name_stress_test
then:
- lambda: |-
id(string_stress)->run_string_name_stress_test();
event:
- platform: template
name: "Test Complete"
id: test_complete
device_class: button
event_types:
- "test_finished"
- platform: template
name: "Test Result"
id: test_result
device_class: button
event_types:
- "passed"
- "failed"
@@ -18,9 +18,6 @@ globals:
- id: interval_counter
type: int
initial_value: '0'
- id: dynamic_counter
type: int
initial_value: '0'
- id: static_tests_done
type: bool
initial_value: 'false'
@@ -103,46 +100,43 @@ script:
- id: test_dynamic_strings
then:
- logger.log: "Testing dynamic string timeouts and intervals"
- logger.log: "Testing const char* timeouts and intervals"
- lambda: |-
auto *component2 = id(test_sensor2);
// Test 8: Dynamic string with set_timeout (std::string)
std::string dynamic_name = "dynamic_timeout_" + std::to_string(id(dynamic_counter)++);
App.scheduler.set_timeout(component2, dynamic_name, 100, []() {
// Test 8: const char* name with set_timeout
App.scheduler.set_timeout(component2, "dynamic_timeout", 100, []() {
ESP_LOGI("test", "Dynamic timeout fired");
id(timeout_counter) += 1;
});
// Test 9: Dynamic string with set_interval
std::string interval_name = "dynamic_interval_" + std::to_string(id(dynamic_counter)++);
App.scheduler.set_interval(component2, interval_name, 250, [interval_name]() {
ESP_LOGI("test", "Dynamic interval fired: %s", interval_name.c_str());
// Test 9: const char* name with set_interval, cancelled from inside the callback
App.scheduler.set_interval(component2, "dynamic_interval", 250, []() {
ESP_LOGI("test", "Dynamic interval fired");
id(interval_counter) += 1;
if (id(interval_counter) >= 6) {
App.scheduler.cancel_interval(id(test_sensor2), interval_name);
App.scheduler.cancel_interval(id(test_sensor2), "dynamic_interval");
ESP_LOGI("test", "Cancelled dynamic interval");
}
});
// Test 10: Cancel with different string object but same content
std::string cancel_name = "cancel_test";
App.scheduler.set_timeout(component2, cancel_name, 2000, []() {
// Test 10: Cancel with a different pointer but identical content.
// STATIC_STRING names match by content, so a distinct static buffer with the
// same characters still cancels the scheduled timeout.
static const char CANCEL_NAME[] = "cancel_test";
App.scheduler.set_timeout(component2, CANCEL_NAME, 2000, []() {
ESP_LOGI("test", "This should be cancelled");
});
static const char CANCEL_NAME_2[] = "cancel_test";
App.scheduler.cancel_timeout(component2, CANCEL_NAME_2);
ESP_LOGI("test", "Cancelled timeout using different buffer with same content");
// Cancel using a different string object
std::string cancel_name_2 = "cancel_test";
App.scheduler.cancel_timeout(component2, cancel_name_2);
ESP_LOGI("test", "Cancelled timeout using different string object");
// Test 11: Dynamic string with defer (using std::string overload)
// Test 11: const char* name with defer
class TestDynamicDeferComponent : public Component {
public:
void test_dynamic_defer() {
std::string defer_name = "dynamic_defer_" + std::to_string(id(dynamic_counter)++);
this->defer(defer_name, [defer_name]() {
ESP_LOGI("test", "Dynamic defer fired: %s", defer_name.c_str());
this->defer("dynamic_defer", []() {
ESP_LOGI("test", "Dynamic defer fired");
id(timeout_counter) += 1;
});
}
@@ -49,15 +49,16 @@ modbus_controller:
- address: 1
id: modbus_controller_ok
max_cmd_retries: 2
update_interval: 1s
# Update interval is set to never to prevent automatic polling: the test will trigger requests by pressing the "Start Scenario" button
update_interval: never
- address: 2
id: modbus_controller_slow
max_cmd_retries: 0
update_interval: 1s
update_interval: never
- address: 3
id: modbus_controller_offline
max_cmd_retries: 0
update_interval: 1s
update_interval: never
sensor:
- platform: modbus_controller
@@ -91,4 +92,11 @@ button:
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
- lambda: |-
id(virtual_uart_dev).start_scenario();
id(modbus_controller_ok).set_update_interval(1000);
id(modbus_controller_ok).start_poller();
id(modbus_controller_slow).set_update_interval(1000);
id(modbus_controller_slow).start_poller();
id(modbus_controller_offline).set_update_interval(1000);
id(modbus_controller_offline).start_poller();
@@ -54,7 +54,11 @@ modbus:
sensor:
- platform: sdm_meter
address: 2
update_interval: 1s
id: sdm_meter_1
# update_interval is set to never to avoid automatic polling before the test starts the scenario.
# The test will manually start the poller after subscribing to states, to ensure no state changes are missed.
# This also allows us to assert there are no modbus errors/warnings during the initial request/response.
update_interval: never
phase_a:
voltage:
name: sdm_voltage
@@ -64,4 +68,7 @@ button:
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
- lambda: |-
id(virtual_uart_dev).start_scenario();
id(sdm_meter_1).set_update_interval(1000);
id(sdm_meter_1).start_poller();
@@ -53,8 +53,8 @@ modbus:
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
update_interval: 1s
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
@@ -176,6 +176,4 @@ button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"
# This test does not have anything to start (mock is autostart)
@@ -113,7 +113,4 @@ button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_server_2).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"
# This test does not have anything to start (mock is autostart)
@@ -326,6 +326,4 @@ button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_server).start_scenario();"
- lambda: "id(virtual_uart_controller).start_scenario();"
# This test does not have anything to start (mock is autostart)
@@ -53,7 +53,11 @@ modbus:
sensor:
- platform: sdm_meter
address: 2
update_interval: 1s
id: sdm_meter_1
# update_interval is set to never to avoid automatic polling before the test starts the scenario.
# The test will manually start the poller after subscribing to states, to ensure no state changes are missed.
# This also allows us to assert there are no modbus errors/warnings during the initial request/response.
update_interval: never
phase_a:
voltage:
name: sdm_voltage
@@ -63,4 +67,7 @@ button:
name: "Start Scenario"
id: start_scenario_btn
on_press:
- lambda: "id(virtual_uart_dev).start_scenario();"
- lambda: |-
id(virtual_uart_dev).start_scenario();
id(sdm_meter_1).set_update_interval(1000);
id(sdm_meter_1).start_poller();
+2 -3
View File
@@ -19,7 +19,6 @@ from aioesphomeapi import (
_LOGGER = logging.getLogger(__name__)
T = TypeVar("T", bound=EntityInfo)
S = TypeVar("S", bound=EntityState)
@@ -58,7 +57,7 @@ async def wait_for_state(
return await asyncio.wait_for(future, timeout=timeout)
def find_entity(
def find_entity[T: EntityInfo](
entities: list[EntityInfo],
object_id_substring: str,
entity_type: type[T] | None = None,
@@ -86,7 +85,7 @@ def find_entity(
return None
def require_entity(
def require_entity[T: EntityInfo](
entities: list[EntityInfo],
object_id_substring: str,
entity_type: type[T] | None = None,
@@ -1,169 +0,0 @@
"""String lifetime test - verify scheduler handles string destruction correctly."""
import asyncio
from pathlib import Path
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_string_lifetime(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that scheduler correctly handles string lifetimes when strings go out of scope."""
# Get the absolute path to the external components directory
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
# Replace the placeholder in the YAML config with the actual path
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
# Create events for synchronization
test1_complete = asyncio.Event()
test2_complete = asyncio.Event()
test3_complete = asyncio.Event()
test4_complete = asyncio.Event()
test5_complete = asyncio.Event()
all_tests_complete = asyncio.Event()
# Track test progress
test_stats = {
"tests_passed": 0,
"tests_failed": 0,
"errors": [],
"current_test": None,
"test_callbacks_executed": {},
}
def on_log_line(line: str) -> None:
# Track test-specific events
if "Test 1 complete" in line:
test1_complete.set()
elif "Test 2 complete" in line:
test2_complete.set()
elif "Test 3 complete" in line:
test3_complete.set()
elif "Test 4 complete" in line:
test4_complete.set()
elif "Test 5 complete" in line:
test5_complete.set()
# Track individual callback executions
callback_match = re.search(r"Callback '(.+?)' executed", line)
if callback_match:
callback_name = callback_match.group(1)
test_stats["test_callbacks_executed"][callback_name] = True
# Track test results from the C++ test output
if "Tests passed:" in line and "string_lifetime" in line:
# Extract the number from "Tests passed: 32"
match = re.search(r"Tests passed:\s*(\d+)", line)
if match:
test_stats["tests_passed"] = int(match.group(1))
elif "Tests failed:" in line and "string_lifetime" in line:
match = re.search(r"Tests failed:\s*(\d+)", line)
if match:
test_stats["tests_failed"] = int(match.group(1))
elif "ERROR" in line and "string_lifetime" in line:
test_stats["errors"].append(line)
# Check for memory corruption indicators
if any(
indicator in line.lower()
for indicator in [
"use after free",
"heap corruption",
"segfault",
"abort",
"assertion",
"sanitizer",
"bad memory",
"invalid pointer",
]
):
pytest.fail(f"Memory corruption detected: {line}")
# Check for completion
if "String lifetime tests complete" in line:
all_tests_complete.set()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
# Verify we can connect
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "scheduler-string-lifetime-test"
# List entities and services
_, services = await asyncio.wait_for(
client.list_entities_services(), timeout=5.0
)
# Find our test services
test_services = {}
for service in services:
if service.name == "run_test1":
test_services["test1"] = service
elif service.name == "run_test2":
test_services["test2"] = service
elif service.name == "run_test3":
test_services["test3"] = service
elif service.name == "run_test4":
test_services["test4"] = service
elif service.name == "run_test5":
test_services["test5"] = service
elif service.name == "run_final_check":
test_services["final"] = service
# Ensure all services are found
required_services = ["test1", "test2", "test3", "test4", "test5", "final"]
for service_name in required_services:
assert service_name in test_services, f"{service_name} service not found"
# Run tests sequentially, waiting for each to complete
try:
# Test 1
await client.execute_service(test_services["test1"], {})
await asyncio.wait_for(test1_complete.wait(), timeout=5.0)
# Test 2
await client.execute_service(test_services["test2"], {})
await asyncio.wait_for(test2_complete.wait(), timeout=5.0)
# Test 3
await client.execute_service(test_services["test3"], {})
await asyncio.wait_for(test3_complete.wait(), timeout=5.0)
# Test 4
await client.execute_service(test_services["test4"], {})
await asyncio.wait_for(test4_complete.wait(), timeout=5.0)
# Test 5
await client.execute_service(test_services["test5"], {})
await asyncio.wait_for(test5_complete.wait(), timeout=5.0)
# Final check
await client.execute_service(test_services["final"], {})
await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0)
except TimeoutError:
pytest.fail(f"String lifetime test timed out. Stats: {test_stats}")
# Check for any errors
assert test_stats["tests_failed"] == 0, f"Tests failed: {test_stats['errors']}"
# Verify we had the expected number of passing tests
assert test_stats["tests_passed"] == 30, (
f"Expected exactly 30 tests to pass, but got {test_stats['tests_passed']}"
)
@@ -1,116 +0,0 @@
"""Stress test for heap scheduler with std::string names from multiple threads."""
import asyncio
from pathlib import Path
import re
from aioesphomeapi import UserService
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_scheduler_string_name_stress(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that set_timeout/set_interval with std::string names doesn't crash when called from multiple threads."""
# Get the absolute path to the external components directory
external_components_path = str(
Path(__file__).parent / "fixtures" / "external_components"
)
# Replace the placeholder in the YAML config with the actual path
yaml_config = yaml_config.replace(
"EXTERNAL_COMPONENT_PATH", external_components_path
)
# Create a future to signal test completion
loop = asyncio.get_running_loop()
test_complete_future: asyncio.Future[None] = loop.create_future()
# Track executed callbacks and any crashes
executed_callbacks: set[int] = set()
error_messages: list[str] = []
def on_log_line(line: str) -> None:
# Check for crash indicators
if any(
indicator in line.lower()
for indicator in [
"segfault",
"abort",
"assertion",
"heap corruption",
"use after free",
]
):
error_messages.append(line)
if not test_complete_future.done():
test_complete_future.set_exception(Exception(f"Crash detected: {line}"))
return
# Track executed callbacks
match = re.search(r"Executed string-named callback (\d+)", line)
if match:
callback_id = int(match.group(1))
executed_callbacks.add(callback_id)
# Check for completion
if (
"String name stress test complete" in line
and not test_complete_future.done()
):
test_complete_future.set_result(None)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
api_client_connected() as client,
):
# Verify we can connect
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "sched-string-name-stress"
# List entities and services
_, services = await asyncio.wait_for(
client.list_entities_services(), timeout=5.0
)
# Find our test service
run_stress_test_service: UserService | None = None
for service in services:
if service.name == "run_string_name_stress_test":
run_stress_test_service = service
break
assert run_stress_test_service is not None, (
"run_string_name_stress_test service not found"
)
# Call the service to start the test
await client.execute_service(run_stress_test_service, {})
# Wait for test to complete or crash
try:
await asyncio.wait_for(test_complete_future, timeout=30.0)
except TimeoutError:
pytest.fail(
f"String name stress test timed out. Executed {len(executed_callbacks)} callbacks. "
f"This might indicate a deadlock."
)
# Verify no errors occurred (crashes already handled by exception)
assert not error_messages, f"Errors detected during test: {error_messages}"
# Verify we executed all 1000 callbacks (10 threads × 100 callbacks each)
assert len(executed_callbacks) == 1000, (
f"Expected 1000 callbacks but got {len(executed_callbacks)}"
)
# Verify each callback ID was executed exactly once
for i in range(1000):
assert i in executed_callbacks, f"Callback {i} was not executed"

Some files were not shown because too many files have changed in this diff Show More