[ble_device_base] Hub provider registry and shared consumer helpers (#18081)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Edvard Filistovič
2026-08-06 12:13:30 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent abc13a7c0a
commit 3221ed2bad
10 changed files with 553 additions and 28 deletions
@@ -0,0 +1,204 @@
"""Tests for the BLE hub provider registry and the missing-hub diagnostics."""
from collections.abc import 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")
@@ -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