From 563b6ade0f21744b41d12072abb74b2059af9c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Filistovi=C4=8D?= Date: Wed, 5 Aug 2026 23:06:01 +0300 Subject: [PATCH] [bluetooth_proxy] Platform-neutral advertisement proxy via ble_device_base (#17880) Co-authored-by: J. Nick Koston --- .../components/bluetooth_proxy/__init__.py | 320 ++++++++++++++---- .../bluetooth_proxy/bluetooth_proxy.cpp | 245 ++++++++++++-- .../bluetooth_proxy/bluetooth_proxy.h | 104 +++++- esphome/core/defines.h | 24 +- .../bluetooth_proxy/__init__.py | 0 .../test_idf_max_connections_mirror.py | 19 ++ .../test_outer_schema_mirror.py | 64 ++++ .../bluetooth_proxy/test.ln882x-ard.yaml | 10 + .../bluetooth_proxy/test.rp2040-ard.yaml | 13 + .../bluetooth_proxy/validate.esp32-idf.yaml | 13 + .../bluetooth_proxy/validate.rp2040-ard.yaml | 11 + 11 files changed, 724 insertions(+), 99 deletions(-) create mode 100644 tests/component_tests/bluetooth_proxy/__init__.py create mode 100644 tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py create mode 100644 tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py create mode 100644 tests/components/bluetooth_proxy/test.ln882x-ard.yaml create mode 100644 tests/components/bluetooth_proxy/test.rp2040-ard.yaml create mode 100644 tests/components/bluetooth_proxy/validate.esp32-idf.yaml create mode 100644 tests/components/bluetooth_proxy/validate.rp2040-ard.yaml diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 4792736dd9..bb05f1b21f 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -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") diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index dbe9c1e30d..ad2fc094ae 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -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 #include -#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(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(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 diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 2b6d29da43..54236dbc67 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -1,6 +1,8 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" + +#ifdef USE_BLUETOOTH_PROXY #include #include @@ -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 #endif #include +#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 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 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 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 32d30c9d07..5ce87f8da5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 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 diff --git a/tests/component_tests/bluetooth_proxy/__init__.py b/tests/component_tests/bluetooth_proxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py new file mode 100644 index 0000000000..3042c91f84 --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_idf_max_connections_mirror.py @@ -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" + ) diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py new file mode 100644 index 0000000000..b7e1736f72 --- /dev/null +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.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" + ) diff --git a/tests/components/bluetooth_proxy/test.ln882x-ard.yaml b/tests/components/bluetooth_proxy/test.ln882x-ard.yaml new file mode 100644 index 0000000000..ae1aed7c22 --- /dev/null +++ b/tests/components/bluetooth_proxy/test.ln882x-ard.yaml @@ -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: diff --git a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml new file mode 100644 index 0000000000..fd327bcc78 --- /dev/null +++ b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml @@ -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: diff --git a/tests/components/bluetooth_proxy/validate.esp32-idf.yaml b/tests/components/bluetooth_proxy/validate.esp32-idf.yaml new file mode 100644 index 0000000000..a92716ebeb --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.esp32-idf.yaml @@ -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: + - {} + - {} diff --git a/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml b/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..fa385dd5bc --- /dev/null +++ b/tests/components/bluetooth_proxy/validate.rp2040-ard.yaml @@ -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