mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
232 lines
9.3 KiB
Python
232 lines
9.3 KiB
Python
"""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"
|