mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 19:44:08 +00:00
Merge branch 'neutral-ble-client' into radon-eye-single-node
This commit is contained in:
@@ -13,3 +13,4 @@ api:
|
||||
port: 6054
|
||||
reboot_timeout: 0s
|
||||
batch_delay: 0ms
|
||||
listen_backlog: 2
|
||||
|
||||
@@ -13,3 +13,4 @@ api:
|
||||
port: 6053
|
||||
reboot_timeout: 15min
|
||||
batch_delay: 100ms
|
||||
listen_backlog: 4
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
api:
|
||||
@@ -12,7 +12,7 @@ def test_default_values_are_not_emitted(
|
||||
component_config_path: Callable[[str], Path],
|
||||
config_file: str,
|
||||
) -> None:
|
||||
"""Port 6053, a 15 min reboot timeout and 100 ms batch delay are C++ initializers.
|
||||
"""Port 6053, a 15 min reboot timeout, 100 ms batch delay and backlog 4 are C++ initializers.
|
||||
|
||||
Both the schema defaults and the same values written explicitly take the skip path.
|
||||
"""
|
||||
@@ -21,6 +21,7 @@ def test_default_values_are_not_emitted(
|
||||
assert "api_apiserver_id->set_port(" not in main_cpp
|
||||
assert "api_apiserver_id->set_reboot_timeout(" not in main_cpp
|
||||
assert "api_apiserver_id->set_batch_delay(" not in main_cpp
|
||||
assert "api_apiserver_id->set_listen_backlog(" not in main_cpp
|
||||
|
||||
|
||||
def test_custom_values_are_emitted(
|
||||
@@ -33,3 +34,14 @@ def test_custom_values_are_emitted(
|
||||
assert "api_apiserver_id->set_port(6054);" in main_cpp
|
||||
assert "api_apiserver_id->set_reboot_timeout(0);" in main_cpp
|
||||
assert "api_apiserver_id->set_batch_delay(0);" in main_cpp
|
||||
assert "api_apiserver_id->set_listen_backlog(2);" in main_cpp
|
||||
|
||||
|
||||
def test_esp8266_listen_backlog_is_emitted(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The ESP8266 schema default of 1 differs from the C++ initializer, so it is set."""
|
||||
main_cpp = generate_main(component_config_path("esp8266.yaml"))
|
||||
|
||||
assert "api_apiserver_id->set_listen_backlog(1);" in main_cpp
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for the api outgoing_connection option."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.api import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
|
||||
ESP32_PLATFORM_DATA = {KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}
|
||||
|
||||
|
||||
def _api_config(outgoing: ConfigType, *, encryption: bool = True) -> ConfigType:
|
||||
config: ConfigType = {"outgoing_connection": outgoing}
|
||||
if encryption:
|
||||
config["encryption"] = {"key": KEY}
|
||||
return config
|
||||
|
||||
|
||||
def test_outgoing_connection_generates_defines(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""A valid config emits the compile-time defines with defaults applied."""
|
||||
generate_main("tests/component_tests/api/test_outgoing_connection.yaml")
|
||||
|
||||
defines = {define.name: define.value for define in CORE.defines}
|
||||
assert "USE_API_OUTGOING_CONNECTION" in defines
|
||||
assert str(defines["API_OUTGOING_CONNECTION_HOST"]) == '"192.168.1.2"'
|
||||
assert str(defines["API_OUTGOING_CONNECTION_PORT"]) == "6054"
|
||||
assert str(defines["API_OUTGOING_CONNECTION_DELAY"]) == "60000"
|
||||
|
||||
|
||||
def test_outgoing_connection_bare_block(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A bare outgoing_connection: block is valid; the device dials the
|
||||
remembered last dial-back client."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
config = CONFIG_SCHEMA(_api_config(None))
|
||||
outgoing = config["outgoing_connection"]
|
||||
assert "host" not in outgoing
|
||||
assert outgoing["port"] == 6054
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("delay", "reboot_timeout"),
|
||||
[("16min", None), ("30s", "30s"), ("31s", "30s")],
|
||||
)
|
||||
def test_outgoing_connection_delay_bounded(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
delay: str,
|
||||
reboot_timeout: str | None,
|
||||
) -> None:
|
||||
"""A delay the reboot watchdog would cut short is rejected."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
config = _api_config({"delay": delay})
|
||||
if reboot_timeout is not None:
|
||||
config["reboot_timeout"] = reboot_timeout
|
||||
with pytest.raises(cv.Invalid, match="shorter than reboot_timeout"):
|
||||
CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("delay", "reboot_timeout"),
|
||||
[("20min", "1h"), ("29s", "30s"), ("30min", "0s")],
|
||||
)
|
||||
def test_outgoing_connection_delay_within_timeout(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
delay: str,
|
||||
reboot_timeout: str,
|
||||
) -> None:
|
||||
"""The bound follows the configured timeout, and a disabled one has none."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
config = _api_config({"delay": delay}) | {"reboot_timeout": reboot_timeout}
|
||||
assert CONFIG_SCHEMA(config)["outgoing_connection"]["delay"] == cv.time_period(
|
||||
delay
|
||||
)
|
||||
|
||||
|
||||
def test_outgoing_connection_requires_encryption(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
with pytest.raises(cv.Invalid, match="requires 'encryption'"):
|
||||
CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}, encryption=False))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"platform_framework",
|
||||
[PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO],
|
||||
)
|
||||
def test_outgoing_connection_accepts_raw_lwip(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform_framework: PlatformFramework,
|
||||
) -> None:
|
||||
"""The raw lwip_tcp socket these platforms default to can dial out."""
|
||||
set_core_config(platform_framework)
|
||||
config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))
|
||||
assert FINAL_VALIDATE_SCHEMA(config) is not None
|
||||
|
||||
|
||||
def test_outgoing_connection_rejects_hostnames(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
with pytest.raises(cv.Invalid, match="not a valid IP address"):
|
||||
CONFIG_SCHEMA(_api_config({"host": "homeassistant.local"}))
|
||||
|
||||
|
||||
def test_outgoing_connection_rejects_scope_id(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A scope id passes Python's parser but not the device's."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
with pytest.raises(cv.Invalid, match="scope id"):
|
||||
CONFIG_SCHEMA(_api_config({"host": "fe80::1%eth0"}))
|
||||
|
||||
|
||||
def test_outgoing_connection_folds_v4_mapped_host(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A v4-mapped host becomes plain IPv4, so it needs no IPv6 build."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
config = CONFIG_SCHEMA(_api_config({"host": "::ffff:192.168.1.2"}))
|
||||
assert config["outgoing_connection"]["host"] == IPv4Address("192.168.1.2")
|
||||
assert FINAL_VALIDATE_SCHEMA(config) is not None
|
||||
|
||||
|
||||
def test_outgoing_connection_ipv6_host_requires_ipv6(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
|
||||
config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"}))
|
||||
with pytest.raises(cv.Invalid, match="IPv6 is not"):
|
||||
FINAL_VALIDATE_SCHEMA(config)
|
||||
|
||||
|
||||
def test_outgoing_connection_ipv6_host_passes_with_ipv6_enabled(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data=ESP32_PLATFORM_DATA,
|
||||
full_config={"network": {"enable_ipv6": True}},
|
||||
)
|
||||
config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"}))
|
||||
assert FINAL_VALIDATE_SCHEMA(config) is not None
|
||||
@@ -0,0 +1,17 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
|
||||
outgoing_connection:
|
||||
host: 192.168.1.2
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
audio_file:
|
||||
- id: chime
|
||||
file:
|
||||
type: local
|
||||
path: ../../../components/audio_file/test.wav
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Tests for the audio_file component codegen."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_audio_file_is_constant_in_flash(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The AudioFile is a global constant behind a const pointer, not a placement new."""
|
||||
main_cpp = generate_main(component_config_path("audio_file.yaml"))
|
||||
|
||||
assert (
|
||||
"static constexpr audio::AudioFile audio_audiofile_id = audio::AudioFile{"
|
||||
in main_cpp
|
||||
)
|
||||
assert (
|
||||
"static const audio::AudioFile *const chime = &audio_audiofile_id;" in main_cpp
|
||||
)
|
||||
assert "new(chime)" not in main_cpp
|
||||
assert 'audio_file::add_named_audio_file(chime, "chime");' in main_cpp
|
||||
@@ -22,7 +22,11 @@ def test_trigger_codegen(
|
||||
main_cpp = generate_main(component_config_path("test_automations.yaml"))
|
||||
|
||||
# on_ble_advertise: multi-mac filter (two addresses in one initializer list)
|
||||
assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp
|
||||
assert (
|
||||
"static constexpr uint64_t ble_mac_filter[] PROGMEM = "
|
||||
"{0xAC3743775F4CULL, 0x112233445566ULL, 0};" in main_cpp
|
||||
)
|
||||
assert "set_addresses(ble_mac_filter)" in main_cpp
|
||||
# 128-bit service uuid goes out reversed (BLE wire order); single-mac filter
|
||||
assert (
|
||||
"set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
|
||||
|
||||
@@ -119,17 +119,19 @@ def set_component_config() -> Callable[[str, Any], None]:
|
||||
|
||||
@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.
|
||||
"""Set the ESP32 variant to the first one on which all internal 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.
|
||||
Pins supplied by external components are validated by the caller's component
|
||||
schema and do not affect ESP32 variant selection.
|
||||
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
|
||||
from esphome.pins import PIN_SCHEMA_REGISTRY, gpio_pin_schema
|
||||
|
||||
def chooser(pins: list) -> None:
|
||||
for variant in VARIANTS:
|
||||
@@ -137,6 +139,14 @@ def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
|
||||
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
|
||||
for pin in pins:
|
||||
if pin is not None:
|
||||
# Pins provided by external components do not constrain
|
||||
# the ESP32 variant. Their own schemas are exercised by
|
||||
# the component configuration test.
|
||||
if (
|
||||
isinstance(pin, dict)
|
||||
and PIN_SCHEMA_REGISTRY.get_key(pin) != CORE.target_platform
|
||||
):
|
||||
continue
|
||||
pin = gpio_pin_schema(
|
||||
{
|
||||
CONF_INPUT: True,
|
||||
|
||||
@@ -18,6 +18,7 @@ from esphome.const import (
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_AMPERE,
|
||||
UNIT_CELSIUS,
|
||||
@@ -42,14 +43,12 @@ def _resolve_via_config_schema(tag: str) -> dict:
|
||||
def test_config_schema_applies_tag_default_state_class():
|
||||
"""If sensor_schema(state_class=...) is reintroduced, the schema-level
|
||||
default wins over apply_tag_defaults' per-prefix value, and E1 would
|
||||
resolve to measurement instead of total_increasing. Driving the real
|
||||
resolve to measurement instead of total. Driving the real
|
||||
CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since
|
||||
sensor_schema() runs before apply_tag_defaults in the cv.All() chain.
|
||||
"""
|
||||
result = _resolve_via_config_schema("E1")
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
|
||||
STATE_CLASS_TOTAL_INCREASING
|
||||
)
|
||||
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(STATE_CLASS_TOTAL)
|
||||
|
||||
|
||||
def test_config_schema_applies_tag_default_accuracy_decimals():
|
||||
@@ -70,8 +69,8 @@ def _make_config(tag: str) -> dict:
|
||||
("tag", "expected_state_class", "expected_decimals"),
|
||||
[
|
||||
# Known numeric-index prefixes
|
||||
("E1", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("E12", STATE_CLASS_TOTAL_INCREASING, 0),
|
||||
("E1", STATE_CLASS_TOTAL, 0),
|
||||
("E12", STATE_CLASS_TOTAL, 0),
|
||||
("P1", STATE_CLASS_MEASUREMENT, 0),
|
||||
("V1", STATE_CLASS_MEASUREMENT, 2),
|
||||
("I1", STATE_CLASS_MEASUREMENT, 2),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
|
||||
esp32_ble_tracker:
|
||||
on_ble_advertise:
|
||||
- mac_address:
|
||||
- AC:37:43:77:5F:4C
|
||||
- 11:22:33:44:55:66
|
||||
then:
|
||||
- logger.log: one
|
||||
- mac_address:
|
||||
- AC:37:43:77:5F:4C
|
||||
- 11:22:33:44:55:66
|
||||
then:
|
||||
- logger.log: two
|
||||
- then:
|
||||
- logger.log: any
|
||||
- mac_address: []
|
||||
then:
|
||||
- logger.log: empty
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Tests for the esp32_ble_tracker on_ble_advertise MAC filter codegen."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.ble_device_base.automation import MAC_FILTER_LIST
|
||||
|
||||
|
||||
def test_mac_filters_share_one_progmem_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Identical MAC lists share one flash table ended by 0; no or an empty filter sets none."""
|
||||
main_cpp = generate_main(component_config_path("mac_filter.yaml"))
|
||||
|
||||
assert (
|
||||
"static constexpr uint64_t ble_mac_filter[] PROGMEM = "
|
||||
"{0xAC3743775F4CULL, 0x112233445566ULL, 0};" in main_cpp
|
||||
)
|
||||
assert main_cpp.count("set_addresses(ble_mac_filter);") == 2
|
||||
assert main_cpp.count("set_addresses(") == 2
|
||||
|
||||
|
||||
def test_zero_mac_is_rejected() -> None:
|
||||
"""00:00:00:00:00:00 ends the flash table, so it cannot be a filter entry."""
|
||||
assert MAC_FILTER_LIST(["11:22:33:44:55:66"])[0].parts == (
|
||||
0x11,
|
||||
0x22,
|
||||
0x33,
|
||||
0x44,
|
||||
0x55,
|
||||
0x66,
|
||||
)
|
||||
with pytest.raises(cv.Invalid):
|
||||
MAC_FILTER_LIST(["00:00:00:00:00:00"])
|
||||
@@ -3,6 +3,8 @@ esphome:
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
version: 5.5.5
|
||||
|
||||
ethernet:
|
||||
id: eth_component
|
||||
|
||||
@@ -4,7 +4,7 @@ esphome:
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
version: 6.0.0
|
||||
version: 6.1.0
|
||||
|
||||
ethernet:
|
||||
id: eth_component
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
esphome:
|
||||
name: test
|
||||
on_boot:
|
||||
then:
|
||||
- sensor.exponential_moving_average.reset: ema_default
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
id: source_sensor
|
||||
unit_of_measurement: "°C"
|
||||
accuracy_decimals: 1
|
||||
device_class: temperature
|
||||
state_class: measurement
|
||||
lambda: return 1.0;
|
||||
- platform: exponential_moving_average
|
||||
id: ema_default
|
||||
name: EMA Default
|
||||
sensor: source_sensor
|
||||
- platform: exponential_moving_average
|
||||
id: ema_alpha
|
||||
name: EMA Alpha
|
||||
sensor: source_sensor
|
||||
alpha: 0.25
|
||||
unit_of_measurement: "K"
|
||||
accuracy_decimals: 3
|
||||
- platform: exponential_moving_average
|
||||
id: ema_time_constant
|
||||
name: EMA Time Constant
|
||||
sensor: source_sensor
|
||||
time_constant: 5min
|
||||
restore: false
|
||||
- platform: exponential_moving_average
|
||||
id: ema_linear
|
||||
name: EMA Linear
|
||||
sensor: source_sensor
|
||||
time_constant: 30s
|
||||
time_weighting: linear
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Tests for the exponential_moving_average sensor."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.exponential_moving_average.sensor import CONFIG_SCHEMA
|
||||
|
||||
|
||||
def test_default_alpha_and_restore(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Without alpha or time_constant, alpha defaults to 0.1 and restore is on."""
|
||||
main_cpp = generate_main(
|
||||
component_config_path("exponential_moving_average_test.yaml")
|
||||
)
|
||||
|
||||
assert (
|
||||
"new(ema_default) exponential_moving_average::ExponentialMovingAverageSensor(source_sensor);"
|
||||
in main_cpp
|
||||
)
|
||||
assert "ema_default->set_alpha(0.1f);" in main_cpp
|
||||
assert "ema_default->set_restore(true);" in main_cpp
|
||||
|
||||
|
||||
def test_alpha(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(
|
||||
component_config_path("exponential_moving_average_test.yaml")
|
||||
)
|
||||
|
||||
assert "ema_alpha->set_alpha(0.25f);" in main_cpp
|
||||
assert "ema_alpha->set_time_constant" not in main_cpp
|
||||
|
||||
|
||||
def test_time_constant_replaces_alpha(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(
|
||||
component_config_path("exponential_moving_average_test.yaml")
|
||||
)
|
||||
|
||||
assert "ema_time_constant->set_time_constant(300000);" in main_cpp
|
||||
assert "ema_time_constant->set_alpha" not in main_cpp
|
||||
assert "ema_time_constant->set_restore(false);" in main_cpp
|
||||
assert "ema_time_constant->set_time_weighting" not in main_cpp
|
||||
|
||||
|
||||
def test_time_weighting(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(
|
||||
component_config_path("exponential_moving_average_test.yaml")
|
||||
)
|
||||
|
||||
assert "ema_linear->set_time_constant(30000);" in main_cpp
|
||||
assert (
|
||||
"ema_linear->set_time_weighting(exponential_moving_average::TIME_WEIGHTING_LINEAR);"
|
||||
in main_cpp
|
||||
)
|
||||
|
||||
|
||||
def test_properties_inherited_from_source(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Unset properties come from the source sensor, with one extra decimal; set ones are kept."""
|
||||
main_cpp = generate_main(
|
||||
component_config_path("exponential_moving_average_test.yaml")
|
||||
)
|
||||
|
||||
assert "ema_default->set_accuracy_decimals(2);" in main_cpp
|
||||
assert "ema_alpha->set_accuracy_decimals(3);" in main_cpp
|
||||
default_line = next(
|
||||
line for line in main_cpp.splitlines() if '"EMA Default"' in line
|
||||
)
|
||||
alpha_line = next(line for line in main_cpp.splitlines() if '"EMA Alpha"' in line)
|
||||
assert "°C" in default_line
|
||||
assert "temperature" in default_line
|
||||
assert "K" in alpha_line
|
||||
|
||||
|
||||
def test_reset_action(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(
|
||||
component_config_path("exponential_moving_average_test.yaml")
|
||||
)
|
||||
|
||||
assert "::ema_default->reset();" in main_cpp
|
||||
|
||||
|
||||
def test_alpha_and_time_constant_are_exclusive() -> None:
|
||||
with pytest.raises(cv.Invalid, match="Cannot specify more than one of"):
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "ema",
|
||||
"name": "EMA",
|
||||
"sensor": "source",
|
||||
"alpha": 0.5,
|
||||
"time_constant": "1min",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha", [0, -0.1, 1.5])
|
||||
def test_alpha_out_of_range(alpha: float) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA({"id": "ema", "name": "EMA", "sensor": "source", "alpha": alpha})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha", [0.01, 1])
|
||||
def test_alpha_in_range(alpha: float) -> None:
|
||||
config = CONFIG_SCHEMA(
|
||||
{"id": "ema", "name": "EMA", "sensor": "source", "alpha": alpha}
|
||||
)
|
||||
|
||||
assert config["alpha"] == alpha
|
||||
|
||||
|
||||
def test_time_weighting_requires_time_constant() -> None:
|
||||
with pytest.raises(cv.Invalid, match="can only be used with 'time_constant'"):
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "ema",
|
||||
"name": "EMA",
|
||||
"sensor": "source",
|
||||
"time_weighting": "previous",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("weighting", ["new", "previous", "linear", "LINEAR"])
|
||||
def test_time_weighting_values(weighting: str) -> None:
|
||||
config = CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "ema",
|
||||
"name": "EMA",
|
||||
"sensor": "source",
|
||||
"time_constant": "1min",
|
||||
"time_weighting": weighting,
|
||||
}
|
||||
)
|
||||
|
||||
assert config["time_weighting"] == weighting.lower()
|
||||
|
||||
|
||||
def test_time_weighting_rejects_unknown_value() -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "ema",
|
||||
"name": "EMA",
|
||||
"sensor": "source",
|
||||
"time_constant": "1min",
|
||||
"time_weighting": "trapezoid",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
font:
|
||||
- file: ../NotoSans-Regular.ttf
|
||||
id: noto
|
||||
size: 12
|
||||
glyphs: "AB"
|
||||
@@ -4,8 +4,10 @@ Focuses on verifying that long multi-byte (Chinese/CJK) glyph strings
|
||||
are correctly processed through the font configuration pipeline.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import functools
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -260,15 +262,12 @@ def mock_cg():
|
||||
with (
|
||||
patch("esphome.components.font.cg.add_define") as mock_define,
|
||||
patch("esphome.components.font.cg.progmem_array") as mock_progmem,
|
||||
patch("esphome.components.font.cg.static_const_array") as mock_static,
|
||||
patch("esphome.components.font.cg.new_Pvariable") as mock_new_pvar,
|
||||
):
|
||||
mock_progmem.return_value = MagicMock()
|
||||
mock_static.return_value = MagicMock()
|
||||
yield {
|
||||
"add_define": mock_define,
|
||||
"progmem_array": mock_progmem,
|
||||
"static_const_array": mock_static,
|
||||
"new_Pvariable": mock_new_pvar,
|
||||
}
|
||||
|
||||
@@ -288,13 +287,13 @@ async def test_to_code_long_latin_generates_all_glyphs(mock_cg):
|
||||
mock_cg["add_define"].assert_any_call("USE_FONT")
|
||||
|
||||
# progmem_array receives the combined bitmap data (non-empty)
|
||||
mock_cg["progmem_array"].assert_called_once()
|
||||
bitmap_data = mock_cg["progmem_array"].call_args.args[1]
|
||||
# bitmap data first, then the glyph table
|
||||
assert mock_cg["progmem_array"].call_count == 2
|
||||
bitmap_data = mock_cg["progmem_array"].call_args_list[0].args[1]
|
||||
assert len(bitmap_data) > 0
|
||||
|
||||
# static_const_array receives one entry per unique glyph
|
||||
mock_cg["static_const_array"].assert_called_once()
|
||||
glyph_initializer = mock_cg["static_const_array"].call_args.args[1]
|
||||
# the glyph table receives one entry per unique glyph
|
||||
glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1]
|
||||
assert len(glyph_initializer) == glyph_count
|
||||
|
||||
# new_Pvariable is called with the correct glyph count
|
||||
@@ -314,7 +313,7 @@ async def test_to_code_glyph_entries_contain_expected_fields(mock_cg):
|
||||
|
||||
await to_code(config)
|
||||
|
||||
glyph_initializer = mock_cg["static_const_array"].call_args.args[1]
|
||||
glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1]
|
||||
for entry in glyph_initializer:
|
||||
assert len(entry) == 7, f"Glyph entry should have 7 fields, got {len(entry)}"
|
||||
codepoint = entry[0]
|
||||
@@ -332,6 +331,17 @@ async def test_to_code_glyphs_sorted_by_utf8(mock_cg):
|
||||
|
||||
await to_code(config)
|
||||
|
||||
glyph_initializer = mock_cg["static_const_array"].call_args.args[1]
|
||||
glyph_initializer = mock_cg["progmem_array"].call_args_list[1].args[1]
|
||||
codepoints = [entry[0] for entry in glyph_initializer]
|
||||
assert codepoints == sorted(codepoints)
|
||||
|
||||
|
||||
def test_glyph_table_is_progmem(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The constexpr glyph table goes to flash, not a RAM static const array."""
|
||||
main_cpp = generate_main(component_config_path("glyph_table.yaml"))
|
||||
|
||||
assert re.search(r"static constexpr font::Glyph \w+\[\] PROGMEM = ", main_cpp)
|
||||
assert "static const font::Glyph" not in main_cpp
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
button:
|
||||
- platform: homeassistant
|
||||
name: HA button
|
||||
entity_id: sensor.example
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
button:
|
||||
- platform: homeassistant
|
||||
id: ha_button
|
||||
name: HA button
|
||||
entity_id: button.example
|
||||
- platform: homeassistant
|
||||
id: ha_input_button
|
||||
name: HA input_button
|
||||
entity_id: input_button.example
|
||||
@@ -0,0 +1,18 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
select:
|
||||
- platform: homeassistant
|
||||
name: HA select
|
||||
entity_id: sensor.example
|
||||
@@ -0,0 +1,25 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
select:
|
||||
- platform: homeassistant
|
||||
id: ha_select
|
||||
name: HA select
|
||||
entity_id: select.example
|
||||
- platform: homeassistant
|
||||
id: ha_input_select
|
||||
name: HA input_select
|
||||
entity_id: input_select.example
|
||||
max_options: 4
|
||||
options_buffer_size: 64
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tests for the homeassistant button platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def test_button_valid_domains(generate_main: Callable[[str | Path], str]) -> None:
|
||||
"""Both button and input_button entities are accepted and wired up."""
|
||||
main_cpp = generate_main(DIR / "button_valid.yaml")
|
||||
|
||||
assert "homeassistant::HomeassistantButton" in main_cpp
|
||||
assert 'ha_button->set_entity_id("button.example");' in main_cpp
|
||||
assert 'ha_input_button->set_entity_id("input_button.example");' in main_cpp
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines)
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines)
|
||||
|
||||
|
||||
def test_button_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An entity from an unsupported domain is rejected."""
|
||||
CORE.config_path = DIR / "button_invalid.yaml"
|
||||
result = read_config({})
|
||||
|
||||
assert result is None
|
||||
assert (
|
||||
"Entity ID sensor.example is not supported by the button platform."
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Tests for the homeassistant number platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
VALID_CONFIG = "tests/component_tests/homeassistant/test_number.yaml"
|
||||
INVALID_CONFIG = "tests/component_tests/homeassistant/test_number_invalid_domain.yaml"
|
||||
|
||||
|
||||
def test_number_and_input_number_entities(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""Both number and input_number entity IDs are accepted and passed to the C++ class."""
|
||||
main_cpp = generate_main(VALID_CONFIG)
|
||||
|
||||
assert 'ha_number->set_entity_id("number.some_number");' in main_cpp
|
||||
assert 'ha_input_number->set_entity_id("input_number.some_input");' in main_cpp
|
||||
|
||||
|
||||
def test_number_rejects_unsupported_domain(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""An entity ID from another domain fails config validation."""
|
||||
CORE.config_path = Path(INVALID_CONFIG)
|
||||
|
||||
assert read_config({}) is None
|
||||
assert "is not supported by the number platform" in capsys.readouterr().out
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
host:
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
number:
|
||||
- platform: homeassistant
|
||||
entity_id: number.some_number
|
||||
id: ha_number
|
||||
- platform: homeassistant
|
||||
entity_id: input_number.some_input
|
||||
id: ha_input_number
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
host:
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
number:
|
||||
- platform: homeassistant
|
||||
entity_id: number.some_number
|
||||
id: ha_number
|
||||
- platform: homeassistant
|
||||
entity_id: sensor.foo
|
||||
id: ha_other_number
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for the homeassistant select platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def test_select_valid_domains(generate_main: Callable[[str | Path], str]) -> None:
|
||||
"""Both select and input_select entities are accepted and wired up."""
|
||||
main_cpp = generate_main(DIR / "select_valid.yaml")
|
||||
|
||||
# Default and configured option storage are passed to the constructor
|
||||
assert "new(ha_select) homeassistant::HomeassistantSelect(16, 256);" in main_cpp
|
||||
assert "new(ha_input_select) homeassistant::HomeassistantSelect(4, 64);" in main_cpp
|
||||
assert 'ha_select->set_entity_id("select.example");' in main_cpp
|
||||
assert 'ha_input_select->set_entity_id("input_select.example");' in main_cpp
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines)
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines)
|
||||
|
||||
|
||||
def test_select_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An entity from an unsupported domain is rejected."""
|
||||
CORE.config_path = DIR / "select_invalid.yaml"
|
||||
result = read_config({})
|
||||
|
||||
assert result is None
|
||||
assert (
|
||||
"Entity ID sensor.example is not supported by the select platform."
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for the homeassistant text platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.config import read_config
|
||||
from esphome.core import CORE
|
||||
|
||||
DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def test_text_valid_domains(generate_main: Callable[[str | Path], str]) -> None:
|
||||
"""Both text and input_text entities are accepted and wired up."""
|
||||
main_cpp = generate_main(DIR / "text_valid.yaml")
|
||||
|
||||
assert "homeassistant::HomeassistantText" in main_cpp
|
||||
assert 'ha_text->set_entity_id("text.example");' in main_cpp
|
||||
assert 'ha_input_text->set_entity_id("input_text.example");' in main_cpp
|
||||
assert "ha_text->traits.set_mode(text::TEXT_MODE_TEXT);" in main_cpp
|
||||
assert "ha_text->traits.set_min_length(0);" in main_cpp
|
||||
assert "ha_text->traits.set_max_length(255);" in main_cpp
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines)
|
||||
assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines)
|
||||
|
||||
|
||||
def test_text_invalid_domain(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""An entity from an unsupported domain is rejected."""
|
||||
CORE.config_path = DIR / "text_invalid.yaml"
|
||||
result = read_config({})
|
||||
|
||||
assert result is None
|
||||
assert (
|
||||
"Entity ID sensor.example is not supported by the text platform."
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
text:
|
||||
- platform: homeassistant
|
||||
name: HA text
|
||||
entity_id: sensor.example
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32
|
||||
|
||||
wifi:
|
||||
ssid: SomeNetwork
|
||||
password: SomePassword
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
|
||||
text:
|
||||
- platform: homeassistant
|
||||
id: ha_text
|
||||
name: HA text
|
||||
entity_id: text.example
|
||||
- platform: homeassistant
|
||||
id: ha_input_text
|
||||
name: HA input_text
|
||||
entity_id: input_text.example
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
bk72xx:
|
||||
board: generic-bk7231n-qfn32-tuya
|
||||
@@ -0,0 +1,5 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
ln882x:
|
||||
board: generic-ln882h
|
||||
@@ -0,0 +1,27 @@
|
||||
"""LN882x builds with 1-byte enums to match the SDK's prebuilt libraries;
|
||||
the other LibreTiny families keep LibreTiny's own enum size."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_ln882x_swaps_in_short_enums(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path("ln882x.yaml"))
|
||||
assert "-fno-short-enums" in CORE.build_unflags
|
||||
assert "-fshort-enums" in CORE.build_flags
|
||||
|
||||
|
||||
def test_bk72xx_keeps_libretiny_enums(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path("bk72xx.yaml"))
|
||||
assert "-fno-short-enums" not in CORE.build_unflags
|
||||
assert "-fshort-enums" not in CORE.build_flags
|
||||
@@ -17,7 +17,11 @@ def test_trigger_codegen(
|
||||
main_cpp = generate_main(component_config_path("test_automations.yaml"))
|
||||
|
||||
# on_ble_advertise: multi-mac filter (two addresses in one initializer list)
|
||||
assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp
|
||||
assert (
|
||||
"static constexpr uint64_t ble_mac_filter[] PROGMEM = "
|
||||
"{0xAC3743775F4CULL, 0x112233445566ULL, 0};" in main_cpp
|
||||
)
|
||||
assert "set_addresses(ble_mac_filter)" in main_cpp
|
||||
# 128-bit service uuid goes out reversed (BLE wire order); single-mac filter
|
||||
assert (
|
||||
"set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
|
||||
|
||||
@@ -6,7 +6,11 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.logger import validate_task_log_buffer_alignment
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.core import CORE
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def test_logger_pre_setup_before_other_components(generate_main):
|
||||
@@ -111,3 +115,26 @@ def test_flash_log_strings_default_does_not_warn(
|
||||
generate_main("tests/component_tests/logger/test_logger.yaml")
|
||||
|
||||
assert "esp8266_store_log_strings_in_flash" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 640, 768, 32768])
|
||||
def test_task_log_buffer_size_aligned_on_esp32(
|
||||
set_core_config: SetCoreConfigCallable, value: int
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
assert validate_task_log_buffer_alignment(value) == value
|
||||
|
||||
|
||||
def test_task_log_buffer_size_unaligned_rejected_on_esp32(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
with pytest.raises(Invalid, match="multiple of 4"):
|
||||
validate_task_log_buffer_alignment(641)
|
||||
|
||||
|
||||
def test_task_log_buffer_size_unaligned_allowed_on_libretiny(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
set_core_config(PlatformFramework.BK72XX_ARDUINO)
|
||||
assert validate_task_log_buffer_alignment(641) == 641
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""LVGL rejects ESP8266 at validation time."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import lvgl
|
||||
from esphome.config_validation import Invalid
|
||||
from esphome.const import PlatformFramework
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def test_esp8266_is_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(PlatformFramework.ESP8266_ARDUINO)
|
||||
with pytest.raises(Invalid, match="not supported on ESP8266"):
|
||||
lvgl.CONFIG_SCHEMA({})
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
matrix_keypad:
|
||||
- id: keypad_a
|
||||
rows:
|
||||
- pin: 12
|
||||
- pin: 13
|
||||
columns:
|
||||
- pin: 14
|
||||
- pin: 15
|
||||
keys: "1234"
|
||||
- id: keypad_b
|
||||
rows:
|
||||
- pin: 4
|
||||
- pin: 5
|
||||
columns:
|
||||
- pin: 0
|
||||
- pin: 2
|
||||
keys: "1234"
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Matrix keypad key codes live in a shared PROGMEM table instead of a std::string."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.matrix_keypad import check_keys
|
||||
import esphome.config_validation as cv
|
||||
|
||||
|
||||
def test_keys_use_shared_progmem_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("keys.yaml"))
|
||||
|
||||
assert (
|
||||
"static constexpr uint8_t matrix_keypad_keys[] PROGMEM = {49, 50, 51, 52};"
|
||||
in main_cpp
|
||||
)
|
||||
assert main_cpp.count("->set_keys(matrix_keypad_keys);") == 2
|
||||
|
||||
|
||||
def test_non_ascii_keys_are_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="'é' is not an ASCII"):
|
||||
check_keys({"keys": "1é34"})
|
||||
@@ -3,6 +3,7 @@
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -201,7 +202,12 @@ def test_code_generation(
|
||||
"new(p4_nano) mipi_dsi::MipiDsi(800, 1280, display::COLOR_BITNESS_565, 16);"
|
||||
in main_cpp
|
||||
)
|
||||
assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp
|
||||
seq = re.search(r"p4_nano->set_init_sequence\((\w+), \d+\);", main_cpp)
|
||||
assert seq is not None
|
||||
assert (
|
||||
f"static constexpr uint8_t {seq.group(1)}[] PROGMEM = "
|
||||
"{224, 1, 0, 225, 1, 147, 226, 1," in main_cpp
|
||||
)
|
||||
assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp
|
||||
assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp
|
||||
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp
|
||||
|
||||
@@ -9,6 +9,7 @@ here.
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
# A model with no reset_pin default: SWRESET ({1, 0}) is prepended ahead of the
|
||||
# inherited ST7701S reset_delay ({50, 255}).
|
||||
@@ -54,6 +55,18 @@ display:
|
||||
"""
|
||||
|
||||
|
||||
def _init_sequence(main_cpp: str, display_id: str) -> str:
|
||||
"""Return the shared PROGMEM init sequence table passed to a display, as rendered."""
|
||||
match = re.search(rf"{display_id}->set_init_sequence\((\w+), \d+\);", main_cpp)
|
||||
assert match is not None
|
||||
table = re.search(
|
||||
rf"static constexpr uint8_t {match.group(1)}\[\] PROGMEM = (\{{[^;]*\}});",
|
||||
main_cpp,
|
||||
)
|
||||
assert table is not None
|
||||
return table.group(1)
|
||||
|
||||
|
||||
def test_swreset_and_reset_delay_without_reset_pin(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
tmp_path: Path,
|
||||
@@ -64,7 +77,7 @@ def test_swreset_and_reset_delay_without_reset_pin(
|
||||
|
||||
main_cpp = generate_main(yaml_file)
|
||||
|
||||
assert "no_reset_display->set_init_sequence({1, 0, 50, 255," in main_cpp
|
||||
assert _init_sequence(main_cpp, "no_reset_display").startswith("{1, 0, 50, 255,")
|
||||
|
||||
|
||||
def test_reset_delay_only_with_reset_pin(
|
||||
@@ -77,5 +90,4 @@ def test_reset_delay_only_with_reset_pin(
|
||||
|
||||
main_cpp = generate_main(yaml_file)
|
||||
|
||||
assert "has_reset_display->set_init_sequence({50, 255," in main_cpp
|
||||
assert "has_reset_display->set_init_sequence({1, 0," not in main_cpp
|
||||
assert _init_sequence(main_cpp, "has_reset_display").startswith("{50, 255,")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: waveshare-spd2010
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
i2c:
|
||||
sda: 11
|
||||
scl: 10
|
||||
|
||||
pca9554:
|
||||
id: board_expander
|
||||
|
||||
spi:
|
||||
type: quad
|
||||
clk_pin: 40
|
||||
data_pins: [46, 45, 42, 41]
|
||||
|
||||
display:
|
||||
- platform: mipi_spi
|
||||
model: WAVESHARE-ESP32-S3-TOUCH-LCD-1.46
|
||||
@@ -4,7 +4,13 @@ from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.mipi import CONF_INVERT_COLORS, CONF_PIXEL_MODE, DriverChip
|
||||
from esphome.components.mipi import (
|
||||
BRIGHTNESS,
|
||||
CONF_INVERT_COLORS,
|
||||
CONF_PIXEL_MODE,
|
||||
DriverChip,
|
||||
)
|
||||
from esphome.const import CONF_BRIGHTNESS
|
||||
|
||||
# A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run
|
||||
# without needing a full display configuration.
|
||||
@@ -62,3 +68,31 @@ def test_get_sequence_skips_reset_delay_validation_without_add_reset() -> None:
|
||||
chip = DriverChip("TEST-GET-SEQUENCE-NO-RESET", reset_delay=999)
|
||||
|
||||
chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=False)
|
||||
|
||||
|
||||
def _has_brightness_command(sequence: tuple[int, ...], value: int) -> bool:
|
||||
"""True if the flattened sequence holds a one-byte brightness (0x51) command."""
|
||||
return any(
|
||||
sequence[i : i + 3] == (BRIGHTNESS, 1, value) for i in range(len(sequence) - 2)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("brightness", [0, 0x80])
|
||||
def test_get_sequence_adds_configured_brightness(brightness: int) -> None:
|
||||
"""A configured brightness is sent at startup, including zero."""
|
||||
chip = DriverChip("TEST-GET-SEQUENCE-BRIGHTNESS")
|
||||
|
||||
sequence = chip.get_sequence(
|
||||
{**_BASE_CONFIG, CONF_BRIGHTNESS: brightness}, add_madctl=False
|
||||
)
|
||||
|
||||
assert _has_brightness_command(sequence, brightness)
|
||||
|
||||
|
||||
def test_get_sequence_omits_brightness_when_not_set() -> None:
|
||||
"""No brightness command is sent when neither config nor model sets one."""
|
||||
chip = DriverChip("TEST-GET-SEQUENCE-NO-BRIGHTNESS")
|
||||
|
||||
sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False)
|
||||
|
||||
assert not any(_has_brightness_command(sequence, v) for v in range(256))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -21,6 +22,10 @@ from esphome.components.mipi_spi.display import (
|
||||
MODELS,
|
||||
dimension_schema,
|
||||
)
|
||||
|
||||
# Register the external pin schema used by the SPD2010 model's reset pin.
|
||||
import esphome.components.pca9554 # noqa: F401
|
||||
from esphome.config import validate_config
|
||||
from esphome.const import (
|
||||
CONF_DC_PIN,
|
||||
CONF_DIMENSIONS,
|
||||
@@ -33,6 +38,18 @@ from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def _init_sequence(main_cpp: str, display_id: str = r"\w+") -> str:
|
||||
"""Return the shared PROGMEM init sequence table passed to a display, as rendered."""
|
||||
match = re.search(rf"{display_id}->set_init_sequence\((\w+), \d+\);", main_cpp)
|
||||
assert match is not None
|
||||
table = re.search(
|
||||
rf"static constexpr uint8_t {match.group(1)}\[\] PROGMEM = (\{{[^;]*\}});",
|
||||
main_cpp,
|
||||
)
|
||||
assert table is not None
|
||||
return table.group(1)
|
||||
|
||||
|
||||
def run_schema_validation(config: ConfigType) -> None:
|
||||
"""Run schema validation on a configuration."""
|
||||
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
|
||||
@@ -362,7 +379,7 @@ def test_native_generation(
|
||||
in main_cpp
|
||||
)
|
||||
# A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands.
|
||||
assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp
|
||||
assert _init_sequence(main_cpp).startswith("{10, 255, 240, 1, 8, 242")
|
||||
assert "show_test_card();" in main_cpp
|
||||
assert "set_write_only(true);" in main_cpp
|
||||
|
||||
@@ -379,11 +396,123 @@ def test_lvgl_generation(
|
||||
in main_cpp
|
||||
)
|
||||
# A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands.
|
||||
assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp
|
||||
assert _init_sequence(main_cpp).startswith("{10, 255, 177, 3, 1, 44, 45, 178")
|
||||
assert "show_test_card();" not in main_cpp
|
||||
assert "set_auto_clear(false);" in main_cpp
|
||||
|
||||
|
||||
def test_spd2010_expander_default_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The SPD2010 default reset pin resolves through a configured PCA9554."""
|
||||
main_cpp = generate_main(component_fixture_path("spd2010.yaml"))
|
||||
|
||||
assert "new(pca9554_pca9554gpiopin_id) pca9554::PCA9554GPIOPin();" in main_cpp
|
||||
assert "pca9554_pca9554gpiopin_id->set_parent(board_expander);" in main_cpp
|
||||
assert "pca9554_pca9554gpiopin_id->set_pin(1);" in main_cpp
|
||||
assert "mipi_spi_mipispi_id->set_reset_pin(pca9554_pca9554gpiopin_id);" in main_cpp
|
||||
|
||||
|
||||
def test_spd2010_default_reset_requires_expander() -> None:
|
||||
"""A registered pin schema alone cannot satisfy the default expander reference."""
|
||||
result = validate_config(
|
||||
{
|
||||
"esphome": {"name": "missing-expander"},
|
||||
"esp32": {
|
||||
"board": "esp32-s3-devkitc-1",
|
||||
"framework": {"type": "esp-idf"},
|
||||
},
|
||||
"spi": {
|
||||
"type": "quad",
|
||||
"clk_pin": 40,
|
||||
"data_pins": [46, 45, 42, 41],
|
||||
},
|
||||
"display": [
|
||||
{
|
||||
"platform": "mipi_spi",
|
||||
"model": "WAVESHARE-ESP32-S3-TOUCH-LCD-1.46",
|
||||
}
|
||||
],
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert len(result.errors) == 1
|
||||
error = result.errors[0]
|
||||
assert (
|
||||
"WAVESHARE-ESP32-S3-TOUCH-LCD-1.46 requires component 'pca9554' "
|
||||
"to be configured" in str(error)
|
||||
)
|
||||
assert error.path == ["display", 0]
|
||||
|
||||
|
||||
def test_spd2010_rejects_invalid_expander_pin() -> None:
|
||||
"""The reset pin must exist on the configured PCA9554 variant."""
|
||||
result = validate_config(
|
||||
{
|
||||
"esphome": {"name": "invalid-expander-pin"},
|
||||
"esp32": {
|
||||
"board": "esp32-s3-devkitc-1",
|
||||
"framework": {"type": "esp-idf"},
|
||||
},
|
||||
"i2c": {"sda": 11, "scl": 10},
|
||||
"pca9554": {"id": "board_expander"},
|
||||
"spi": {
|
||||
"type": "quad",
|
||||
"clk_pin": 40,
|
||||
"data_pins": [46, 45, 42, 41],
|
||||
},
|
||||
"display": [
|
||||
{
|
||||
"platform": "mipi_spi",
|
||||
"model": "WAVESHARE-ESP32-S3-TOUCH-LCD-1.46",
|
||||
"reset_pin": {
|
||||
"pca9554": "board_expander",
|
||||
"number": 8,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert len(result.errors) == 1
|
||||
error = result.errors[0]
|
||||
assert "Pin number must be in range 0-7" in str(error)
|
||||
assert error.path == ["display", 0]
|
||||
|
||||
|
||||
def test_spd2010_rejects_invalid_internal_pin() -> None:
|
||||
"""An invalid internal reset pin is still checked by the ESP32 schema."""
|
||||
result = validate_config(
|
||||
{
|
||||
"esphome": {"name": "invalid-internal-pin"},
|
||||
"esp32": {
|
||||
"board": "esp32-s3-devkitc-1",
|
||||
"framework": {"type": "esp-idf"},
|
||||
},
|
||||
"i2c": {"sda": 11, "scl": 10},
|
||||
"pca9554": {"id": "board_expander"},
|
||||
"spi": {
|
||||
"type": "quad",
|
||||
"clk_pin": 40,
|
||||
"data_pins": [46, 45, 42, 41],
|
||||
},
|
||||
"display": [
|
||||
{
|
||||
"platform": "mipi_spi",
|
||||
"model": "WAVESHARE-ESP32-S3-TOUCH-LCD-1.46",
|
||||
"reset_pin": 99,
|
||||
}
|
||||
],
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert len(result.errors) == 1
|
||||
error = result.errors[0]
|
||||
assert "Invalid pin number: 99" in str(error)
|
||||
assert error.path == ["display", 0, "reset_pin"]
|
||||
|
||||
|
||||
# A 10ms delay (flattened to {10, 0xFF}, where 0xFF is the delay marker byte) is
|
||||
# always prepended to the init sequence, since both a software and a hardware reset
|
||||
# need to settle before further commands. A custom model has no reset_pin default
|
||||
@@ -428,7 +557,9 @@ def test_swreset_prepended_without_reset_pin(
|
||||
|
||||
# SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of
|
||||
# the model's own commands.
|
||||
assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp
|
||||
assert _init_sequence(main_cpp, "swreset_display").startswith(
|
||||
"{1, 0, 10, 255, 160, 1, 1,"
|
||||
)
|
||||
|
||||
|
||||
def test_swreset_not_prepended_with_reset_pin(
|
||||
@@ -449,5 +580,29 @@ def test_swreset_not_prepended_with_reset_pin(
|
||||
main_cpp = generate_main(yaml_file)
|
||||
|
||||
# The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}).
|
||||
assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp
|
||||
assert "hwreset_display->set_init_sequence({1, 0," not in main_cpp
|
||||
assert _init_sequence(main_cpp, "hwreset_display").startswith(
|
||||
"{10, 255, 160, 1, 1,"
|
||||
)
|
||||
|
||||
|
||||
def test_identical_init_sequences_share_one_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Two displays with the same init sequence point at one PROGMEM table."""
|
||||
yaml_file = tmp_path / "shared.yaml"
|
||||
display = _SWRESET_YAML.split("display:\n", 1)[1]
|
||||
yaml_file.write_text(
|
||||
_SWRESET_YAML.format(display_id="first", reset_line=" reset_pin: 5")
|
||||
+ display.format(display_id="second", reset_line=" reset_pin: 6")
|
||||
.replace("cs_pin: 8", "cs_pin: 9")
|
||||
.replace("dc_pin: 4", "dc_pin: 7")
|
||||
)
|
||||
|
||||
main_cpp = generate_main(yaml_file)
|
||||
|
||||
first = re.search(r"first->set_init_sequence\((\w+), \d+\);", main_cpp)
|
||||
second = re.search(r"second->set_init_sequence\((\w+), \d+\);", main_cpp)
|
||||
assert first is not None and second is not None
|
||||
assert first.group(1) == second.group(1)
|
||||
assert main_cpp.count("mipi_spi_init_sequence[] PROGMEM") == 1
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for the mipi_spi light platform validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import final_validate
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.mipi_spi.display import CONFIG_SCHEMA as DISPLAY_SCHEMA
|
||||
from esphome.components.mipi_spi.light import (
|
||||
CONFIG_SCHEMA as LIGHT_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA as LIGHT_FINAL_VALIDATE_SCHEMA,
|
||||
)
|
||||
from esphome.config import Config
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BRIGHTNESS,
|
||||
CONF_DISPLAY_ID,
|
||||
CONF_GAMMA_CORRECT,
|
||||
CONF_ID,
|
||||
CONF_MAX_BRIGHTNESS,
|
||||
CONF_MIN_BRIGHTNESS,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
DISPLAY_ID = "test_display"
|
||||
|
||||
_CUSTOM: ConfigType = {
|
||||
"model": "custom",
|
||||
"dimensions": {"width": 240, "height": 240},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _esp32(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
|
||||
def _light(**extra: object) -> ConfigType:
|
||||
return {
|
||||
"name": "Display Brightness",
|
||||
CONF_DISPLAY_ID: DISPLAY_ID,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _final_validate_with_display(display: ConfigType) -> None:
|
||||
"""Validate a display, place it in the full config, then final-validate a light on it."""
|
||||
display = DISPLAY_SCHEMA(
|
||||
{"id": DISPLAY_ID, "dc_pin": 18, **display},
|
||||
)
|
||||
full = Config()
|
||||
full["display"] = [display]
|
||||
full.declare_ids.append((display[CONF_ID], ["display", 0, CONF_ID]))
|
||||
final_validate.full_config.set(full)
|
||||
LIGHT_FINAL_VALIDATE_SCHEMA({CONF_DISPLAY_ID: ID(DISPLAY_ID, is_declaration=False)})
|
||||
|
||||
|
||||
def test_light_defaults() -> None:
|
||||
"""Gamma defaults to 1.0 and the range to the full 0-255."""
|
||||
config = LIGHT_SCHEMA(_light())
|
||||
|
||||
assert config[CONF_GAMMA_CORRECT] == 1.0
|
||||
assert config[CONF_MIN_BRIGHTNESS] == 0
|
||||
assert config[CONF_MAX_BRIGHTNESS] == 255
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("min_brightness", "max_brightness"),
|
||||
[(100, 100), (200, 100)],
|
||||
ids=["equal", "inverted"],
|
||||
)
|
||||
def test_light_rejects_bad_range(min_brightness: int, max_brightness: int) -> None:
|
||||
with pytest.raises(
|
||||
cv.Invalid, match="'min_brightness' must be less than 'max_brightness'"
|
||||
):
|
||||
LIGHT_SCHEMA(
|
||||
_light(min_brightness=min_brightness, max_brightness=max_brightness)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [CONF_MIN_BRIGHTNESS, CONF_MAX_BRIGHTNESS])
|
||||
def test_light_rejects_out_of_range_value(key: str) -> None:
|
||||
with pytest.raises(cv.Invalid):
|
||||
LIGHT_SCHEMA(_light(**{key: 256}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"display",
|
||||
[
|
||||
pytest.param({"model": "rm690b0"}, id="model_with_default_brightness"),
|
||||
pytest.param({**_CUSTOM, CONF_BRIGHTNESS: 0}, id="custom_with_brightness"),
|
||||
],
|
||||
)
|
||||
def test_light_accepts_display_with_brightness(display: ConfigType) -> None:
|
||||
_final_validate_with_display(display)
|
||||
|
||||
|
||||
def test_light_rejects_model_without_brightness() -> None:
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match="Display model ILI9488 does not support setting brightness",
|
||||
):
|
||||
_final_validate_with_display({"model": "ili9488"})
|
||||
|
||||
|
||||
def test_light_rejects_custom_without_brightness() -> None:
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match="The 'brightness' option must be set in the display config",
|
||||
):
|
||||
_final_validate_with_display(_CUSTOM)
|
||||
|
||||
|
||||
def test_brightness_not_allowed_on_model_without_default() -> None:
|
||||
with pytest.raises(cv.Invalid, match=r"extra keys not allowed.*brightness"):
|
||||
DISPLAY_SCHEMA(
|
||||
{"id": DISPLAY_ID, "dc_pin": 18, "model": "ili9488", CONF_BRIGHTNESS: 10}
|
||||
)
|
||||
@@ -4,7 +4,11 @@ import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import modbus
|
||||
from esphome.components.modbus import CONF_MODBUS_ID, _validate_server_address
|
||||
from esphome.components.modbus import (
|
||||
_HUB_TIME_PERIOD,
|
||||
CONF_MODBUS_ID,
|
||||
_validate_server_address,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS
|
||||
|
||||
|
||||
@@ -36,3 +40,13 @@ def test_client_schema_still_accepts_address_zero() -> None:
|
||||
# A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read.
|
||||
schema = modbus.modbus_device_schema(0x01)
|
||||
assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0
|
||||
|
||||
|
||||
def test_hub_time_accepts_up_to_65535_ms() -> None:
|
||||
assert _HUB_TIME_PERIOD("65535ms").total_milliseconds == 65535
|
||||
|
||||
|
||||
def test_hub_time_rejects_values_the_hub_would_truncate() -> None:
|
||||
# The setters take 16-bit milliseconds: 70 s would silently become 4464 ms.
|
||||
with pytest.raises(cv.Invalid):
|
||||
_HUB_TIME_PERIOD("70s")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -19,10 +20,20 @@ from esphome.components.motion import (
|
||||
_transform_matrix,
|
||||
_validate_matrix_options,
|
||||
)
|
||||
from esphome.components.motion.binary_sensor import (
|
||||
CONFIG_SCHEMA as BINARY_SENSOR_CONFIG_SCHEMA,
|
||||
to_code as binary_sensor_to_code,
|
||||
)
|
||||
from esphome.components.motion.event import (
|
||||
CONFIG_SCHEMA as EVENT_CONFIG_SCHEMA,
|
||||
to_code as event_to_code,
|
||||
)
|
||||
from esphome.components.motion.sensor import (
|
||||
_ACCELERATIONS,
|
||||
_ANGULAR_RATES,
|
||||
_GYROSCOPES,
|
||||
CONF_FLAT_THRESHOLD,
|
||||
CONF_ORIENTATION,
|
||||
CONF_PITCH,
|
||||
CONF_ROLL,
|
||||
CONFIG_SCHEMA,
|
||||
@@ -263,7 +274,7 @@ class TestAxisMapToMatrix:
|
||||
|
||||
def _expr_str(sensor_type: str) -> str:
|
||||
"""Build a sensor expression via the production function and return its string form."""
|
||||
return str(build_sensor_expr(sensor_type, MockObj("data")))
|
||||
return str(build_sensor_expr(sensor_type, MockObj("data"), {}))
|
||||
|
||||
|
||||
class TestSensorExpressions:
|
||||
@@ -326,6 +337,19 @@ class TestSensorExpressions:
|
||||
# Pitch negates the x component
|
||||
assert "(-data.acceleration[0])" in expr
|
||||
|
||||
def test_orientation_expression_custom_threshold(self):
|
||||
"""The configured flat_threshold (degrees) is converted to a sine and passed
|
||||
to the helper."""
|
||||
|
||||
expr = str(
|
||||
build_sensor_expr(
|
||||
CONF_ORIENTATION, MockObj("data"), {CONF_FLAT_THRESHOLD: 45}
|
||||
)
|
||||
)
|
||||
assert "orientation_degrees(data" in expr
|
||||
expected = round(math.sin(math.radians(45)), 6) # 0.707107
|
||||
assert str(expected) in expr
|
||||
|
||||
|
||||
# --- Calibration math ---
|
||||
#
|
||||
@@ -372,7 +396,6 @@ def _calibrate_level(
|
||||
|
||||
Composes the correction with *matrix* (defaults to identity).
|
||||
"""
|
||||
import math
|
||||
|
||||
if matrix is None:
|
||||
matrix = list(IDENTITY)
|
||||
@@ -410,7 +433,6 @@ def _calibrate_level(
|
||||
|
||||
def _calibrate_heading(matrix: list[float], raw: list[float]) -> list[float]:
|
||||
"""Python port of MotionComponent::calibrate_heading."""
|
||||
import math
|
||||
|
||||
mapped = _mat_vec(matrix, raw)
|
||||
mx, my = mapped[0], mapped[1]
|
||||
@@ -438,7 +460,6 @@ class TestCalibrateLevel:
|
||||
|
||||
def _assert_maps_to_z(self, raw: list[float]) -> list[float]:
|
||||
"""Assert that the calibration matrix maps raw to [0, 0, 1]."""
|
||||
import math
|
||||
|
||||
m = _calibrate_level(raw)
|
||||
mag = math.sqrt(sum(v * v for v in raw))
|
||||
@@ -461,7 +482,6 @@ class TestCalibrateLevel:
|
||||
|
||||
def test_composes_with_existing_matrix(self):
|
||||
"""Level calibration should correct tilt while preserving an existing axis swap."""
|
||||
import math
|
||||
|
||||
swap = [0, 1, 0, 1, 0, 0, 0, 0, 1] # swap X↔Y
|
||||
# Tilted raw: gravity has X component in raw frame
|
||||
@@ -495,7 +515,6 @@ class TestCalibrateLevel:
|
||||
self._assert_maps_to_z([0, 1.0, 0])
|
||||
|
||||
def test_tilted_45_degrees(self):
|
||||
import math
|
||||
|
||||
self._assert_maps_to_z(
|
||||
[math.sin(math.radians(45)), 0, math.cos(math.radians(45))]
|
||||
@@ -534,7 +553,6 @@ class TestCalibrateHeading:
|
||||
|
||||
def test_y_axis_tilt_no_heading_error(self):
|
||||
"""Device tilted purely around Y — heading should already be correct."""
|
||||
import math
|
||||
|
||||
flat_raw = [0, 0, 1.0]
|
||||
level_m = _calibrate_level(flat_raw)
|
||||
@@ -547,7 +565,6 @@ class TestCalibrateHeading:
|
||||
|
||||
def test_corrects_heading_rotation(self):
|
||||
"""After level+heading calibration, mapped Y should be ~0 when tilted."""
|
||||
import math
|
||||
|
||||
# Simulate a sensor whose chip is rotated 30° around Z relative to enclosure
|
||||
angle = math.radians(30)
|
||||
@@ -573,7 +590,6 @@ class TestCalibrateHeading:
|
||||
|
||||
def test_full_calibration_sequence(self):
|
||||
"""End-to-end: level then heading produces correct frame alignment."""
|
||||
import math
|
||||
|
||||
# Chip is mounted tilted 15° around Y and 25° around Z
|
||||
# Build the chip-to-enclosure rotation: Rz(25°) * Ry(15°)
|
||||
@@ -846,7 +862,10 @@ class TestSensorConfigSchema:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sensor_type",
|
||||
_ACCELERATIONS + _GYROSCOPES + _ANGULAR_RATES + [CONF_PITCH, CONF_ROLL],
|
||||
_ACCELERATIONS
|
||||
+ _GYROSCOPES
|
||||
+ _ANGULAR_RATES
|
||||
+ [CONF_PITCH, CONF_ROLL, CONF_ORIENTATION],
|
||||
)
|
||||
def test_valid_types_accepted(self, sensor_type):
|
||||
"""Valid sensor types should pass type validation (errors from missing
|
||||
@@ -858,3 +877,211 @@ class TestSensorConfigSchema:
|
||||
assert "Unknown value" not in str(e), (
|
||||
f"Type '{sensor_type}' was rejected as unknown"
|
||||
)
|
||||
|
||||
|
||||
# --- Binary sensor & Event platform tests ---
|
||||
|
||||
|
||||
class TestBinarySensorSchema:
|
||||
def test_valid_types(self):
|
||||
res = BINARY_SENSOR_CONFIG_SCHEMA(
|
||||
{
|
||||
"type": "face_up",
|
||||
"motion_id": "my_motion_component",
|
||||
"name": "LSM6DS3 Face Up",
|
||||
}
|
||||
)
|
||||
assert res["type"] == "face_up"
|
||||
assert str(res["motion_id"]) == "my_motion_component"
|
||||
# face_up / face_down configure their threshold as a tilt angle in degrees.
|
||||
assert res["threshold"] == pytest.approx(30.0)
|
||||
|
||||
def test_free_fall_defaults(self):
|
||||
res = BINARY_SENSOR_CONFIG_SCHEMA(
|
||||
{
|
||||
"type": "free_fall",
|
||||
"motion_id": "my_motion_component",
|
||||
"name": "LSM6DS3 Free Fall",
|
||||
}
|
||||
)
|
||||
assert res["threshold"] == pytest.approx(0.15)
|
||||
assert res["duration"].total_milliseconds == 100
|
||||
|
||||
@pytest.mark.parametrize("sensor_type", ["free_fall", "moving"])
|
||||
def test_zero_threshold_rejected(self, sensor_type: str) -> None:
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
BINARY_SENSOR_CONFIG_SCHEMA(
|
||||
{
|
||||
"type": sensor_type,
|
||||
"motion_id": "my_motion_component",
|
||||
"name": "Test",
|
||||
"threshold": 0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_invalid_type(self):
|
||||
with pytest.raises((Invalid, MultipleInvalid)):
|
||||
BINARY_SENSOR_CONFIG_SCHEMA(
|
||||
{
|
||||
"type": "invalid_type",
|
||||
"motion_id": "my_motion_component",
|
||||
"name": "Test",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestEventSchema:
|
||||
def test_valid_event(self):
|
||||
res = EVENT_CONFIG_SCHEMA(
|
||||
{"motion_id": "my_motion_component", "name": "LSM6DS3 Shake"}
|
||||
)
|
||||
assert str(res["motion_id"]) == "my_motion_component"
|
||||
assert res["threshold"] == pytest.approx(0.5)
|
||||
assert res["cooldown"].total_milliseconds == 500
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_binary_sensor_codegen():
|
||||
mock_var = MagicMock()
|
||||
mock_parent = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.cg.get_variable",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_parent,
|
||||
) as mock_get_var,
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.cg.new_Pvariable",
|
||||
return_value=mock_var,
|
||||
) as mock_new_pvar,
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.binary_sensor.register_binary_sensor",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reg_bin,
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.cg.register_component",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reg_comp,
|
||||
patch(
|
||||
"esphome.components.motion.binary_sensor.cg.add",
|
||||
) as mock_add,
|
||||
):
|
||||
yield {
|
||||
"get_variable": mock_get_var,
|
||||
"new_Pvariable": mock_new_pvar,
|
||||
"register_binary_sensor": mock_reg_bin,
|
||||
"register_component": mock_reg_comp,
|
||||
"add": mock_add,
|
||||
"var": mock_var,
|
||||
"parent": mock_parent,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binary_sensor_to_code(mock_binary_sensor_codegen):
|
||||
|
||||
# face_up has no duration: only the threshold is configured.
|
||||
config = {
|
||||
"id": "my_binary_sensor_id",
|
||||
"type": "face_up",
|
||||
"motion_id": "my_motion_component",
|
||||
"threshold": 30.0,
|
||||
}
|
||||
await binary_sensor_to_code(config)
|
||||
mock_binary_sensor_codegen["get_variable"].assert_called_once_with(
|
||||
"my_motion_component"
|
||||
)
|
||||
mock_binary_sensor_codegen["new_Pvariable"].assert_called_once()
|
||||
mock_binary_sensor_codegen["register_binary_sensor"].assert_called_once_with(
|
||||
mock_binary_sensor_codegen["var"], config
|
||||
)
|
||||
mock_binary_sensor_codegen["register_component"].assert_called_once_with(
|
||||
mock_binary_sensor_codegen["var"], config
|
||||
)
|
||||
assert mock_binary_sensor_codegen["add"].call_count == 1
|
||||
# face_up threshold is configured in degrees and converted to a cosine for C++.
|
||||
mock_binary_sensor_codegen["var"].set_threshold.assert_called_once_with(
|
||||
pytest.approx(math.cos(math.radians(30.0)))
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_binary_sensor_to_code_with_duration(mock_binary_sensor_codegen):
|
||||
from esphome.core import TimePeriod
|
||||
|
||||
# free_fall has a duration: both threshold and duration are configured, and the
|
||||
# threshold is passed straight through (no degrees-to-cosine conversion).
|
||||
config = {
|
||||
"id": "my_binary_sensor_id",
|
||||
"type": "free_fall",
|
||||
"motion_id": "my_motion_component",
|
||||
"threshold": 0.15,
|
||||
"duration": TimePeriod(milliseconds=100),
|
||||
}
|
||||
await binary_sensor_to_code(config)
|
||||
assert mock_binary_sensor_codegen["add"].call_count == 2
|
||||
mock_binary_sensor_codegen["var"].set_threshold.assert_called_once_with(0.15)
|
||||
mock_binary_sensor_codegen["var"].set_duration.assert_called_once_with(
|
||||
TimePeriod(milliseconds=100)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_codegen():
|
||||
mock_var = MagicMock()
|
||||
mock_parent = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"esphome.components.motion.event.cg.get_variable",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_parent,
|
||||
) as mock_get_var,
|
||||
patch(
|
||||
"esphome.components.motion.event.cg.new_Pvariable",
|
||||
return_value=mock_var,
|
||||
) as mock_new_pvar,
|
||||
patch(
|
||||
"esphome.components.motion.event.event.register_event",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reg_event,
|
||||
patch(
|
||||
"esphome.components.motion.event.cg.register_component",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_reg_comp,
|
||||
patch(
|
||||
"esphome.components.motion.event.cg.add",
|
||||
) as mock_add,
|
||||
):
|
||||
yield {
|
||||
"get_variable": mock_get_var,
|
||||
"new_Pvariable": mock_new_pvar,
|
||||
"register_event": mock_reg_event,
|
||||
"register_component": mock_reg_comp,
|
||||
"add": mock_add,
|
||||
"var": mock_var,
|
||||
"parent": mock_parent,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_to_code(mock_event_codegen):
|
||||
from esphome.core import TimePeriod
|
||||
|
||||
config = {
|
||||
"id": "my_event_id",
|
||||
"motion_id": "my_motion_component",
|
||||
"threshold": 0.5,
|
||||
"cooldown": TimePeriod(milliseconds=500),
|
||||
}
|
||||
await event_to_code(config)
|
||||
mock_event_codegen["get_variable"].assert_called_once_with("my_motion_component")
|
||||
mock_event_codegen["new_Pvariable"].assert_called_once_with(
|
||||
"my_event_id", mock_event_codegen["parent"]
|
||||
)
|
||||
mock_event_codegen["register_event"].assert_called_once_with(
|
||||
mock_event_codegen["var"], config, event_types=["shake"]
|
||||
)
|
||||
mock_event_codegen["register_component"].assert_called_once_with(
|
||||
mock_event_codegen["var"], config
|
||||
)
|
||||
assert mock_event_codegen["add"].call_count == 2
|
||||
|
||||
@@ -560,7 +560,7 @@ def test_encryption_offer_codegen(
|
||||
own_key = encrypted and "USE_OTA_ENCRYPTION_PROVISIONED" not in defines_present
|
||||
assert ("esphome_esphomeotacomponent_id->set_noise_psk(" in main_cpp) is own_key
|
||||
# The api shares the ota's array instead of emitting the same key twice
|
||||
assert main_cpp.count("_psk[] PROGMEM") == (1 if own_key else 0)
|
||||
assert main_cpp.count("noise_psk[] PROGMEM") == (1 if own_key else 0)
|
||||
assert ("set_auth_password(" in main_cpp) is ("USE_OTA_PASSWORD" in defines_present)
|
||||
# The noise transport source compiles only when the define is set
|
||||
assert FILTER_SOURCE_FILES() == ([] if encrypted else ["ota_esphome_noise.cpp"])
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
remote_transmitter:
|
||||
pin: GPIO4
|
||||
carrier_duty_percent: 100%
|
||||
|
||||
remote_receiver:
|
||||
pin: GPIO5
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Numbered
|
||||
on_press:
|
||||
- remote_transmitter.transmit_rc_switch_raw:
|
||||
code: '1000100000000000'
|
||||
protocol: 1
|
||||
- platform: template
|
||||
name: Custom A
|
||||
on_press:
|
||||
- remote_transmitter.transmit_rc_switch_raw:
|
||||
code: '1010101010101010'
|
||||
protocol:
|
||||
pulse_length: 320
|
||||
- platform: template
|
||||
name: Custom B
|
||||
on_press:
|
||||
- remote_transmitter.transmit_rc_switch_type_b:
|
||||
address: 4
|
||||
channel: 2
|
||||
state: true
|
||||
protocol:
|
||||
pulse_length: 320
|
||||
- platform: template
|
||||
name: Lambda
|
||||
on_press:
|
||||
- remote_transmitter.transmit_rc_switch_raw:
|
||||
code: '1010101010101011'
|
||||
protocol: !lambda return remote_base::rc_switch_protocol(2);
|
||||
|
||||
binary_sensor:
|
||||
- platform: remote_receiver
|
||||
name: Numbered RX
|
||||
rc_switch_raw:
|
||||
code: '1000100000000000'
|
||||
protocol: 1
|
||||
- platform: remote_receiver
|
||||
name: Custom RX
|
||||
rc_switch_raw:
|
||||
code: '1010101010101010'
|
||||
protocol:
|
||||
pulse_length: 320
|
||||
- platform: template
|
||||
id: rc_switch_custom_protocol
|
||||
@@ -0,0 +1,25 @@
|
||||
"""RC switch protocols are read from flash tables instead of being copied per entity."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_rc_switch_protocols_share_flash_tables(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("rc_switch_protocol.yaml"))
|
||||
|
||||
# One shared table for the identical custom protocols; the user id keeps its name.
|
||||
assert main_cpp.count("static constexpr remote_base::RCSwitchBase") == 1
|
||||
assert (
|
||||
"remote_base::RCSwitchBase rc_switch_custom_protocol_2[] PROGMEM = "
|
||||
"{remote_base::RCSwitchBase(320, 9920, 320, 960, 960, 320, false)};"
|
||||
) in main_cpp
|
||||
assert main_cpp.count("rc_switch_protocol_copy(rc_switch_custom_protocol_2)") == 2
|
||||
# Receivers point into flash instead of holding a copy.
|
||||
assert "->set_protocol(&remote_base::RC_SWITCH_PROTOCOLS[1]);" in main_cpp
|
||||
assert "->set_protocol(rc_switch_custom_protocol_2);" in main_cpp
|
||||
# Numbered and lambda protocols keep their existing paths.
|
||||
assert "return remote_base::rc_switch_protocol(1);" in main_cpp
|
||||
assert "return remote_base::rc_switch_protocol(2);" in main_cpp
|
||||
@@ -19,8 +19,12 @@ def test_trigger_codegen(
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("test_automations.yaml"))
|
||||
|
||||
# on_ble_advertise: multi-mac filter (two addresses in one initializer list)
|
||||
assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp
|
||||
# on_ble_advertise: multi-mac filter, a shared flash table ended by 0
|
||||
assert (
|
||||
"static constexpr uint64_t ble_mac_filter[] PROGMEM = "
|
||||
"{0xAC3743775F4CULL, 0x112233445566ULL, 0};" in main_cpp
|
||||
)
|
||||
assert "set_addresses(ble_mac_filter)" in main_cpp
|
||||
# 128-bit service uuid goes out reversed (BLE wire order); single-mac filter
|
||||
assert (
|
||||
"set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
select:
|
||||
- platform: template
|
||||
id: mode
|
||||
optimistic: true
|
||||
options: [one, two, three]
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: A
|
||||
on_press:
|
||||
- if:
|
||||
condition:
|
||||
select.is:
|
||||
id: mode
|
||||
options: [one, two]
|
||||
then:
|
||||
- logger.log: a
|
||||
- platform: template
|
||||
name: B
|
||||
on_press:
|
||||
- if:
|
||||
condition:
|
||||
select.is:
|
||||
id: mode
|
||||
options: [one, two]
|
||||
then:
|
||||
- logger.log: b
|
||||
- platform: template
|
||||
name: C
|
||||
on_press:
|
||||
- if:
|
||||
condition:
|
||||
select.is:
|
||||
id: mode
|
||||
options: three
|
||||
then:
|
||||
- logger.log: c
|
||||
- if:
|
||||
condition:
|
||||
select.is:
|
||||
id: mode
|
||||
lambda: return current == "two";
|
||||
then:
|
||||
- logger.log: d
|
||||
|
||||
logger:
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
select:
|
||||
- platform: template
|
||||
id: fan_a
|
||||
name: Fan A
|
||||
optimistic: true
|
||||
options: [low, medium, high]
|
||||
- platform: template
|
||||
id: fan_b
|
||||
name: Fan B
|
||||
optimistic: true
|
||||
options: [low, medium, high]
|
||||
- platform: template
|
||||
id: mode
|
||||
name: Mode
|
||||
optimistic: true
|
||||
options: [auto, manual]
|
||||
- platform: copy
|
||||
id: fan_copy
|
||||
name: Fan Copy
|
||||
source_id: fan_a
|
||||
@@ -0,0 +1,22 @@
|
||||
"""select.is option lists live in shared PROGMEM tables."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def test_select_is_options_use_shared_progmem_tables(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("select_is.yaml"))
|
||||
|
||||
tables = re.findall(
|
||||
r"static constexpr const char \* (select_is_options\w*)\[\] PROGMEM = \{([^;]*)\};",
|
||||
main_cpp,
|
||||
)
|
||||
# Two conditions with [one, two] share one table; [three] gets its own
|
||||
assert sorted(body for _, body in tables) == ['"one", "two"', '"three"']
|
||||
one_two = next(name for name, body in tables if body == '"one", "two"')
|
||||
assert main_cpp.count(f", {one_two});") == 2
|
||||
assert "SelectIsCondition<0" in main_cpp
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Select options are shared PROGMEM tables of option pointers."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def test_select_options_use_shared_tables(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("select_options.yaml"))
|
||||
|
||||
calls = {
|
||||
var: table
|
||||
for var, table, _ in re.findall(
|
||||
r"(\w+)->traits\.set_options_static\((\w+), (\d+)\);", main_cpp
|
||||
)
|
||||
}
|
||||
assert set(calls) == {"fan_a", "fan_b", "mode"}
|
||||
assert calls["fan_a"] == calls["fan_b"] != calls["mode"]
|
||||
assert (
|
||||
f'static constexpr const char * {calls["fan_a"]}[] PROGMEM = {{"low", "medium", "high"}};'
|
||||
in main_cpp
|
||||
)
|
||||
# The copy select takes its options from the source at setup
|
||||
assert "fan_copy->traits.set_options_static(" not in main_cpp
|
||||
@@ -0,0 +1,23 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: esp01_1m
|
||||
|
||||
i2c:
|
||||
sda: 4
|
||||
scl: 5
|
||||
|
||||
sx1509:
|
||||
- id: sx_a
|
||||
address: 0x3E
|
||||
keypad:
|
||||
key_rows: 2
|
||||
key_columns: 2
|
||||
keys: "abcd"
|
||||
- id: sx_b
|
||||
address: 0x3F
|
||||
keypad:
|
||||
key_rows: 2
|
||||
key_columns: 2
|
||||
keys: "abcd"
|
||||
@@ -0,0 +1,27 @@
|
||||
"""SX1509 keypad key codes live in a shared PROGMEM table instead of a std::string."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.sx1509 import check_keys
|
||||
import esphome.config_validation as cv
|
||||
|
||||
|
||||
def test_keys_use_shared_progmem_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("keys.yaml"))
|
||||
|
||||
assert (
|
||||
"static constexpr uint8_t sx1509_keys[] PROGMEM = {97, 98, 99, 100};"
|
||||
in main_cpp
|
||||
)
|
||||
assert main_cpp.count("->set_keys(sx1509_keys);") == 2
|
||||
|
||||
|
||||
def test_non_ascii_keys_are_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="'é' is not an ASCII"):
|
||||
check_keys({"keys": "1é34"})
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
uart:
|
||||
- id: default_uart
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
baud_rate: 9600
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
uart:
|
||||
- id: even_uart
|
||||
tx_pin: GPIO17
|
||||
rx_pin: GPIO16
|
||||
baud_rate: 9600
|
||||
parity: EVEN
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for final validation of UART device requirements against usb_uart channels."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config, yaml_util
|
||||
from esphome.core import CORE
|
||||
|
||||
USB_UART_CONFIG = """
|
||||
esphome:
|
||||
name: usb-uart-final-validate
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
usb_uart:
|
||||
- type: ch340
|
||||
channels:
|
||||
- id: meter_uart
|
||||
baud_rate: 1200
|
||||
stop_bits: {stop_bits}
|
||||
sensor:
|
||||
- platform: kamstrup_kmp
|
||||
uart_id: meter_uart
|
||||
heat_energy:
|
||||
name: Heat energy
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stop_bits", "valid"), [("2", True), ("1", False), ("1.5", False)]
|
||||
)
|
||||
def test_stop_bits_requirement_on_usb_uart_channel(
|
||||
stop_bits: str, valid: bool, tmp_path: Path
|
||||
) -> None:
|
||||
"""usb_uart stores stop bits as strings; they must still satisfy a numeric requirement."""
|
||||
path = tmp_path / "usb_uart.yaml"
|
||||
path.write_text(USB_UART_CONFIG.format(stop_bits=stop_bits), encoding="utf-8")
|
||||
CORE.config_path = path
|
||||
result = config.validate_config(yaml_util.load_yaml(path), {})
|
||||
errors = [str(err) for err in result.errors]
|
||||
if valid:
|
||||
assert errors == []
|
||||
else:
|
||||
assert any("requires 2 stop bits" in err for err in errors), errors
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Tests for UART parity code generation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_default_parity_is_not_emitted(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Parity NONE is the C++ initializer, so the setter is skipped."""
|
||||
main_cpp = generate_main(component_config_path("parity_default.yaml"))
|
||||
|
||||
assert "default_uart->set_parity(" not in main_cpp
|
||||
|
||||
|
||||
def test_custom_parity_is_emitted(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A non default parity still reaches the setter."""
|
||||
main_cpp = generate_main(component_config_path("parity_even.yaml"))
|
||||
|
||||
assert "even_uart->set_parity(uart::UART_CONFIG_PARITY_EVEN);" in main_cpp
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for the uart_tcp final validation: one reader per UART, one server per port."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import uart_tcp
|
||||
from esphome.components.const import CONF_ROLE
|
||||
from esphome.config import Config
|
||||
from esphome.const import (
|
||||
CONF_DEBUG,
|
||||
CONF_DUMMY_RECEIVER,
|
||||
CONF_ID,
|
||||
CONF_PORT,
|
||||
CONF_UART_ID,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
_final_validate = uart_tcp._final_validate
|
||||
|
||||
|
||||
def _full_config(uarts: list[ConfigType] | None = None, **domains) -> Config:
|
||||
"""A full config declaring uart_0 and uart_1, as the ID pass leaves it."""
|
||||
uarts = uarts or [{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1")}]
|
||||
full = Config()
|
||||
full["uart"] = uarts
|
||||
for index, uart_conf in enumerate(uarts):
|
||||
full.declare_ids.append((uart_conf[CONF_ID], ["uart", index, CONF_ID]))
|
||||
full.update(domains)
|
||||
return full
|
||||
|
||||
|
||||
def _entry(uart_id: str, port: int = 8899, role: str = "server") -> ConfigType:
|
||||
return {CONF_UART_ID: ID(uart_id), CONF_PORT: port, CONF_ROLE: role}
|
||||
|
||||
|
||||
def _set(set_core_config: SetCoreConfigCallable, full: Config) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, full_config=full)
|
||||
|
||||
|
||||
def test_accepts_entries_on_distinct_uarts_and_ports(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(set_core_config, _full_config())
|
||||
_final_validate(_entry("uart_0", 8899))
|
||||
_final_validate(_entry("uart_1", 8900))
|
||||
|
||||
|
||||
def test_rejects_two_entries_on_one_uart(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(set_core_config, _full_config())
|
||||
_final_validate(_entry("uart_0", 8899))
|
||||
with pytest.raises(cv.Invalid, match="already used by another 'uart_tcp'"):
|
||||
_final_validate(_entry("uart_0", 8900))
|
||||
|
||||
|
||||
def test_rejects_uart_shared_with_another_component(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(modbus=[{CONF_ID: ID("hub"), CONF_UART_ID: ID("uart_0")}]),
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="also used by 'modbus'"):
|
||||
_final_validate(_entry("uart_0"))
|
||||
|
||||
|
||||
def test_ignores_other_components_on_other_uarts(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(
|
||||
modbus=[{CONF_ID: ID("hub"), CONF_UART_ID: ID("uart_1")}],
|
||||
uart_tcp=[_entry("uart_0")],
|
||||
),
|
||||
)
|
||||
_final_validate(_entry("uart_0"))
|
||||
|
||||
|
||||
def test_testing_mode_allows_a_shared_bus(
|
||||
set_core_config: SetCoreConfigCallable, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Grouped CI builds put several components on one bus package.
|
||||
monkeypatch.setattr(CORE, "testing_mode", True)
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(modbus=[{CONF_ID: ID("hub"), CONF_UART_ID: ID("uart_0")}]),
|
||||
)
|
||||
_final_validate(_entry("uart_0"))
|
||||
|
||||
|
||||
def test_rejects_dummy_receiver(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(
|
||||
uarts=[{CONF_ID: ID("uart_0"), CONF_DEBUG: {CONF_DUMMY_RECEIVER: True}}]
|
||||
),
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="dummy_receiver"):
|
||||
_final_validate(_entry("uart_0"))
|
||||
|
||||
|
||||
def test_rejects_two_servers_on_one_port(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(set_core_config, _full_config())
|
||||
_final_validate(_entry("uart_0", 8899))
|
||||
with pytest.raises(cv.Invalid, match="Port 8899 is already the listen port"):
|
||||
_final_validate(_entry("uart_1", 8899))
|
||||
|
||||
|
||||
def test_rejects_server_on_a_tcp_uart_server_port(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(
|
||||
tcp_uart=[{CONF_ID: ID("tcp"), CONF_ROLE: "server", CONF_PORT: 502}]
|
||||
),
|
||||
)
|
||||
with pytest.raises(cv.Invalid, match="Port 502 is already the listen port"):
|
||||
_final_validate(_entry("uart_0", 502))
|
||||
|
||||
|
||||
def test_clients_may_share_a_port_number(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# A client port is the remote end; only listeners collide.
|
||||
_set(
|
||||
set_core_config,
|
||||
_full_config(
|
||||
tcp_uart=[{CONF_ID: ID("tcp"), CONF_ROLE: "client", CONF_PORT: 502}]
|
||||
),
|
||||
)
|
||||
_final_validate(_entry("uart_0", 502))
|
||||
_final_validate(_entry("uart_1", 502, role="client"))
|
||||
@@ -0,0 +1,14 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: password1
|
||||
|
||||
udp:
|
||||
addresses:
|
||||
- 10.0.0.1
|
||||
- 10.0.0.2
|
||||
@@ -0,0 +1,12 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: password1
|
||||
|
||||
udp:
|
||||
addresses: []
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for the udp component codegen."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_addresses_are_a_progmem_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Addresses live in a nullptr-terminated flash table instead of a heap vector."""
|
||||
main_cpp = generate_main(component_config_path("udp_addresses.yaml"))
|
||||
|
||||
assert (
|
||||
"static constexpr const char * udp_addresses[] PROGMEM = "
|
||||
'{"10.0.0.1", "10.0.0.2", nullptr};' in main_cpp
|
||||
)
|
||||
assert "->set_addresses(udp_addresses);" in main_cpp
|
||||
|
||||
|
||||
def test_empty_addresses_emit_no_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""An empty address list leaves the pointer null instead of emitting a table."""
|
||||
main_cpp = generate_main(component_config_path("udp_no_addresses.yaml"))
|
||||
|
||||
assert "udp_addresses" not in main_cpp
|
||||
assert "set_addresses(" not in main_cpp
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Tests for the usb_uart component's driver source filtering."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_only_configured_driver_compiled(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
generate_main("tests/component_tests/usb_uart/test_usb_uart_ft232.yaml")
|
||||
from esphome.components.usb_uart import FILTER_SOURCE_FILES
|
||||
|
||||
defines = {define.name for define in CORE.defines}
|
||||
# cdc_acm is built into usb_uart.cpp and adds no driver define
|
||||
assert {d for d in defines if d.startswith("USE_USB_UART_")} == {
|
||||
"USE_USB_UART_FT23XX"
|
||||
}
|
||||
assert sorted(FILTER_SOURCE_FILES()) == ["ch34x.cpp", "cp210x.cpp", "pl2303.cpp"]
|
||||
@@ -0,0 +1,18 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32s3
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
usb_uart:
|
||||
- type: ft232
|
||||
channels:
|
||||
- id: ft_channel
|
||||
baud_rate: 115200
|
||||
- type: cdc_acm
|
||||
vid: 0x1234
|
||||
pid: 0x5678
|
||||
channels:
|
||||
- id: cdc_channel
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
text_sensor:
|
||||
- platform: version
|
||||
id: default_version
|
||||
name: Default Version
|
||||
- platform: version
|
||||
id: hidden_version
|
||||
name: Hidden Version
|
||||
hide_hash: true
|
||||
hide_timestamp: true
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Tests for the version text sensor codegen."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_default_hide_flags_are_not_emitted(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Both flags are false in C++, so only true reaches the setters."""
|
||||
main_cpp = generate_main(component_config_path("version.yaml"))
|
||||
|
||||
assert "default_version->set_hide_hash(" not in main_cpp
|
||||
assert "default_version->set_hide_timestamp(" not in main_cpp
|
||||
assert "hidden_version->set_hide_hash(true);" in main_cpp
|
||||
assert "hidden_version->set_hide_timestamp(true);" in main_cpp
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
|
||||
web_server:
|
||||
allowed_origins:
|
||||
- https://app.esphome.io
|
||||
- "*"
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Tests for the web_server allowed_origins codegen."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_allowed_origins_are_a_progmem_table(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Allowed origins live in a nullptr-terminated flash table instead of a heap vector."""
|
||||
main_cpp = generate_main(component_config_path("allowed_origins.yaml"))
|
||||
|
||||
assert (
|
||||
"static constexpr const char * web_server_allowed_origins[] PROGMEM = "
|
||||
'{"https://app.esphome.io", "*", nullptr};' in main_cpp
|
||||
)
|
||||
assert "->set_allowed_origins(web_server_allowed_origins);" in main_cpp
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Tests for ESP-IDF web server sdkconfig defaults."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from esphome.components.esp32 import RawSdkconfigValue
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_web_server_idf_uses_default_header_limit(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""The web server retains its existing 1024-byte default."""
|
||||
generate_main("tests/component_tests/web_server/web_server_no_auth.yaml")
|
||||
|
||||
assert (
|
||||
CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_HTTPD_MAX_REQ_HDR_LEN"]
|
||||
== 1024
|
||||
)
|
||||
|
||||
|
||||
def test_web_server_idf_respects_explicit_header_limit(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""An explicit sdkconfig option takes precedence over the component default."""
|
||||
generate_main(
|
||||
"tests/component_tests/web_server/web_server_header_limit_override.yaml"
|
||||
)
|
||||
|
||||
value = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_HTTPD_MAX_REQ_HDR_LEN"]
|
||||
assert isinstance(value, RawSdkconfigValue)
|
||||
assert value.value == "4096"
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: nodemcu-32s
|
||||
framework:
|
||||
type: esp-idf
|
||||
sdkconfig_options:
|
||||
CONFIG_HTTPD_MAX_REQ_HDR_LEN: "4096"
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
web_server:
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
ln882x:
|
||||
board: generic-ln882h
|
||||
|
||||
wifi:
|
||||
ssid: test
|
||||
password: testtest
|
||||
ap:
|
||||
ssid: fallback
|
||||
@@ -6,7 +6,9 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"])
|
||||
@pytest.mark.parametrize(
|
||||
"config_file", ["bare.yaml", "defaults.yaml", "bare_ln882x.yaml"]
|
||||
)
|
||||
def test_default_values_are_not_emitted(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
|
||||
@@ -149,6 +149,38 @@ api:
|
||||
args:
|
||||
- error.c_str()
|
||||
- name.c_str()
|
||||
# Constant and lambda fields mixed in one action, with lambdas of several
|
||||
# return types reading the trigger args, and a templated action name
|
||||
- action: test_homeassistant_fields
|
||||
variables:
|
||||
room: string
|
||||
level: int
|
||||
then:
|
||||
- homeassistant.action:
|
||||
action: !lambda 'return std::string("light.") + (level > 0 ? "turn_on" : "turn_off");'
|
||||
data:
|
||||
entity_id: light.living_room
|
||||
brightness: !lambda 'return level;'
|
||||
data_template:
|
||||
message: "{{ room }} is at {{ level }}"
|
||||
variables:
|
||||
room: !lambda 'return room.c_str();'
|
||||
level: !lambda 'static char buf[12]; snprintf(buf, sizeof(buf), "%d", level); return buf;'
|
||||
- homeassistant.action:
|
||||
action: notify.html5
|
||||
data:
|
||||
message: Button was pressed
|
||||
# Same constant fields as above, so codegen can share them
|
||||
- homeassistant.action:
|
||||
action: notify.html5
|
||||
data:
|
||||
message: Button was pressed
|
||||
- homeassistant.event:
|
||||
event: esphome.room_level
|
||||
data:
|
||||
room: !lambda 'return room;'
|
||||
ratio: !lambda 'return level / 100.0f;'
|
||||
- homeassistant.tag_scanned: !lambda 'return room;'
|
||||
# Test ContinuationAction (IfAction with then/else branches)
|
||||
- action: test_if_action
|
||||
variables:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Outgoing connection on the lwip_sockets implementation used by LibreTiny
|
||||
api:
|
||||
outgoing_connection:
|
||||
host: 192.168.1.2
|
||||
@@ -0,0 +1,14 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Outgoing connection: the device dials out when no dial-back client is
|
||||
# connected. Requires encryption so the peer is verified by key.
|
||||
api:
|
||||
outgoing_connection:
|
||||
host: 192.168.1.2
|
||||
port: 6054
|
||||
delay: 60s
|
||||
@@ -0,0 +1,11 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Outgoing connection on the raw lwip_tcp implementation used by ESP8266 and RP2040
|
||||
api:
|
||||
outgoing_connection:
|
||||
host: 192.168.1.2
|
||||
@@ -0,0 +1,9 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
network:
|
||||
|
||||
# No host set: the device dials the last remembered Home Assistant address
|
||||
api:
|
||||
outgoing_connection:
|
||||
delay: 30s
|
||||
@@ -0,0 +1,11 @@
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Outgoing connection on the raw lwip_tcp implementation used by ESP8266 and RP2040
|
||||
api:
|
||||
outgoing_connection:
|
||||
host: 192.168.1.2
|
||||
@@ -0,0 +1,6 @@
|
||||
packages:
|
||||
api: !include common.yaml
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
@@ -0,0 +1,84 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/api/proto.h"
|
||||
|
||||
namespace esphome::api::testing {
|
||||
|
||||
// The original byte at a time implementation.
|
||||
static uint16_t reference_count(const uint8_t *data, size_t len) {
|
||||
uint16_t count = 0;
|
||||
while (len > 0) {
|
||||
while (len > 0 && (*data & 0x80)) {
|
||||
data++;
|
||||
len--;
|
||||
}
|
||||
if (len > 0) {
|
||||
data++;
|
||||
len--;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
TEST(CountPackedVarints, EmptyBuffer) {
|
||||
const uint8_t data[1] = {0x00};
|
||||
EXPECT_EQ(count_packed_varints(data, 0), 0);
|
||||
}
|
||||
|
||||
TEST(CountPackedVarints, SingleByteVarints) {
|
||||
const uint8_t data[] = {0x00, 0x01, 0x7F};
|
||||
EXPECT_EQ(count_packed_varints(data, sizeof(data)), 3);
|
||||
}
|
||||
|
||||
TEST(CountPackedVarints, MultiByteVarints) {
|
||||
// 3 varints: 2 bytes, 3 bytes, 1 byte
|
||||
const uint8_t data[] = {0x80, 0x01, 0x80, 0x80, 0x01, 0x05};
|
||||
EXPECT_EQ(count_packed_varints(data, sizeof(data)), 3);
|
||||
}
|
||||
|
||||
TEST(CountPackedVarints, TruncatedTrailingVarintIsNotCounted) {
|
||||
const uint8_t data[] = {0x05, 0x80, 0x80, 0x80};
|
||||
EXPECT_EQ(count_packed_varints(data, sizeof(data)), 1);
|
||||
}
|
||||
|
||||
TEST(CountPackedVarints, AllContinuationBytes) {
|
||||
std::vector<uint8_t> data(5000, 0x80);
|
||||
EXPECT_EQ(count_packed_varints(data.data(), data.size()), 0);
|
||||
}
|
||||
|
||||
TEST(CountPackedVarints, EveryStartOffsetAndLength) {
|
||||
// Cover every alignment and length around word boundaries.
|
||||
std::mt19937 rng(42); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
|
||||
std::vector<uint8_t> buf(300);
|
||||
for (auto &byte : buf)
|
||||
byte = static_cast<uint8_t>(rng() & 0xFF);
|
||||
for (size_t offset = 0; offset < 16; offset++) {
|
||||
for (size_t len = 0; len + offset <= buf.size(); len++) {
|
||||
const uint16_t expected = reference_count(buf.data() + offset, len);
|
||||
EXPECT_EQ(count_packed_varints<uint32_t>(buf.data() + offset, len), expected)
|
||||
<< "offset=" << offset << " len=" << len;
|
||||
EXPECT_EQ(count_packed_varints<uint64_t>(buf.data() + offset, len), expected)
|
||||
<< "offset=" << offset << " len=" << len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(CountPackedVarints, LongBuffer) {
|
||||
std::mt19937 rng(7); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
|
||||
std::vector<uint8_t> buf(5000);
|
||||
for (auto &byte : buf)
|
||||
byte = static_cast<uint8_t>((rng() % 4 == 0) ? (0x80 | (rng() & 0x7F)) : (rng() & 0x7F));
|
||||
for (size_t offset = 0; offset < 8; offset++) {
|
||||
const size_t len = buf.size() - offset;
|
||||
const uint16_t expected = reference_count(buf.data() + offset, len);
|
||||
EXPECT_EQ(count_packed_varints<uint32_t>(buf.data() + offset, len), expected) << "offset=" << offset;
|
||||
EXPECT_EQ(count_packed_varints<uint64_t>(buf.data() + offset, len), expected) << "offset=" << offset;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::api::testing
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -0,0 +1,96 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <type_traits>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::testing {
|
||||
|
||||
static constexpr const char *const TABLE[] = {"a", "b", "c"};
|
||||
|
||||
// Exposes the owned flag, which is protected.
|
||||
class ProbeVector : public ConstVector<const char *, true> {
|
||||
public:
|
||||
using ConstVector::ConstVector;
|
||||
bool owned() const { return (this->size_ & OWNED_BIT) != 0; }
|
||||
};
|
||||
|
||||
TEST(ConstVector, StaticTableIsViewedNotOwned) {
|
||||
ProbeVector list;
|
||||
EXPECT_TRUE(list.empty());
|
||||
list.assign_static(TABLE, 3);
|
||||
EXPECT_EQ(list.data(), TABLE);
|
||||
EXPECT_EQ(list.size(), 3U);
|
||||
EXPECT_FALSE(list.owned());
|
||||
EXPECT_STREQ(list[1], "b");
|
||||
EXPECT_STREQ(list.at(2), "c");
|
||||
}
|
||||
|
||||
TEST(ConstVector, CopyIsOwnedAndSizeMasksTheFlag) {
|
||||
ProbeVector list;
|
||||
list.assign_copy(TABLE, 3);
|
||||
EXPECT_NE(list.data(), TABLE);
|
||||
EXPECT_TRUE(list.owned());
|
||||
EXPECT_EQ(list.size(), 3U);
|
||||
EXPECT_STREQ(list[0], "a");
|
||||
|
||||
const char *const next[] = {"x", "y"};
|
||||
list.assign_copy(next, 2); // frees the previous copy
|
||||
EXPECT_TRUE(list.owned());
|
||||
EXPECT_EQ(list.size(), 2U);
|
||||
EXPECT_STREQ(list[1], "y");
|
||||
}
|
||||
|
||||
TEST(ConstVector, StaticThenRuntimeCopiesNeverFreeTheTable) {
|
||||
ProbeVector list;
|
||||
list.assign_static(TABLE, 3);
|
||||
EXPECT_FALSE(list.owned());
|
||||
list.assign_copy(TABLE, 2); // the static table is not owned, so nothing is freed
|
||||
EXPECT_TRUE(list.owned());
|
||||
EXPECT_NE(list.data(), TABLE);
|
||||
const char *const next[] = {"x"};
|
||||
list.assign_copy(next, 1); // frees the previous copy
|
||||
EXPECT_EQ(list.size(), 1U);
|
||||
EXPECT_STREQ(list[0], "x");
|
||||
EXPECT_STREQ(TABLE[0], "a");
|
||||
}
|
||||
|
||||
TEST(ConstVector, EmptyCopyIsEmptyAndFreedOnNextSet) {
|
||||
ProbeVector list;
|
||||
list.assign_copy(TABLE, 3);
|
||||
list.assign_copy(TABLE, 0);
|
||||
EXPECT_TRUE(list.empty());
|
||||
list.assign_copy(TABLE, 2); // frees the empty copy
|
||||
EXPECT_EQ(list.size(), 2U);
|
||||
}
|
||||
|
||||
TEST(ConstVector, OwningVariantIsNotCopyable) {
|
||||
static_assert(!std::is_copy_constructible_v<ConstVector<const char *, true>>);
|
||||
static_assert(!std::is_copy_assignable_v<ConstVector<const char *, true>>);
|
||||
}
|
||||
|
||||
TEST(ConstVector, CopyFromItsOwnStorage) {
|
||||
ProbeVector list;
|
||||
list.assign_copy(TABLE, 3);
|
||||
list.assign_copy(list.data(), list.size());
|
||||
EXPECT_EQ(list.size(), 3U);
|
||||
EXPECT_STREQ(list[2], "c");
|
||||
}
|
||||
|
||||
TEST(ConstVector, PlainViewHasNoOwnershipCost) {
|
||||
static_assert(std::is_trivially_copyable_v<ConstVector<const char *>>);
|
||||
static_assert(std::is_trivially_destructible_v<ConstVector<const char *>>);
|
||||
ConstVector<const char *> list(TABLE, 3);
|
||||
EXPECT_EQ(list.size(), 3U);
|
||||
EXPECT_STREQ(list[2], "c");
|
||||
}
|
||||
|
||||
TEST(ConstVector, IteratorsAreRawPointers) {
|
||||
ConstVector<const char *, true> list(TABLE, 3);
|
||||
static_assert(std::is_same_v<decltype(list.begin()), const char *const *>);
|
||||
const auto *it = std::find(list.begin(), list.end(), TABLE[1]);
|
||||
EXPECT_EQ(it - list.begin(), 1);
|
||||
}
|
||||
|
||||
} // namespace esphome::testing
|
||||
@@ -281,6 +281,27 @@ TEST(Base64, Rfc4648Vectors) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(Base64, DecodeTruncatesToBuffer) {
|
||||
uint8_t buf[4];
|
||||
size_t len = base64_decode(std::string("Zm9vYmFy"), buf, sizeof(buf));
|
||||
EXPECT_EQ(len, 4u);
|
||||
EXPECT_EQ(memcmp(buf, "foob", 4), 0);
|
||||
}
|
||||
|
||||
TEST(Base64, DecodeStopsAtNonAlphabetChar) {
|
||||
uint8_t buf[8];
|
||||
EXPECT_EQ(base64_decode(std::string("Zm9v!Zm9v"), buf, sizeof(buf)), 3u);
|
||||
EXPECT_EQ(memcmp(buf, "foo", 3), 0);
|
||||
EXPECT_EQ(base64_decode(std::string("Zm9v Zm9v"), buf, sizeof(buf)), 3u);
|
||||
EXPECT_EQ(base64_decode(std::string("Zm9v\xC3Zm9v"), buf, sizeof(buf)), 3u);
|
||||
}
|
||||
|
||||
TEST(Base64, DecodeDropsPartialGroup) {
|
||||
uint8_t buf[8];
|
||||
EXPECT_EQ(base64_decode(std::string("Z"), buf, sizeof(buf)), 0u);
|
||||
EXPECT_EQ(base64_decode(std::string("Zm9vY"), buf, sizeof(buf)), 3u);
|
||||
}
|
||||
|
||||
// --- step_to_accuracy_decimals() ---
|
||||
|
||||
TEST(StepToAccuracyDecimals, TypicalSteps) {
|
||||
|
||||
@@ -8,7 +8,7 @@ packages:
|
||||
# exercised, not the user-override path.
|
||||
|
||||
sensor:
|
||||
# Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh,
|
||||
# Energy sensor (E prefix): expects state_class=total, unit=Wh,
|
||||
# device_class=energy, accuracy_decimals=0
|
||||
- platform: emontx
|
||||
tag_name: E1
|
||||
|
||||
@@ -190,6 +190,24 @@ display:
|
||||
allow_other_uses: true
|
||||
number: GPIO4
|
||||
|
||||
# Waveshare 1.54" V2 mono e-paper (200x200, SSD1681)
|
||||
- platform: epaper_spi
|
||||
spi_id: spi_bus
|
||||
model: waveshare-1.54in-v2
|
||||
full_update_every: 30
|
||||
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
|
||||
|
||||
# Waveshare 2.13" V4 B series 3-color e-paper (122x250, BWR, SSD1680)
|
||||
- platform: epaper_spi
|
||||
spi_id: spi_bus
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# The real schema needs an ESP32 target and Wi-Fi; the host test build has neither.
|
||||
manifest.dependencies = ["binary_sensor", "sensor", "button", "select"]
|
||||
manifest.config_schema = cv.Schema({})
|
||||
manifest.final_validate_schema = None
|
||||
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
# Resolve <espectre_sdk.h> to the test double copied next to the tests.
|
||||
cg.add_build_flag(f"-I{CORE.relative_src_path('espectre')}")
|
||||
cg.add_define("USE_ESPECTRE")
|
||||
# No entity platforms are configured, so count them to emit USE_BINARY_SENSOR,
|
||||
# USE_SENSOR and USE_SELECT for the entity code under test.
|
||||
CORE.platform_counts["binary_sensor"] += 2
|
||||
CORE.platform_counts["sensor"] += 1
|
||||
CORE.platform_counts["select"] += 1
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user