[bluetooth_proxy] Enable active connections on rp2 (#18132)

This commit is contained in:
J. Nick Koston
2026-08-07 13:51:41 -05:00
committed by GitHub
parent 950cfc4da3
commit 2e1c517821
11 changed files with 287 additions and 47 deletions
+142 -30
View File
@@ -59,15 +59,17 @@ _LOGGER = logging.getLogger(__name__)
CONF_CONNECTION_SLOTS = "connection_slots"
CONF_CACHE_SERVICES = "cache_services"
CONF_CONNECTIONS = "connections"
CONF_BACKEND_ID = "backend_id"
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 as a literal so the statically walkable
# CONFIG_SCHEMA below can state the connection_slots range without importing the
# esp32 BLE stack. tests/component_tests/bluetooth_proxy/ pins the two together.
# 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
@@ -147,17 +149,99 @@ def _validate_no_active(config: ConfigType) -> ConfigType:
return config
# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement
# callback feeds the same API batching. GATT/active connections are excluded at
# compile time — only the esp32 build compiles the connection stack; nothing
# reads HubCapabilities::gatt at runtime for this today.
# Keys both platform schemas must declare identically; each arm spreads this
# dict so the shared surface cannot drift. CONF_ACTIVE deliberately stays
# per-arm: its default differs (esp32 True, hub arms False — no GATT).
@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. The slot limit comes from the
prebuilt BTstack library (one connection today); the code is built for N."""
from esphome.components import rp2040_ble
connection_schema = cv.Schema(
{
cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection),
cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(
bluetooth_connection.RP2GattClient
),
}
)
def populate_connections(config: ConfigType) -> ConfigType:
# 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
return {
**config,
CONF_CONNECTIONS: [
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
],
}
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
schema = (
cv.Schema(
{
**_COMMON_SCHEMA_KEYS,
# The GATT backend drives the controller directly (connect, GATT
# ops), not through the tracker hub.
cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(
rp2040_ble.RP2040BLE
),
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 framework's BTstack library is built with "
f"MAX_NR_GATT_CLIENTS {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 _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
from esphome.components import rp2040_ble
# One wrapper + backend pair per slot (the esp32 arm's pattern).
for connection_conf in config[CONF_CONNECTIONS]:
ble_device_base.request_gatt_client()
backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID])
await cg.register_component(backend, connection_conf)
await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID])
connection = cg.new_Pvariable(connection_conf[CONF_ID])
cg.add(connection.set_backend(backend))
cg.add(var.register_connection(connection))
# Per-platform schema builders and connection codegen; every key of
# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by
# tests/component_tests/bluetooth_proxy/).
_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema}
_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code}
# 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(
{
@@ -178,9 +262,10 @@ _BLE_HUB_CONFIG_SCHEMA = cv.All(
def _validate_platform(config: ConfigType) -> ConfigType:
"""Apply the schema for the platform actually being compiled.
esp32 keeps the full GATT proxy; every other platform gets the
advertisement-only shape, which rejects the connection-oriented options
above because its schema does not define them.
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
@@ -196,18 +281,21 @@ def _validate_platform(config: ConfigType) -> ConfigType:
raise cv.Invalid(
f"bluetooth_proxy is not supported on {CORE.target_platform}: no "
"active-scan-capable BLE tracker hub is available for this "
"platform. It runs on esp32 (full proxy), and the ln882x and rp2 "
"families (advertisement-only)."
"platform. It runs on esp32 and rp2 (full proxy) and the ln882x "
"family (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_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
"""Reject connection-oriented options by name on hub-only platforms.
def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType:
"""Reject connection options a platform does not support, by name.
Runs before the walkable schema below so the user gets "this option does
not exist here" instead of the option's esp32 value range (which would
imply a smaller number is accepted).
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
@@ -216,14 +304,28 @@ def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
# reports "not supported on {platform}" instead of a key-level message
# implying an advertisement-only proxy is available.
return config
for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS):
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}' requires active connection support, which needs the "
"esp32 GATT stack; this platform runs the advertisement-only "
"proxy and has no such option",
path=[key],
)
raise cv.Invalid(f"'{key}' {reason}", path=[key])
return config
@@ -241,11 +343,14 @@ def _reject_connection_keys_off_esp32(config: ConfigType) -> ConfigType:
# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched
# for _ESP32_CONFIG_SCHEMA to validate exactly once.
CONFIG_SCHEMA = cv.All(
_reject_connection_keys_off_esp32,
_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),
@@ -295,8 +400,15 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
cg.add(var.set_ble_hub(hub))
# The api component sizes BluetoothConnectionsFreeResponse.allocated with
# this define whenever a proxy is present; no connections off-esp32.
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 0)
# this define whenever a proxy is present. Zero on advertisement-only hubs.
# Sized from the instantiated connections so the define can never diverge
# from the loop below (the define sizes fixed storage in the proxy).
slots = len(config.get(CONF_CONNECTIONS, ()))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots)
if not slots:
return
await _GATT_HUB_TO_CODE[CORE.target_platform](var, config)
async def to_code(config: ConfigType) -> None:
@@ -6,6 +6,8 @@ from esphome.types import ConfigType
DEPENDENCIES = ["rp2"]
CODEOWNERS = ["@bdraco"]
CONF_RP2040_BLE_ID = "rp2040_ble_id"
rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble")
RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component)
@@ -12,6 +12,7 @@ Scan modes:
import esphome.codegen as cg
from esphome.components import ble_device_base, ota, rp2040_ble
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.rp2040_ble import CONF_RP2040_BLE_ID
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTIVE,
@@ -22,8 +23,6 @@ from esphome.const import (
)
from esphome.types import ConfigType
CONF_RP2040_BLE_ID = "rp2040_ble_id"
DEPENDENCIES = ["rp2"]
AUTO_LOAD = ["ble_device_base", "rp2040_ble"]
CODEOWNERS = ["@bdraco"]
+5 -2
View File
@@ -253,10 +253,13 @@
#if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2)
#define USE_BLUETOOTH_PROXY
// Mirror the codegen values per platform: _to_code_esp32() emits the connection
// count (default 3), _to_code_ble_hub() emits 0 — so static analysis checks the
// same std::array<uint64_t, N> instantiation a real build produces.
// count (default 3), _to_code_ble_hub() emits the slot count (1 on rp2, 0 on
// advertisement-only hubs) — so static analysis checks the same
// std::array<uint64_t, N> instantiation a real build produces.
#ifdef USE_ESP32
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
#elif defined(USE_RP2)
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 1
#else
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 0
#endif
@@ -1,10 +1,10 @@
"""bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together.
The mirror exists so the statically walkable CONFIG_SCHEMA can express the
connection_slots range without importing the esp32 BLE stack (that import
registers esp32-only automations on every platform). The runtime check in
_esp32_config_schema() only fires while validating an esp32 config, so this
test is what actually catches drift when the upstream constant changes.
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
@@ -4,8 +4,10 @@ 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; validator bounds are pinned
separately only for connection_slots (test_idf_max_connections_mirror).
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
@@ -2,6 +2,9 @@
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
@@ -19,9 +22,10 @@ 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.LN882X_ARDUINO,
PlatformFramework.RP2_ARDUINO,
]
HUB_TRACKERS = {
@@ -32,9 +36,11 @@ HUB_TRACKERS = {
def test_hub_platform_list_covers_every_hub_platform() -> None:
# A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise
# get no gate coverage at all.
covered = {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS}
assert covered == set(bluetooth_proxy._HUB_PLATFORMS)
# 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)
@@ -122,6 +128,55 @@ def test_hub_platform_accepts_the_advertisement_only_shape(
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] == 1
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1
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 prebuilt BTstack library allows exactly one GATT client connection.
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
with pytest.raises(cv.Invalid, match="at most 1 connection slot"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2})
# 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_bluetooth_connection_auto_load_covers_its_includes() -> None:
# The esp32 connection header includes esp32_ble_client; the auto load
# must satisfy that closure itself (regression: it once relied on the
@@ -134,3 +189,40 @@ def test_bluetooth_connection_auto_load_covers_its_includes() -> None:
# dependency closures stay complete for build_codeowners and friends.
_set_platform(None)
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"]
def test_every_registered_hub_platform_has_a_schema_arm() -> None:
# A platform added to HUB_MAX_CONNECTIONS without a schema builder,
# codegen arm, or _HUB_PLATFORMS entry would only fail when a config for
# it is validated (or not even then); pin all three couplings here.
registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS)
assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS)
assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE)
assert registered <= set(bluetooth_proxy._HUB_PLATFORMS)
# 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.
match = re.search(r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", defines)
assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from defines.h"
assert int(match.group(1)) == cap, (
f"ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}"
)
@@ -0,0 +1,8 @@
wifi:
ssid: MySSID
password: password1
ota:
- platform: esphome
api:
@@ -0,0 +1,11 @@
# Distinct shape from bluetooth_proxy's own rp2 fixtures: explicit slot count
# on the platform whose backend lives in this component (validate-only, so it
# never collides with grouped builds).
packages:
common: !include common.yaml
rp2_ble_tracker:
bluetooth_proxy:
active: true
connection_slots: 1
@@ -0,0 +1,11 @@
# Advertisement-only proxy on rp2 by explicit choice. Variant tests compile
# as their own builds when this component is tested individually; under CI
# batch grouping the active default build is what runs, so this fixture's
# guarantee is the individual run plus config validation.
packages:
common: !include common.yaml
rp2_ble_tracker:
bluetooth_proxy:
active: false
@@ -1,5 +1,5 @@
# Advertisement-only proxy on the rp2 BLE hub — the one non-esp32 platform the
# proxy admits today (active-scan-capable), and a target CI fully compiles.
# Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity),
# so this compiles the BTstack GATT client backend and one connection slot.
# No explicit ble_hub_id: the generated binding resolves the single declared
# hub, and an inline id here would collide with rp2_ble_tracker's own fixture
# once CI merges both components into one grouped rp2040-ard build (grouped