[bluetooth_proxy] Platform-neutral advertisement proxy via ble_device_base (#17880)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Edvard Filistovič
2026-08-05 15:06:01 -05:00
committed by GitHub
co-authored by J. Nick Koston
parent 17f3c9d780
commit 563b6ade0f
11 changed files with 724 additions and 99 deletions
+260 -60
View File
@@ -1,14 +1,50 @@
import functools
import logging
import esphome.codegen as cg
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32_ble import BTLoggers
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_ACTIVE, CONF_ID
from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2
from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"]
DEPENDENCIES = ["api", "esp32"]
# The esp32 BLE stack (esp32_ble, esp32_ble_client, 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 ["esp32_ble_client", "esp32_ble_tracker"]
if CORE.target_platform in _HUB_PLATFORMS:
return ["ble_device_base"]
# 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", "esp32_ble_client", "esp32_ble_tracker"]
# Platforms with an in-tree ble_device_base BLE tracker hub whose controller
# supports active scanning. Passive-only hubs (bk72xx) are deliberately NOT
# admitted yet: every current client (aioesphomeapi, bleak-esphome, Home
# Assistant) assumes an ESPHome proxy can scan actively, so a passive-only
# proxy would be misdriven — bk72xx follows once the API carries a feature
# flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs).
_HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2)
DEPENDENCIES = ["api"]
CODEOWNERS = ["@jesserockz", "@bdraco"]
_LOGGER = logging.getLogger(__name__)
@@ -20,65 +56,209 @@ DEFAULT_CONNECTION_SLOTS = 3
bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
BluetoothProxy = bluetooth_proxy_ns.class_(
"BluetoothProxy", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
BluetoothConnection = bluetooth_proxy_ns.class_(
"BluetoothConnection", esp32_ble_client.BLEClientBase
)
BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component)
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BluetoothConnection),
}
).extend(cv.COMPONENT_SCHEMA)
# 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.
_IDF_MAX_CONNECTIONS = 9
def validate_connections(config):
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)
@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_client, esp32_ble_tracker
return {
**config,
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
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"
)
BluetoothConnection = bluetooth_proxy_ns.class_(
"BluetoothConnection", esp32_ble_client.BLEClientBase
)
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(BluetoothConnection),
}
).extend(cv.COMPONENT_SCHEMA)
def validate_connections(config):
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
CONFIG_SCHEMA = cv.All(
(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BluetoothProxy),
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,
# 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).
_COMMON_SCHEMA_KEYS = {
cv.GenerateID(): cv.declare_id(BluetoothProxy),
}
_BLE_HUB_CONFIG_SCHEMA = cv.All(
cv.Schema(
{
**_COMMON_SCHEMA_KEYS,
# Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator
# after a strict schema rejects an explicit `ble_hub_id` before it
# runs, and that key is the documented way to disambiguate once a
# platform has two trackers.
cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id(
ble_device_base.BLEHub
),
cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA),
_validate_no_active,
)
async def to_code(config):
@schema_extractor("schema")
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.
"""
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.
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)."
)
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.
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).
"""
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
for key in (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS):
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],
)
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_connection_keys_off_esp32,
cv.Schema(
{
cv.Optional(CONF_ACTIVE): cv.boolean,
cv.Optional(CONF_CACHE_SERVICES): cv.boolean,
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)
@@ -93,12 +273,6 @@ async def to_code(config):
connection_count = len(config.get(CONF_CONNECTIONS, []))
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count)
# 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)
for connection_conf in config.get(CONF_CONNECTIONS, []):
connection_var = cg.new_Pvariable(connection_conf[CONF_ID])
await cg.register_component(connection_var, connection_conf)
@@ -108,4 +282,30 @@ async def to_code(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))
# 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)
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")
@@ -1,6 +1,9 @@
#include "bluetooth_proxy.h"
#ifdef USE_BLUETOOTH_PROXY
#include "esphome/components/api/api_server.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
@@ -8,8 +11,6 @@
#include <cstring>
#include <limits>
#ifdef USE_ESP32
namespace esphome::bluetooth_proxy {
static const char *const TAG = "bluetooth_proxy";
@@ -23,6 +24,8 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62
BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; }
#ifdef USE_ESP32
void BluetoothProxy::setup() {
this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS;
this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS;
@@ -48,6 +51,62 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta
this->api_connection_->send_message(resp);
}
#else // !USE_ESP32
void BluetoothProxy::setup() {
this->connections_free_response_.limit = 0;
this->connections_free_response_.free = 0;
// Capture the configured scan mode from YAML before any API changes
this->configured_scan_active_ = this->hub_->scan_active();
this->last_scan_running_ = this->hub_->scan_running();
// The hub delivers raw advertisements on the ESPHome main loop:
// mac is least-significant octet first (BLE controller convention).
this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) {
static_cast<BluetoothProxy *>(self)->on_raw_advertisement_(adv);
}});
}
void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) {
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
auto &adv = this->response_.advertisements[this->response_.advertisements_len];
// raw.mac is LSB-first; this yields the same uint64 the esp32 proxy sends.
adv.address = ble_device_base::mac_lsb_first_to_uint64(raw.mac);
adv.rssi = raw.rssi;
adv.address_type = raw.addr_type;
uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast<uint8_t>(raw.data_len);
adv.data_len = length;
std::memcpy(adv.data, raw.data, length);
this->response_.advertisements_len++;
ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", raw.mac[5], raw.mac[4],
raw.mac[3], raw.mac[2], raw.mac[1], raw.mac[0], length, raw.rssi);
// Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE
if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) {
this->flush_pending_advertisements_();
}
}
void BluetoothProxy::send_bluetooth_scanner_state_() {
api::BluetoothScannerStateResponse resp;
resp.state = this->hub_->scan_running() ? api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_RUNNING
: api::enums::BluetoothScannerState::BLUETOOTH_SCANNER_STATE_IDLE;
resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
resp.configured_mode = this->configured_scan_active_
? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
: api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
this->api_connection_->send_message(resp);
}
#endif // USE_ESP32
#ifdef USE_ESP32
void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) {
ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(),
connection->address_str(), espbt::client_state_to_string(state));
@@ -56,6 +115,7 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec
void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) {
ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message);
}
#endif // USE_ESP32
void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) {
ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type);
@@ -67,6 +127,8 @@ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handl
this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED);
}
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_DEVICE
bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
// This method should never be called since bluetooth_proxy always uses raw advertisements
@@ -107,18 +169,38 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results,
return true;
}
#endif // USE_ESP32
void BluetoothProxy::log_advertisement_flush_() {
ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len);
}
void BluetoothProxy::dump_config() {
#ifdef USE_ESP32
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Active: %s\n"
" Connections: %d",
YESNO(this->active_), this->connection_count_);
#else
// Advertisement-only: print configured facts. dump_config runs right after
// setup, before the radio is up, so live scan state would always read
// "stopped" here — the loop's BluetoothScannerStateResponse carries the
// changing value instead.
char mac_str[18];
this->get_bluetooth_mac_address_pretty(mac_str);
ESP_LOGCONFIG(TAG,
"Bluetooth Proxy:\n"
" Mode: advertisement-only (no GATT connections)\n"
" Configured scan: %s\n"
" Adapter MAC: %s",
this->configured_scan_active_ ? "active" : "passive",
mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)");
#endif
}
#ifdef USE_ESP32
void BluetoothProxy::loop() {
// Run advertisement flush / connection cleanup every 100ms
uint32_t now = App.get_loop_component_start_time();
@@ -252,13 +334,8 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
esp_bd_addr_t address;
uint64_to_bd_addr(msg.address, address);
esp_err_t ret = esp_ble_gattc_cache_clean(address);
api::BluetoothDeviceClearCacheResponse call;
call.address = msg.address;
call.success = ret == ESP_OK;
call.error = ret;
this->api_connection_->send_message(call);
// Shares the sender with the neutral path, which also null-checks api_connection_.
this->send_device_clear_cache(msg.address, ret == ESP_OK, ret);
break;
}
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: {
@@ -376,6 +453,120 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
this->api_connection_->send_message(resp);
}
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->parent_->get_scan_active() == active) {
return;
}
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
this->parent_->set_scan_active(active);
this->parent_->stop_scan();
this->parent_->set_scan_continuous(
true); // Set this to true to automatically start scanning again when it has cleaned up.
}
#else // !USE_ESP32
// Advertisement-only proxy. GATT client connections are excluded at compile
// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults
// HubCapabilities at runtime today — so every connection-oriented request is
// answered with a clean error instead of silence, and Home Assistant treats
// the proxy as passive.
void BluetoothProxy::loop() {
// Run advertisement flush / scanner-state poll every 100ms
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_advertisement_flush_time_ < 100)
return;
this->last_advertisement_flush_time_ = now;
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
return;
// The hub has no scanner-state listener interface; poll and report on change.
bool running = this->hub_->scan_running();
if (running != this->last_scan_running_) {
this->last_scan_running_ = running;
this->send_bluetooth_scanner_state_();
}
this->flush_pending_advertisements_();
}
void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {
switch (msg.request_type) {
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE:
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE:
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT:
ESP_LOGW(TAG, "Active connections are not supported on this platform");
this->send_device_connection(msg.address, false, 0, ESP_GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT:
// Not an error: the device is already disconnected, which is the requested state.
this->send_device_connection(msg.address, false);
this->send_connections_free();
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR:
this->send_device_pairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR:
this->send_device_unpairing(msg.address, false, ESP_GATT_NOT_CONNECTED);
break;
case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE:
this->send_device_clear_cache(msg.address, false, ESP_GATT_NOT_CONNECTED);
break;
}
}
void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic");
}
void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic");
}
void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor");
}
void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor");
}
void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {
this->handle_gatt_not_connected_(msg.address, 0, "get", "services");
}
void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {
this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic");
}
void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothSetConnectionParamsResponse resp;
resp.address = msg.address;
resp.error = ESP_GATT_NOT_CONNECTED;
this->api_connection_->send_message(resp);
}
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->hub_->scan_active() != active) {
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
if (!this->hub_->request_scan_mode(active)) {
// Passive-only controller asked for active scanning; the state report
// below carries the real, unchanged mode so the subscriber does not
// assume the change happened.
ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive");
}
}
if (this->api_connection_ != nullptr) {
this->send_bluetooth_scanner_state_();
}
}
#endif // USE_ESP32
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
// A previous subscriber still holds the slot. This is almost always a stale
@@ -390,9 +581,13 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection
this->api_connection_->get_peername_to(old_peername));
}
this->api_connection_ = api_connection;
#ifdef USE_ESP32
this->parent_->recalculate_advertisement_parser_types();
this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state());
#else
this->last_scan_running_ = this->hub_->scan_running();
this->send_bluetooth_scanner_state_();
#endif
}
void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connection) {
@@ -401,10 +596,12 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti
return;
}
this->api_connection_ = nullptr;
#ifdef USE_ESP32
this->parent_->recalculate_advertisement_parser_types();
#endif
}
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, esp_err_t error) {
void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceConnectionResponse call;
@@ -432,7 +629,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) {
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) {
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothGATTErrorResponse call;
@@ -442,7 +639,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) {
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDevicePairingResponse call;
@@ -453,7 +650,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_
this->api_connection_->send_message(call);
}
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) {
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceUnpairingResponse call;
@@ -464,19 +661,21 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_e
this->api_connection_->send_message(call);
}
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
if (this->parent_->get_scan_active() == active) {
// Shared by both platform paths: the neutral bluetooth_device_request() uses it to
// answer a clear-cache request with a clean error, so it must not be esp32-guarded.
void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, proxy_err_t error) {
if (this->api_connection_ == nullptr)
return;
}
ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive");
this->parent_->set_scan_active(active);
this->parent_->stop_scan();
this->parent_->set_scan_continuous(
true); // Set this to true to automatically start scanning again when it has cleaned up.
api::BluetoothDeviceClearCacheResponse call;
call.address = address;
call.success = success;
call.error = error;
this->api_connection_->send_message(call);
}
BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::bluetooth_proxy
#endif // USE_ESP32
#endif // USE_BLUETOOTH_PROXY
@@ -1,6 +1,8 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/core/defines.h"
#ifdef USE_BLUETOOTH_PROXY
#include <array>
#include <map>
@@ -8,11 +10,12 @@
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "bluetooth_connection.h"
@@ -20,14 +23,31 @@
#include <esp_bt.h>
#endif
#include <esp_bt_device.h>
#else
#include "esphome/components/ble_device_base/ble_hub.h"
#endif // USE_ESP32
namespace esphome::bluetooth_proxy {
static constexpr esp_err_t ESP_GATT_NOT_CONNECTED = -1;
// Proxy-owned error type for the API error fields, which are plain integers on
// the wire. Aliases esp_err_t on esp32 (where the values come from IDF calls);
// a bare int elsewhere. Owning the name instead of probing for esp_err_t keeps
// the header independent of how a hub platform's SDK spells its error type.
#ifdef USE_ESP32
using proxy_err_t = esp_err_t;
static constexpr proxy_err_t PROXY_OK = ESP_OK;
#else
using proxy_err_t = int;
static constexpr proxy_err_t PROXY_OK = 0;
#endif
static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = -1;
static constexpr int DONE_SENDING_SERVICES = -2;
static constexpr int INIT_SENDING_SERVICES = -3;
#ifdef USE_ESP32
using namespace esp32_ble_client;
#endif
// Legacy versions:
// Version 1: Initial version without active connections
@@ -53,21 +73,28 @@ enum BluetoothProxySubscriptionFlag : uint32_t {
SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0,
};
#ifdef USE_ESP32
class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
public esp32_ble_tracker::BLEScannerStateListener,
public Component {
friend class BluetoothConnection; // Allow connection to update connections_free_response_
#else
class BluetoothProxy final : public Component {
#endif
public:
BluetoothProxy();
#ifdef USE_ESP32
#ifdef USE_ESP32_BLE_DEVICE
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
#endif
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
#endif // USE_ESP32
void dump_config() override;
void setup() override;
void loop() override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
#ifdef USE_ESP32
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
@@ -78,6 +105,14 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
}
#endif
}
#else
void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; }
// Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below
// snapshots scan_active()/scan_running() and installs the raw callback, and
// the BLEHub contract does not promise those are settled any earlier than
// the hub's own setup().
float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; }
#endif // USE_ESP32
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg);
@@ -92,17 +127,18 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
void unsubscribe_api_connection(api::APIConnection *api_connection);
api::APIConnection *get_api_connection() { return this->api_connection_; }
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK);
void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, proxy_err_t error = PROXY_OK);
void send_connections_free();
void send_connections_free(api::APIConnection *api_connection);
void send_gatt_services_done(uint64_t address);
void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error);
void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK);
void send_device_unpairing(uint64_t address, bool success, esp_err_t error = ESP_OK);
void send_device_clear_cache(uint64_t address, bool success, esp_err_t error = ESP_OK);
void send_gatt_error(uint64_t address, uint16_t handle, proxy_err_t error);
void send_device_pairing(uint64_t address, bool paired, proxy_err_t error = PROXY_OK);
void send_device_unpairing(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
void send_device_clear_cache(uint64_t address, bool success, proxy_err_t error = PROXY_OK);
void bluetooth_scanner_set_mode(bool active);
#ifdef USE_ESP32
static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) {
bd_addr[0] = (address >> 40) & 0xff;
bd_addr[1] = (address >> 32) & 0xff;
@@ -111,12 +147,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
bd_addr[4] = (address >> 8) & 0xff;
bd_addr[5] = (address >> 0) & 0xff;
}
#endif
void set_active(bool active) { this->active_ = active; }
bool has_active() { return this->active_; }
#ifdef USE_ESP32
/// BLEScannerStateListener interface
void on_scanner_state(esp32_ble_tracker::ScannerState state) override;
#endif
uint32_t get_legacy_version() const {
if (this->active_) {
@@ -129,7 +168,17 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
uint32_t flags = 0;
flags |= BluetoothProxyFeature::FEATURE_PASSIVE_SCAN;
flags |= BluetoothProxyFeature::FEATURE_RAW_ADVERTISEMENTS;
#ifdef USE_ESP32
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
#else
// Advertise mode switching only where the hub honors request_scan_mode();
// scan_mode_switch is the capability bit for exactly that (#18079) —
// active_scan alone is not enough, a hub may support active scanning yet
// refuse the runtime switch.
if (this->hub_->get_capabilities().scan_mode_switch) {
flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE;
}
#endif
if (this->active_) {
flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS;
flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING;
@@ -142,16 +191,37 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
}
void get_bluetooth_mac_address_pretty(std::span<char, 18> output) {
#ifdef USE_ESP32
const uint8_t *mac = esp_bt_dev_get_address();
if (mac != nullptr) {
format_mac_addr_upper(mac, output.data());
} else {
output[0] = '\0';
}
#else
uint8_t mac[6] = {};
this->hub_->get_adapter_mac(mac);
// Mirror the esp32 arm's unavailable -> empty-string fallback: some hubs
// (rp2040's BTstack) only learn the address once the link layer is up, and
// report all-zero until then.
bool nonzero = false;
for (uint8_t b : mac)
nonzero |= b != 0;
if (nonzero) {
format_mac_addr_upper(mac, output.data());
} else {
output[0] = '\0';
}
#endif
}
protected:
#ifdef USE_ESP32
void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state);
#else
void send_bluetooth_scanner_state_();
void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw);
#endif
/// Caller must ensure api_connection_ is non-null and API server is connected.
void flush_pending_advertisements_() {
@@ -165,9 +235,11 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
}
void log_advertisement_flush_();
#ifdef USE_ESP32
BluetoothConnection *get_connection_(uint64_t address, bool reserve);
void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state);
void log_connection_info_(BluetoothConnection *connection, const char *message);
#endif
void log_not_connected_gatt_(const char *action, const char *type);
void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type);
@@ -175,8 +247,12 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
// Group 1: Pointers (4 bytes each, naturally aligned)
api::APIConnection *api_connection_{nullptr};
#ifdef USE_ESP32
// Group 2: Fixed-size array of connection pointers
std::array<BluetoothConnection *, BLUETOOTH_PROXY_MAX_CONNECTIONS> connections_{};
#else
ble_device_base::BLEHub *hub_{nullptr};
#endif
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
@@ -191,11 +267,13 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
bool active_;
uint8_t connection_count_{0};
bool configured_scan_active_{false}; // Configured scan mode from YAML
// 3 bytes used, 1 byte padding
#ifndef USE_ESP32
bool last_scan_running_{false}; // Last scanner state reported to the subscriber
#endif
};
extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::bluetooth_proxy
#endif // USE_ESP32
#endif // USE_BLUETOOTH_PROXY
+21 -3
View File
@@ -242,6 +242,27 @@
#define USE_NATIVE_64BIT_TIME
#endif
// bluetooth_proxy runs on any platform with a BLE hub (advertisement-only off
// esp32). Declared here per analysis ENVIRONMENT, not per hub platform —
// USE_LIBRETINY also covers chips with no hub, e.g. rtl87xx (the authoritative
// gate is _HUB_PLATFORMS in bluetooth_proxy/__init__.py) — so the neutral
// declarations in bluetooth_proxy.h are parsed under LibreTiny static analysis
// (the header is included by api_connection.cpp, which the tidy filter selects;
// the proxy's own .cpp is not a selected translation unit). Not declared for
// platforms whose API/network types the proxy header cannot assume.
#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.
#ifdef USE_ESP32
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
#else
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 0
#endif
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
#endif
// ESP32-specific feature flags
#ifdef USE_ESP32
#define USE_ESP32_CRASH_HANDLER
@@ -264,9 +285,6 @@
#define USE_ESPNOW
#define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470
#define USE_BLUETOOTH_PROXY
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
#define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16
#define USE_CAPTIVE_PORTAL
#define USE_WIFI_SCAN_RESULTS_LOCK
#define USE_ESP32_BLE
@@ -0,0 +1,19 @@
"""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.
"""
from esphome.components import esp32_ble
from esphome.components.bluetooth_proxy import _IDF_MAX_CONNECTIONS
def test_mirror_matches_esp32_ble() -> None:
assert _IDF_MAX_CONNECTIONS == esp32_ble.IDF_MAX_CONNECTIONS, (
"bluetooth_proxy._IDF_MAX_CONNECTIONS is out of sync with "
"esp32_ble.IDF_MAX_CONNECTIONS; update the mirror in "
"esphome/components/bluetooth_proxy/__init__.py"
)
@@ -0,0 +1,64 @@
"""The outer CONFIG_SCHEMA re-declares the esp32 scalar keys so tooling can walk
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 re-bounded in
_esp32_config_schema() but not here would silently vanish from the dashboard's
field extractor. This test is what catches that.
"""
import voluptuous as vol
from esphome import config_validation as cv
from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema
# esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA
# plumbing (derived, so a future core key does not fail this component's test),
# generated IDs (not user-walkable options), and connections (must validate
# exactly once — see the comment above CONFIG_SCHEMA).
_NOT_MIRRORED = {str(key.schema) for key in cv.COMPONENT_SCHEMA.schema} | {
"connections"
}
def _schema_of(validator: cv.All) -> vol.Schema:
"""The vol.Schema stage of a cv.All chain, found by type rather than by
position so reordering the chain cannot silently break these tests."""
schemas = [v for v in validator.validators if isinstance(v, vol.Schema)]
assert len(schemas) == 1, f"expected exactly one vol.Schema stage, got {schemas}"
return schemas[0]
def _keys(schema: vol.Schema) -> dict[str, object]:
return {str(key.schema): key for key in schema.schema}
def test_outer_scalar_keys_exist_in_esp32_schema() -> None:
outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _keys(_schema_of(_esp32_config_schema()))
missing = set(outer) - set(esp32)
assert not missing, (
f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema "
"does not; update one of them in "
"esphome/components/bluetooth_proxy/__init__.py"
)
def test_esp32_scalars_all_walkable() -> None:
"""Every non-generated esp32 scalar option must appear in the outer schema
(connections is deliberately excluded — it must validate exactly once)."""
outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _keys(_schema_of(_esp32_config_schema()))
scalar = {
name
for name, key in esp32.items()
if isinstance(key, vol.Optional)
and not isinstance(key, cv.GenerateID)
and name not in _NOT_MIRRORED
}
missing = scalar - set(outer)
assert not missing, (
f"esp32 scalar options {sorted(missing)} are missing from the outer "
"CONFIG_SCHEMA and invisible to schema tooling; update "
"esphome/components/bluetooth_proxy/__init__.py"
)
@@ -0,0 +1,10 @@
# Advertisement-only proxy on the ln882x BLE hub (active-scan-capable, in-tree
# since #16691) — a target CI fully compiles. Same bare-hub arrangement as
# test.rp2040-ard.yaml: no explicit ble_hub_id so a grouped build cannot
# collide with ln882h_ble_tracker's own fixture id.
packages:
common: !include common.yaml
ln882h_ble_tracker:
bluetooth_proxy:
@@ -0,0 +1,13 @@
# 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.
# 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
# component dicts collapse; only one id survives). The explicit-key form is
# covered by validate.rp2040-ard.yaml, which never participates in grouping.
packages:
common: !include common.yaml
rp2_ble_tracker:
bluetooth_proxy:
@@ -0,0 +1,13 @@
# Connections given as a bare list, with no explicit per-entry id. The ids are
# generated during validation, so this config breaks if the schema validates the
# connections list more than once.
packages:
common: !include common.yaml
esp32_ble_tracker:
bluetooth_proxy:
active: true
connections:
- {}
- {}
@@ -0,0 +1,11 @@
# Explicit ble_hub_id on the rp2 hub — the documented disambiguator once a
# platform has more than one tracker. Validate-only: never merged into grouped
# builds, so the inline id cannot collide with rp2_ble_tracker's own fixture.
packages:
common: !include common.yaml
rp2_ble_tracker:
id: ble_hub
bluetooth_proxy:
ble_hub_id: ble_hub