Merge branch 'dev' into jesserockz-2026-503

This commit is contained in:
Jesse Hills
2026-08-12 17:27:35 +12:00
committed by GitHub
984 changed files with 50402 additions and 7012 deletions
@@ -52,7 +52,7 @@ def _prime_cache(yaml_path: Path) -> None:
Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``,
then ``update_storage_json`` writes both the StorageJSON sidecar and
the ``.validated.yaml`` compiled-config cache.
the ``.validated.json`` compiled-config cache.
"""
CORE.config_path = yaml_path
config = read_config({}, skip_external_update=True)
@@ -32,9 +32,10 @@ class SerialProxy {
api::enums::SerialProxyPortType get_port_type() const { return {}; }
api::APIConnection *get_api_connection() { return nullptr; }
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {}
void write_from_client(const uint8_t *data, size_t len) {}
void set_modem_pins(uint32_t line_states) {}
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint32_t stop_bits, uint32_t data_size) {}
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {}
uint32_t get_modem_pins() const { return 0; }
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
@@ -0,0 +1,32 @@
"""Tests for the ADC sensor component."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
def test_adc_temperature_pin_is_deprecated(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""`pin: TEMPERATURE` still works, but warns and points at internal_temperature."""
main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml")
assert "adc_temperature->set_is_temperature();" in main_cpp
assert "`pin: TEMPERATURE` is deprecated" in caplog.text
assert "internal_temperature" in caplog.text
assert "2027.2.0" in caplog.text
def test_adc_regular_pin_is_not_deprecated(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A normal ADC pin does not emit the temperature deprecation warning."""
main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml")
assert "adc_voltage->set_is_temperature();" not in main_cpp
assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1
@@ -0,0 +1,16 @@
esphome:
name: test
rp2:
board: rpipicow
sensor:
- platform: adc
pin: TEMPERATURE
name: Deprecated ADC Temperature
id: adc_temperature
- platform: adc
pin: 26
name: ADC Voltage
id: adc_voltage
@@ -0,0 +1,45 @@
esphome:
name: bk-trigger-codegen
on_boot:
then:
- bk72xx_ble_tracker.start_scan:
continuous: true
# Bare form: restores the configured scan_parameters mode — no
# set_continuous emitted (asserted in the codegen test).
- bk72xx_ble_tracker.start_scan:
- bk72xx_ble_tracker.stop_scan
bk72xx:
board: cb2s
bk72xx_ble_tracker:
scan_parameters:
continuous: false
active: false
on_ble_advertise:
- mac_address:
- AC:37:43:77:5F:4C
- 11:22:33:44:55:66
then:
- lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));'
on_ble_service_data_advertise:
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
mac_address: AC:37:43:77:5F:4C
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- service_uuid: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_scan_end:
- then:
- lambda: 'ESP_LOGD("t", "end");'
@@ -0,0 +1,62 @@
"""Codegen tests for the tracker automations.
The shared trigger classes (ble_device_base/automation.h) are compiled by every
esp32 BLE compile test via AUTO_LOAD, but the BK-specific side — automation.h's
action templates and restart_scan_duration() — compiles on no CI board (the
bk72xx base board generic-bk7252 is BLE 4.2 and cannot build the tracker), and
validate fixtures never run to_code. The generated main is therefore the only
automated check on the setter spellings and the listener accounting."""
from collections.abc import Callable
from pathlib import Path
import re
from esphome.components import ble_device_base
from tests.component_tests.helpers import get_define_value
def test_trigger_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> 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
# 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,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
assert "set_address(0xAC3743775F4CULL)" in main_cpp
# 32-bit middle branch of the width dispatch
assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp
# All three manufacturer widths: getattr() builds these names as strings,
# so a misspelling only ever fails here.
assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp
assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp
assert (
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
# active: false (non-default) flows through to the setter.
assert "->set_scan_active(false)" in main_cpp
# Constructor call, not just the declaration: the parent argument is what
# registers the trigger as a listener.
assert re.search(
r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp
)
# Seven triggers register as listeners; an undercount silently drops the
# last trigger at runtime (StaticVector::push_back past capacity), so the
# define is the assertion that matters most.
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7"
@@ -0,0 +1,86 @@
"""Tests for ble_client config validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.ble_client import (
CONF_DESCRIPTOR_UUID,
CONF_ON_NOTIFY,
notify_from_on_notify,
validate_descriptor_not_notify,
)
from esphome.components.ble_client.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA
from esphome.components.ble_client.text_sensor import (
CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA,
)
from esphome.const import (
CONF_CHARACTERISTIC_UUID,
CONF_NAME,
CONF_NOTIFY,
CONF_SERVICE_UUID,
CONF_TYPE,
)
from esphome.types import ConfigType
DESCRIPTOR_CONFIG: ConfigType = {
CONF_NAME: "test",
CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
CONF_CHARACTERISTIC_UUID: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E",
CONF_DESCRIPTOR_UUID: "2902",
}
def test_notify_with_descriptor_uuid_rejected() -> None:
config: ConfigType = {CONF_NOTIFY: True, CONF_DESCRIPTOR_UUID: "2902"}
with pytest.raises(cv.Invalid, match="cannot send notifications"):
validate_descriptor_not_notify(config)
def test_on_notify_with_descriptor_uuid_rejected() -> None:
config: ConfigType = {
CONF_NOTIFY: False,
CONF_ON_NOTIFY: [{}],
CONF_DESCRIPTOR_UUID: "2902",
}
with pytest.raises(cv.Invalid, match="cannot send notifications"):
validate_descriptor_not_notify(config)
def test_descriptor_uuid_without_notify_allowed() -> None:
config: ConfigType = {CONF_NOTIFY: False, CONF_DESCRIPTOR_UUID: "2902"}
assert validate_descriptor_not_notify(config) is config
def test_notify_without_descriptor_uuid_allowed() -> None:
config: ConfigType = {CONF_NOTIFY: True}
assert validate_descriptor_not_notify(config) is config
def test_sensor_schema_rejects_notify_with_descriptor() -> None:
config = {**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic", CONF_NOTIFY: True}
with pytest.raises(cv.Invalid, match="cannot send notifications"):
SENSOR_SCHEMA(config)
def test_text_sensor_schema_rejects_notify_with_descriptor() -> None:
config = {**DESCRIPTOR_CONFIG, CONF_NOTIFY: True}
with pytest.raises(cv.Invalid, match="cannot send notifications"):
TEXT_SENSOR_SCHEMA(config)
def test_sensor_schema_allows_descriptor_polling() -> None:
assert SENSOR_SCHEMA({**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic"})
def test_text_sensor_schema_allows_descriptor_polling() -> None:
assert TEXT_SENSOR_SCHEMA(dict(DESCRIPTOR_CONFIG))
def test_on_notify_implies_notify() -> None:
config: ConfigType = {CONF_NOTIFY: False, CONF_ON_NOTIFY: [{}]}
assert notify_from_on_notify(config)[CONF_NOTIFY] is True
def test_notify_unchanged_without_on_notify() -> None:
config: ConfigType = {CONF_NOTIFY: False}
assert notify_from_on_notify(config)[CONF_NOTIFY] is False
@@ -0,0 +1,7 @@
esphome:
name: slotcount-controller
bk72xx:
board: generic-bk7252
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-tracker
bk72xx:
board: generic-bk7252
bk72xx_ble_tracker:
@@ -0,0 +1,16 @@
esphome:
name: slotcount-esp32-proxy
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
api:
bluetooth_proxy:
active: true
@@ -0,0 +1,9 @@
esphome:
name: slotcount-esp32-tracker
esp32:
board: esp32dev
framework:
type: esp-idf
esp32_ble_tracker:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-ln882h-tracker
ln882x:
board: generic-ln882h
ln882h_ble_tracker:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-rp2-controller
rp2:
board: rpipicow
rp2040_ble:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-rp2-tracker
rp2:
board: rpipicow
rp2_ble_tracker:
@@ -0,0 +1,231 @@
"""Tests for the BLE hub provider registry and the missing-hub diagnostics."""
from collections.abc import Callable, Generator
from importlib import import_module
from pathlib import Path
import pytest
import esphome.codegen as cg
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import KEY_TARGET_PLATFORM, Platform
from esphome.core import CORE, ID, KEY_CORE
from esphome.cpp_generator import MockObjClass
COMPONENTS_DIR = Path(ble_device_base.__file__).parent.parent
@pytest.fixture
def hub_registry() -> Generator[set[str]]:
"""Save/restore _HUB_PROVIDERS — a module global with no reset hook.
CORE state needs no bookkeeping here: conftest's autouse reset_core
fixture reassigns it after every test.
"""
saved = set(ble_device_base._HUB_PROVIDERS)
yield ble_device_base._HUB_PROVIDERS
ble_device_base._HUB_PROVIDERS.clear()
ble_device_base._HUB_PROVIDERS.update(saved)
def _generated_id() -> ID:
"""An ID as cv.GenerateID leaves it before the ID-assignment pass."""
return ID(None, is_declaration=False, type="ble_device_base::BLEHub")
def _set_platform(platform: str | None) -> None:
core_data = CORE.data.setdefault(KEY_CORE, {})
if platform is None:
core_data.pop(KEY_TARGET_PLATFORM, None)
else:
core_data[KEY_TARGET_PLATFORM] = platform
# The missing-hub diagnostics: one test per path so a regression in one
# scenario cannot mask the others. The hub binding must fail with a
# tracker-naming message, not use_id's C++-class error, regardless of
# config-step ordering internals.
def test_empty_registry_names_every_in_tree_tracker(hub_registry: set[str]) -> None:
# The common failure: a fresh CLI process where the tracker was simply
# forgotten, so no tracker module was ever imported and the registry is
# empty. The error must still name the in-tree trackers.
hub_registry.clear()
CORE.loaded_integrations.clear()
_set_platform(None)
with pytest.raises(
cv.Invalid,
match="add one of: bk72xx_ble_tracker, esp32_ble_tracker, ln882h_ble_tracker, rp2_ble_tracker",
):
ble_device_base._require_hub(_generated_id())
def test_platform_filters_the_suggested_trackers(hub_registry: set[str]) -> None:
hub_registry.clear()
CORE.loaded_integrations.clear()
_set_platform("esp32")
with pytest.raises(cv.Invalid, match="add one of: esp32_ble_tracker$"):
ble_device_base._require_hub(_generated_id())
def test_ble_less_platform_is_not_misdirected(hub_registry: set[str]) -> None:
# A known platform with no in-tree hub must not be pointed at other
# platforms' trackers; out-of-tree BLE hubs are not supported.
hub_registry.clear()
CORE.loaded_integrations.clear()
_set_platform("esp8266")
with pytest.raises(
cv.Invalid,
match="No BLE tracker exists for esp8266; BLE components are not supported",
):
ble_device_base._require_hub(_generated_id())
def test_explicit_id_bypasses_the_registry(hub_registry: set[str]) -> None:
# Explicit ble_hub_id: is the multi-hub disambiguation case; the ID pass
# owns that diagnosis and its error names the missing id.
hub_registry.clear()
CORE.loaded_integrations.clear()
explicit = ID("my_hub", is_declaration=False, type="ble_device_base::BLEHub")
assert ble_device_base._require_hub(explicit) is explicit
def test_registered_and_loaded_provider_passes(hub_registry: set[str]) -> None:
hub_registry.add("esp32_ble_tracker")
CORE.loaded_integrations.add("esp32_ble_tracker")
generated = _generated_id()
assert ble_device_base._require_hub(generated) is generated
def _module_name(path: Path) -> str:
"""Dotted module name for a file under esphome/components."""
rel = path.relative_to(COMPONENTS_DIR.parent)
parts = rel.with_suffix("").parts
if parts[-1] == "__init__":
parts = parts[:-1]
return "esphome." + ".".join(parts)
def _hub_component_modules() -> list[str]:
"""Components whose codegen class inherits ble_device_base.BLEHub.
The source-text pass only selects import candidates (importing all ~900
component packages is too slow); membership is decided by the class
hierarchy via MockObjClass.inherits_from on every module whose source
matched — nested declaring modules included — so a comment mentioning
BLEHub in a consumer cannot produce a false positive.
"""
hub_modules = []
for pkg in sorted(COMPONENTS_DIR.iterdir()):
if pkg.name == "ble_device_base" or not (pkg / "__init__.py").is_file():
continue
matched = [
path
for path in pkg.rglob("*.py")
if "BLEHub" in path.read_text(encoding="utf-8")
]
if not matched:
continue
for path in matched:
mod = import_module(_module_name(path))
if any(
isinstance(attr, MockObjClass)
and attr is not ble_device_base.BLEHub
and attr.inherits_from(ble_device_base.BLEHub)
for attr in vars(mod).values()
):
hub_modules.append(pkg.name)
break
return hub_modules
def test_every_in_tree_hub_registers_as_provider() -> None:
"""A BLEHub subclass that forgets register_hub_provider() makes _require_hub
reject valid configs for that platform — fail CI instead of the user."""
hub_modules = _hub_component_modules()
assert hub_modules, "hub discovery found no BLEHub subclasses — scan stale?"
for name in hub_modules:
assert name in ble_device_base._HUB_PROVIDERS, (
f"{name} subclasses ble_device_base.BLEHub but never calls "
"register_hub_provider(); a valid config using it would be rejected"
)
# The per-platform error table must know every in-tree hub, keyed by real
# platform names — a typo'd key would silently route that platform into
# the no-in-tree-tracker branch.
assert set(ble_device_base._IN_TREE_HUB_PROVIDERS.values()) == set(hub_modules)
platforms = {platform.value for platform in Platform}
assert set(ble_device_base._IN_TREE_HUB_PROVIDERS) <= platforms
def test_ble_device_schema_declares_the_binding_key(hub_registry: set[str]) -> None:
"""Extending BLE_DEVICE_SCHEMA keeps ble_hub_id a declared key on a strict
schema, for both the generated and the explicit form, and the missing-hub
rejection surfaces through the schema itself."""
schema = cv.Schema({}).extend(ble_device_base.BLE_DEVICE_SCHEMA)
hub_registry.clear()
CORE.loaded_integrations.discard("esp32_ble_tracker")
with pytest.raises(cv.Invalid, match="No BLE tracker configured"):
schema({})
hub_registry.add("esp32_ble_tracker")
CORE.loaded_integrations.add("esp32_ble_tracker")
generated = schema({})[ble_device_base.CONF_BLE_HUB_ID]
assert isinstance(generated, ID) and generated.id is None
explicit = schema({"ble_hub_id": "my_hub"})[ble_device_base.CONF_BLE_HUB_ID]
assert explicit.id == "my_hub"
def test_rename_legacy_hub_id_migrates_the_old_key() -> None:
validator = ble_device_base.rename_legacy_hub_id("my_sensor")
migrated = validator({"esp32_ble_id": "tracker1"})
assert migrated == {ble_device_base.CONF_BLE_HUB_ID: "tracker1"}
untouched = validator({"name": "x"})
assert untouched == {"name": "x"}
def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) -> None:
emitted: list[str] = []
monkeypatch.setattr(
"esphome.components.ble_device_base.cg.add", lambda e: emitted.append(str(e))
)
var = cg.MockObj("trig")
ble_device_base.add_service_uuid(var, "11AA")
ble_device_base.add_service_uuid(var, "11223344")
ble_device_base.add_service_uuid(var, "11223344-5566-7788-99aa-bbccddeeff00")
assert "set_service_uuid16" in emitted[0]
assert "set_service_uuid32" in emitted[1]
assert "set_service_uuid128" in emitted[2]
# BLE wire order: the 128-bit array must be byte-reversed — as_hex_array
# in its place would still emit the right setter name and silently never
# match on-air.
assert "0x00,0xff,0xee,0xdd" in emitted[2]
with pytest.raises(ValueError, match="Unsupported UUID format"):
ble_device_base.add_service_uuid(var, "123")
@pytest.mark.parametrize(
("config_name", "define"),
[
("esp32_tracker_only.yaml", "USE_ESP32_BLE_TRACKER"),
("rp2_tracker.yaml", "USE_RP2_BLE_TRACKER"),
("bk72xx_tracker.yaml", "USE_BK72XX_BLE_TRACKER"),
("ln882h_tracker.yaml", "USE_LN882H_BLE_TRACKER"),
],
)
def test_every_tracker_emits_its_alias_define(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_name: str,
define: str,
) -> None:
"""Each tracker's codegen must emit its USE_*_BLE_TRACKER define - the
ble_hub_impl.h alias ladder selects on it. Checked through real codegen
(the other two legs of the invariant, the ladder arm and the defines.h
mirror, are compile-enforced: a missing arm fails any build containing a
BLEHub consumer - today bluetooth_proxy, which CI compiles or tidy-parses
on every tracker platform - and clang-tidy compiles each arm's
static_assert)."""
generate_main(component_config_path(config_name))
assert define in {d.name for d in CORE.defines}, f"{define} not emitted by codegen"
@@ -1,16 +1,24 @@
"""Tests for bk72xx_ble_tracker scan parameter validation."""
"""Tests for the shared BLE tracker scan parameter validation."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.bk72xx_ble_tracker import SCAN_PARAMETERS_SCHEMA, to_ble_units
from esphome.components.bk72xx_ble_tracker import (
SCAN_PARAMETERS_SCHEMA as BK72XX_SCHEMA,
)
from esphome.components.ble_device_base import to_ble_units
from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA
from esphome.components.ln882h_ble_tracker import (
SCAN_PARAMETERS_SCHEMA as LN882H_SCHEMA,
)
from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA
def _validate(**kwargs: str) -> dict:
"""Run a scan_parameters config through the schema, applying defaults."""
return SCAN_PARAMETERS_SCHEMA(dict(kwargs))
def _validate(**kwargs: str | bool) -> dict:
"""Run a scan_parameters config through the bk72xx tracker's real schema."""
return BK72XX_SCHEMA(kwargs)
# --- to_ble_units ---
@@ -36,14 +44,55 @@ def test_to_ble_units_truncates() -> None:
assert to_ble_units(cv.positive_time_period("2500us")) == 4
# --- accepted configurations ---
# --- the real per-chip schemas ---
def test_defaults_are_valid() -> None:
"""The documented default 100 ms / 30 ms pair validates."""
def test_bk72xx_defaults_are_valid() -> None:
"""bk72xx pins the BK reference rate — 100 ms interval, shared 30 ms window —
and exposes active (default on, like every active-capable tracker)."""
config = _validate()
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 48
assert config["active"] is True
def test_esp32_defaults_are_valid() -> None:
"""esp32 pins the ESP-IDF reference rate and exposes active (default on)."""
config = ESP32_SCHEMA({})
assert to_ble_units(config["interval"]) == 512
assert to_ble_units(config["window"]) == 48
assert config["active"] is True
def test_rp2_defaults_are_valid() -> None:
"""rp2 pins 100 ms interval / 30 ms window — a 30 % duty cycle leaving the
shared CYW43 radio mostly free for WiFi — and exposes active (default on)."""
config = RP2_SCHEMA({})
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 48
assert config["active"] is True
def test_ln882h_defaults_are_valid() -> None:
"""ln882h pins the LN SDK reference rate — 100 ms interval / 50 ms window
(50 % duty) — and exposes active (default on)."""
config = LN882H_SCHEMA({})
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 80
assert config["active"] is True
def test_esp32_active_can_disable() -> None:
config = ESP32_SCHEMA({"active": False})
assert config["active"] is False
def test_bk72xx_active_can_disable() -> None:
config = _validate(active=False)
assert config["active"] is False
# --- accepted configurations ---
def test_minimum_separation_accepted() -> None:
@@ -75,6 +124,11 @@ def test_window_equal_to_interval_accepted() -> None:
assert to_ble_units(config["interval"]) == to_ble_units(config["window"])
def test_duration_equal_to_three_intervals_accepted() -> None:
"""The three-interval floor is inclusive, mirroring the ceilings above."""
_validate(duration="3s", interval="1s", window="500ms")
# --- rejected configurations ---
@@ -100,12 +154,8 @@ def test_out_of_range_rejected(interval: str, window: str, offender: str) -> Non
def test_unit_collapse_rejected() -> None:
"""Regression: 3000us/2500us both floor to 4 units — a hidden 100 % duty cycle.
This is the configuration that previously validated and programmed the radio
permanently on despite asking for roughly 83 %.
"""
with pytest.raises(cv.Invalid, match="both round to 4 x 0.625 ms"):
"""3000us/2500us both floor to 4 units — a hidden 100 % duty cycle."""
with pytest.raises(cv.Invalid, match="both truncate to 4 x 0.625 ms"):
_validate(interval="3000us", window="2500us")
@@ -0,0 +1,130 @@
"""Tests for the shared slot_counter codegen factory.
The factory is exercised end to end through the real controllers: a tracker
config must emit the platform's scan listener count define, and a
controller-only config must emit nothing so the guarded StaticVector storage
compiles out.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
from ..helpers import get_define_value
@pytest.mark.parametrize(
("config", "define"),
[
("bk72xx_tracker.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"),
("rp2_tracker.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"),
],
)
def test_tracker_requests_one_slot(
config: str,
define: str,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The tracker's to_code requests a slot; the FINAL job emits the count.
The neutral listener count must stay absent from the same build: no BLE
consumer registered through register_ble_device().
"""
generate_main(component_config_path(config))
assert get_define_value(define) == "1"
assert get_define_value("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT") is None
@pytest.mark.parametrize(
("config", "define"),
[
("bk72xx_controller_only.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"),
("rp2_controller_only.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"),
],
)
def test_controller_only_emits_no_count(
config: str,
define: str,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""No consumer, no define — the guarded listener storage compiles out."""
generate_main(component_config_path(config))
assert get_define_value(define) is None
def test_neutral_listener_count_emitted_when_requested() -> None:
"""Registering through register_ble_device() emits the neutral count.
No in-tree sensor registers through ble_device_base.register_ble_device()
yet (consumer migration is a follow-up), so the coroutine is driven with a
mock hub instead of a config; every tracker's #ifdef-guarded listener
storage keys on this define, and a broken emit path would compile the
storage out silently.
"""
import esphome.codegen as cg
from esphome.components import ble_device_base
from esphome.core import ID
hub_id = ID("hub", type=ble_device_base.BLEHub)
CORE.register_variable(hub_id, cg.MockObj("hub"))
CORE.add_job(
ble_device_base.register_ble_device,
cg.MockObj("listener"),
{ble_device_base.CONF_BLE_HUB_ID: hub_id},
)
CORE.flush_tasks()
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "1"
def test_esp32_tracker_handler_counts(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A bare tracker registers its four esp32_ble handlers and nothing else."""
generate_main(component_config_path("esp32_tracker_only.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
assert get_define_value("ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT") == "1"
assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1"
assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1"
assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None
# No advertisement listener or client is registered, so both storages compile out.
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None
def test_esp32_bluetooth_proxy_requests_client_slots_only(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The proxy requests a client slot per connection (three by default with
active: true); advertisements and scanner state arrive through the hub
callbacks, so no listener slot exists."""
generate_main(component_config_path("esp32_bluetooth_proxy.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3"
# One neutral GATT backend slot per connection (the hub-model flip).
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3"
def test_counts_reset_between_compiles(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A second compile in the same process starts from zero.
The module level counters this change removes leaked across compiles in a
long lived host process (dashboard, device-builder), growing the handler
counts by one per compile and oversizing the StaticCallbackManager storage.
"""
generate_main(component_config_path("esp32_tracker_only.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
CORE.reset()
generate_main(component_config_path("esp32_tracker_only.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
@@ -0,0 +1,19 @@
"""bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together.
The mirror doubles as the outer CONFIG_SCHEMA's connection_slots bound, and
the esp32 schema builder lazily imports esp32_ble and asserts the two values
agree, but that assert only fires while building the esp32 schema. This test
catches drift when the upstream constant changes without any esp32 config
being validated.
"""
from esphome.components import esp32_ble
from esphome.components.bluetooth_proxy import _IDF_MAX_CONNECTIONS
def test_mirror_matches_esp32_ble() -> None:
assert _IDF_MAX_CONNECTIONS == esp32_ble.IDF_MAX_CONNECTIONS, (
"bluetooth_proxy._IDF_MAX_CONNECTIONS is out of sync with "
"esp32_ble.IDF_MAX_CONNECTIONS; update the mirror in "
"esphome/components/bluetooth_proxy/__init__.py"
)
@@ -0,0 +1,74 @@
"""The outer CONFIG_SCHEMA re-declares the esp32 scalar keys so tooling can walk
them without importing the esp32 BLE stack; pin the two declarations together.
The outer schema carries no defaults (the per-platform schema applies them), so
drift cannot surface in validation output — a key renamed or removed in
_esp32_config_schema() but not here would silently vanish from the dashboard's
field extractor. This test is what catches that. The outer schema bounds
connection_slots with the loosest platform cap (_IDF_MAX_CONNECTIONS) so range
walkers see a real Range; per-platform schemas tighten it, and the cap itself
is pinned by test_idf_max_connections_mirror.
"""
import voluptuous as vol
from esphome import config_validation as cv
from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema
def _esp32_schema_keys() -> dict[str, object]:
# The builder names its platform explicitly, so no CORE state is needed
# (this also mirrors how the language-schema dumper calls it).
return _keys(_schema_of(_esp32_config_schema()))
# esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA
# plumbing (derived, so a future core key does not fail this component's test),
# generated IDs (not user-walkable options), and connections (must validate
# exactly once — see the comment above CONFIG_SCHEMA).
_NOT_MIRRORED = {str(key.schema) for key in cv.COMPONENT_SCHEMA.schema} | {
"connections"
}
def _schema_of(validator: cv.All) -> vol.Schema:
"""The vol.Schema stage of a cv.All chain, found by type rather than by
position so reordering the chain cannot silently break these tests."""
schemas = [v for v in validator.validators if isinstance(v, vol.Schema)]
assert len(schemas) == 1, f"expected exactly one vol.Schema stage, got {schemas}"
return schemas[0]
def _keys(schema: vol.Schema) -> dict[str, object]:
return {str(key.schema): key for key in schema.schema}
def test_outer_scalar_keys_exist_in_esp32_schema() -> None:
outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _esp32_schema_keys()
missing = set(outer) - set(esp32)
assert not missing, (
f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema "
"does not; update one of them in "
"esphome/components/bluetooth_proxy/__init__.py"
)
def test_esp32_scalars_all_walkable() -> None:
"""Every non-generated esp32 scalar option must appear in the outer schema
(connections is deliberately excluded — it must validate exactly once)."""
outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _esp32_schema_keys()
scalar = {
name
for name, key in esp32.items()
if isinstance(key, vol.Optional)
and not isinstance(key, cv.GenerateID)
and name not in _NOT_MIRRORED
}
missing = scalar - set(outer)
assert not missing, (
f"esp32 scalar options {sorted(missing)} are missing from the outer "
"CONFIG_SCHEMA and invisible to schema tooling; update "
"esphome/components/bluetooth_proxy/__init__.py"
)
@@ -0,0 +1,263 @@
"""The three platform-gate branches: BLE-less platforms are rejected with the
real reason, hub platforms reject GATT-only options by name, and the
advertisement-only arm applies its own defaults."""
from pathlib import Path
import re
import pytest
from esphome import config_validation as cv
from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy
from esphome.config_helpers import frameworks_for_platforms
from esphome.const import (
CONF_ACTIVE,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_LN882X,
PLATFORM_RP2,
PlatformFramework,
)
from esphome.core import CORE
from ..types import SetCoreConfigCallable
# Advertisement-only hub platforms; rp2 runs the full proxy and has its own
# tests below.
HUB_PLATFORM_FRAMEWORKS = [
PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.LN882X_ARDUINO,
]
HUB_TRACKERS = {
PLATFORM_BK72XX: "bk72xx_ble_tracker",
PLATFORM_LN882X: "ln882h_ble_tracker",
PLATFORM_RP2: "rp2_ble_tracker",
}
def test_hub_platform_list_covers_every_hub_platform() -> None:
# A platform added to _HUB_PLATFORMS would otherwise get no gate coverage
# at all; GATT platforms have their own tests.
advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set(
bluetooth_connection.HUB_MAX_CONNECTIONS
)
assert {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} == advertisement_only
assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS)
def _set_platform(platform: str | None) -> None:
# For arms set_core_config cannot express (bare platform, no framework).
CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform
def _register_tracker(platform: str) -> None:
# The ble_hub_id guard needs a loaded tracker, normally registered as an
# import side effect of the tracker module.
tracker = HUB_TRACKERS[platform]
ble_device_base.register_hub_provider(tracker)
CORE.loaded_integrations.add(tracker)
def test_ble_less_platform_gets_the_real_reason(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP8266_ARDUINO)
with pytest.raises(cv.Invalid, match="not supported on esp8266"):
bluetooth_proxy.CONFIG_SCHEMA({})
def test_ble_less_platform_connection_keys_fall_through(
set_core_config: SetCoreConfigCallable,
) -> None:
# The key-level rejection must not fire here — it would imply an
# advertisement-only proxy exists on this platform.
set_core_config(PlatformFramework.ESP8266_ARDUINO)
with pytest.raises(cv.Invalid, match="not supported on esp8266"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2})
def test_no_target_platform_keeps_the_key_gate_out_of_the_way() -> None:
# set_core_config cannot express "no platform"; script/build_codeowners.py
# sets exactly this shape, and the key gate returns early on it so the
# platform gate is what reports.
CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None}
with pytest.raises(cv.Invalid, match="not supported on None"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2})
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
def test_hub_platform_rejects_active(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
) -> None:
set_core_config(platform_framework)
_register_tracker(platform_framework.value[0])
with pytest.raises(cv.Invalid, match="Active connections are not supported"):
bluetooth_proxy.CONFIG_SCHEMA({"active": True})
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
@pytest.mark.parametrize(
("key", "value"),
[
("connection_slots", 2),
("cache_services", True),
# Absent from the outer CONFIG_SCHEMA, so this gate is the only test
# that touches it.
("connections", [{}]),
],
)
def test_hub_platform_rejects_connection_keys_by_name(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
key: str,
value: object,
) -> None:
set_core_config(platform_framework)
with pytest.raises(cv.Invalid, match=f"'{key}' requires active"):
bluetooth_proxy.CONFIG_SCHEMA({key: value})
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
def test_hub_platform_accepts_the_advertisement_only_shape(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
) -> None:
set_core_config(platform_framework)
_register_tracker(platform_framework.value[0])
validated = bluetooth_proxy.CONFIG_SCHEMA({})
assert validated[CONF_ACTIVE] is False
def test_rp2_defaults_to_the_full_proxy(
set_core_config: SetCoreConfigCallable,
) -> None:
# esp32 parity: active defaults to true, with the platform's slot limit,
# and one populated connection entry for the codegen to index.
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
validated = bluetooth_proxy.CONFIG_SCHEMA({})
assert validated[CONF_ACTIVE] is True
assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 3
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 3
def test_rp2_accepts_explicit_passive(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
validated = bluetooth_proxy.CONFIG_SCHEMA({CONF_ACTIVE: False})
assert validated[CONF_ACTIVE] is False
assert bluetooth_proxy.CONF_CONNECTIONS not in validated
def test_rp2_rejects_slots_beyond_the_btstack_limit(
set_core_config: SetCoreConfigCallable,
) -> None:
# The BTstack pool overrides are sized for RP2_MAX_CONNECTIONS slots.
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
with pytest.raises(cv.Invalid, match="at most 3 connection slot"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 4})
# Fewer slots than the cap stay accepted (the prebuilt single-client pool
# path for 1, the wrap path for 2).
validated = bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 1})
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1
# Values past even the loosest platform cap stop at the outer walkable
# schema, which stays bounded for range walkers (device-builder sync);
# in-range values get the platform message above.
with pytest.raises(cv.Invalid, match="at most 9"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 12})
def test_rp2_rejects_esp32_only_keys_by_name(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
with pytest.raises(cv.Invalid, match="'cache_services' is esp32-only"):
bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True})
with pytest.raises(cv.Invalid, match="'connections' has no per-connection options"):
bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]})
def test_hub_source_filter_covers_every_hub_platform() -> None:
# bluetooth_connection cannot import this module to derive the hub.cpp
# framework set, so pin it here: a platform admitted to the proxy but
# missing from the filter would validate, then fail at link.
expected = frameworks_for_platforms(
[*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32]
)
hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[
"bluetooth_connection_hub.cpp"
]
assert expected == hub_frameworks
def test_bluetooth_connection_auto_load_covers_its_includes() -> None:
# The backend registers with its platform BLE stack (and the Bluedroid
# header includes the tracker's), so that closure lives here and
# consumers stay platform-blind; the platform-less arm is the union for
# manifest-resolving tooling.
_set_platform("esp32")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"]
_set_platform("rp2")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"]
_set_platform("ln882x")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"]
_set_platform(None)
assert bluetooth_connection.AUTO_LOAD() == [
"ble_device_base",
"esp32_ble_tracker",
"rp2040_ble",
]
def test_every_registered_hub_platform_has_a_schema_arm() -> None:
# A platform added to HUB_MAX_CONNECTIONS without a schema builder or
# _HUB_PLATFORMS entry would only fail when a config for it is validated
# (or not even then); pin both couplings here. Connection codegen is
# shared (bluetooth_connection.new_gatt_backend), so it needs no arm.
registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS)
assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS)
assert registered <= set(bluetooth_proxy._HUB_PLATFORMS)
# Hub platforms must also be in the backend registry the shared codegen
# helpers dispatch on.
assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS)
# The outer walkable schema's bound must stay the loosest platform cap.
assert (
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
<= bluetooth_proxy._IDF_MAX_CONNECTIONS
)
def test_defines_h_mirrors_the_rp2_slot_cap() -> None:
# esphome/core/defines.h carries a literal BLUETOOTH_PROXY_MAX_CONNECTIONS
# for static analysis; pin it to the real rp2 cap.
defines = (Path(__file__).parents[3] / "esphome" / "core" / "defines.h").read_text()
cap = bluetooth_connection.RP2_MAX_CONNECTIONS
# The rp2 arm's define, tolerating blank/comment lines in between.
match = re.search(
r"#elif defined\(USE_RP2\)\s*(?:(?://[^\n]*)?\n)+#define BLUETOOTH_PROXY_MAX_CONNECTIONS (\d+)",
defines,
)
assert match is not None, "no USE_RP2 arm defines BLUETOOTH_PROXY_MAX_CONNECTIONS"
assert int(match.group(1)) == cap, (
f"defines.h rp2 arm carries {match.group(1)}, expected {cap}"
)
# The static-analysis client count scales with the same cap. Scoped to
# the USE_RP2 block: the esp32 arm carries its own count.
rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines)
assert rp2_block is not None, "no USE_RP2 platform block in defines.h"
match = re.search(
r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1)
)
assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block"
assert int(match.group(1)) == cap, (
f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}"
)
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: t133a01
dc_pin: GPIO21
reset_pin: GPIO38
cs_pin: GPIO10
cs1_pin: GPIO2
busy_pin: GPIO13
update_interval: never
dimensions:
width: 200
height: 200
@@ -462,3 +462,24 @@ def test_enable_pin_code_generation(
# Both pin objects must be passed to the display via set_enable_pins() as a
# std::vector initializer list, in the configured order.
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
def test_model_with_no_default_init_sequence_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that code generation succeeds for a model with no default init sequence.
The base "t133a01" model (used directly, not via one of its `.extend()`
variants) doesn't override `get_init_sequence()` or pass `initsequence` to
its constructor, and the user didn't supply `init_sequence:` either.
`EpaperModel.get_init_sequence()` used to default to `None` in this case,
which made `flatten_sequence()` raise a `TypeError` during code
generation. Regression test for that crash.
"""
main_cpp = generate_main(component_config_path("t133a01_no_init_sequence.yaml"))
# The generated constructor call takes (name, width, height, init_sequence,
# init_sequence_length, ...); a length of 0 confirms the empty init
# sequence array was generated instead of raising during code generation.
assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp)
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: ecdsa256
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
variant: esp32
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: ecdsa_v1
verification_key: ../../../components/esp32/dummy_signing_key_v1_ecdsa.pem
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
signing_key: ../../../components/esp32/dummy_signing_key.pem
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
verification_keys:
- ../../../components/esp32/dummy_signing_key.pem
+300 -10
View File
@@ -10,11 +10,16 @@ from typing import Any
import pytest
from esphome.components.esp32 import (
KEY_FATFS_REQUIRED,
KEY_VFS_DIR_REQUIRED,
KEY_VFS_SELECT_REQUIRED,
KEY_VFS_TERMIOS_REQUIRED,
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
_ota_downgrade_protection_errors,
_reconcile_network_sdkconfig,
_reconcile_vfs_fatfs_sdkconfig,
)
from esphome.components.esp32.const import (
KEY_ESP32,
@@ -108,6 +113,24 @@ def test_esp32_default_toolchain_is_esp_idf(
assert CORE.toolchain == expected
@pytest.mark.parametrize(
"config_toolchain",
[Toolchain.SDK_NRF.value, "nonsense"],
)
def test_esp32_rejects_unsupported_toolchains(
set_core_config: SetCoreConfigCallable,
config_toolchain: str,
) -> None:
"""Toolchains esp32 does not support are rejected at validation time."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
CORE.toolchain = None
with pytest.raises(cv.Invalid, match="Unknown value"):
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
@pytest.mark.parametrize(
("config", "error_match"),
[
@@ -252,6 +275,53 @@ def test_nvs_encryption_sdkconfig(
assert "PERMANENT and IRREVERSIBLE" in caplog.text
@pytest.mark.parametrize(
("fixture", "multi_key", "idf_on_update"),
[
# Externally-signed RSA with a declared trusted-key list hands
# verification to ESPHome's multi-key verifier, so IDF's single-block
# on-update check must be OFF. It defaults ON under
# SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it has to be set to False
# explicitly -- not merely omitted.
("signed_ota_verification_keys_s3.yaml", True, False),
# Externally-signed RSA without a trusted-key list has no trust anchor,
# so it falls back to IDF's built-in check.
("signed_ota_external_rsa_s3.yaml", False, True),
# Build-time signing and the other schemes keep IDF's check.
("signed_ota_signing_key_s3.yaml", False, True),
("signed_ota_ecdsa256_c6.yaml", False, True),
("signed_ota_ecdsa_v1.yaml", False, True),
],
)
def test_signed_ota_verification_sdkconfig(
fixture: str,
multi_key: bool,
idf_on_update: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only external RSA disables IDF's on-update check and uses ESPHome's verifier."""
generate_main(component_config_path(fixture))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
# The padded, externally-signable image is always produced.
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") is True
# Explicit value (never left to the Kconfig default) decides who verifies.
assert (
sdkconfig.get("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT") is idf_on_update
)
defines = {define.name for define in CORE.defines}
assert ("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" in defines) is multi_key
if multi_key:
# The padding / reserved signature sector the verifier depends on keys
# off the RSA scheme symbol, not the hidden CONFIG_SECURE_SIGNED_APPS
# (which the explicit `n` above drives to n). Pin the real dependency.
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME") is True
# The compiled-in trust anchor: the fixture lists one key.
define_values = {define.name: str(define.value) for define in CORE.defines}
assert define_values["OTA_TRUSTED_KEY_COUNT"] == "1"
assert "OTA_TRUSTED_KEY_DIGESTS" in define_values
@pytest.mark.parametrize(
("fixture", "expect_warning"),
[
@@ -454,26 +524,18 @@ def test_flash_mode_unset_leaves_defaults(
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(
wifi=True, bluetooth=True, ble_42=True, software_coexistence=True
),
NetworkSdkconfigData(wifi=True, bluetooth=True, software_coexistence=True),
{},
{
"CONFIG_BT_ENABLED": True,
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
"CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False,
"CONFIG_SW_COEXIST_ENABLE": True,
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_ble_tracker_coexistence",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(bluetooth=True),
{},
{"CONFIG_BT_ENABLED": True},
id="idf_ble_server_only_no_ble42",
),
# --- IDF: user sdkconfig_options always win ---
pytest.param(
PlatformFramework.ESP32_IDF,
@@ -557,6 +619,160 @@ def test_reconcile_network_sdkconfig(
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
@pytest.mark.parametrize(
("requires", "fatfs_required", "disables", "preset", "expected"),
[
# Nothing required and every disable_* flag off (NOT the shipped defaults, which
# disable everything): VFS enabled, FATFS left untouched entirely.
pytest.param(
{},
False,
(False, False, False, False),
{},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
},
id="nothing_disabled_nothing_required",
),
# The shipped out-of-the-box path: every disable_* flag defaults to True and nothing
# is required -- VFS off, FATFS at the smallest footprint (8.3 names, one volume).
pytest.param(
{},
False,
(True, True, True, True),
{},
{
"CONFIG_VFS_SUPPORT_TERMIOS": False,
"CONFIG_VFS_SUPPORT_SELECT": False,
"CONFIG_VFS_SUPPORT_DIR": False,
"CONFIG_FATFS_LFN_NONE": True,
"CONFIG_FATFS_VOLUME_COUNT": 1,
},
id="all_disabled_fatfs_fallback",
),
# A component's require_* beats the user's disable_* flag for every VFS feature.
pytest.param(
{
KEY_VFS_TERMIOS_REQUIRED: True,
KEY_VFS_SELECT_REQUIRED: True,
KEY_VFS_DIR_REQUIRED: True,
},
False,
(True, True, True, False),
{},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
},
id="require_beats_disable",
),
# A user sdkconfig_options preset wins over a require (the set_opt guard).
pytest.param(
{KEY_VFS_SELECT_REQUIRED: True},
False,
(False, False, False, False),
{"CONFIG_VFS_SUPPORT_SELECT": False},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": False,
"CONFIG_VFS_SUPPORT_DIR": True,
},
id="user_preset_wins_over_require",
),
# require_fatfs() with no user preset: long filenames on the heap, 255 chars,
# four volumes.
pytest.param(
{},
True,
(False, False, False, False),
{},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
"CONFIG_FATFS_LFN_NONE": False,
"CONFIG_FATFS_LFN_HEAP": True,
"CONFIG_FATFS_MAX_LFN": 255,
"CONFIG_FATFS_VOLUME_COUNT": 4,
},
id="fatfs_required_defaults",
),
# CONFIG_FATFS_LONG_FILENAMES is a Kconfig choice: a user picking any member
# (here LFN_STACK) leaves the whole group untouched -- no second =y in the choice.
pytest.param(
{},
True,
(False, False, False, False),
{"CONFIG_FATFS_LFN_STACK": "y"},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
"CONFIG_FATFS_LFN_STACK": "y",
"CONFIG_FATFS_VOLUME_COUNT": 4,
},
id="fatfs_user_lfn_stack_untouched",
),
# disable_fatfs (the shipped default) with a user LFN pick: the choice group is the
# user's -- no LFN_NONE=y written next to their member, only the volume fallback.
pytest.param(
{},
False,
(False, False, False, True),
{"CONFIG_FATFS_LFN_HEAP": "y"},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
"CONFIG_FATFS_LFN_HEAP": "y",
"CONFIG_FATFS_VOLUME_COUNT": 1,
},
id="disable_fatfs_user_lfn_untouched",
),
# Same for an explicit LFN_NONE preset: the group is the user's, only the volume
# count default is added.
pytest.param(
{},
True,
(False, False, False, False),
{"CONFIG_FATFS_LFN_NONE": "y"},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
"CONFIG_FATFS_LFN_NONE": "y",
"CONFIG_FATFS_VOLUME_COUNT": 4,
},
id="fatfs_user_lfn_none_untouched",
),
],
)
def test_reconcile_vfs_fatfs_sdkconfig(
set_core_config: SetCoreConfigCallable,
requires: dict[str, bool],
fatfs_required: bool,
disables: tuple[bool, bool, bool, bool],
preset: dict[str, Any],
expected: dict[str, Any],
) -> None:
"""The FINAL-priority reconciler resolves the VFS feature flags and the FATFS
defaults from the recorded require_* calls, with user sdkconfig_options winning
and the LFN Kconfig choice treated as one group."""
set_core_config(PlatformFramework.ESP32_IDF)
CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: dict(preset)}
if fatfs_required:
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
for key, value in requires.items():
CORE.data[key] = value
asyncio.run(_reconcile_vfs_fatfs_sdkconfig(*disables))
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
def test_network_wifi_only_reconciles_end_to_end(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -594,6 +810,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
@@ -696,6 +913,9 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None:
# V1 ECDSA: exactly one of signing key / verification key.
{"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"},
{"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"},
# External RSA with a compiled-in trusted-key list (digests).
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32]},
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "cd" * 32]},
],
)
def test_signed_ota_keys_valid_combinations(config: dict) -> None:
@@ -750,6 +970,34 @@ def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -
},
"not both",
),
# A trusted-key list only applies to external RSA.
(
{"signing_scheme": "ecdsa256", "verification_keys": ["ab" * 32]},
"only used with signing scheme 'rsa3072'",
),
# Can't both auto-sign and verify against a fixed trusted set.
(
{
"signing_scheme": "rsa3072",
"signing_key": "key.pem",
"verification_keys": ["ab" * 32],
},
"cannot be combined with",
),
# The singular V1 key and the RSA trusted-key list are mutually exclusive.
(
{
"signing_scheme": "rsa3072",
"verification_key": "key.bin",
"verification_keys": ["ab" * 32],
},
"at most one",
),
# Duplicate trusted keys are rejected.
(
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "ab" * 32]},
"must be unique",
),
],
)
def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
@@ -759,6 +1007,48 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
_validate_signed_ota_keys(config)
def test_sbv2_rsa_key_digest_known_answer() -> None:
"""The compiled-in trust anchor is the block-format digest the device
computes per signature block; pin it to espsecure's known output for the
shipped dummy key so a future change to the derivation can't drift silently.
"""
from esphome.components.esp32 import _sbv2_rsa_key_digest
key = (
Path(__file__).parent.parent.parent
/ "components"
/ "esp32"
/ "dummy_signing_key.pem"
)
assert (
_sbv2_rsa_key_digest(key).hex()
== "957671f5ec1b55b3fb1d32c5525a68d3b8c33847922daddb4feefe64cd679f65"
)
def test_validate_trusted_key_hex_forms() -> None:
"""The digest-input branch: the same key as an uppercase 64-hex digest
normalizes to the PEM-derived value (the two forms are interchangeable), and
a mangled digest fails clearly instead of as a missing file.
"""
from esphome.components.esp32 import _sbv2_rsa_key_digest, _validate_trusted_key
key = (
Path(__file__).parent.parent.parent
/ "components"
/ "esp32"
/ "dummy_signing_key.pem"
)
pem_digest = _sbv2_rsa_key_digest(key).hex()
assert _validate_trusted_key(pem_digest.upper()) == pem_digest
for bad in (pem_digest[:-1], "0x" + pem_digest):
with pytest.raises(cv.Invalid, match="64 hex"):
_validate_trusted_key(bad)
# An unquoted 0x.../all-digit digest reaches the validator as a YAML int.
with pytest.raises(cv.Invalid, match="Quote the digest"):
_validate_trusted_key(0x957671F5EC1B55B3)
@pytest.mark.parametrize(
("value", "expected"),
[
@@ -1,13 +1,31 @@
"""Tests for the ethernet final-validation coexistence gate."""
"""Tests for the ethernet final-validation coexistence gate and schema bounds."""
import pytest
from voluptuous import Invalid
from esphome.components.ethernet import _final_validate
from esphome import config_validation as cv
from esphome.components.esp32 import (
KEY_BOARD,
KEY_IDF_VERSION,
KEY_VARIANT,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import CONF_CLOCK_SPEED, CONFIG_SCHEMA, _final_validate
from esphome.components.network import _validate_priority_list
from esphome.const import CONF_PRIORITY
from esphome.const import CONF_PRIORITY, PlatformFramework
from esphome.core import CORE
import esphome.final_validate as fv
from ..types import SetCoreConfigCallable
_CH390_CONFIG = {
"type": "CH390",
"clk_pin": 47,
"mosi_pin": 48,
"miso_pin": 14,
"cs_pin": 21,
}
@pytest.fixture(autouse=True)
def _reset_full_config():
@@ -35,3 +53,40 @@ def test_rejects_wifi_and_ethernet_with_incomplete_priority() -> None:
)
with pytest.raises(Invalid, match=r"must.*list both interfaces; missing: wifi"):
_final_validate({})
@pytest.mark.parametrize("clock_speed", ["26.67MHz", "72MHz"])
def test_ch390_accepts_clock_speed_up_to_the_datasheet_maximum(
set_core_config: SetCoreConfigCallable, clock_speed: str
) -> None:
"""CH390 SCK is rated to 72MHz, so the schema must accept the whole range."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
KEY_IDF_VERSION: cv.Version(5, 3, 2),
},
)
# _validate derives use_address from the node name, which has no default here.
CORE.name = "ch390-test"
config = CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: clock_speed})
assert config[CONF_CLOCK_SPEED] == cv.frequency(clock_speed)
def test_ch390_rejects_clock_speed_above_the_datasheet_maximum(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The shared 80MHz ceiling is out of spec for this part."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
KEY_IDF_VERSION: cv.Version(5, 3, 2),
},
)
# _validate derives use_address from the node name, which has no default here.
CORE.name = "ch390-test"
with pytest.raises(Invalid, match="value must be at most 72000000"):
CONFIG_SCHEMA({**_CH390_CONFIG, CONF_CLOCK_SPEED: "80MHz"})
@@ -0,0 +1,26 @@
"""Tests for the heatpumpir climate config validation."""
from esphome.components.heatpumpir.climate import _default_visual
from esphome.const import CONF_MAX_TEMPERATURE, CONF_MIN_TEMPERATURE, CONF_VISUAL
from esphome.types import ConfigType
def test_default_visual_seeds_from_required_min_max() -> None:
"""Without a visual block, the required min/max_temperature seed the visual
range so the entity reports it in Home Assistant instead of 0-100 (#17983)."""
config: ConfigType = {CONF_MIN_TEMPERATURE: 18, CONF_MAX_TEMPERATURE: 30}
_default_visual(config)
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == 18
assert config[CONF_VISUAL][CONF_MAX_TEMPERATURE] == 30
def test_default_visual_keeps_explicit() -> None:
"""An explicit visual min/max is not overwritten by the required temps."""
config: ConfigType = {
CONF_MIN_TEMPERATURE: 16,
CONF_MAX_TEMPERATURE: 32,
CONF_VISUAL: {CONF_MIN_TEMPERATURE: 18, CONF_MAX_TEMPERATURE: 30},
}
_default_visual(config)
assert config[CONF_VISUAL][CONF_MIN_TEMPERATURE] == 18
assert config[CONF_VISUAL][CONF_MAX_TEMPERATURE] == 30
+16
View File
@@ -27,3 +27,19 @@ def extract_packed_value(main_cpp: str, var_name: str) -> int:
match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp)
assert match, f"configure call not found for {var_name}"
return int(match.group(1))
def get_define_value(name: str) -> str | None:
"""Rendered value of a CORE define, or None when absent.
Values are codegen expressions (IntLiteral); they are compared rendered.
A value-less define (e.g. USE_BK72XX_BLE) is present but renders as the
string "None", while an absent define returns the None object — easy to
conflate in assertions, so use this helper for valued defines only.
"""
from esphome.core import CORE
for define in CORE.defines:
if define.name == name:
return str(define.value)
return None
@@ -0,0 +1,216 @@
"""Tests for the ld6002b validators that reach across platforms.
wake needs a pin on its own hub, apply_area needs a select on its own hub, and
area_config needs both a button and a select on its own hub. Every one of them
is a same-instance check, which is the half that breaks quietly.
"""
from __future__ import annotations
import pytest
from esphome.components.ld6002b.button import (
CONFIG_SCHEMA as BUTTON_CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA as BUTTON_FINAL_VALIDATE_SCHEMA,
)
from esphome.components.ld6002b.const import CONF_AREA_CONFIG, CONF_Z_MIN
from esphome.components.ld6002b.number import (
CONFIG_SCHEMA as NUMBER_CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA as NUMBER_FINAL_VALIDATE_SCHEMA,
)
from esphome.config import Config
import esphome.config_validation as cv
from esphome.const import (
CONF_AREA_ID,
CONF_BUTTON,
CONF_ID,
CONF_WAKEUP_PIN,
PlatformFramework,
)
from esphome.core import ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "ld6002b_hub"
OTHER_HUB_ID = "ld6002b_other"
def _full_config(
hub: ConfigType,
*,
selects: list[ConfigType] | None = None,
buttons: list[ConfigType] | None = None,
) -> Config:
"""A full config carrying one ld6002b hub, as the ID pass leaves it.
final_validate resolves the hub through get_path_for_id, so the declaring
path has to be registered the way validate_config registers it: the path of
the id value itself, whose parent is the hub's own config.
The platform lists are what the cross-platform validators scan, so a test can
say which of them exist and which hub each one names.
"""
full = Config()
full["ld6002b"] = [hub]
full.declare_ids.append((hub[CONF_ID], ["ld6002b", 0, CONF_ID]))
if selects is not None:
full["select"] = selects
if buttons is not None:
full[CONF_BUTTON] = buttons
return full
def _hub(*, wakeup_pin: bool) -> ConfigType:
hub: ConfigType = {CONF_ID: ID(HUB_ID, is_declaration=True, type="ld6002b")}
if wakeup_pin:
hub[CONF_WAKEUP_PIN] = {"number": 4}
return hub
def _buttons(**buttons: str) -> ConfigType:
"""A button platform config naming the given buttons on the shared hub."""
config: ConfigType = {
"ld6002b_id": ID(HUB_ID, is_declaration=False, type="ld6002b")
}
config.update({key: {"name": name} for key, name in buttons.items()})
return config
def _select(*, hub_id: str = HUB_ID) -> ConfigType:
"""A select platform config naming area_id on the given hub."""
return {
"ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"),
CONF_AREA_ID: {"name": "Area ID"},
}
def _area_numbers(*, hub_id: str = HUB_ID) -> ConfigType:
"""A number platform config carrying one area_config bound."""
return {
"ld6002b_id": ID(hub_id, is_declaration=False, type="ld6002b"),
CONF_AREA_CONFIG: {CONF_Z_MIN: {"name": "Area Z Min"}},
}
def _validated(config: ConfigType) -> ConfigType:
"""Run the button schema, then the final validation the hub is checked in."""
config = BUTTON_CONFIG_SCHEMA(config)
BUTTON_FINAL_VALIDATE_SCHEMA(config)
return config
def _validated_numbers(config: ConfigType) -> ConfigType:
"""The same two passes for the number platform."""
config = NUMBER_CONFIG_SCHEMA(config)
NUMBER_FINAL_VALIDATE_SCHEMA(config)
return config
def test_wake_without_wakeup_pin_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The wake button drives the pin directly, so a hub without one cannot serve it."""
set_core_config(
PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False))
)
with pytest.raises(cv.Invalid, match="wake requires wakeup_pin"):
_validated(_buttons(wake="Wake"))
def test_wake_with_wakeup_pin_passes(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=True))
)
_validated(_buttons(wake="Wake"))
def test_other_buttons_do_not_need_the_pin(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Only wake drives the pin; the query buttons stay usable without one."""
set_core_config(
PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False))
)
_validated(_buttons(get_delay="Get Delay"))
def test_apply_area_without_select_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""apply_area sends the staged bounds to whichever area the select names."""
set_core_config(
PlatformFramework.ESP32_IDF, full_config=_full_config(_hub(wakeup_pin=False))
)
with pytest.raises(
cv.Invalid,
match=(
r"^apply_area requires select\.area_id for the same ld6002b instance"
r" @ data\['apply_area'\]$"
),
):
_validated(_buttons(apply_area="Apply Area"))
def test_apply_area_select_on_another_hub_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A select exists, but on a second ld6002b -- which cannot serve this one."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(
_hub(wakeup_pin=False), selects=[_select(hub_id=OTHER_HUB_ID)]
),
)
with pytest.raises(
cv.Invalid,
match=(
r"^apply_area requires select\.area_id for the same ld6002b instance"
r" @ data\['apply_area'\]$"
),
):
_validated(_buttons(apply_area="Apply Area"))
def test_area_config_without_apply_area_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The six numbers only stage a write; apply_area is what sends it."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(_hub(wakeup_pin=False), selects=[_select()]),
)
with pytest.raises(
cv.Invalid,
match=(
r"^area_config requires button\.apply_area for the same ld6002b instance"
r" @ data\['area_config'\]$"
),
):
_validated_numbers(_area_numbers())
def test_area_config_without_select_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The validator's other half: the staged bounds also need an area to land in."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config=_full_config(
_hub(wakeup_pin=False), buttons=[_buttons(apply_area="Apply Area")]
),
)
with pytest.raises(
cv.Invalid,
match=(
r"^area_config requires select\.area_id for the same ld6002b instance"
r" @ data\['area_config'\]$"
),
):
_validated_numbers(_area_numbers())
@@ -0,0 +1,44 @@
esphome:
name: ln-trigger-codegen
on_boot:
then:
- ln882h_ble_tracker.start_scan:
continuous: true
# Bare form: restores the configured scan_parameters mode — no
# set_continuous emitted (asserted in the codegen test).
- ln882h_ble_tracker.start_scan:
- ln882h_ble_tracker.stop_scan:
ln882x:
board: generic-ln882h
ln882h_ble_tracker:
scan_parameters:
continuous: false
on_ble_advertise:
- mac_address:
- AC:37:43:77:5F:4C
- 11:22:33:44:55:66
then:
- lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));'
on_ble_service_data_advertise:
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
mac_address: AC:37:43:77:5F:4C
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- service_uuid: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_scan_end:
- then:
- lambda: 'ESP_LOGD("t", "end");'
@@ -0,0 +1,55 @@
"""Codegen tests for the tracker automations: the generated main is the
automated check on the setter calls and the listener accounting (the
test.ln882x-ard.yaml compile fixture proves linkage, not codegen shape)."""
from collections.abc import Callable
from pathlib import Path
import re
from esphome.components import ble_device_base
from tests.component_tests.helpers import get_define_value
def test_trigger_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> 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
# 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,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
assert "set_address(0xAC3743775F4CULL)" in main_cpp
# 32-bit middle branch of the width dispatch
assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp
# All three manufacturer widths: getattr() builds these names as strings,
# so a misspelling only ever fails here.
assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp
assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp
assert (
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
# Constructor call, not just the declaration: the parent argument is what
# registers the trigger as a listener.
assert re.search(
r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp
)
# Seven triggers register as listeners; an undercount silently drops the
# last trigger at runtime (StaticVector::push_back past capacity), so the
# define is the assertion that matters most.
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7"
@@ -0,0 +1,60 @@
esphome:
name: test-set-z-index
esp32:
board: esp32dev
framework:
type: esp-idf
spi:
- id: spi_bus
clk_pin: GPIO18
mosi_pin: GPIO23
display:
- platform: mipi_spi
spi_id: spi_bus
model: st7789v
id: tft_display
dimensions:
width: 240
height: 320
cs_pin: GPIO22
dc_pin: GPIO21
auto_clear_enabled: false
invert_colors: false
update_interval: never
lvgl:
displays: tft_display
widgets:
- label:
id: label_a
text: "A"
- label:
id: label_b
text: "B"
- button:
id: trigger_btn
on_click:
- lvgl.widget.set_z_index:
id: label_a
position: top
- lvgl.widget.set_z_index:
id: label_a
position: bottom
- lvgl.widget.set_z_index:
id: label_a
position: up
- lvgl.widget.set_z_index:
id: label_a
position: down
- lvgl.widget.set_z_index:
id: label_a
position: 3
- lvgl.widget.set_z_index:
id: label_a
position: -2
- lvgl.widget.set_z_index:
id: [label_a, label_b]
position: up
+16 -3
View File
@@ -169,14 +169,27 @@ class TestTimingSchema:
def test_round_trip_string(self) -> None:
assert TIMING_SCHEMA("round_trip")["type"] == "round_trip"
def test_round_trip_default_pause(self) -> None:
# Back-compat default: no pause, matching the pre-existing round_trip behavior.
assert TIMING_SCHEMA("round_trip")["pause"] == pytest.approx(0.0)
def test_round_trip_pause_percentage_string(self) -> None:
result = TIMING_SCHEMA({"type": "round_trip", "pause": "50%"})
assert result["pause"] == pytest.approx(0.5)
def test_round_trip_pause_rejects_one(self) -> None:
# pause == 1.0 would make moving_length_ zero and divide by zero in map_progress.
with pytest.raises((Invalid, MultipleInvalid)):
TIMING_SCHEMA({"type": "round_trip", "pause": 1.0})
def test_ease_in_out_default_weight(self) -> None:
result = TIMING_SCHEMA("ease_in_out")
assert result["type"] == "ease_in_out"
assert result["weight"] == pytest.approx(2.0)
assert result["weight"] == pytest.approx(1.0)
def test_ease_in_out_custom_weight(self) -> None:
result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3})
assert result["weight"] == pytest.approx(3.0)
result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 0.5})
assert result["weight"] == pytest.approx(0.5)
def test_gravity_defaults(self) -> None:
result = TIMING_SCHEMA("gravity")
@@ -0,0 +1,128 @@
"""Tests for the ``lvgl.widget.set_z_index`` action: schema validation and
code generation.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.components.lvgl.automation import SET_Z_INDEX_SCHEMA
from esphome.config_validation import Invalid
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestSetZIndexSchemaValidation:
"""Test that SET_Z_INDEX_SCHEMA accepts the documented forms and rejects
everything else.
"""
@pytest.mark.parametrize("position", ["top", "bottom", "up", "down"])
def test_keyword_position_accepted(self, position: str) -> None:
config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position})
assert config["position"] == position.upper()
@pytest.mark.parametrize("position", ["Top", "BOTTOM", "Up", "dOwN"])
def test_keyword_position_case_insensitive(self, position: str) -> None:
config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position})
assert config["position"] == position.upper()
@pytest.mark.parametrize("position", [0, 1, 5, -1, -5])
def test_integer_position_accepted(self, position: int) -> None:
config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": position})
assert config["position"] == position
def test_unknown_keyword_rejected(self) -> None:
with pytest.raises(Invalid):
SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "sideways"})
def test_float_position_rejected(self) -> None:
with pytest.raises(Invalid):
SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": 1.5})
def test_missing_id_rejected(self) -> None:
with pytest.raises(Invalid):
SET_Z_INDEX_SCHEMA({"position": "top"})
def test_missing_position_rejected(self) -> None:
with pytest.raises(Invalid):
SET_Z_INDEX_SCHEMA({"id": "my_widget"})
def test_single_id_is_wrapped_in_list(self) -> None:
config = SET_Z_INDEX_SCHEMA({"id": "my_widget", "position": "top"})
assert len(config["id"]) == 1
assert config["id"][0]["id"].id == "my_widget"
def test_list_of_ids_accepted(self) -> None:
config = SET_Z_INDEX_SCHEMA({"id": ["widget_a", "widget_b"], "position": "top"})
assert [entry["id"].id for entry in config["id"]] == ["widget_a", "widget_b"]
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared set_z_index YAML config once
per module. See ``test_widget_state.py`` for why this is module-scoped
and self-contained rather than using the function-scoped ``generate_main``
fixture from ``conftest.py``.
"""
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
config_path = Path(request.fspath).parent / "config" / "set_z_index_test.yaml"
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_global_section + CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_top_emits_move_foreground(main_cpp: str) -> None:
assert "lv_obj_move_foreground(label_a);" in main_cpp
def test_bottom_emits_move_background(main_cpp: str) -> None:
assert "lv_obj_move_background(label_a);" in main_cpp
def test_up_emits_unguarded_index_increment(main_cpp: str) -> None:
assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);" in main_cpp
def test_down_emits_guarded_index_decrement(main_cpp: str) -> None:
"""``down`` must be guarded so that a widget already at index 0 isn't
reinterpreted by LVGL as "move to the top" (LVGL treats a negative
index as "count from the back").
"""
assert "if (lv_obj_get_index(label_a) > 0) {" in main_cpp
assert "lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) - 1);" in main_cpp
def test_positive_integer_emits_direct_index(main_cpp: str) -> None:
assert "lv_obj_move_to_index(label_a, 3);" in main_cpp
def test_negative_integer_emits_direct_index(main_cpp: str) -> None:
assert "lv_obj_move_to_index(label_a, -2);" in main_cpp
def test_list_of_ids_applies_to_each_widget(main_cpp: str) -> None:
"""``id: [label_a, label_b]`` must emit the move call once per widget."""
assert (
main_cpp.count("lv_obj_move_to_index(label_a, lv_obj_get_index(label_a) + 1);")
== 2
)
assert "lv_obj_move_to_index(label_b, lv_obj_get_index(label_b) + 1);" in main_cpp
@@ -0,0 +1,10 @@
mitsubishi_cn105:
id: ac_hub
climate:
- platform: mitsubishi_cn105
mitsubishi_cn105_id: ac_hub
name: AC
current_temperature_min_interval: 30s
uart_id: uart_bus
update_interval: 10s
@@ -0,0 +1,30 @@
"""Tests for Mitsubishi CN105 climate configuration migration diagnostics."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.mitsubishi_cn105 import climate
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.yaml_util import load_yaml
def test_top_level_hub_rejects_leftover_legacy_climate_keys(
component_fixture_path: Callable[[str], Path],
) -> None:
config = load_yaml(
component_fixture_path("top_level_hub_with_legacy_climate_keys.yaml")
)
CORE.raw_config = config
with pytest.raises(cv.Invalid) as exc_info:
climate.CONFIG_SCHEMA(config["climate"][0])
message = str(exc_info.value)
assert "'current_temperature_min_interval'" in message
assert "'uart_id'" in message
assert "'update_interval'" in message
assert "top-level 'mitsubishi_cn105:' block" in message
assert "'telemetry_request_min_interval'" in message
@@ -0,0 +1,39 @@
"""Tests for modbus configuration validation."""
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.const import CONF_ADDRESS
def test_server_address_accepts_valid_unit_address() -> None:
# A normal unit address (1-247) is accepted and returned as an int.
assert _validate_server_address(1) == 1
assert _validate_server_address(247) == 247
def test_server_address_accepts_hex_string() -> None:
# hex_uint8_t parses hex strings, and the validator returns the parsed int.
assert _validate_server_address("0x10") == 0x10
def test_server_address_zero_rejected() -> None:
# Address 0 is the Modbus broadcast address and cannot identify a server device.
with pytest.raises(cv.Invalid, match="broadcast address"):
_validate_server_address(0)
def test_server_schema_rejects_address_zero() -> None:
# The server-role schema wires in _validate_server_address, so address 0 is rejected there too.
schema = modbus.modbus_device_schema(0x01, role="server")
with pytest.raises(cv.Invalid, match="broadcast address"):
schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})
def test_client_schema_still_accepts_address_zero() -> None:
# Not rejected for clients today, but not supported either: a client broadcast gets no reply and
# stalls the hub for the full send-wait.
schema = modbus.modbus_device_schema(0x01)
assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0
@@ -0,0 +1,158 @@
"""Tests for modbus_client configuration validation.
Handler PDU spans point into hub buffers reused once the handler returns, so the deferring-actions
guard is a safety property: these tests pin it to every handler slot.
"""
import pytest
from esphome import config_validation as cv
from esphome.components import modbus_client
from esphome.components.modbus_client import (
CONF_ON_NO_RESPONSE,
CONF_ON_NOT_SENT,
CONF_ON_SENT,
CONF_PDU,
CONFIG_SCHEMA,
MODBUS_CLIENT_SEND_SCHEMA,
)
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
from esphome.core import Lambda
from esphome.types import ConfigType
# Every handler slot on modbus_client.send. All five must reject deferring actions.
HANDLER_KEYS = [
CONF_ON_SENT,
CONF_ON_RESPONSE,
CONF_ON_ERROR,
CONF_ON_NO_RESPONSE,
CONF_ON_NOT_SENT,
]
# A deferring action (registered synchronous=False) and a synchronous one, for contrast.
DEFERRING_ACTION = {"delay": "1s"}
SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')}
TRUE_CONDITION = {"lambda": Lambda("return true;")}
# The same deferring action buried inside nested control flow, which the guard must still find.
NESTED_ACTIONS = [
pytest.param(
[{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}],
id="if",
),
pytest.param([{"repeat": {"count": 2, "then": [DEFERRING_ACTION]}}], id="repeat"),
pytest.param(
[
{
"repeat": {
"count": 2,
"then": [
{
"if": {
"condition": TRUE_CONDITION,
"then": [DEFERRING_ACTION],
}
}
],
}
}
],
id="repeat_if",
),
]
DEFER_MESSAGE = "Deferring actions"
def _config(handler_key: str, actions: list) -> ConfigType:
"""A minimal valid modbus_client.send config with one handler populated."""
return {
CONF_ADDRESS: 0x01,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
handler_key: {"then": actions},
}
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
def test_synchronous_handler_accepted(handler_key: str) -> None:
# The guard must not get in the way of an ordinary inline handler.
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [SYNCHRONOUS_ACTION]))
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
def test_deferring_action_rejected(handler_key: str) -> None:
with pytest.raises(cv.Invalid, match=DEFER_MESSAGE):
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, [DEFERRING_ACTION]))
@pytest.mark.parametrize("handler_key", HANDLER_KEYS)
@pytest.mark.parametrize("actions", NESTED_ACTIONS)
def test_nested_deferring_action_rejected(handler_key: str, actions: list) -> None:
# has_non_synchronous_actions recurses, so a delay buried in if:/repeat: is still caught.
with pytest.raises(cv.Invalid, match=DEFER_MESSAGE):
MODBUS_CLIENT_SEND_SCHEMA(_config(handler_key, actions))
def test_on_no_response_lambda_form_accepted() -> None:
# The returning-lambda form has no action list; the guard is a no-op on it.
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
CONF_ON_NO_RESPONSE: Lambda("return false;"),
}
)
def test_on_no_response_retry_lambda_accepted() -> None:
# The automation form may also carry a nested retry: lambda.
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
CONF_ON_NO_RESPONSE: {
"then": [SYNCHRONOUS_ACTION],
"retry": Lambda("return true;"),
},
}
)
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
# components rather than this one.
def test_component_requires_an_address() -> None:
"""The address identifies the device on the bus, so there is no sensible default."""
with pytest.raises(cv.Invalid, match=CONF_ADDRESS):
CONFIG_SCHEMA({CONF_ID: "bare_client"})
def test_component_requires_an_id() -> None:
"""The device is reachable only through id() in a lambda, so a generated id would be dead config."""
with pytest.raises(cv.Invalid, match=CONF_ID):
CONFIG_SCHEMA({CONF_ADDRESS: 0x01})
def test_component_accepts_an_id_and_address() -> None:
"""modbus_id stays optional: it resolves to the single hub when only one is declared."""
config = CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x01})
assert config[CONF_ADDRESS] == 0x01
def test_component_rejects_an_out_of_range_address() -> None:
"""A Modbus device address is one byte."""
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({CONF_ID: "bare_client", CONF_ADDRESS: 0x100})
def test_multi_conf_no_default_is_set() -> None:
"""Load-bearing: the modbus hub auto-loads this component to register its actions.
Without MULTI_CONF_NO_DEFAULT that auto-load builds a default entry, which then fails the required
address above - breaking every configuration that uses modbus but never declares a modbus_client
block. validate-autoload.esp32-idf.yaml covers the same path end to end; this names the reason.
"""
assert modbus_client.MULTI_CONF is True
assert modbus_client.MULTI_CONF_NO_DEFAULT is True
@@ -7,8 +7,13 @@ from esphome.components.modbus_server import (
SERVER_SENSOR_VALUE_TYPE,
_validate_no_overlapping_registers,
_validate_register_ranges,
_validate_unique_bit_addresses,
)
from esphome.components.modbus_server.const import (
CONF_BITS,
CONF_REGISTERS,
CONF_VALUE_TYPE,
)
from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE
from esphome.const import CONF_ADDRESS
@@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict:
}
def _bits_config(addresses: list[int]) -> dict:
return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]}
def test_non_overlapping_registers_pass() -> None:
# Values that tile the address space without gaps or overlaps are accepted.
config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")])
@@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None:
_validate_no_overlapping_registers(config)
def test_unique_bit_addresses_pass() -> None:
config = _bits_config([0x00, 0x01, 0x02])
assert _validate_unique_bit_addresses(config) is config
def test_duplicate_bit_address_rejected() -> None:
# Coils and discrete inputs share one bit address space, so a repeated address is rejected.
config = _bits_config([0x05, 0x05])
with pytest.raises(cv.Invalid, match="more than once"):
_validate_unique_bit_addresses(config)
def test_multi_register_value_overlapping_neighbour_rejected() -> None:
# U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word.
config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")])
@@ -82,3 +103,5 @@ def test_raw_value_type_rejected() -> None:
with pytest.raises(cv.Invalid):
validator("RAW")
assert validator("U_WORD") == "U_WORD"
assert validator("U_WORD_S") == "U_WORD_S"
assert validator("S_WORD_S") == "S_WORD_S"
@@ -0,0 +1,26 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: arduino
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
network:
priority:
- ethernet
- wifi
@@ -0,0 +1,24 @@
esphome:
name: test
rp2:
board: rpipicow
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
mac_address: "02:AA:BB:CC:DD:01"
network:
priority:
- ethernet
- wifi
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
network:
priority:
- wifi
+70 -1
View File
@@ -16,9 +16,10 @@ from esphome.components.network import (
_validate_priority_list,
get_network_priority,
)
from esphome.const import CONF_PRIORITY
from esphome.const import CONF_PRIORITY, PlatformFramework
from esphome.core import CORE
import esphome.final_validate as fv
from tests.component_tests.types import SetCoreConfigCallable
@pytest.fixture(autouse=True)
@@ -138,6 +139,22 @@ def test_final_validate_noop_without_priority_list() -> None:
_final_validate({}) # must not raise
def test_final_validate_rejects_unsupported_arbitration_interface(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The ethernet/wifi-only arbitration tripwire fails as a clean config error.
Unreachable through the public schema today (VALID_NETWORK_TYPES gates the
list), so the config is hand-built to simulate a future interface type that
was added to the schema without extending NetworkComponent::loop().
"""
set_core_config(PlatformFramework.ESP32_IDF)
fv.full_config.set({"openthread": {}, "wifi": {}})
config = {CONF_PRIORITY: [{"interface": "openthread"}, {"interface": "wifi"}]}
with pytest.raises(Invalid, match="arbitration does not support: openthread"):
_final_validate(config)
def _cpp_setup_priority(name: str) -> float:
"""Read a setup_priority constant straight from esphome/core/component.h."""
header = Path(__file__).parents[3] / "esphome" / "core" / "component.h"
@@ -199,3 +216,55 @@ def test_no_primary_interface_define_without_priority(
assert not any(
d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines
)
def _dns_per_default_netif_option() -> bool | None:
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all
return None
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(
"CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF"
)
@pytest.mark.parametrize(
"config_file",
[
"priority_wifi_first.yaml",
"priority_ethernet_first.yaml",
"priority_arduino.yaml",
],
)
def test_multi_interface_priority_enables_default_route_arbitration(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""More than one interface in 'priority' enables default-route arbitration."""
generate_main(component_config_path(config_file))
assert "USE_NETWORK_DEFAULT_ROUTE" in {d.name for d in CORE.defines}
assert _dns_per_default_netif_option() is True
@pytest.mark.parametrize(
"config_file",
[
# Single-entry priority list / no list at all.
"priority_single.yaml",
"wifi_only.yaml",
# Dual-interface on rp2040: validates, but the arbitration is ESP32-only
# (NetworkComponent::loop() is compiled under USE_ESP32) — emitting the
# define here would be a hard build break.
"priority_rp2040.yaml",
],
)
def test_single_interface_has_no_default_route_arbitration(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Single-interface and non-ESP32 configs must not compile in the arbitration."""
generate_main(component_config_path(config_file))
assert "USE_NETWORK_DEFAULT_ROUTE" not in {d.name for d in CORE.defines}
assert _dns_per_default_netif_option() is None
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome import bundle
from esphome.components.packages import (
CONFIG_SCHEMA,
_substitute_package_definition,
@@ -1264,6 +1265,75 @@ def test_remote_packages_no_revert(
]
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_packages_skipped_revert_does_not_retry(
mock_clone_or_update, mock_is_file, mock_load_yaml
) -> None:
"""When revert() reports the rollback was skipped, the load is not
retried (the checkout is unchanged) and the error says so."""
mock_revert = MagicMock(return_value=False)
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
mock_is_file.return_value = True
mock_load_yaml.side_effect = cv.Invalid("bad yaml")
config = {
CONF_PACKAGES: {
"pkg": {
CONF_URL: "https://github.com/esphome/repo",
CONF_REF: "main",
CONF_FILES: [{CONF_PATH: "file.yaml"}],
CONF_REFRESH: "1d",
}
}
}
with pytest.raises(cv.Invalid, match="could not revert the cached checkout"):
packages_pass(config)
assert mock_revert.call_count == 1
assert mock_load_yaml.call_count == 1
@patch("esphome.yaml_util.load_yaml")
@patch("pathlib.Path.is_file")
@patch("esphome.git.clone_or_update")
def test_remote_packages_successful_revert_retries(
mock_clone_or_update, mock_is_file, mock_load_yaml, caplog: pytest.LogCaptureFixture
) -> None:
"""A successful revert retries the load against the reverted checkout and
logs the original error, the only trace that upstream was broken."""
mock_revert = MagicMock(return_value=True)
mock_clone_or_update.return_value = (Path("/tmp/noexists"), mock_revert)
mock_is_file.return_value = True
mock_load_yaml.side_effect = [
cv.Invalid("bad yaml"),
OrderedDict(
{CONF_SENSOR: [{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}]}
),
]
config = {
CONF_PACKAGES: {
"pkg": {
CONF_URL: "https://github.com/esphome/repo",
CONF_REF: "main",
CONF_FILES: [{CONF_PATH: "file.yaml"}],
CONF_REFRESH: "1d",
}
}
}
with caplog.at_level(logging.WARNING):
actual = packages_pass(config)
assert actual[CONF_SENSOR] == [
{CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, CONF_NAME: "test"}
]
assert mock_revert.call_count == 1
assert mock_load_yaml.call_count == 2
assert any("reverted the cached checkout" in r.getMessage() for r in caplog.records)
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
"""Test that CORE.raw_config contains esphome section from merged package.
@@ -1625,3 +1695,34 @@ def test_resolve_packages_does_not_apply_extend_remove() -> None:
# over the package value during merge), and the marker is not
# resolved by this wrapper.
assert isinstance(result[CONF_WIFI], Remove)
@patch("esphome.git.clone_or_update")
def test_remote_package_registers_checkout_for_secret_scan(
mock_clone_or_update, tmp_path: Path
) -> None:
"""Loading a remote package registers its path-narrowed checkout dir
as a bundle secret-scan dir (issue 18023)."""
repo_root = tmp_path / "repo"
package_dir = repo_root / "packages"
package_dir.mkdir(parents=True)
(package_dir / "base.yml").write_text(
f"sensor:\n - platform: {TEST_SENSOR_PLATFORM_1}\n name: {TEST_SENSOR_NAME_1}\n"
)
mock_clone_or_update.return_value = (repo_root, None)
config = {
CONF_PACKAGES: {
"package1": {
CONF_URL: "https://github.com/esphome/non-existant-repo",
CONF_REF: "main",
CONF_PATH: "packages",
CONF_FILES: ["base.yml"],
CONF_REFRESH: "1d",
}
}
}
packages_pass(config)
assert package_dir in bundle._get_data().secret_scan_dirs
assert repo_root not in bundle._get_data().secret_scan_dirs
@@ -0,0 +1,5 @@
esphome:
name: preftest
bk72xx:
board: generic-bk7252
@@ -0,0 +1,5 @@
esphome:
name: preftest
esp32:
board: esp32dev
@@ -0,0 +1,5 @@
esphome:
name: preftest
esp8266:
board: esp01_1m
@@ -0,0 +1,4 @@
esphome:
name: preftest
host:
@@ -0,0 +1,6 @@
esphome:
name: preftest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
@@ -0,0 +1,5 @@
esphome:
name: preftest
rp2:
board: rpipicow
@@ -0,0 +1,39 @@
"""Every preferences platform either emits USE_PREFERENCE_KEY_LOOKUP from
codegen (key-lookup backends) or must not (slot-based backends, whose managers
have no load_from_key()). Run each platform's real codegen and assert the
emission, mirroring the split the deny-list in esphome/core/defines.h assumes
for static analysis.
The fixtures cover every distinct preferences backend today: ln882x and
rtl87xx route through libretiny (bk72xx stands in for the family), rp2040 is
an alias of rp2, and nrf52 exercises zephyr. A seventh backend needs a new
fixture here."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
@pytest.mark.parametrize(
("fixture", "emits"),
[
("esp32.yaml", True),
("bk72xx.yaml", True), # libretiny
("host.yaml", True),
("nrf52.yaml", True), # zephyr
("esp8266.yaml", False),
("rp2.yaml", False),
],
)
def test_key_lookup_define_matches_the_platform_backend(
fixture: str,
emits: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
defines = {define.name for define in CORE.defines}
assert ("USE_PREFERENCE_KEY_LOOKUP" in defines) is emits
@@ -0,0 +1,15 @@
esphome:
name: poolwrap-rp2-default
rp2:
board: rpipicow
wifi:
ssid: MySSID
password: password1
api:
rp2_ble_tracker:
bluetooth_proxy:
@@ -0,0 +1,16 @@
esphome:
name: poolwrap-rp2-single
rp2:
board: rpipicow
wifi:
ssid: MySSID
password: password1
api:
rp2_ble_tracker:
bluetooth_proxy:
connection_slots: 1
@@ -0,0 +1,16 @@
esphome:
name: poolwrap-rp2-two
rp2:
board: rpipicow
wifi:
ssid: MySSID
password: password1
api:
rp2_ble_tracker:
bluetooth_proxy:
connection_slots: 2
@@ -0,0 +1,41 @@
"""Connection-slot accounting: consumers claim against MAX_CONNECTIONS and
final validation rejects over-subscription with the consumer list."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components import rp2040_ble
from esphome.core import CORE
def test_proxy_claims_its_slots_through_the_shared_accounting(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
# A default (3-slot) proxy build records one claim per slot, attributed
# to the consumer, and passes final validation.
generate_main(component_config_path("rp2_proxy_default.yaml"))
used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS]
assert used == ["bluetooth_proxy"] * 3
def test_oversubscription_is_rejected_with_the_consumer_list() -> None:
# No YAML shape reaches this today (the proxy schema caps at the same
# limit); the guard exists for a second consumer such as ble_client.
rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({})
rp2040_ble.consume_connection_slots(1, "ble_client")({})
with pytest.raises(
cv.Invalid,
match=r"4 connection slots.*maximum is 3.*bluetooth_proxy.*ble_client",
):
rp2040_ble.validate_connection_slots()
def test_at_cap_passes() -> None:
rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({})
rp2040_ble.validate_connection_slots()
@@ -0,0 +1,52 @@
"""The rp2 BTstack pool overrides: multi-slot builds emit the --wrap flags
that swap the prebuilt single-client pools for the codegen-sized ones;
single-slot builds emit none and stay byte-identical to previous releases."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from esphome.core import CORE
from ..helpers import get_define_value
# Spelled out rather than derived from rp2040_ble's symbol tuple, so a typo
# in the component's list fails here instead of mirroring into the test.
WRAP_FLAGS = (
"-Wl,--wrap=btstack_memory_gatt_client_get",
"-Wl,--wrap=btstack_memory_gatt_client_free",
"-Wl,--wrap=btstack_memory_hci_connection_get",
"-Wl,--wrap=btstack_memory_hci_connection_free",
)
def test_default_slots_emit_the_pool_wrap(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("rp2_proxy_default.yaml"))
assert all(flag in CORE.build_flags for flag in WRAP_FLAGS)
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3"
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "3"
def test_two_slots_emit_the_pool_wrap(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
# Two slots: the wrap pools are smaller than the cap, sized from the count.
generate_main(component_config_path("rp2_proxy_two_slots.yaml"))
assert all(flag in CORE.build_flags for flag in WRAP_FLAGS)
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "2"
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "2"
def test_single_slot_keeps_the_prebuilt_pools(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("rp2_proxy_single_slot.yaml"))
assert not any(flag in CORE.build_flags for flag in WRAP_FLAGS)
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "1"
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "1"
@@ -0,0 +1,114 @@
"""Validation tests for the sendspin image platform.
These cover the rejection branches, which a compile test cannot reach: a
`test*.yaml` can only assert that a configuration is accepted.
"""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import IMAGE_FORMAT_JPEG, MAX_ARTWORK_SLOTS, _get_data
from esphome.components.sendspin.image import CONFIG_SCHEMA, MAX_IMAGE_DIMENSION
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _slot_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid artwork slot config, allowing field overrides."""
config: ConfigType = {
"id": "album_slot",
"format": "JPEG",
"type": "RGB565",
"resize": "240x240",
"current_image": {"id": "album_art"},
}
config.update(overrides)
return config
def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None:
"""The baseline the rejection tests vary is itself valid."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(_slot_config())
assert config["slot"] == 0
assert config["source"] == "ALBUM"
assert config["display_offset"].total_milliseconds == 0
@pytest.mark.parametrize("image_format", ["JPEG", "JPG"])
def test_jpeg_alias_maps_to_one_enum(
set_core_config: SetCoreConfigCallable, image_format: str
) -> None:
"""runtime_image takes JPG as an alias for JPEG, so both spellings must reach the
library's single JPEG enum."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_slot_config(format=image_format))
assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG
def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Slot numbers run out after MAX_ARTWORK_SLOTS entries."""
set_core_config(PlatformFramework.ESP32_IDF)
for slot in range(MAX_ARTWORK_SLOTS):
assert CONFIG_SCHEMA(_slot_config(id=f"slot_{slot}"))["slot"] == slot
with pytest.raises(cv.Invalid, match="Too many Sendspin image slots"):
CONFIG_SCHEMA(_slot_config(id="one_too_many"))
@pytest.mark.parametrize(
"resize",
[f"{MAX_IMAGE_DIMENSION + 1}x240", f"240x{MAX_IMAGE_DIMENSION + 1}"],
)
def test_oversized_resize_rejected(
set_core_config: SetCoreConfigCallable, resize: str
) -> None:
"""Either dimension past the decoder's limit is refused."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match=f"must be {MAX_IMAGE_DIMENSION} or less"):
CONFIG_SCHEMA(_slot_config(resize=resize))
def test_sub_millisecond_display_offset_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The library field is whole milliseconds, so finer values are refused
rather than silently rounded down to zero."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="Maximum precision is milliseconds"):
CONFIG_SCHEMA(_slot_config(display_offset="500us"))
@pytest.mark.parametrize("display_offset", ["61s", "-61s"])
def test_out_of_range_display_offset_rejected(
set_core_config: SetCoreConfigCallable, display_offset: str
) -> None:
"""Offsets more than a minute either side of the boundary are refused."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="value must be at (most|least)"):
CONFIG_SCHEMA(_slot_config(display_offset=display_offset))
@pytest.mark.parametrize(
("display_offset", "expected_ms"), [("250ms", 250), ("-2s", -2000)]
)
def test_display_offset_accepted(
set_core_config: SetCoreConfigCallable, display_offset: str, expected_ms: int
) -> None:
"""Whole-millisecond offsets pass through in both directions."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset))
assert config["display_offset"].total_milliseconds == expected_ms
@@ -33,14 +33,49 @@ def test_web_server_auth_explicit_basic_no_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type basic builds Basic and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml")
"""Auth type basic on ESP32 uses plaintext credentials and does not warn."""
main_cpp = generate_main(
"tests/component_tests/web_server/web_server_auth_basic.yaml"
)
assert '->set_auth_username("admin");' in main_cpp
assert '->set_auth_password("password");' in main_cpp
assert "set_auth_basic_hash" not in main_cpp
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_auth_basic_esp8266_uses_precomputed_hash(
generate_main: Callable[[str], str],
) -> None:
"""Auth type basic on ESP8266 emits the precomputed base64 hash, not the credentials."""
main_cpp = generate_main(
"tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml"
)
assert '->set_auth_basic_hash("YWRtaW46cGFzc3dvcmQ=");' in main_cpp
assert "set_auth_username" not in main_cpp
assert "set_auth_password" not in main_cpp
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
def test_web_server_auth_digest_esp8266_uses_plaintext_credentials(
generate_main: Callable[[str], str],
) -> None:
"""Auth type digest on ESP8266 uses plaintext credentials, not the basic hash."""
main_cpp = generate_main(
"tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml"
)
assert '->set_auth_username("admin");' in main_cpp
assert '->set_auth_password("password");' in main_cpp
assert "set_auth_basic_hash" not in main_cpp
assert _has_define("USE_WEBSERVER_AUTH")
assert _has_define("USE_WEBSERVER_AUTH_DIGEST")
def test_web_server_auth_explicit_digest(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
@@ -0,0 +1,16 @@
---
esphome:
name: test
esp8266:
board: esp01_1m
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: basic
@@ -0,0 +1,16 @@
---
esphome:
name: test
esp8266:
board: esp01_1m
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: digest
@@ -0,0 +1,7 @@
# Deprecated `pin: TEMPERATURE`, superseded by the `internal_temperature` platform.
# Remove before 2027.2.0
sensor:
- id: adc_temperature_sensor
platform: adc
pin: TEMPERATURE
name: ADC Test temperature
@@ -0,0 +1 @@
airthings_ble:
@@ -0,0 +1,6 @@
esp32_ble_tracker:
id: ble_tracker_hub
# Explicit ble_hub_id: pins the neutral binding as a declared key.
airthings_ble:
ble_hub_id: ble_tracker_hub
@@ -0,0 +1,3 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
airthings_ble: !include common.yaml
@@ -0,0 +1,3 @@
packages:
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
airthings_ble: !include common-ln.yaml
@@ -0,0 +1,8 @@
# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the
# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding)
# on a non-esp32 platform; codegen and compilation are not exercised here.
bk72xx_ble_tracker:
id: ble_tracker_hub
airthings_ble:
ble_hub_id: ble_tracker_hub
@@ -0,0 +1,7 @@
sensor:
- platform: atc_mithermometer
mac_address: A4:C1:38:4E:16:78
temperature:
name: ATC Temperature
humidity:
name: ATC Humidity
@@ -1,7 +1,10 @@
esp32_ble_tracker:
id: ble_tracker_hub
sensor:
# Explicit ble_hub_id: pins the neutral binding as a declared key.
- platform: atc_mithermometer
ble_hub_id: ble_tracker_hub
mac_address: A4:C1:38:4E:16:78
temperature:
name: ATC Temperature
@@ -0,0 +1,3 @@
packages:
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
atc_mithermometer: !include common-ln.yaml
@@ -0,0 +1,10 @@
# The esp32_ble_id -> ble_hub_id alias (removal 2027.2.0) still validates.
esp32_ble_tracker:
id: ble_tracker_hub
sensor:
- platform: atc_mithermometer
esp32_ble_id: ble_tracker_hub
mac_address: A4:C1:38:4E:16:78
temperature:
name: ATC Legacy Key Temperature
@@ -0,0 +1,17 @@
# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the
# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding)
# on a non-esp32 platform; codegen and compilation are not exercised here.
bk72xx_ble_tracker:
id: ble_hub
sensor:
- platform: atc_mithermometer
ble_hub_id: ble_hub
mac_address: A4:C1:38:4E:16:78
temperature:
name: BK ATC Temperature
# No ble_hub_id: exercises the generated binding real configs use.
- platform: atc_mithermometer
mac_address: A4:C1:38:4E:16:79
temperature:
name: BK ATC Implicit Temperature
@@ -0,0 +1,7 @@
sensor:
- platform: b_parasite
mac_address: F0:CA:F0:CA:01:01
humidity:
name: b-parasite Air Humidity
temperature:
name: b-parasite Air Temperature
+3
View File
@@ -1,7 +1,10 @@
esp32_ble_tracker:
id: ble_tracker_hub
sensor:
# Explicit ble_hub_id: pins the neutral binding as a declared key.
- platform: b_parasite
ble_hub_id: ble_tracker_hub
mac_address: F0:CA:F0:CA:01:01
humidity:
name: b-parasite Air Humidity
@@ -0,0 +1,3 @@
packages:
ln882h_ble_tracker: !include ../ln882h_ble_tracker/common.yaml
b_parasite: !include common-ln.yaml
@@ -0,0 +1,26 @@
# Config-only: the CI base board (generic-bk7252, BLE 4.2) cannot compile the
# BLE 5.x tracker, so this fixture proves validation (schema + neutral binding)
# on a non-esp32 platform; codegen and compilation are not exercised here.
bk72xx_ble_tracker:
id: ble_tracker_hub
sensor:
# Explicit ble_hub_id: pins the neutral binding as a declared key.
- platform: b_parasite
ble_hub_id: ble_tracker_hub
mac_address: F0:CA:F0:CA:01:01
humidity:
name: b-parasite Air Humidity
temperature:
name: b-parasite Air Temperature
moisture:
name: b-parasite Soil Moisture
battery_voltage:
name: b-parasite Battery Voltage
illuminance:
name: b-parasite Illuminance
# No ble_hub_id: exercises the generated binding real configs use.
- platform: b_parasite
mac_address: F0:CA:F0:CA:01:02
temperature:
name: BK b-parasite Implicit Temperature
@@ -0,0 +1,54 @@
packages:
bk72xx_ble_tracker: !include common.yaml
esphome:
on_boot:
then:
- bk72xx_ble_tracker.start_scan
- bk72xx_ble_tracker.start_scan:
continuous: true
- bk72xx_ble_tracker.stop_scan
- bk72xx_ble_tracker.stop_scan: ble_tracker
bk72xx_ble_tracker:
on_ble_advertise:
- mac_address: AC:37:43:77:5F:4C
then:
- lambda: |-
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
- mac_address:
- AC:37:43:77:5F:4C
- AC:37:43:77:5F:4D
then:
- lambda: |-
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
on_ble_service_data_advertise:
- service_uuid: ABCD
# mac_address exercises the UUID triggers' set_address() codegen branch.
mac_address: AC:37:43:77:5F:4C
then:
- lambda: |-
ESP_LOGD("main", "Length of service data is %zu", x.size());
- service_uuid: ABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "32-bit service data is %zu", x.size());
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "128-bit service data is %zu", x.size());
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: |-
ESP_LOGD("main", "Length of manufacturer data is %zu", x.size());
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size());
on_scan_end:
- then:
- lambda: |-
ESP_LOGD("main", "Scan ended");
@@ -0,0 +1,8 @@
# Passive scanning variant: the package merge keeps the shared parameters from
# common.yaml and overrides only the mode.
packages:
bk72xx_ble_tracker: !include common.yaml
bk72xx_ble_tracker:
scan_parameters:
active: false
@@ -6,7 +6,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# resolve_irk() is compiled only when a sensor configures irk:
# (request_irk_support() emits USE_BLE_DEVICE_IRK). The unit-test build has
# no sensors, so emit the define here to put the real IRK path under test.
# Likewise the scan-response merger (emitted by the split-report trackers)
# and the listener vector it dispatches into (codegen-sized by consumers).
async def to_code_testing(config):
cg.add_define("USE_BLE_DEVICE_IRK")
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", 4)
manifest.to_code = to_code_testing
@@ -8,7 +8,7 @@ namespace esphome::ble_device_base::testing {
// from_scan_result() ingests BLE controller order (LSB-first); the public
// accessors must expose the historical esp32 semantics: address() in printable
// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str()
// (MSB-first) order, address_uint64() with byte 0 in the LSB, address_str_to()
// printed MSB-first.
namespace {
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
@@ -25,7 +25,14 @@ TEST(BleDeviceAddress, AccessorsMatchEsp32Semantics) {
EXPECT_EQ(device.address_uint64(), 0xAABBCCDDEEFFULL);
char buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE];
EXPECT_STREQ(device.address_str_to(buf), "AA:BB:CC:DD:EE:FF");
// The deprecated wrapper must keep returning the same string until its 2027.2.0 removal.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
EXPECT_EQ(device.address_str(), "AA:BB:CC:DD:EE:FF");
#pragma GCC diagnostic pop
}
// mac_lsb_first_to_uint64() packs the controller-order bytes a raw-advertisement
@@ -43,4 +50,18 @@ TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) {
EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64());
}
// uint64_to_mac_msb_first() is the inverse: unpacking the wire value yields
// printable (MSB-first) order, and round-tripping through the LSB-first
// packer restores the original value.
TEST(BleDeviceAddress, Uint64ToMacMsbFirstRoundTrip) {
uint8_t msb_first[6];
uint64_to_mac_msb_first(0xAABBCCDDEEFFULL, msb_first);
EXPECT_EQ(msb_first[0], 0xaa);
EXPECT_EQ(msb_first[5], 0xff);
uint8_t lsb_first[6];
for (int i = 0; i < 6; i++)
lsb_first[i] = msb_first[5 - i];
EXPECT_EQ(mac_lsb_first_to_uint64(lsb_first), 0xAABBCCDDEEFFULL);
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,91 @@
#include "esphome/components/ble_device_base/ble_device.h"
#include <gtest/gtest.h>
#include <cstring>
#include <string>
#include <vector>
namespace esphome::ble_device_base {
namespace {
// AD types under test
constexpr uint8_t AD_SHORT_NAME = 0x08;
constexpr uint8_t AD_COMPLETE_NAME = 0x09;
void append_name(std::vector<uint8_t> &adv, uint8_t ad_type, const char *name) {
size_t len = strlen(name);
adv.push_back(static_cast<uint8_t>(len + 1));
adv.push_back(ad_type);
adv.insert(adv.end(), name, name + len);
}
ESPBTDevice device_from(const std::vector<uint8_t> &adv) {
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
ESPBTDevice device;
device.from_scan_result(mac, -59, 0, adv.data(), static_cast<uint16_t>(adv.size()));
return device;
}
} // namespace
TEST(BleAdvName, ParsesACompleteName) {
std::vector<uint8_t> adv;
append_name(adv, AD_COMPLETE_NAME, "TP96");
ESPBTDevice device = device_from(adv);
EXPECT_EQ(device.get_name(), "TP96");
// The backing buffer is NUL-terminated so c_str() is usable directly.
EXPECT_STREQ(device.get_name().c_str(), "TP96");
}
TEST(BleAdvName, LongestNameWinsShortenedThenComplete) {
// A merged adv + scan-response frame can carry both forms; the shortened
// one must never replace the complete one.
std::vector<uint8_t> adv;
append_name(adv, AD_SHORT_NAME, "Radon");
append_name(adv, AD_COMPLETE_NAME, "RadonEye");
EXPECT_EQ(device_from(adv).get_name(), "RadonEye");
}
TEST(BleAdvName, LongestNameWinsCompleteThenShortened) {
std::vector<uint8_t> adv;
append_name(adv, AD_COMPLETE_NAME, "RadonEye");
append_name(adv, AD_SHORT_NAME, "Radon");
EXPECT_EQ(device_from(adv).get_name(), "RadonEye");
}
TEST(BleAdvName, MaxLengthNameFitsAndTerminates) {
// 29 bytes is the largest name a legacy AD element can carry and exactly
// fills the fixed buffer.
std::string max_name(29, 'a');
std::vector<uint8_t> adv;
append_name(adv, AD_COMPLETE_NAME, max_name.c_str());
ESPBTDevice device = device_from(adv);
EXPECT_EQ(device.get_name().size(), 29u);
EXPECT_EQ(device.get_name(), max_name);
EXPECT_STREQ(device.get_name().c_str(), max_name.c_str());
}
TEST(BleAdvName, ReparseResetsThePreviousName) {
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
std::vector<uint8_t> first;
append_name(first, AD_COMPLETE_NAME, "RadonEye");
std::vector<uint8_t> second;
append_name(second, AD_COMPLETE_NAME, "TP96");
ESPBTDevice device;
device.from_scan_result(mac, -59, 0, first.data(), static_cast<uint16_t>(first.size()));
ASSERT_EQ(device.get_name(), "RadonEye");
// A shorter name from a fresh report must fully replace the longer one:
// the longest-name rule applies within one report, not across reports.
device.from_scan_result(mac, -59, 0, second.data(), static_cast<uint16_t>(second.size()));
EXPECT_EQ(device.get_name(), "TP96");
EXPECT_STREQ(device.get_name().c_str(), "TP96");
}
TEST(BleAdvName, NoNamePresentIsEmpty) {
std::vector<uint8_t> adv = {0x02, 0x0A, 0x00}; // TX power only
EXPECT_TRUE(device_from(adv).get_name().empty());
}
} // namespace esphome::ble_device_base
@@ -19,6 +19,33 @@ const uint8_t TAG[4] = {0x48, 0x4d, 0xaa, 0x56};
const uint8_t PLAINTEXT[7] = {0x02, 0x01, 0x64, 0x03, 0x10, 0x8a, 0x01};
} // namespace
// Xiaomi's parameters differ from BTHome's: a 12-byte nonce and a 1-byte AAD
// (0x11). Both the AAD block and the l = 3 length encoding are only reachable
// through this shape, so they need their own vector. Generated the same way.
namespace {
const uint8_t NONCE_XIAOMI[12] = {0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b};
const uint8_t AAD_XIAOMI[1] = {0x11};
const uint8_t CIPHERTEXT_XIAOMI[5] = {0xc3, 0x7e, 0x0a, 0x1d, 0x23};
const uint8_t TAG_XIAOMI[4] = {0x98, 0x79, 0x87, 0xc6};
const uint8_t PLAINTEXT_XIAOMI[5] = {0x04, 0x10, 0x02, 0xd4, 0x00};
} // namespace
TEST(BleAesCcm, DecryptsXiaomiShapedVector) {
uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {};
EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), AAD_XIAOMI, sizeof(AAD_XIAOMI),
CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI)));
EXPECT_EQ(0, memcmp(out, PLAINTEXT_XIAOMI, sizeof(PLAINTEXT_XIAOMI)));
}
TEST(BleAesCcm, RejectsWrongAssociatedData) {
uint8_t bad_aad[sizeof(AAD_XIAOMI)];
memcpy(bad_aad, AAD_XIAOMI, sizeof(AAD_XIAOMI));
bad_aad[0] ^= 0x01;
uint8_t out[sizeof(PLAINTEXT_XIAOMI)] = {};
EXPECT_FALSE(aes_ccm_auth_decrypt(KEY, NONCE_XIAOMI, sizeof(NONCE_XIAOMI), bad_aad, sizeof(bad_aad),
CIPHERTEXT_XIAOMI, sizeof(CIPHERTEXT_XIAOMI), out, TAG_XIAOMI, sizeof(TAG_XIAOMI)));
}
TEST(BleAesCcm, DecryptsAndAuthenticatesKnownVector) {
uint8_t out[sizeof(PLAINTEXT)] = {};
EXPECT_TRUE(aes_ccm_auth_decrypt(KEY, NONCE, sizeof(NONCE), nullptr, 0, CIPHERTEXT, sizeof(CIPHERTEXT), out, TAG,
@@ -27,6 +27,60 @@ TEST(BleDeviceUuid, ThirtyTwoBitMatchesEquivalentLongForm) {
EXPECT_TRUE(u32 == u128);
}
// A default-constructed UUID is UNSET, the historical "not configured" sentinel
// (len 0 through the esp32 get_uuid() adapter).
TEST(BleDeviceUuid, DefaultConstructedIsUnset) {
const ESPBTUUID unset;
EXPECT_EQ(unset.type(), ESPBTUUID::Type::UNSET);
EXPECT_TRUE(unset == ESPBTUUID());
EXPECT_FALSE(unset == ESPBTUUID::from_uint16(0x1234));
EXPECT_FALSE(unset.contains(0x00, 0x00));
}
// Every factory yields a non-UNSET UUID, even for 0x0000: only default construction and a
// failed text parse are unset, keeping type() != UNSET equivalent to the old len > 0 check.
TEST(BleDeviceUuid, AllFactoriesProduceSetUuids) {
const uint8_t raw[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x34, 0x12, 0x00, 0x00};
EXPECT_NE(ESPBTUUID::from_uint16(0x0000).type(), ESPBTUUID::Type::UNSET);
EXPECT_NE(ESPBTUUID::from_uint32(0).type(), ESPBTUUID::Type::UNSET);
EXPECT_NE(ESPBTUUID::from_raw(raw).type(), ESPBTUUID::Type::UNSET);
EXPECT_NE(ESPBTUUID::from_raw_reversed(raw).type(), ESPBTUUID::Type::UNSET);
EXPECT_NE(ESPBTUUID::from_raw("180F", 4).type(), ESPBTUUID::Type::UNSET);
EXPECT_NE(ESPBTUUID::from_raw("0000180F", 8).type(), ESPBTUUID::Type::UNSET);
EXPECT_NE(ESPBTUUID::from_raw(reinterpret_cast<const char *>(raw), 16).type(), ESPBTUUID::Type::UNSET);
EXPECT_NE(ESPBTUUID::from_raw("6E400001-B5A3-F393-E0A9-E50E24DCCA9E").type(), ESPBTUUID::Type::UNSET);
}
// 0x0000 is a valid short UUID on real devices (esphome/aioesphomeapi#1742); an unset
// UUID must never compare equal to it. Unset equals only unset.
TEST(BleDeviceUuid, UnsetIsNotEqualToZeroUuid) {
EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_uint16(0x0000));
EXPECT_FALSE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID());
const uint8_t base[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
EXPECT_FALSE(ESPBTUUID() == ESPBTUUID::from_raw(base));
EXPECT_TRUE(ESPBTUUID() == ESPBTUUID());
EXPECT_FALSE(ESPBTUUID().as_128bit().is_set()); // widening preserves the unset state
// A configured 0x0000 still matches its own 128-bit base UUID expansion.
EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000) == ESPBTUUID::from_raw(base));
}
// is_set() is the sentinel check; an unset UUID prints as "None" instead of a
// valid-looking all-zero 128-bit UUID.
TEST(BleDeviceUuid, IsSetAndUnsetToStr) {
char buf[UUID_STR_LEN];
EXPECT_FALSE(ESPBTUUID().is_set());
EXPECT_STREQ(ESPBTUUID().to_str(buf), "None");
EXPECT_TRUE(ESPBTUUID::from_uint16(0x0000).is_set());
EXPECT_STREQ(ESPBTUUID::from_uint16(0x0000).to_str(buf), "0x0000");
}
// Text parsing of an invalid length historically produced a len-0 (unset) UUID.
TEST(BleDeviceUuid, InvalidTextFormParsesToUnset) {
EXPECT_EQ(ESPBTUUID::from_raw("nope", 3).type(), ESPBTUUID::Type::UNSET);
}
TEST(BleDeviceUuid, DifferentUuidsDoNotMatch) {
EXPECT_FALSE(ESPBTUUID::from_uint16(0x1234) == ESPBTUUID::from_uint16(0x1235));
const uint8_t raw128[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
@@ -0,0 +1,82 @@
// The GATT client contract compiles in no real build until a hub backend is
// configured; this TU pins it on the host so the header cannot rot unseen.
// The contract is a concept (BLEGattConnection is a per-platform alias), so
// the minimal backend here proves the concept stays satisfiable and routes
// events through the GattClientListener interface the way a real backend does.
#define USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include <gtest/gtest.h>
namespace esphome::ble_device_base::testing {
// Overrides only what it records; the interface's defaults cover the rest.
class RecordingListener : public GattClientListener {
public:
void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; }
void on_service_discovery_done(int error) override { this->discovery_error_ = error; }
void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; }
bool connected_{false};
int discovery_error_{0};
uint16_t write_handle_{0};
};
class MinimalConnection {
public:
void set_listener(GattClientListener *listener) { this->listener_ = listener; }
int connect(uint64_t address, uint8_t addr_type) {
this->listener_->on_connection_state(true, 517, 0);
return 0;
}
bool cancel_gatt_disconnect() { return false; }
int gatt_disconnect() { return 0; }
int discover_services() {
this->listener_->on_service_discovery_done(0);
return 0;
}
int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
this->listener_->on_write_result(handle, 0);
return 0;
}
int read_descriptor(uint16_t handle) { return 0; }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; }
int notify_characteristic(uint16_t handle, bool enable) { return 0; }
int pair() { return GATT_ERR_NOT_CONNECTED; }
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
return 0;
}
GattServiceTable get_service_table() { return {}; }
void release_services() {}
void set_connection_type(ConnectionType ct) {}
protected:
GattClientListener *listener_{nullptr};
};
static_assert(BLEGattConnectionContract<MinimalConnection>,
"a minimal backend must satisfy the contract the alias asserts");
TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
MinimalConnection connection;
RecordingListener listener;
connection.set_listener(&listener);
EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0);
EXPECT_TRUE(listener.connected_);
EXPECT_EQ(connection.discover_services(), 0);
EXPECT_EQ(listener.discovery_error_, 0);
EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED);
EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0);
EXPECT_EQ(listener.write_handle_, 7);
// A default table is empty and safe to walk.
GattServiceTable table = connection.get_service_table();
EXPECT_EQ(table.service_count, 0);
EXPECT_EQ(table.characteristic_count, 0);
EXPECT_EQ(table.descriptor_count, 0);
}
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,158 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::ble_device_base::testing {
// from_manufacturer_data() accepts exactly the iBeacon frame: Apple company ID,
// 23 payload bytes, and the 0x02/0x15 sub-type/length prefix. The prefix check
// is stricter than the legacy esp32 parser (which surfaced any 23-byte Apple
// payload as a beacon) — a declared behavior change; these tests pin the
// accept/reject boundary.
namespace {
ServiceData make_apple_payload(uint8_t sub_type, uint8_t length, size_t size = 23) {
ServiceData data;
data.uuid = ESPBTUUID::from_uint16(0x004C); // Apple company ID
data.data.assign(size, 0);
if (size >= 2) {
data.data[0] = sub_type;
data.data[1] = length;
}
// BeaconData layout: sub_type[0], length[1], proximity_uuid[2..17],
// major[18..19], minor[20..21], signal_power[22] — all wire values big-endian.
if (size >= 23) {
data.data[18] = 0x12; // major 0x1234
data.data[19] = 0x34;
data.data[20] = 0x56; // minor 0x5678
data.data[21] = 0x78;
data.data[22] = 0xC5; // signal power -59 dBm
}
return data;
}
} // namespace
TEST(BleIBeacon, AcceptsWellFormedFrame) {
auto beacon = ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15));
ASSERT_TRUE(beacon.has_value());
// Explicit guard: clang-tidy's unchecked-optional-access models neither
// gtest's ASSERT_TRUE nor value() as a check.
if (beacon.has_value()) {
// Pins every scalar accessor's offset and the on-wire big-endian order.
EXPECT_EQ(beacon->get_major(), 0x1234);
EXPECT_EQ(beacon->get_minor(), 0x5678);
EXPECT_EQ(beacon->get_signal_power(), -59);
}
}
TEST(BleIBeacon, RejectsWrongSubType) {
// Apple "nearby" and other frames of coincidental length must not parse.
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15)).has_value());
}
TEST(BleIBeacon, RejectsWrongLengthByte) {
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x14)).has_value());
}
TEST(BleIBeacon, RejectsWrongPayloadSize) {
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 22)).has_value());
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15, 24)).has_value());
}
TEST(BleIBeacon, RejectsNonAppleCompany) {
auto data = make_apple_payload(0x02, 0x15);
data.uuid = ESPBTUUID::from_uint16(0x0059); // Nordic
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(data).has_value());
}
TEST(BleIBeacon, PrefixRejectedFlagsOnlyTheSubTypeCase) {
// The out-param drives the get_ibeacon() diagnostic for frames the legacy
// parser accepted: exactly the 23-byte Apple payload with a wrong prefix.
// Wrong size and non-Apple frames were never accepted and must stay silent.
bool flagged = false;
EXPECT_FALSE(ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15), &flagged).has_value());
EXPECT_TRUE(flagged);
flagged = false;
ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x02, 0x15), &flagged);
EXPECT_FALSE(flagged);
flagged = false;
ESPBLEiBeacon::from_manufacturer_data(make_apple_payload(0x10, 0x15, 22), &flagged);
EXPECT_FALSE(flagged);
flagged = false;
auto nordic = make_apple_payload(0x10, 0x15);
nordic.uuid = ESPBTUUID::from_uint16(0x0059);
ESPBLEiBeacon::from_manufacturer_data(nordic, &flagged);
EXPECT_FALSE(flagged);
}
namespace {
// One AD manufacturer-data record: [len][0xFF][company LE][payload...].
void append_mfr_record(std::vector<uint8_t> &adv, uint16_t company, const std::vector<uint8_t> &payload) {
adv.push_back(static_cast<uint8_t>(1 + 2 + payload.size()));
adv.push_back(0xFF);
adv.push_back(static_cast<uint8_t>(company & 0xFF));
adv.push_back(static_cast<uint8_t>(company >> 8));
adv.insert(adv.end(), payload.begin(), payload.end());
}
std::vector<uint8_t> beacon_payload(uint8_t sub_type, uint8_t length) {
std::vector<uint8_t> p(23, 0);
p[0] = sub_type;
p[1] = length;
p[18] = 0x12;
p[19] = 0x34;
p[20] = 0x56;
p[21] = 0x78;
p[22] = 0xC5;
return p;
}
ESPBTDevice device_from(const std::vector<uint8_t> &adv) {
const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
ESPBTDevice device;
device.from_scan_result(mac, -59, 0, adv.data(), static_cast<uint16_t>(adv.size()));
return device;
}
} // namespace
// get_ibeacon() wraps the parser with first-rejection capture and the log
// gate; pin its short circuits so a regression there needs a code change, not
// a review, to surface.
TEST(BleIBeacon, GetIbeaconReturnsBeaconDespitePrecedingRejectedFrame) {
std::vector<uint8_t> adv;
append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15)); // rejected prefix
append_mfr_record(adv, 0x004C, beacon_payload(0x02, 0x15)); // real iBeacon
auto device = device_from(adv);
auto beacon = device.get_ibeacon();
ASSERT_TRUE(beacon.has_value());
if (beacon.has_value()) {
EXPECT_EQ(beacon->get_major(), 0x1234);
}
}
TEST(BleIBeacon, GetIbeaconEmptyWhenOnlyRejectedFrames) {
std::vector<uint8_t> adv;
append_mfr_record(adv, 0x004C, beacon_payload(0x10, 0x15));
auto device = device_from(adv);
EXPECT_FALSE(device.get_ibeacon().has_value());
}
TEST(BleIBeacon, GetIbeaconEmptyWithoutManufacturerData) {
std::vector<uint8_t> adv;
adv.push_back(0x02); // flags record only
adv.push_back(0x01);
adv.push_back(0x06);
auto device = device_from(adv);
EXPECT_FALSE(device.get_ibeacon().has_value());
}
} // namespace esphome::ble_device_base::testing
@@ -13,17 +13,12 @@ namespace esphome::ble_device_base::testing {
//
// The in-tree emit site (BK72xxBLETracker::on_scan_report) compiles against
// the Beken SDK and cannot run host-side, so the guard-and-fire semantics are
// pinned here through a minimal host BLEHub implementation instead.
// pinned here through a minimal host hub carrying only the slot under test.
namespace {
class FakeHub : public BLEHub {
class FakeHub {
public:
void register_listener(ESPBTDeviceListener *listener) override {}
void set_raw_advertisement_callback(RawAdvertisementCallback callback) override { this->callback_ = callback; }
HubCapabilities get_capabilities() const override { return {false, false, false}; }
void get_adapter_mac(uint8_t out[6]) override {}
bool scan_running() override { return false; }
bool scan_active() override { return false; }
void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->callback_ = callback; }
/// The emit path every tracker implements: fire only when a subscriber is set.
void emit(const RawAdvertisement &adv) {
@@ -46,13 +41,13 @@ struct CapturingSubscriber {
}
};
// Device AA:BB:CC:DD:EE:FF — controller order delivers FF first.
const uint8_t MAC_LSB_FIRST[6] = {0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa};
// Device AA:BB:CC:DD:EE:FF, packed the way the API speaks it.
constexpr uint64_t TEST_ADDRESS = 0xAABBCCDDEEFFULL;
const uint8_t ADV_DATA[4] = {0x02, 0x01, 0x06, 0x00};
RawAdvertisement make_test_adv() {
return RawAdvertisement{
.mac = MAC_LSB_FIRST, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1};
.address = TEST_ADDRESS, .data = ADV_DATA, .data_len = sizeof(ADV_DATA), .rssi = -63, .addr_type = 1};
}
} // namespace
@@ -70,7 +65,7 @@ TEST(RawAdvertisementCallback, SubscriberSeesFieldsUnchanged) {
hub.emit(make_test_adv());
ASSERT_EQ(subscriber.calls, 1);
EXPECT_EQ(subscriber.last.mac, MAC_LSB_FIRST);
EXPECT_EQ(subscriber.last.address, TEST_ADDRESS);
EXPECT_EQ(subscriber.last.data, ADV_DATA);
EXPECT_EQ(subscriber.last.data_len, sizeof(ADV_DATA));
EXPECT_EQ(subscriber.last.rssi, -63);
@@ -0,0 +1,193 @@
// The host test build gets this from the manifest override; clang-tidy does not.
#ifndef USE_BLE_SCAN_RESPONSE_MERGER
#define USE_BLE_SCAN_RESPONSE_MERGER
#endif
#include <gtest/gtest.h>
#include <cstdint>
#include <cstring>
#include <vector>
#include "esphome/components/ble_device_base/scan_response_merger.h"
namespace esphome::ble_device_base::testing {
namespace {
// Pins the merge policy three trackers share (ln882h, rp2, bk72xx): slot
// bookkeeping, the same-device reuse path, the table-full fallback, the
// 62-byte truncation, the advertisement-RSSI choice and the raw_only gate.
// Delivery is observed through a real AdvDispatcher: the raw callback sees
// every frame (including raw_only), a listener only the parsed ones.
struct DeliveredFrame {
uint64_t address;
std::vector<uint8_t> data;
int8_t rssi;
};
struct RawCapture {
std::vector<DeliveredFrame> frames;
static void trampoline(void *self, const RawAdvertisement &adv) {
auto *capture = static_cast<RawCapture *>(self);
capture->frames.push_back({adv.address, std::vector<uint8_t>(adv.data, adv.data + adv.data_len), adv.rssi});
}
};
class CountingListener : public ESPBTDeviceListener {
public:
bool parse_device(const ESPBTDevice &device) override {
this->parsed++;
return true; // claimed: keeps the discovered log quiet
}
int parsed{0};
};
class ScanResponseMergerTest : public ::testing::Test {
protected:
void SetUp() override {
this->dispatcher_.set_raw_advertisement_callback({&this->raw_, &RawCapture::trampoline});
this->dispatcher_.register_listener(&this->listener_);
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, "test");
}
void stash_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill, uint32_t now = 0) {
std::vector<uint8_t> data(data_len, fill);
this->merger_.stash_adv(mac, rssi, 0, data.data(), data_len, now);
}
void scan_rsp_(const uint8_t (&mac)[6], int8_t rssi, uint8_t data_len, uint8_t fill) {
std::vector<uint8_t> data(data_len, fill);
this->merger_.submit_scan_rsp(mac, rssi, 0, data.data(), data_len);
}
ScanResponseMerger merger_;
AdvDispatcher dispatcher_;
RawCapture raw_;
CountingListener listener_;
bool scan_continuous_{true};
};
constexpr uint8_t MAC_A[6] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
constexpr uint8_t MAC_B[6] = {0x11, 0x12, 0x13, 0x14, 0x15, 0x16};
TEST_F(ScanResponseMergerTest, MatchedPairDeliversOneMergedFrameWithAdvRssi) {
this->stash_(MAC_A, -40, 20, 0xAA);
EXPECT_TRUE(this->raw_.frames.empty()); // held, not delivered
this->scan_rsp_(MAC_A, -70, 10, 0xBB);
ASSERT_EQ(this->raw_.frames.size(), 1u);
const auto &frame = this->raw_.frames[0];
ASSERT_EQ(frame.data.size(), 30u); // adv + response as ONE frame
EXPECT_EQ(frame.data[0], 0xAA);
EXPECT_EQ(frame.data[19], 0xAA);
EXPECT_EQ(frame.data[20], 0xBB);
// The advertisement's RSSI, never the scan response's.
EXPECT_EQ(frame.rssi, -40);
EXPECT_EQ(this->listener_.parsed, 1);
EXPECT_TRUE(this->merger_.empty());
}
TEST_F(ScanResponseMergerTest, ReAdvertisementDeliversHeldFrameAndReusesSlot) {
this->stash_(MAC_A, -40, 20, 0xAA);
this->stash_(MAC_A, -45, 22, 0xCC); // same device again: first frame is delivered
ASSERT_EQ(this->raw_.frames.size(), 1u);
EXPECT_EQ(this->raw_.frames[0].data.size(), 20u);
EXPECT_EQ(this->raw_.frames[0].rssi, -40);
EXPECT_FALSE(this->merger_.empty()); // the second advertisement now holds the slot
this->scan_rsp_(MAC_A, -70, 5, 0xBB);
ASSERT_EQ(this->raw_.frames.size(), 2u);
EXPECT_EQ(this->raw_.frames[1].data.size(), 27u); // 22 + 5, merged from the reused slot
EXPECT_EQ(this->raw_.frames[1].rssi, -45);
}
TEST_F(ScanResponseMergerTest, FullTableDegradesToUnmergedDelivery) {
uint8_t mac[6] = {0x20, 0x00, 0x00, 0x00, 0x00, 0x00};
for (uint8_t i = 0; i < 8; i++) {
mac[5] = i;
this->stash_(mac, -50, 10, i);
}
EXPECT_TRUE(this->raw_.frames.empty()); // 8 slots, all held
mac[5] = 8;
this->stash_(mac, -50, 10, 8); // 9th device: no slot left
ASSERT_EQ(this->raw_.frames.size(), 1u); // delivered immediately, unmerged
EXPECT_EQ(this->raw_.frames[0].data.size(), 10u);
this->merger_.flush(); // the 8 held frames are all still intact
EXPECT_EQ(this->raw_.frames.size(), 9u);
EXPECT_TRUE(this->merger_.empty());
}
TEST_F(ScanResponseMergerTest, MergeTruncatesAtBufferCapacity) {
this->stash_(MAC_A, -40, 31, 0xAA);
this->scan_rsp_(MAC_A, -70, 40, 0xBB); // only 31 bytes of room remain
ASSERT_EQ(this->raw_.frames.size(), 1u);
EXPECT_EQ(this->raw_.frames[0].data.size(), 62u);
EXPECT_EQ(this->raw_.frames[0].data[31], 0xBB);
EXPECT_EQ(this->raw_.frames[0].data[61], 0xBB);
}
TEST_F(ScanResponseMergerTest, UnmatchedScanResponseIsRawOnly) {
this->scan_rsp_(MAC_B, -60, 12, 0xDD);
ASSERT_EQ(this->raw_.frames.size(), 1u); // still forwarded on the raw path
EXPECT_EQ(this->raw_.frames[0].rssi, -60);
EXPECT_EQ(this->listener_.parsed, 0); // but never parsed for listeners
}
TEST_F(ScanResponseMergerTest, AddrTypeIsPartOfTheMatchKey) {
std::vector<uint8_t> adv(20, 0xAA);
this->merger_.stash_adv(MAC_A, -40, /*addr_type=*/0, adv.data(), adv.size(), 0);
std::vector<uint8_t> rsp(10, 0xBB);
this->merger_.submit_scan_rsp(MAC_A, -70, /*addr_type=*/1, rsp.data(), rsp.size());
// Same MAC, different addr_type: no merge — the response goes out raw_only.
ASSERT_EQ(this->raw_.frames.size(), 1u);
EXPECT_EQ(this->raw_.frames[0].data.size(), 10u);
EXPECT_EQ(this->listener_.parsed, 0);
EXPECT_FALSE(this->merger_.empty()); // the advertisement is still held
}
TEST_F(ScanResponseMergerTest, SweepDeliversOnlyPastTheTimeout) {
this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000);
this->merger_.sweep(1300); // exactly 300 ms: not yet past the timeout
EXPECT_TRUE(this->raw_.frames.empty());
this->merger_.sweep(1301);
ASSERT_EQ(this->raw_.frames.size(), 1u);
EXPECT_EQ(this->raw_.frames[0].rssi, -40);
EXPECT_EQ(this->listener_.parsed, 1); // timeout delivery is a full parse, not raw_only
EXPECT_TRUE(this->merger_.empty());
}
TEST_F(ScanResponseMergerTest, FlushDeliversEverythingImmediately) {
this->stash_(MAC_A, -40, 20, 0xAA, /*now=*/1000);
this->stash_(MAC_B, -50, 15, 0xBB, /*now=*/1000);
this->merger_.flush();
EXPECT_EQ(this->raw_.frames.size(), 2u);
EXPECT_EQ(this->listener_.parsed, 2);
EXPECT_TRUE(this->merger_.empty());
}
TEST_F(ScanResponseMergerTest, UnboundMergerDropsInsteadOfCrashing) {
ScanResponseMerger unbound;
std::vector<uint8_t> data(20, 0xAA);
unbound.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0);
unbound.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size());
unbound.sweep(1000);
unbound.flush(); // no null jump anywhere
EXPECT_TRUE(unbound.empty());
}
TEST_F(ScanResponseMergerTest, PartialBindIsTreatedAsUnbound) {
ScanResponseMerger partial;
partial.bind(&this->dispatcher_, nullptr, "test");
std::vector<uint8_t> data(20, 0xAA);
partial.submit_scan_rsp(MAC_A, -70, 0, data.data(), data.size());
partial.stash_adv(MAC_A, -40, 0, data.data(), data.size(), 0);
partial.flush(); // dropped, not dispatched through half a binding
EXPECT_TRUE(this->raw_.frames.empty());
}
} // namespace
} // namespace esphome::ble_device_base::testing
@@ -0,0 +1,52 @@
#include <gtest/gtest.h>
#include <cstdint>
#include "esphome/components/ble_device_base/ble_hub.h"
namespace esphome::ble_device_base::testing {
// Pins the ScannerStateCallback slot semantics, mirroring test_raw_callback:
// a default-constructed slot is "no subscriber", a set slot delivers the
// state, and a new registration replaces the old.
namespace {
struct CapturingSubscriber {
ScannerState last{ScannerState::IDLE};
int calls{0};
static void trampoline(void *self, ScannerState state) {
auto *sub = static_cast<CapturingSubscriber *>(self);
sub->last = state;
sub->calls++;
}
};
} // namespace
TEST(ScannerStateCallback, DefaultConstructedSlotIsNotSet) {
const ScannerStateCallback callback{};
EXPECT_FALSE(callback.is_set());
}
TEST(ScannerStateCallback, SubscriberSeesState) {
CapturingSubscriber subscriber;
ScannerStateCallback callback{&subscriber, CapturingSubscriber::trampoline};
ASSERT_TRUE(callback.is_set());
callback.invoke(ScannerState::RUNNING);
EXPECT_EQ(subscriber.calls, 1);
EXPECT_EQ(subscriber.last, ScannerState::RUNNING);
}
TEST(ScannerStateCallback, NewSubscriberReplacesOld) {
CapturingSubscriber first;
CapturingSubscriber second;
ScannerStateCallback callback{&first, CapturingSubscriber::trampoline};
callback = {&second, CapturingSubscriber::trampoline};
callback.invoke(ScannerState::STOPPED);
EXPECT_EQ(first.calls, 0);
EXPECT_EQ(second.calls, 1);
EXPECT_EQ(second.last, ScannerState::STOPPED);
}
} // namespace esphome::ble_device_base::testing

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