Merge branch 'neutral-ble-client' into radon-eye-single-node

This commit is contained in:
J. Nick Koston
2026-10-06 21:35:46 -10:00
535 changed files with 16156 additions and 2566 deletions
@@ -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,"
+12 -2
View File
@@ -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
@@ -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"
+20 -10
View File
@@ -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))
+160 -5
View File
@@ -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}
)
+15 -1
View File
@@ -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")
+237 -10
View File
@@ -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"
+27
View File
@@ -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
+24
View File
@@ -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
+142
View File
@@ -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],
+32
View File
@@ -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
+6
View File
@@ -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
+21
View File
@@ -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
+24
View File
@@ -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