mirror of
https://github.com/esphome/esphome.git
synced 2026-10-09 21:13:12 +00:00
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
422 lines
17 KiB
Python
422 lines
17 KiB
Python
import functools
|
||
import logging
|
||
|
||
import esphome.codegen as cg
|
||
from esphome.components import ble_device_base, bluetooth_connection
|
||
import esphome.config_validation as cv
|
||
from esphome.const import (
|
||
CONF_ACTIVE,
|
||
CONF_ID,
|
||
PLATFORM_BK72XX,
|
||
PLATFORM_ESP32,
|
||
PLATFORM_LN882X,
|
||
PLATFORM_RP2,
|
||
)
|
||
from esphome.core import CORE
|
||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||
from esphome.types import ConfigType
|
||
|
||
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily
|
||
# inside _esp32_config_schema()/_to_code_esp32(): importing those modules
|
||
# registers esp32-only automations (ble.enable, ble.disable, ...) as a side
|
||
# effect, and a module-scope import would leak them into every platform's
|
||
# registry the moment a config declares `bluetooth_proxy:` — degrading
|
||
# "Unable to find action" config errors into C++ compile failures.
|
||
|
||
|
||
def AUTO_LOAD(config: ConfigType | None = None) -> list[str]:
|
||
"""Components to auto-load for the platform being compiled.
|
||
|
||
Callable with no argument so tooling that resolves AUTO_LOAD without a
|
||
target platform (the device-builder catalog sync does exactly this) gets
|
||
the union of every arm instead of an empty list — which is what lets it
|
||
keep cross-referencing the esp32 BLE stack. A real build always has a
|
||
target platform set, so it takes one of the concrete branches.
|
||
"""
|
||
if CORE.is_esp32:
|
||
return ["bluetooth_connection", "esp32_ble_tracker"]
|
||
if CORE.target_platform in _HUB_PLATFORMS:
|
||
return ["ble_device_base", "bluetooth_connection"]
|
||
# No target platform, or one this component does not support: tooling
|
||
# resolving the manifest (including the host-pinned dependency resolver) —
|
||
# expose every arm so the closure keeps the esp32 BLE stack.
|
||
return [
|
||
"ble_device_base",
|
||
"bluetooth_connection",
|
||
"esp32_ble_tracker",
|
||
]
|
||
|
||
|
||
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
|
||
# supports active scanning — every current client (aioesphomeapi, bleak-esphome,
|
||
# Home Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
|
||
# hub must not be admitted (it would be misdriven).
|
||
# Coupled to bluetooth_connection: platforms with a GATT backend are also
|
||
# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and
|
||
# FILTER_SOURCE_FILES hub entry.
|
||
_HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2)
|
||
|
||
DEPENDENCIES = ["api"]
|
||
CODEOWNERS = ["@jesserockz", "@bdraco"]
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
CONF_CONNECTION_SLOTS = "connection_slots"
|
||
CONF_CACHE_SERVICES = "cache_services"
|
||
CONF_CONNECTIONS = "connections"
|
||
DEFAULT_CONNECTION_SLOTS = 3
|
||
|
||
bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
|
||
|
||
BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component)
|
||
|
||
# Mirrors esp32_ble.IDF_MAX_CONNECTIONS (the loosest platform cap): the esp32
|
||
# schema builder asserts the two agree, tests/component_tests/bluetooth_proxy/
|
||
# pins them together, and the outer walkable schema uses it as the
|
||
# connection_slots bound (per-platform schemas tighten it).
|
||
_IDF_MAX_CONNECTIONS = 9
|
||
|
||
|
||
@functools.cache
|
||
def _esp32_config_schema() -> cv.All:
|
||
"""Build the esp32 schema, importing the esp32 BLE stack only when used."""
|
||
from esphome.components import esp32_ble, esp32_ble_tracker
|
||
|
||
if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS:
|
||
raise cv.Invalid(
|
||
f"bluetooth_proxy's connection-slot limit mirror "
|
||
f"({_IDF_MAX_CONNECTIONS}) is out of sync with "
|
||
f"esp32_ble.IDF_MAX_CONNECTIONS ({esp32_ble.IDF_MAX_CONNECTIONS}); "
|
||
f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py"
|
||
)
|
||
|
||
CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32)
|
||
|
||
def validate_connections(config: ConfigType) -> ConfigType:
|
||
if CONF_CONNECTIONS in config:
|
||
if not config[CONF_ACTIVE]:
|
||
raise cv.Invalid(
|
||
"Connections can only be used if the proxy is set to active"
|
||
)
|
||
elif config[CONF_ACTIVE]:
|
||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
|
||
config
|
||
)
|
||
|
||
return {
|
||
**config,
|
||
CONF_CONNECTIONS: [
|
||
CONNECTION_SCHEMA({}) for _ in range(connection_slots)
|
||
],
|
||
}
|
||
return config
|
||
|
||
return cv.All(
|
||
(
|
||
cv.Schema(
|
||
{
|
||
**_COMMON_SCHEMA_KEYS,
|
||
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
||
cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean,
|
||
cv.Optional(
|
||
CONF_CONNECTION_SLOTS,
|
||
default=DEFAULT_CONNECTION_SLOTS,
|
||
): cv.All(
|
||
cv.positive_int,
|
||
cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
|
||
),
|
||
cv.Optional(CONF_CONNECTIONS): cv.All(
|
||
cv.ensure_list(CONNECTION_SCHEMA),
|
||
cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS),
|
||
),
|
||
}
|
||
)
|
||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
|
||
.extend(cv.COMPONENT_SCHEMA)
|
||
),
|
||
validate_connections,
|
||
)
|
||
|
||
|
||
def _validate_no_active(config: ConfigType) -> ConfigType:
|
||
if config[CONF_ACTIVE]:
|
||
raise cv.Invalid(
|
||
"Active connections are not supported on this platform; the proxy "
|
||
"forwards advertisements only (set active: false)"
|
||
)
|
||
return config
|
||
|
||
|
||
@functools.cache
|
||
def _rp2_config_schema() -> cv.All:
|
||
"""Full proxy on the rp2 BLE hub: active connections through the BTstack
|
||
GATT client backend in bluetooth_connection. Multi-slot builds replace the
|
||
prebuilt library's one-client BTstack pools via linker --wrap, owned by
|
||
rp2040_ble and requested when a second backend registers."""
|
||
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
|
||
|
||
def populate_connections(config: ConfigType) -> ConfigType:
|
||
from esphome.components import rp2040_ble
|
||
|
||
# One wrapper + backend pair per slot, declared during validation so
|
||
# their ids exist for codegen (the esp32 arm's `connections` pattern).
|
||
if not config[CONF_ACTIVE]:
|
||
return config
|
||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||
return {
|
||
**config,
|
||
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
|
||
}
|
||
|
||
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
|
||
schema = (
|
||
cv.Schema(
|
||
{
|
||
**_COMMON_SCHEMA_KEYS,
|
||
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
||
cv.Optional(
|
||
CONF_CONNECTION_SLOTS,
|
||
default=min(DEFAULT_CONNECTION_SLOTS, max_conn),
|
||
): cv.All(
|
||
cv.positive_int,
|
||
cv.Range(
|
||
min=1,
|
||
max=max_conn,
|
||
msg=f"rp2 supports at most {max_conn} connection slot(s); "
|
||
"the BTstack pool overrides in rp2040_ble are sized "
|
||
f"for {max_conn}",
|
||
),
|
||
),
|
||
}
|
||
)
|
||
.extend(
|
||
# ble_hub_id with the friendly no-tracker-configured guard.
|
||
ble_device_base.BLE_DEVICE_SCHEMA
|
||
)
|
||
.extend(cv.COMPONENT_SCHEMA)
|
||
)
|
||
return cv.All(schema, populate_connections)
|
||
|
||
|
||
async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
|
||
"""One wrapper + backend pair per slot; the platform-specific backend
|
||
registration lives in bluetooth_connection.new_gatt_backend()."""
|
||
connections = config.get(CONF_CONNECTIONS, [])
|
||
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
|
||
# this define whenever a proxy is present (zero on advertisement-only
|
||
# hubs); sized here so it can never diverge from the loop below.
|
||
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections))
|
||
if connections:
|
||
# Gates the connection and GATT half of the API surface. A proxy
|
||
# without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never
|
||
# sends those requests and their handlers and encoders are dead.
|
||
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
|
||
for connection_conf in connections:
|
||
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
|
||
connection = cg.new_Pvariable(connection_conf[CONF_ID])
|
||
cg.add(connection.set_backend(backend))
|
||
cg.add(var.register_connection(connection))
|
||
|
||
|
||
# Per-platform schema builders; every key of
|
||
# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by
|
||
# tests/component_tests/bluetooth_proxy/). Connection codegen is shared.
|
||
_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema}
|
||
|
||
|
||
# Keys every platform arm declares identically; each arm spreads this dict so
|
||
# the shared surface cannot drift. CONF_ACTIVE stays per-arm: its default
|
||
# differs (esp32 True, rp2 True, advertisement-only False).
|
||
_COMMON_SCHEMA_KEYS = {
|
||
cv.GenerateID(): cv.declare_id(BluetoothProxy),
|
||
}
|
||
|
||
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
|
||
# callback feeds the same API batching, no connection stack compiled.
|
||
_BLE_HUB_CONFIG_SCHEMA = cv.All(
|
||
cv.Schema(
|
||
{
|
||
**_COMMON_SCHEMA_KEYS,
|
||
cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
|
||
}
|
||
)
|
||
.extend(
|
||
# ble_hub_id with the friendly no-tracker-configured guard.
|
||
ble_device_base.BLE_DEVICE_SCHEMA
|
||
)
|
||
.extend(cv.COMPONENT_SCHEMA),
|
||
_validate_no_active,
|
||
)
|
||
|
||
|
||
@schema_extractor("schema")
|
||
def _validate_platform(config: ConfigType) -> ConfigType:
|
||
"""Apply the schema for the platform actually being compiled.
|
||
|
||
Three-way dispatch: esp32 gets the full GATT proxy, HUB_MAX_CONNECTIONS
|
||
platforms get their _GATT_HUB_SCHEMAS arm, the remaining hub platforms get
|
||
the advertisement-only shape; unsupported keys were already rejected by
|
||
name in _reject_unsupported_connection_keys.
|
||
"""
|
||
if config is SCHEMA_EXTRACT:
|
||
# The language-schema dumper runs without a platform. Expose the esp32
|
||
# shape so `connections`, the ids and every default stay in the
|
||
# generated schema the editor and dashboard consume.
|
||
return _esp32_config_schema()
|
||
if CORE.is_esp32:
|
||
return _esp32_config_schema()(config)
|
||
if CORE.target_platform not in _HUB_PLATFORMS:
|
||
# Fail here with the actual reason. Without this gate the error surfaces
|
||
# later as an unresolvable hub ID ("Are you missing a hub declaration?")
|
||
# on platforms where no hub component can be declared.
|
||
full = ", ".join(["esp32", *sorted(bluetooth_connection.HUB_MAX_CONNECTIONS)])
|
||
adv_only = ", ".join(
|
||
sorted(set(_HUB_PLATFORMS) - set(bluetooth_connection.HUB_MAX_CONNECTIONS))
|
||
)
|
||
raise cv.Invalid(
|
||
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
|
||
"active-scan-capable BLE tracker hub is available for this "
|
||
f"platform. It runs on {full} (full proxy) and {adv_only} "
|
||
"(advertisement-only)."
|
||
)
|
||
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
|
||
return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config)
|
||
return _BLE_HUB_CONFIG_SCHEMA(config)
|
||
|
||
|
||
def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType:
|
||
"""Reject connection options a platform does not support, by name.
|
||
|
||
GATT hub platforms keep connection_slots but reject the esp32-only keys;
|
||
advertisement-only hubs reject all three. Runs before the walkable schema
|
||
below so the user gets "this option does not exist here" instead of a
|
||
value-range error implying the option works.
|
||
"""
|
||
if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None:
|
||
return config
|
||
if CORE.target_platform not in _HUB_PLATFORMS:
|
||
# No proxy of any kind exists here: fall through so _validate_platform
|
||
# reports "not supported on {platform}" instead of a key-level message
|
||
# implying an advertisement-only proxy is available.
|
||
return config
|
||
if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS:
|
||
# Full proxy: connection_slots is real here; the per-connection list
|
||
# exists internally but carries no user options, and the Bluedroid
|
||
# NVS service cache is esp32-only.
|
||
rejected = {
|
||
CONF_CONNECTIONS: (
|
||
"has no per-connection options on this platform; use "
|
||
"'connection_slots' to set the count"
|
||
),
|
||
CONF_CACHE_SERVICES: "is esp32-only (Bluedroid NVS service cache)",
|
||
}
|
||
else:
|
||
reason = (
|
||
"requires active connection support; this platform runs the "
|
||
"advertisement-only proxy and has no such option"
|
||
)
|
||
rejected = dict.fromkeys(
|
||
(CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS), reason
|
||
)
|
||
for key, reason in rejected.items():
|
||
if key in config:
|
||
raise cv.Invalid(f"'{key}' {reason}", path=[key])
|
||
return config
|
||
|
||
|
||
# CONFIG_SCHEMA stays a statically walkable schema: tooling (the dashboard's
|
||
# field-range extractor among others) introspects it to discover options and
|
||
# their bounds, which a bare dispatch function would hide. It carries the scalar
|
||
# keys with no defaults; _validate_platform then runs the real per-platform
|
||
# schema, which applies the defaults and rejects options the platform does not
|
||
# support.
|
||
#
|
||
# It deliberately does NOT declare `connections`: this outer schema runs before
|
||
# the per-platform one, so any key it transforms is transformed twice. Running
|
||
# CONNECTION_SCHEMA twice re-validates an already-generated ID through
|
||
# declare_id(), which (unlike use_id) has no guard for an ID instance and
|
||
# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched
|
||
# for _ESP32_CONFIG_SCHEMA to validate exactly once.
|
||
CONFIG_SCHEMA = cv.All(
|
||
_reject_unsupported_connection_keys,
|
||
cv.Schema(
|
||
{
|
||
cv.Optional(CONF_ACTIVE): cv.boolean,
|
||
cv.Optional(CONF_CACHE_SERVICES): cv.boolean,
|
||
# Bounded by the loosest platform cap so range walkers (the
|
||
# device-builder field-range sync) see a real Range; the
|
||
# per-platform schemas tighten it (1 on rp2) with their own error.
|
||
cv.Optional(CONF_CONNECTION_SLOTS): cv.All(
|
||
cv.positive_int,
|
||
cv.Range(min=1, max=_IDF_MAX_CONNECTIONS),
|
||
),
|
||
},
|
||
extra=cv.ALLOW_EXTRA,
|
||
),
|
||
_validate_platform,
|
||
)
|
||
|
||
|
||
async def _to_code_esp32(config: ConfigType) -> None:
|
||
from esphome.components import esp32_ble, esp32_ble_tracker
|
||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||
from esphome.components.esp32_ble import BTLoggers
|
||
|
||
# Register the loggers this component needs
|
||
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP)
|
||
|
||
var = cg.new_Pvariable(config[CONF_ID])
|
||
await cg.register_component(var, config)
|
||
|
||
cg.add(var.set_active(config[CONF_ACTIVE]))
|
||
tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID])
|
||
cg.add(var.set_ble_hub(tracker))
|
||
|
||
# Compiles the scanner-state push slot into the tracker and the matching
|
||
# registration into the proxy; the other hubs are polled instead.
|
||
cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK")
|
||
|
||
await _connections_to_code(var, config)
|
||
|
||
if config.get(CONF_CACHE_SERVICES):
|
||
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True)
|
||
|
||
|
||
async def _to_code_ble_hub(config: ConfigType) -> None:
|
||
var = cg.new_Pvariable(config[CONF_ID])
|
||
await cg.register_component(var, config)
|
||
|
||
cg.add(var.set_active(config[CONF_ACTIVE]))
|
||
hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID])
|
||
cg.add(var.set_ble_hub(hub))
|
||
|
||
await _connections_to_code(var, config)
|
||
|
||
|
||
def enable_advertisement_filter() -> None:
|
||
"""Compile the advertisement filter hook into bluetooth_proxy.
|
||
|
||
Called by external filtering components from to_code(). The define behind
|
||
this is an implementation detail; do not emit it directly.
|
||
|
||
Public API for external components. Do not remove.
|
||
"""
|
||
cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER")
|
||
|
||
|
||
async def to_code(config: ConfigType) -> None:
|
||
if CORE.is_esp32:
|
||
await _to_code_esp32(config)
|
||
else:
|
||
await _to_code_ble_hub(config)
|
||
|
||
# Define batch size for BLE advertisements
|
||
# Each advertisement is up to 80 bytes when packaged (including protocol overhead)
|
||
# 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
|
||
# This achieves ~97% WiFi MTU utilization while staying under the limit
|
||
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
|
||
|
||
cg.add_define("USE_BLUETOOTH_PROXY")
|