mirror of
https://github.com/esphome/esphome.git
synced 2026-09-27 23:10:21 +00:00
Merge branch 'dev' into store-yaml-firmware
This commit is contained in:
@@ -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()]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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(®);
|
||||
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -21,6 +21,8 @@ sx126x:
|
||||
coding_rate: CR_4_6
|
||||
tcxo_voltage: 1_8V
|
||||
tcxo_delay: 5ms
|
||||
whitening_enable: false
|
||||
whitening_initial: 0x1FF
|
||||
on_packet:
|
||||
then:
|
||||
- lambda: |-
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
)
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
@@ -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"
|
||||
+13
-6
@@ -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) {
|
||||
|
||||
+9
-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);
|
||||
|
||||
|
||||
+6
-8
@@ -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);
|
||||
|
||||
-21
@@ -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)
|
||||
-260
@@ -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
|
||||
-35
@@ -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
|
||||
-21
@@ -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
Reference in New Issue
Block a user