Merge branch 'dev' into store-yaml-firmware

This commit is contained in:
J. Nick Koston
2026-07-03 19:04:12 -05:00
committed by GitHub
1326 changed files with 27006 additions and 16857 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()]
+38
View File
@@ -104,6 +104,44 @@ def set_component_config() -> Callable[[str, Any], None]:
return setter
@pytest.fixture
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
"""Set the ESP32 variant to the first one on which all the given pins are valid.
For ESP32 only, since the other platforms do not have variants. The core
configuration must already have been set up for an ESP32 target.
Using local imports to avoid importing when ESP32 is not the target.
"""
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS
from esphome.components.esp32.gpio import validate_gpio_pin
from esphome.const import CONF_INPUT, CONF_OUTPUT
from esphome.pins import gpio_pin_schema
def chooser(pins: list) -> None:
for variant in VARIANTS:
try:
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
for pin in pins:
if pin is not None:
pin = gpio_pin_schema(
{
CONF_INPUT: True,
CONF_OUTPUT: True,
},
internal=True,
)(pin)
validate_gpio_pin(pin)
return
except cv.Invalid:
continue
raise cv.Invalid(
f"No compatible variant found for pins: {', '.join(map(str, pins))}"
)
yield chooser
@pytest.fixture
def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]:
"""Return a function to get absolute paths relative to the component's fixtures directory."""
@@ -0,0 +1,24 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: ssd1677
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO23
cs_pin: GPIO5
enable_pin:
- GPIO25
- GPIO26
dimensions:
width: 200
height: 200
@@ -0,0 +1,156 @@
"""Tests for display metadata created by the epaper_spi component."""
from collections.abc import Callable
from pathlib import Path
from typing import Any
from esphome import config_validation as cv
from esphome.components.display import get_all_display_metadata, get_display_metadata
from esphome.components.epaper_spi.display import CONFIG_SCHEMA
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _base_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid ssd1677 config, allowing field overrides."""
config: ConfigType = {
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"dimensions": {"width": 200, "height": 300},
}
config.update(overrides)
return config
def test_metadata_dimensions_and_defaults(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Metadata picks up explicit dimensions and epaper_spi defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config())
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 200
assert meta.height == 300
# epaper_spi always reports full hardware rotation
assert meta.has_hardware_rotation is True
# epaper_spi does not declare a byte order
assert meta.byte_order is cv.UNDEFINED
assert meta.draw_rounding == 0
# no drawing methods configured -> no writer
assert meta.has_writer is False
def test_metadata_default_dimensions_from_model(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""A model with built-in dimensions reports those without explicit dimensions."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
# waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions
config = CONFIG_SCHEMA(
{
"id": "wave_display",
"model": "waveshare-4.26in",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
}
)
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 800
assert meta.height == 480
def test_metadata_has_writer_with_auto_clear(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""A display with auto_clear_enabled reports has_writer=True."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.has_writer is True
def test_metadata_rotation_propagated(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The configured rotation is stored in the metadata."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config(rotation=90))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.rotation == 90
def test_metadata_multiple_displays_independent(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Each display gets its own independent metadata entry."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300}))
CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480}))
all_meta = get_all_display_metadata()
assert all_meta["disp_a"].width == 200
assert all_meta["disp_a"].height == 300
assert all_meta["disp_b"].width == 400
assert all_meta["disp_b"].height == 480
def test_metadata_via_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Full code generation registers metadata for the configured display."""
generate_main(component_config_path("enable_pin_test.yaml"))
all_meta = get_all_display_metadata()
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
# enable_pin_test.yaml: ssd1677 at 200x200
assert meta.width == 200
assert meta.height == 200
assert meta.has_hardware_rotation is True
+151 -39
View File
@@ -1,6 +1,8 @@
"""Tests for epaper_spi configuration validation."""
from collections.abc import Callable
from pathlib import Path
import re
from typing import Any
import pytest
@@ -11,17 +13,13 @@ from esphome.components.epaper_spi.display import (
FINAL_VALIDATE_SCHEMA,
MODELS,
)
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.const import (
CONF_BUSY_PIN,
CONF_CS_PIN,
CONF_DC_PIN,
CONF_DIMENSIONS,
CONF_ENABLE_PIN,
CONF_HEIGHT,
CONF_INIT_SEQUENCE,
CONF_RESET_PIN,
@@ -31,6 +29,30 @@ from esphome.const import (
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
# Pin options whose values must be valid on the chosen ESP32 variant.
_PIN_CONF_KEYS = (
CONF_CS_PIN,
CONF_DC_PIN,
CONF_RESET_PIN,
CONF_BUSY_PIN,
CONF_ENABLE_PIN,
)
def _pins_for(model: Any, config: ConfigType) -> list:
"""Collect every GPIO the config will actually use (model defaults or injected)."""
pins: list = []
for key in _PIN_CONF_KEYS:
# An injected value in the config takes precedence over the model default.
value = config[key] if key in config else model.get_default(key)
if not value: # get_default returns False for pins the model omits
continue
if isinstance(value, list):
pins.extend(value)
else:
pins.append(value)
return pins
def run_schema_validation(
config: ConfigType, with_final_validate: bool = False
@@ -90,29 +112,20 @@ def test_basic_configuration_errors(
def test_all_predefined_models(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
choose_variant_with_pins: Callable[[list], None],
) -> None:
"""Test all predefined epaper models validate successfully with appropriate defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
# Test all models, providing default values where necessary
for name, model in MODELS.items():
# SEEED models are designed for ESP32-S3 hardware
if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"):
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
else:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = {"model": name}
# Add ID field
@@ -141,6 +154,10 @@ def test_all_predefined_models(
if not model.get_default(CONF_CS_PIN):
config[CONF_CS_PIN] = 5
# 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))
run_schema_validation(config)
@@ -152,27 +169,19 @@ def test_individual_models(
model_name: str,
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
choose_variant_with_pins: Callable[[list], None],
) -> None:
"""Test each epaper model individually to ensure it validates correctly."""
# SEEED models are designed for ESP32-S3 hardware
if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"):
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
else:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
model = MODELS[model_name]
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
model = MODELS[model_name]
config: dict[str, Any] = {"model": model_name, "id": "test_display"}
# Add required fields based on model defaults
@@ -195,6 +204,10 @@ def test_individual_models(
if not model.get_default(CONF_CS_PIN):
config[CONF_CS_PIN] = 5
# 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))
# This should not raise any exceptions
run_schema_validation(config)
@@ -342,3 +355,102 @@ def test_busy_pin_input_mode_ssd1677(
reset_pin_config = result[CONF_RESET_PIN]
assert "mode" in reset_pin_config
assert reset_pin_config["mode"]["output"] is True
def test_enable_pin_single(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Test that a single enable_pin is accepted and normalised to a list of output pins."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
result = run_schema_validation(
{
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"enable_pin": 25,
"dimensions": {
"width": 200,
"height": 200,
},
}
)
# A single pin is normalised to a list by cv.ensure_list
assert CONF_ENABLE_PIN in result
enable_pins = result[CONF_ENABLE_PIN]
assert isinstance(enable_pins, list)
assert len(enable_pins) == 1
# enable pins are configured as outputs
assert enable_pins[0]["mode"]["output"] is True
def test_enable_pin_multiple(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Test that a list of enable_pins is accepted."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
result = run_schema_validation(
{
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"enable_pin": [25, 26],
"dimensions": {
"width": 200,
"height": 200,
},
}
)
assert CONF_ENABLE_PIN in result
enable_pins = result[CONF_ENABLE_PIN]
assert isinstance(enable_pins, list)
assert len(enable_pins) == 2
assert all(pin["mode"]["output"] is True for pin in enable_pins)
def test_enable_pin_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that enable_pins are wired up in the generated C++ code."""
main_cpp = generate_main(component_config_path("enable_pin_test.yaml"))
# Derive the auto-generated pin variable names from the set_pin() lines
# rather than hard-coding them, so the test does not break when unrelated
# codegen details shift the generated IDs.
def pin_var_for(gpio_num: int) -> str:
match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp)
assert match is not None, (
f"GPIO_NUM_{gpio_num} pin not set up in generated code"
)
return match.group(1)
pin_25 = pin_var_for(25)
pin_26 = pin_var_for(26)
# Both pin objects must be passed to the display via set_enable_pins() as a
# std::vector initializer list, in the configured order.
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
@@ -0,0 +1,7 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
board: esp32dev
flash_mode: qio
flash_frequency: 80MHz
toolchain: platformio
framework:
type: esp-idf
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
esp32_ble_tracker:
software_coexistence: true
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
@@ -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
+277 -2
View File
@@ -2,14 +2,25 @@
Test ESP32 configuration
"""
import asyncio
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from esphome.components.esp32 import VARIANT_ESP32, VARIANTS
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS, KEY_VARIANT
from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
_reconcile_network_sdkconfig,
)
from esphome.components.esp32.const import (
KEY_ESP32,
KEY_NETWORK_SDKCONFIG,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
)
from esphome.components.esp32.gpio import validate_gpio_pin
import esphome.config_validation as cv
from esphome.const import (
@@ -64,6 +75,38 @@ def test_esp32_config(
assert VARIANT_FRIENDLY[variant].lower() in config["board"]
@pytest.mark.parametrize(
("config_toolchain", "expected"),
[
# No `toolchain:` set -> the new default for esp32.
(None, Toolchain.ESP_IDF),
# An explicit `toolchain:` still wins over the default.
(Toolchain.PLATFORMIO.value, Toolchain.PLATFORMIO),
(Toolchain.ESP_IDF.value, Toolchain.ESP_IDF),
],
)
def test_esp32_default_toolchain_is_esp_idf(
set_core_config: SetCoreConfigCallable,
config_toolchain: str | None,
expected: Toolchain,
) -> None:
"""With no `toolchain:` set (and nothing pinned via the CLI), esp32 resolves
to the ESP-IDF toolchain; an explicit `toolchain:` still wins."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
# Fresh run: no --toolchain CLI and no prior config pinned CORE.toolchain.
CORE.toolchain = None
config: dict[str, Any] = {"variant": VARIANT_ESP32}
if config_toolchain is not None:
config["toolchain"] = config_toolchain
CONFIG_SCHEMA(config)
assert CORE.toolchain == expected
@pytest.mark.parametrize(
("config", "error_match"),
[
@@ -170,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,
@@ -285,3 +354,209 @@ def test_native_idf_enables_reproducible_build(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
def test_flash_mode_sets_sdkconfig_and_pio_option(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""flash_mode/flash_frequency select the esptool flash parameters on both backends."""
generate_main(component_config_path("flash_mode_idf.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True
assert CORE.platformio_options.get("board_build.flash_mode") == "qio"
assert CORE.platformio_options.get("board_build.f_flash") == "80000000L"
def test_flash_mode_unset_leaves_defaults(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without flash_mode the board/sdkconfig defaults stay untouched."""
generate_main(component_config_path("flash_mode_default.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig)
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig)
assert "board_build.flash_mode" not in CORE.platformio_options
assert "board_build.f_flash" not in CORE.platformio_options
@pytest.mark.parametrize(
("framework", "net", "preset", "expected"),
[
# --- IDF: single-interface cases (must match pre-refactor behavior) ---
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(wifi=True),
{},
{
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_no_ap",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(wifi=True, wifi_ap=True),
{},
{},
id="idf_wifi_ap_leaves_softap_dhcps",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(ethernet=True),
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
},
id="idf_ethernet_only",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(
wifi=True, bluetooth=True, ble_42=True, software_coexistence=True
),
{},
{
"CONFIG_BT_ENABLED": True,
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
"CONFIG_SW_COEXIST_ENABLE": True,
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_ble_tracker_coexistence",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(bluetooth=True),
{},
{"CONFIG_BT_ENABLED": True},
id="idf_ble_server_only_no_ble42",
),
# --- IDF: user sdkconfig_options always win ---
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(wifi=True),
{"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True},
{
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": True,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_user_override_wins",
),
# --- IDF: user advanced enable_lwip_dhcp_server: false, even with AP ---
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(
wifi=True, wifi_ap=True, enable_lwip_dhcp_server=False
),
{},
{"CONFIG_LWIP_DHCPS": False},
id="idf_user_disables_dhcps_with_ap",
),
# --- IDF: WiFi + Ethernet coexist (the multi-interface unlock) ---
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(wifi=True, ethernet=True),
{},
{
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_and_ethernet_keeps_wifi_enabled",
),
# --- Arduino: SoftAP/DHCPS disable is IDF-only ---
pytest.param(
PlatformFramework.ESP32_ARDUINO,
NetworkSdkconfigData(wifi=True),
{},
{},
id="arduino_wifi_no_ap_untouched",
),
pytest.param(
PlatformFramework.ESP32_ARDUINO,
NetworkSdkconfigData(ethernet=True),
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
},
id="arduino_ethernet_only_disables_wifi",
),
# --- Arduino + Ethernet: DHCPS stays available even if user disabled it ---
pytest.param(
PlatformFramework.ESP32_ARDUINO,
NetworkSdkconfigData(ethernet=True, enable_lwip_dhcp_server=False),
{},
{
"CONFIG_ESP_WIFI_ENABLED": False,
"CONFIG_SW_COEXIST_ENABLE": False,
},
id="arduino_ethernet_dhcps_exclusion",
),
],
)
def test_reconcile_network_sdkconfig(
set_core_config: SetCoreConfigCallable,
framework: PlatformFramework,
net: NetworkSdkconfigData,
preset: dict[str, Any],
expected: dict[str, Any],
) -> None:
"""The FINAL-priority reconciler resolves WiFi/Ethernet/Bluetooth/coexistence
sdkconfig flags from the requests recorded in NetworkSdkconfigData."""
set_core_config(framework)
CORE.data[KEY_ESP32] = {
KEY_SDKCONFIG_OPTIONS: dict(preset),
KEY_NETWORK_SDKCONFIG: net,
}
asyncio.run(_reconcile_network_sdkconfig())
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
def test_network_wifi_only_reconciles_end_to_end(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""End-to-end: codegen for an ESP-IDF WiFi (no AP) config runs the reconciler
after wifi's request_wifi(), disabling SoftAP support and the DHCP server."""
generate_main(component_config_path("network_wifi_only.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
# WiFi stack stays enabled (no ethernet) and no Bluetooth requested.
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
assert "CONFIG_BT_ENABLED" not in sdkconfig
def test_network_ethernet_only_reconciles_end_to_end(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""End-to-end: ethernet's request_ethernet() makes the reconciler disable the
WiFi stack and coexistence when WiFi is absent."""
generate_main(component_config_path("network_ethernet_only.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False
def test_network_wifi_ble_coexistence_reconciles_end_to_end(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""End-to-end: WiFi + esp32_ble_tracker software_coexistence resolves to
BT enabled and coexistence on, with SoftAP/DHCP server dropped (no AP)."""
generate_main(component_config_path("network_wifi_ble_coexistence.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
# WiFi present alongside BT -> WiFi stack must stay enabled.
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
+1 -38
View File
@@ -1,16 +1,10 @@
"""Tests for mpip_spi configuration validation."""
from collections.abc import Callable, Generator
from unittest import mock
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS
from esphome.components.esp32.gpio import validate_gpio_pin
from esphome.const import CONF_INPUT, CONF_OUTPUT
from esphome.core import CORE
from esphome.pins import gpio_pin_schema
# choose_variant_with_pins is provided by the shared parent conftest.
@pytest.fixture(autouse=True)
@@ -21,34 +15,3 @@ def mock_spi_final_validate():
return_value=lambda config: None,
):
yield
@pytest.fixture
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
"""
Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms
do not have variants.
"""
def chooser(pins: list) -> None:
for variant in VARIANTS:
try:
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
for pin in pins:
if pin is not None:
pin = gpio_pin_schema(
{
CONF_INPUT: True,
CONF_OUTPUT: True,
},
internal=True,
)(pin)
validate_gpio_pin(pin)
return
except cv.Invalid:
continue
raise cv.Invalid(
f"No compatible variant found for pins: {', '.join(map(str, pins))}"
)
yield chooser
+47 -3
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],
@@ -314,7 +358,7 @@ def test_native_generation(
main_cpp = generate_main(component_fixture_path("native.yaml"))
assert (
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, true, 1, 1>()"
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
in main_cpp
)
assert "set_init_sequence({240, 1, 8, 242" in main_cpp
@@ -330,9 +374,9 @@ def test_lvgl_generation(
main_cpp = generate_main(component_fixture_path("lvgl.yaml"))
assert (
"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, true>();"
"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
@@ -0,0 +1,599 @@
"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
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,
MODELS,
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,
CONF_DISABLED,
CONF_TRANSFORM,
PlatformFramework,
)
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def validated_config(config: ConfigType) -> ConfigType:
"""Run schema + final validation and return the validated config."""
config = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(config)
return config
class TestSPIModeCalculation:
"""Test default SPI mode calculation logic."""
@pytest.mark.parametrize(
("bus_mode", "cs_pin", "expected_mode"),
[
pytest.param(
TYPE_OCTAL,
None,
"MODE3",
id="octal_bus_no_cs",
),
pytest.param(
TYPE_OCTAL,
14,
"MODE3",
id="octal_bus_with_cs",
),
pytest.param(
TYPE_SINGLE,
None,
"MODE3",
id="single_bus_no_cs",
),
pytest.param(
TYPE_SINGLE,
14,
"MODE0",
id="single_bus_with_cs",
),
pytest.param(
TYPE_QUAD,
None,
"MODE0",
id="quad_bus_no_cs",
),
pytest.param(
TYPE_QUAD,
14,
"MODE0",
id="quad_bus_with_cs",
),
],
)
def test_default_spi_mode_calculation(
self,
bus_mode: str,
cs_pin: int | None,
expected_mode: str,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that SPI mode is correctly calculated based on bus mode and CS pin."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
config: ConfigType = {
"model": "custom",
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"bus_mode": bus_mode,
}
# Add dc_pin for modes that require it (single and octal)
# quad mode does not allow dc_pin
if bus_mode != TYPE_QUAD:
config[CONF_DC_PIN] = 11
# Add CS pin if specified
if cs_pin is not None:
config[CONF_CS_PIN] = cs_pin
validated = validated_config(config)
# The validated config should have the correct SPI mode set by model_schema
assert validated.get(CONF_SPI_MODE) == expected_mode
def test_explicit_spi_mode_overrides_default(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that an explicitly configured SPI mode is not overridden."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# For octal bus, default is MODE3, but we specify MODE0
config = validated_config(
{
"model": "custom",
"dc_pin": 11, # Required for octal mode
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"bus_mode": TYPE_OCTAL,
"spi_mode": "MODE0", # Explicitly set
}
)
assert config[CONF_SPI_MODE] == "MODE0"
class TestModelWithPaddingDimensions:
"""Test that padding dimensions are correctly returned by models."""
def test_model_get_dimensions_returns_six_values(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that get_dimensions() returns 6 values including padding."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# Test with a real model
model = MODELS["ST7735"]
config = {"model": "ST7735", "dc_pin": 18}
# Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height)
dimensions = model.get_dimensions(config)
assert len(dimensions) == 6
assert all(isinstance(v, int) for v in dimensions)
def test_custom_model_padding_values(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test padding values for a custom model with explicit offset."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 20,
"offset_height": 10,
},
"init_sequence": [[0xA0, 0x01]],
}
)
# For custom models, the model is created dynamically from the config
# We can verify the config has the right dimensions
assert config["dimensions"]["width"] == 240
assert config["dimensions"]["height"] == 320
assert config["dimensions"]["offset_width"] == 20
assert config["dimensions"]["offset_height"] == 10
# Padding is not stored in config for custom models (defaults to 0)
assert config["dimensions"].get("offset_width_pad", 0) == 0
assert config["dimensions"].get("offset_height_pad", 0) == 0
class TestNewModelVariants:
"""Test new model variants added in this change."""
def test_m5core2_with_native_dimensions(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test M5CORE2 variant with reset native_width and native_height."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# M5CORE2 should validate successfully
config = validated_config({"model": "M5CORE2"})
assert config is not None
# Verify the model has correct dimensions
model = MODELS["M5CORE2"]
dimensions = model.get_dimensions(config)
width, height, _, _, _, _ = dimensions
assert width == 320
assert height == 240
def test_geekmagic_smalltv_variant(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test GEEKMAGIC-SMALLTV variant of ST7789V."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# GEEKMAGIC-SMALLTV should validate successfully
config = validated_config({"model": "GEEKMAGIC-SMALLTV"})
assert config is not None
# Verify it's a variant of ST7789V with expected dimensions
model = MODELS["GEEKMAGIC-SMALLTV"]
dimensions = model.get_dimensions(config)
width, height, offset_x, offset_y, _, _ = dimensions
assert width == 240
assert height == 240
assert offset_x == 0
assert offset_y == 0
def test_all_predefined_models_with_new_get_dimensions_signature(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Verify all predefined models work with new 6-value get_dimensions()."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
for name, model in MODELS.items():
# Skip custom model
if name == "custom":
continue
config = {"model": name}
# Try to get dimensions - should return 6 values for all models
dimensions = model.get_dimensions(config)
assert len(dimensions) == 6, (
f"Model {name} should return 6 dimensions, got {len(dimensions)}"
)
class TestTemplateParameterPassing:
"""Test that padding parameters are correctly passed to C++ templates."""
def test_instance_creation_with_padding(
self,
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""Test that get_instance() correctly passes padding parameters to template."""
main_cpp = generate_main(component_fixture_path("native.yaml"))
# native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer
# (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE,
# WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION,
# FRACTION, ROUNDING)
# The instantiation should include padding values (0, 0 for default)
assert (
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
in main_cpp
), (
"Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation"
)
def test_single_mode_with_offset_padding(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that single-mode display with custom offset works with padding."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 40,
"offset_height": 20,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Should not raise any errors
instance = get_instance(config)
assert instance is not None
class TestUserConfiguredPadding:
"""Test that pad_width and pad_height can be configured in user dimensions."""
def test_explicit_pad_width_and_height_in_dimensions(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that pad_width and pad_height can be explicitly set in dimensions."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 40,
"offset_height": 20,
"pad_width": 80,
"pad_height": 40,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Config should validate successfully with padding dimensions
assert config is not None
assert config["dimensions"]["pad_width"] == 80
assert config["dimensions"]["pad_height"] == 40
def test_padding_for_native_dimension_calculation(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that explicit padding allows native dimensions to be calculated."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# A controller that has 320x320 total pixels with:
# - 240x320 active display area
# - offset_width=40, offset_height=20
# - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240, # Active display width
"height": 320, # Active display height
"offset_width": 40,
"offset_height": 0,
"pad_width": 40, # Pixels after width+offset
"pad_height": 0, # Pixels after height+offset
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Get instance should work and correctly calculate native dimensions
instance = get_instance(config)
assert instance is not None
def test_padding_without_offset(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test padding can be used without offset for controllers with top-left-aligned displays."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# A display with no offset but padding on right and bottom
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 240,
"offset_width": 0,
"offset_height": 0,
"pad_width": 0,
"pad_height": 16,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
assert config is not None
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 = {
+57 -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"],
}
@@ -97,6 +105,54 @@ def test_psram_configuration_valid_supported_variants(
FINAL_VALIDATE_SCHEMA(config)
def test_psram_applies_single_mode_default(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On a single-mode variant the omitted mode/speed fall back to defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
config = CONFIG_SCHEMA({})
assert config["mode"] == "quad"
assert config["speed"] == "40MHZ"
assert config["disabled"] is False
assert config["ignore_not_found"] is True
def test_psram_requires_mode_on_multi_mode_variant(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A variant with multiple modes requires an explicit mode selection."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32S3},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"):
CONFIG_SCHEMA({})
def test_psram_rejects_mode_invalid_for_variant(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A mode not supported by the active variant is rejected by the schema."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"):
CONFIG_SCHEMA({"mode": "octal"})
def _setup_psram_final_validation_test(
esp32_config: dict,
set_core_config: SetCoreConfigCallable,
@@ -139,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
+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
@@ -161,3 +161,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));
@@ -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
@@ -0,0 +1,5 @@
network:
enable_ipv6: true
openthread:
tlv: 0E080000000000010000
+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
@@ -0,0 +1,5 @@
# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses
# octal; this exercises the other branch of the per-variant mode enum (quad) and
# lets speed fall back to its 40MHz default.
psram:
mode: quad
@@ -0,0 +1,4 @@
# Config-only: with no options the single-mode ESP32 resolves mode -> quad and
# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here,
# so this only runs through `esphome config`.
psram:
@@ -0,0 +1,4 @@
# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz).
# With no options it resolves mode -> hex and speed -> 20MHz, exercising the
# P4-specific default branch of the per-variant enums.
psram:
+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
+5
View File
@@ -7,3 +7,8 @@ speaker:
- platform: resampler
id: resampler_speaker_id
output_speaker: resampler_i2s_speaker_id
bits_per_sample: 16
- platform: resampler
id: resampler_speaker_2_id
output_speaker: resampler_speaker_id
bits_per_sample: passthrough
@@ -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: |-
+30
View File
@@ -0,0 +1,30 @@
ufm01:
id: ufm01_component
uart_id: uart_bus
sensor:
- platform: ufm01
accumulated_flow:
id: accumulated_flow
name: "Accumulated flow"
flow:
id: flow
name: "Flow"
temperature:
id: temperature
name: "Temperature"
binary_sensor:
- platform: ufm01
ufc_chip_error:
id: ufc_chip_error
name: "UFC chip error"
flow_direction_wrong:
id: flow_direction_wrong
name: "Flow direction wrong"
empty_tube:
id: empty_tube
name: "Empty tube"
flow_rate_out_of_range:
id: flow_rate_out_of_range
name: "Flow rate out of range"
@@ -0,0 +1,4 @@
packages:
uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml
<<: !include common.yaml
@@ -0,0 +1,4 @@
packages:
uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml
<<: !include common.yaml
@@ -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
+5 -1
View File
@@ -1,15 +1,19 @@
psram:
# Tests the high performance request and release; requires the USE_WIFI_RUNTIME_POWER_SAVE define
# Tests the high performance and roaming suppression request/release APIs;
# requires the USE_WIFI_RUNTIME_POWER_SAVE and USE_WIFI_RUNTIME_ROAMING_SUPPRESSION defines
esphome:
platformio_options:
build_flags:
- "-DUSE_WIFI_RUNTIME_POWER_SAVE"
- "-DUSE_WIFI_RUNTIME_ROAMING_SUPPRESSION"
on_boot:
- then:
- lambda: |-
esphome::wifi::global_wifi_component->request_high_performance();
esphome::wifi::global_wifi_component->release_high_performance();
esphome::wifi::global_wifi_component->request_roaming_suppression();
esphome::wifi::global_wifi_component->release_roaming_suppression();
wifi:
use_psram: true
+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)

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