mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
356 lines
15 KiB
Python
356 lines
15 KiB
Python
"""
|
|
ble_device_base — the platform-neutral BLE layer.
|
|
|
|
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
|
|
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
|
|
(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h)
|
|
on every platform.
|
|
|
|
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
|
|
configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared
|
|
subclass, so there is no Python platform table here and no dependency in
|
|
either direction (C++-side, the compile-time alias header ble_hub_impl.h and the
|
|
defines.h mirror are the deliberate exceptions). A sensor extends
|
|
BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a
|
|
declared key even on strict schemas) and calls register_ble_device() in
|
|
to_code; a tracker component declares BLEHub as its codegen-class parent and
|
|
MUST call register_hub_provider() at import time — without it _require_hub
|
|
rejects configs that bind through the generated id (an explicit ble_hub_id:
|
|
bypasses the registry). Adding a new BLE chip requires a new in-tree tracker
|
|
component plus its alias arm and define (see above); out-of-tree BLE hubs
|
|
are not supported.
|
|
|
|
AES-CCM decryption for encrypted advertisements is provided portably in
|
|
ble_aes_ccm.h.
|
|
"""
|
|
|
|
from collections.abc import Callable
|
|
import re
|
|
|
|
import esphome.codegen as cg
|
|
from esphome.components.const import CONF_WINDOW
|
|
import esphome.config_validation as cv
|
|
from esphome.const import (
|
|
CONF_ACTIVE,
|
|
CONF_CONTINUOUS,
|
|
CONF_DURATION,
|
|
CONF_INTERVAL,
|
|
KEY_TARGET_PLATFORM,
|
|
)
|
|
from esphome.core import CORE, ID, KEY_CORE, TimePeriod
|
|
from esphome.types import ConfigType
|
|
|
|
CODEOWNERS = ["@Bl00d-B0b"]
|
|
|
|
CONF_BLE_HUB_ID = "ble_hub_id"
|
|
|
|
# Number of parsed-advertisement listeners registered in this build; read via
|
|
# cg.get_slot_count() by esp32_ble_tracker's feature coupling.
|
|
LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT"
|
|
|
|
|
|
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
|
|
|
|
# The neutral tracker contract. Every tracker's codegen class declares this as
|
|
# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python
|
|
# only: C++-side the name is a per-platform alias (ble_hub_impl.h).
|
|
BLEHub = ble_device_base_ns.class_("BLEHub")
|
|
|
|
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
|
|
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
|
|
|
|
|
|
# Config keys that provide a BLEHub, registered by each tracker component at
|
|
# import time (a tracker's module is imported iff it can end up in the build).
|
|
# Used only to phrase an actionable error when a BLE consumer is configured
|
|
# without any tracker — the binding itself resolves any BLEHub subclass and
|
|
# needs no platform table. Out-of-tree BLE hubs are not supported; the
|
|
# registry and the messages below deal in in-tree trackers only.
|
|
_HUB_PROVIDERS: set[str] = set()
|
|
|
|
# The in-tree trackers per target platform, so the missing-tracker error names
|
|
# them even in a fresh process where no tracker module has been imported yet (a
|
|
# consumer imports only ble_device_base, so the registry is empty exactly in
|
|
# the most common failure: the tracker was simply forgotten). Filtered by the
|
|
# current platform so an esp32 config is not told to add a Beken tracker; an
|
|
# unknown/absent platform falls back to every in-tree name.
|
|
_IN_TREE_HUB_PROVIDERS: dict[str, str] = {
|
|
"esp32": "esp32_ble_tracker",
|
|
"bk72xx": "bk72xx_ble_tracker",
|
|
"rp2": "rp2_ble_tracker",
|
|
"ln882x": "ln882h_ble_tracker",
|
|
}
|
|
|
|
|
|
def register_hub_provider(component: str) -> None:
|
|
"""Called at import time by every component whose config key declares a BLEHub."""
|
|
_HUB_PROVIDERS.add(component)
|
|
|
|
|
|
def _require_hub(value: ID) -> ID:
|
|
# Without this check a missing tracker surfaces at ID resolution as
|
|
# "Couldn't find any component that can be used for 'ble_device_base::BLEHub'"
|
|
# — a C++ class name the user never types. Component final validation cannot
|
|
# phrase it better: the ID pass runs first and its error skips all later
|
|
# steps. All explicitly configured components are loaded before any schema
|
|
# validates, so a registered provider in loaded_integrations is exact here.
|
|
if value.id is not None:
|
|
# Explicit ble_hub_id: — the user is pointing at a specific hub (the
|
|
# multi-hub disambiguation case). Let the ID pass judge it; its error
|
|
# names the missing id, which is accurate.
|
|
return value
|
|
if not _HUB_PROVIDERS & CORE.loaded_integrations:
|
|
# Defensive lookup rather than CORE.target_platform: the property
|
|
# raises when no platform is registered, and this message must never
|
|
# be the thing that crashes. In a real run the platform is always set
|
|
# (LoadTargetPlatformValidationStep runs before any other domain), so
|
|
# the unfiltered all-platforms fallback is reachable only from tests.
|
|
platform = CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM)
|
|
if platform is not None and platform not in _IN_TREE_HUB_PROVIDERS:
|
|
# Known platform with no in-tree hub (esp8266, host, rtl87xx, …):
|
|
# listing the other platforms' trackers would misdirect, and
|
|
# out-of-tree BLE hubs are not supported.
|
|
raise cv.Invalid(
|
|
f"No BLE tracker exists for {platform}; BLE components are "
|
|
"not supported on this platform"
|
|
)
|
|
in_tree = (
|
|
{tracker}
|
|
if (tracker := _IN_TREE_HUB_PROVIDERS.get(platform))
|
|
else set(_IN_TREE_HUB_PROVIDERS.values())
|
|
)
|
|
# in_tree only: _HUB_PROVIDERS is import-time state that outlives
|
|
# CORE.reset() in a long-lived process (dashboard), so a tracker from
|
|
# an earlier build of another platform must not leak into the message.
|
|
# The gate above is immune — loaded_integrations resets per run.
|
|
names = ", ".join(sorted(in_tree))
|
|
raise cv.Invalid(f"No BLE tracker configured — add one of: {names}")
|
|
return value
|
|
|
|
|
|
# Schema fragment binding a consumer to the configured BLE tracker: extend a
|
|
# consumer's CONFIG_SCHEMA with this so ble_hub_id: is a declared key — a
|
|
# trailing validator after a PREVENT_EXTRA schema would reject the explicit
|
|
# form before ever running. An omitted id resolves to the single declared
|
|
# tracker on any platform; multiple trackers are disambiguated with an
|
|
# explicit ble_hub_id.
|
|
BLE_DEVICE_SCHEMA = cv.Schema(
|
|
{cv.GenerateID(CONF_BLE_HUB_ID): cv.All(cv.use_id(BLEHub), _require_hub)}
|
|
)
|
|
|
|
|
|
def rename_legacy_hub_id(component: str) -> Callable[[ConfigType], ConfigType]:
|
|
"""Transitional alias for the pre-migration binding key: esp32_ble_id ->
|
|
ble_hub_id. Warns and auto-migrates until removal; every migrated platform
|
|
prepends this to its CONFIG_SCHEMA so existing configs keep validating."""
|
|
return cv.rename_key(
|
|
"esp32_ble_id", CONF_BLE_HUB_ID, removed_in="2027.2.0", component=component
|
|
)
|
|
|
|
|
|
def request_irk_support() -> None:
|
|
"""Compile in resolve_irk()'s software-AES path. Called by sensors with an
|
|
irk: option so builds without IRK do not carry the resolution code."""
|
|
cg.add_define("USE_BLE_DEVICE_IRK")
|
|
|
|
|
|
# Number of GATT client connection slots in this build; sizes the platform
|
|
# backend's connection storage.
|
|
GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT"
|
|
|
|
_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE)
|
|
|
|
|
|
def request_gatt_client() -> None:
|
|
"""Compile in the neutral GATT client contract (ble_gatt_client.h) and
|
|
claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT;
|
|
distinct from the proxy's validated connection budget). Called by
|
|
bluetooth_connection.new_gatt_backend() once per backend instance."""
|
|
cg.add_define("USE_BLE_GATT_CLIENT")
|
|
_request_gatt_connection_slot()
|
|
|
|
|
|
_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE)
|
|
|
|
|
|
async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
|
|
"""Register `var` as a parsed-advertisement listener on the configured hub."""
|
|
hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
|
|
cg.add(hub.register_listener(var))
|
|
_request_listener_slot()
|
|
return var
|
|
|
|
|
|
# ---- shared validation / codegen helpers (platform-neutral) ----
|
|
|
|
|
|
def to_ble_units(value: cv.TimePeriod) -> int:
|
|
"""Convert a scan time to the controller's 0.625 ms units.
|
|
|
|
Used by both validation and codegen so what is validated is exactly what is
|
|
programmed — the truncation here is what makes the duty-cycle check below
|
|
meaningful.
|
|
"""
|
|
return value.total_microseconds // 625
|
|
|
|
|
|
def validate_scan_parameters(config: ConfigType) -> ConfigType:
|
|
"""Reject impossible window/interval/duration combinations at config time.
|
|
|
|
The controller cannot scan for longer than the interval, and a too-short
|
|
duration would end the scan period almost immediately. Catching it here
|
|
gives a clear error instead of a runtime controller failure and a retry
|
|
loop.
|
|
"""
|
|
duration = config[CONF_DURATION]
|
|
interval = config[CONF_INTERVAL]
|
|
window = config[CONF_WINDOW]
|
|
|
|
if window > interval:
|
|
raise cv.Invalid(
|
|
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
|
|
)
|
|
|
|
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
|
|
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
|
|
# values here instead of letting the unit conversion silently overflow.
|
|
for name, value in (("interval", interval), ("window", window)):
|
|
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
|
|
raise cv.Invalid(
|
|
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
|
|
)
|
|
|
|
# Validate what actually reaches the controller: both values are truncated to
|
|
# whole 0.625 ms units, so a window/interval pair that differs by less than one
|
|
# unit collapses to the same value — silently programming a 100 % duty cycle
|
|
# (radio permanently on) from a config that asked for less.
|
|
interval_units = to_ble_units(interval)
|
|
window_units = to_ble_units(window)
|
|
if window_units == interval_units and window < interval:
|
|
raise cv.Invalid(
|
|
f"Scan window ({window}) and interval ({interval}) both truncate to "
|
|
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
|
|
f"cycle. Separate them by at least 0.625 ms."
|
|
)
|
|
|
|
if interval.total_microseconds * 3 > duration.total_microseconds:
|
|
raise cv.Invalid(
|
|
f"Scan duration ({duration}) must cover at least three scan intervals "
|
|
f"({interval}): the scanner listens on one of the three BLE advertising "
|
|
f"channels per interval, so a shorter duration can miss devices entirely."
|
|
)
|
|
|
|
return config
|
|
|
|
|
|
# The historical scan window default shared by the trackers that do not pin
|
|
# their own; also the fallback for esp32's conditional default.
|
|
DEFAULT_SCAN_WINDOW = "30ms"
|
|
|
|
|
|
def scan_parameters_schema(
|
|
interval_default: str,
|
|
*,
|
|
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
|
|
) -> cv.All:
|
|
"""Build the scan_parameters value schema shared by all BLE trackers.
|
|
|
|
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
|
|
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
|
|
LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg
|
|
callable evaluated per validation when the user omits the key (esp32 uses
|
|
this to record that the window was defaulted, so a later validation step
|
|
can adjust it once sibling keys are resolved). The `active` option
|
|
(default on) is unconditional: active scanning is part of the tracker
|
|
contract — every current proxy client assumes it, so a passive-only
|
|
tracker must not share this schema.
|
|
"""
|
|
schema = {
|
|
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
|
cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period,
|
|
cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period,
|
|
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
|
|
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
|
}
|
|
return cv.All(cv.Schema(schema), validate_scan_parameters)
|
|
|
|
|
|
BT_UUID16_FORMAT = "XXXX"
|
|
BT_UUID32_FORMAT = "XXXXXXXX"
|
|
BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
|
|
|
_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$")
|
|
_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$")
|
|
_BT_UUID128_RE = re.compile(
|
|
"^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$"
|
|
)
|
|
|
|
|
|
# Validator table keyed by input length: (compiled pattern, label used in errors).
|
|
_BT_UUID_FORMATS = {
|
|
len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"),
|
|
len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"),
|
|
len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"),
|
|
}
|
|
|
|
|
|
def bt_uuid(value: str) -> str:
|
|
in_value = cv.string_strict(value)
|
|
value = in_value.upper()
|
|
|
|
fmt = _BT_UUID_FORMATS.get(len(value))
|
|
if fmt is None:
|
|
raise cv.Invalid(
|
|
f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format"
|
|
)
|
|
pattern, label = fmt
|
|
if not pattern.match(value):
|
|
raise cv.Invalid(
|
|
f"Invalid hexadecimal value for {label} UUID format: '{in_value}'"
|
|
)
|
|
return value
|
|
|
|
|
|
def as_hex(value: str) -> cg.RawExpression:
|
|
return cg.RawExpression(f"0x{value}ULL")
|
|
|
|
|
|
def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression:
|
|
value = value.replace("-", "")
|
|
cpp_array = [
|
|
f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)]
|
|
]
|
|
if reverse:
|
|
cpp_array.reverse()
|
|
return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}")
|
|
|
|
|
|
def as_hex_array(value: str) -> cg.RawExpression:
|
|
return _hex_array_expression(value, reverse=False)
|
|
|
|
|
|
def as_reversed_hex_array(value: str) -> cg.RawExpression:
|
|
return _hex_array_expression(value, reverse=True)
|
|
|
|
|
|
def add_service_uuid(var: cg.MockObj, service_uuid: str) -> None:
|
|
"""Emit the width-matched service-UUID setter for a consumer.
|
|
|
|
16-/32-bit UUIDs go out as plain hex literals, 128-bit as a reversed byte
|
|
array (BLE wire order). Shared here so every sensor platform dispatches the
|
|
same way instead of carrying its own if/elif copy.
|
|
"""
|
|
if len(service_uuid) == len(BT_UUID16_FORMAT):
|
|
cg.add(var.set_service_uuid16(as_hex(service_uuid)))
|
|
elif len(service_uuid) == len(BT_UUID32_FORMAT):
|
|
cg.add(var.set_service_uuid32(as_hex(service_uuid)))
|
|
elif len(service_uuid) == len(BT_UUID128_FORMAT):
|
|
cg.add(var.set_service_uuid128(as_reversed_hex_array(service_uuid)))
|
|
else:
|
|
# bt_uuid restricts lengths to exactly these three formats; if that
|
|
# ever loosens, fail the build instead of emitting no setter (a
|
|
# sensor whose match_by_ is unset silently never matches). ValueError,
|
|
# not cv.Invalid: this runs from to_code, after validation, where
|
|
# voluptuous errors surface as raw tracebacks.
|
|
raise ValueError(f"Unsupported UUID format: {service_uuid}")
|