From 2724ec3c30f208cc6ec47bb8b5585c6238e47efd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2026 00:16:15 -0500 Subject: [PATCH] Combined-stack simplify: loop discipline, gated materializer, registry, peer keys --- .../ble_device_base/ble_gatt_client.h | 19 +- .../bluetooth_connection/__init__.py | 173 ++++++++++++------ .../bluetooth_connection.cpp | 22 +++ .../bluetooth_connection_bluedroid.cpp | 9 + .../bluetooth_connection_bluedroid.h | 23 ++- .../bluetooth_connection_esp32.cpp | 29 --- .../bluetooth_connection_gatt_backend.h | 2 - .../bluetooth_connection_hub.cpp | 14 +- .../bluetooth_connection_hub.h | 14 +- .../bluetooth_connection_rp2.cpp | 3 +- .../bluetooth_connection_rp2.h | 5 +- .../components/bluetooth_proxy/__init__.py | 26 +-- .../bluetooth_proxy/bluetooth_proxy.cpp | 12 +- .../bluetooth_proxy/bluetooth_proxy.h | 2 - .../radon_eye_rd200/radon_eye_rd200.cpp | 2 +- .../radon_eye_rd200/radon_eye_rd200.h | 4 +- esphome/components/radon_eye_rd200/sensor.py | 10 +- esphome/core/defines.h | 1 + .../bluetooth_proxy/test_platform_gates.py | 3 + .../test_gatt_client_contract.cpp | 5 +- .../test-passive.esp32-c6-idf.yaml | 2 +- 21 files changed, 223 insertions(+), 157 deletions(-) delete mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 4aa3581d49..3b8c362d93 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -5,8 +5,9 @@ // Exactly one GATT backend exists per build, so BLEGattConnection is a // compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract // interface. -// A consumer (the hub BluetoothConnection wrapper, or a component owning a -// dedicated backend instance such as radon_eye_rd200) drives it and receives +// A consumer - a streaming consumer that forwards the raw database (the hub +// BluetoothConnection wrapper) or a direct consumer owning a dedicated +// backend and resolving handles by UUID - drives it and receives // completions through a GattEventSink — a pointer-sized-entry function table // rather than a concrete consumer type, because one build can hold several // consumer types while the backend stays a single non-virtual class. All sink @@ -197,20 +198,18 @@ concept BLEGattConnectionContract = requires(T conn, GattEventSink sink, const u { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; { conn.get_service_table() } -> std::same_as; { conn.release_services() } -> std::same_as; - // Deferred-disconnect visibility and the connection-type hint; backends - // without the underlying state carry inline no-ops. - { conn.disconnect_pending() } -> std::same_as; - { conn.cancel_pending_disconnect() } -> std::same_as; + // Connection-type hint for backends that tune parameters by it; others + // carry an inline no-op. { conn.set_connection_type(ConnectionType{}) } -> std::same_as; }; // ---- service table lookup helpers ---- // // Neutral, bounds-checked walks over a materialized GattServiceTable for -// consumers that resolve a known device's handles by UUID (the proxy streams -// the whole table to HA instead and never needs these). Linear search: the -// table exists only between discovery and release_services(), for one small -// known device. +// direct consumers that resolve a known device's handles by UUID (streaming +// consumers forward the raw database and never need these). Linear search: +// the table exists only between discovery and release_services(), for one +// small known device. /// Client Characteristic Configuration descriptor UUID (Bluetooth spec). static constexpr uint16_t CCCD_UUID = 0x2902; diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 7936c12b62..6229e7b2fd 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -2,19 +2,25 @@ Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a consumer's codegen declares and registers the backend instances — the -Bluetooth proxy through its per-slot connection wrappers, and components -owning a dedicated backend (e.g. radon_eye_rd200) through -gatt_client_schema() + new_gatt_backend(). +Bluetooth proxy through its per-slot connection wrappers (a streaming +consumer), and direct consumers owning a dedicated backend through +gatt_client_config_schema() + new_gatt_backend(). """ +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework +from esphome.const import ( + CONF_MAC_ADDRESS, + PLATFORM_ESP32, + PLATFORM_RP2, + PlatformFramework, +) from esphome.core import CORE -from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType DOMAIN = "bluetooth_connection" @@ -45,12 +51,7 @@ RP2_MAX_CONNECTIONS = 1 # registry of which hub platforms run the connection-capable proxy. HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} -# Every platform with a GATT backend; gates dedicated-backend consumers. -# Derived from the hub registry so a platform gaining a backend is admitted -# everywhere at once (esp32 is the non-hub arm). -GATT_CLIENT_PLATFORMS = [PLATFORM_ESP32, *HUB_MAX_CONNECTIONS] - -# The hub-platform wrapper and the rp2 BTstack backend codegen classes. +# The hub-platform wrapper and the backend codegen classes. HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) BluedroidGattClient = bluetooth_connection_ns.class_( @@ -58,6 +59,70 @@ BluedroidGattClient = bluetooth_connection_ns.class_( ) CONF_BACKEND_ID = "backend_id" +CONF_ADDRESS_TYPE = "address_type" + +# BLE_ADDR_TYPE_* code space shared with the API and the backends. +ADDRESS_TYPES = {"public": 0, "random": 1} + + +def _esp32_schema_fragment() -> cv.Schema: + from esphome.components import esp32_ble_tracker + + return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA + + +def _rp2_schema_fragment() -> cv.Schema: + from esphome.components import rp2040_ble + + return cv.Schema( + {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} + ) + + +async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import esp32_ble_tracker + + # The tracker's promote loop owns connect timing; the backend's + # tracker-facing shim registers as a raw client. + await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config) + + +async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import rp2040_ble + + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + + +@dataclass(frozen=True) +class _PlatformBackend: + """One platform's backend: codegen class, extra schema keys (lazy so the + platform stack is only imported when targeted), and stack registration.""" + + backend_class: cg.MockObjClass + schema_fragment: Callable[[], cv.Schema] + register: Callable[[cg.MockObj, ConfigType], Awaitable[None]] + + +# The single registry of platforms with a GATT client backend; a platform +# missing here fails loudly everywhere instead of falling into another +# platform's arm. +_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = { + PLATFORM_ESP32: _PlatformBackend( + BluedroidGattClient, _esp32_schema_fragment, _esp32_register + ), + PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register), +} + +# Gates dedicated-backend consumers (cv.only_on). +GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS) + + +def _backend_entry() -> _PlatformBackend: + if (entry := _PLATFORM_BACKENDS.get(CORE.target_platform)) is None: + raise cv.Invalid( + f"no GATT client backend is registered for {CORE.target_platform}" + ) + return entry def gatt_client_schema() -> cv.Schema: @@ -66,21 +131,9 @@ def gatt_client_schema() -> cv.Schema: dispatch happens at call time, so call this from inside a validator or a per-platform schema builder, never at module import. """ - if CORE.is_esp32: - from esphome.components import esp32_ble_tracker - - return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(BluedroidGattClient)} - ) - from esphome.components import rp2040_ble - - return cv.Schema( - { - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(RP2GattClient), - cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( - rp2040_ble.RP2040BLE - ), - } + entry = _backend_entry() + return entry.schema_fragment().extend( + {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)} ) @@ -106,17 +159,18 @@ def _ledger() -> _SlotLedger: return CORE.data[DOMAIN] -def consume_gatt_slot(consumer: str): - """Validator claiming one GATT connection slot: the neutral ledger feeds - the platform cap check in FINAL_VALIDATE_SCHEMA, and esp32 additionally - charges the controller's connection budget.""" +def consume_gatt_slot(consumer: str, count: int = 1): + """Validator claiming GATT connection slots — the one spelling for every + claimant (the proxy per configured slot, dedicated backends once). The + neutral ledger feeds the platform cap check in FINAL_VALIDATE_SCHEMA; + esp32 additionally charges the controller's connection budget.""" def validator(config: ConfigType) -> ConfigType: - _ledger().consumers.append(consumer) + _ledger().consumers.extend([consumer] * count) if CORE.is_esp32: from esphome.components import esp32_ble - esp32_ble.consume_connection_slots(1, consumer)(config) + esp32_ble.consume_connection_slots(count, consumer)(config) return config return validator @@ -140,48 +194,59 @@ def _validate_slot_totals(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = _validate_slot_totals +# The peer keys every dedicated-backend consumer shares: one target device. +_PEER_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_ADDRESS_TYPE, default="public"): cv.enum( + ADDRESS_TYPES, lower=True + ), + } +) + + def gatt_client_config_schema(base_schema: cv.Schema, consumer: str): """Wrap a dedicated-backend consumer's schema so the consumer stays platform-blind: gates on the platforms with a backend, folds in - gatt_client_schema(), and claims the connection slot. - `consumer` names the component in slot-exhaustion errors.""" + gatt_client_schema() plus the peer keys (mac_address, address_type), + and claims the connection slot. `consumer` names the component in + slot-exhaustion errors.""" + @schema_extractor("schema") def apply(config: ConfigType) -> ConfigType: if config is SCHEMA_EXTRACT: # The language-schema dumper runs without a platform; expose the - # consumer's own keys. Checked before the platform gate so the - # dumper is not rejected by only_on. - return base_schema + # consumer's keys plus the platform-free peer keys. + return base_schema.extend(_PEER_SCHEMA) cv.only_on(GATT_CLIENT_PLATFORMS)(config) - config = base_schema.extend(gatt_client_schema())(config) + schema = base_schema.extend(_PEER_SCHEMA).extend(gatt_client_schema()) + config = schema(config) return consume_gatt_slot(consumer)(config) return apply -async def new_gatt_backend(config: ConfigType) -> cg.MockObj: - """Instantiate the backend declared by gatt_client_schema(), register it - with its platform stack, and claim one neutral GATT client slot. +async def new_gatt_backend( + config: ConfigType, *, service_table: bool = True +) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema() and register + it with its platform stack. The connection slot is claimed at validation + (gatt_client_config_schema / the proxy's slot validators), not here. - On esp32 the tracker's promote loop owns connect timing, so the backend's - tracker-facing shim registers as a raw client; on rp2 the backend parents - on the BTstack controller. + service_table compiles the on-demand service-table materializer into the + backend; direct consumers need it, the streaming proxy does not, so + proxy-only builds keep the smaller footprint. """ from esphome.components import ble_device_base ble_device_base.request_gatt_client() + if service_table: + cg.add_define("USE_BLE_GATT_SERVICE_TABLE") backend = cg.new_Pvariable(config[CONF_BACKEND_ID]) # The backend has no user-facing component options; an empty config keeps # the consumer's own keys (update_interval, ...) off it. await cg.register_component(backend, {}) - if CORE.is_esp32: - from esphome.components import esp32_ble_tracker - - await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config) - else: - from esphome.components import rp2040_ble - - await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + await _backend_entry().register(backend, config) return backend @@ -191,10 +256,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, - "bluetooth_connection_esp32.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, # Every hub platform the proxy admits (the file compiles empty where # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend # cannot hit a missing-symbol trap here. diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 652ecf8d5e..1dccec0ffe 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -1,5 +1,10 @@ #include "bluetooth_connection.h" +#ifdef USE_ESP32 +#include +#include +#endif + #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "esphome/components/api/api_pb2.h" @@ -37,6 +42,23 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size return BatchClose::CONTINUE; } +#ifdef USE_ESP32 +// Address-scoped Bluedroid maintenance shared by every esp32 proxy build, +// including advertisement-only ones where no GATT backend is compiled. + +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_remove_bond_device(bda); +} + +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_gattc_cache_clean(bda); +} +#endif // USE_ESP32 + } // namespace esphome::bluetooth_connection #endif // BLUETOOTH_CONNECTION_SERVES_PROXY diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index d6f4061091..b8a23b6b93 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -234,7 +234,9 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_ void BluedroidGattClient::release_services() { this->service_total_ = 0; +#ifdef USE_BLE_GATT_SERVICE_TABLE this->free_service_table_(); +#endif #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH // Only the cache clean makes the stack's database unsafe to walk. this->services_released_ = true; @@ -242,6 +244,7 @@ void BluedroidGattClient::release_services() { #endif } +#ifdef USE_BLE_GATT_SERVICE_TABLE ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() { if (this->table_storage_ == nullptr && (this->services_released_ || this->service_total_ == 0 || !this->build_service_table_())) { @@ -413,6 +416,7 @@ bool BluedroidGattClient::build_service_table_() { this->table_desc_total_ = desc_total; return true; } +#endif // USE_BLE_GATT_SERVICE_TABLE // ---- internals ---- @@ -657,6 +661,9 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { // matching the previous esp32 behavior). this->seen_mtu_ = true; this->report_connection_state_(true, 0); + // Settled: only the disconnect safety net needs the loop, and + // set_disconnecting_() re-enables it. + this->disable_loop(); } } @@ -750,6 +757,8 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_); this->set_state_(ClientState::ESTABLISHED); this->handle_search_cmpl_(); + // Settled (see the V3_WITH_CACHE arm in handle_open_evt_). + this->disable_loop(); break; } case ESP_GATTC_READ_CHAR_EVT: diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index a1bdc32176..f100c5068e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -72,11 +72,16 @@ class BluedroidGattClient final : public Component { int pair(); int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); // Materialized on demand from Bluedroid's cached database for direct - // consumers that resolve handles by UUID (e.g. radon_eye_rd200). The proxy - // wrapper never calls this - it streams through stream_service_batch - so - // proxy peak RAM is unchanged; a direct consumer's peak is bounded by its - // one known device's table. + // consumers that resolve handles by UUID. The streaming consumer (the + // proxy wrapper) never calls this - it uses stream_service_batch - so the + // materializer only compiles when codegen declares a direct consumer + // (USE_BLE_GATT_SERVICE_TABLE) and proxy-only builds keep the old + // footprint; a direct consumer's peak is bounded by its one known device. +#ifdef USE_BLE_GATT_SERVICE_TABLE ble_device_base::GattServiceTable get_service_table(); +#else + ble_device_base::GattServiceTable get_service_table() { return {}; } +#endif void release_services(); #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY @@ -86,9 +91,7 @@ class BluedroidGattClient final : public Component { void stream_service_batch(BluetoothConnection &conn); #endif - void set_connection_type(esp32_ble_tracker::ConnectionType ct) { this->connection_type_ = ct; } - bool disconnect_pending() const { return this->shim_.disconnect_pending(); } - void cancel_pending_disconnect() { this->shim_.cancel_pending_disconnect(); } + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } protected: friend class BluedroidTrackerShim; @@ -110,20 +113,24 @@ class BluedroidGattClient final : public Component { const char *param_type); int check_and_log_error_(const char *operation, esp_err_t err); void log_gattc_warning_(const char *operation, int code); +#ifdef USE_BLE_GATT_SERVICE_TABLE template bool walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc); bool build_service_table_(); void free_service_table_(); ble_device_base::GattServiceTable table_view_() const; +#endif // Group 1: pointers / composed objects BluedroidTrackerShim shim_{this}; ble_device_base::GattEventSink sink_; +#ifdef USE_BLE_GATT_SERVICE_TABLE // One exact-size block carved into the table's three arrays; owned here, // freed by release_services(). Null when no table is materialized. The // GattServiceTable view is rebuilt from this pointer and the counts on // each (cold) get_service_table() call instead of being cached. uint8_t *table_storage_{nullptr}; +#endif // Group 2: 4-byte types int gattc_if_{ESP_GATT_IF_NONE}; uint32_t disconnecting_started_{0}; @@ -135,9 +142,11 @@ class BluedroidGattClient final : public Component { uint16_t conn_id_{0xFFFF}; uint16_t mtu_{23}; uint16_t service_total_{0}; +#ifdef USE_BLE_GATT_SERVICE_TABLE // Filled element counts of the materialized table (0 when none). uint16_t table_char_total_{0}; uint16_t table_desc_total_{0}; +#endif // Group 5: 1-byte types // Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call. diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp deleted file mode 100644 index 1c716a5605..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ /dev/null @@ -1,29 +0,0 @@ -// Address-scoped Bluedroid maintenance shared by every esp32 proxy build, -// including advertisement-only ones where no GATT backend is compiled. - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "bluetooth_connection.h" - -#include -#include - -namespace esphome::bluetooth_connection { - -conn_err_t unpair_device(uint64_t address) { - esp_bd_addr_t bda; - ble_device_base::uint64_to_mac_msb_first(address, bda); - return esp_ble_remove_bond_device(bda); -} - -conn_err_t clear_gatt_cache(uint64_t address) { - esp_bd_addr_t bda; - ble_device_base::uint64_to_mac_msb_first(address, bda); - return esp_ble_gattc_cache_clean(bda); -} - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h index 55badf4e60..ff7ff0eeca 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -44,8 +44,6 @@ class StubGattBackend { return ble_device_base::GATT_ERR_NOT_CONNECTED; } ble_device_base::GattServiceTable get_service_table() { return {}; } - bool disconnect_pending() const { return false; } - void cancel_pending_disconnect() {} void set_connection_type(ble_device_base::ConnectionType ct) {} void release_services() {} }; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index fe8af7c86b..d1719a0617 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,4 +1,4 @@ -// Hub-platform connection wrapper (USE_RP2 hub builds today). +// The proxy's per-slot connection wrapper, shared by every platform. #include "bluetooth_connection_hub.h" #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY @@ -27,7 +27,7 @@ void BluetoothConnection::set_address(uint64_t address) { } void BluetoothConnection::start_connect_() { - // No connect timeout here (esp32 parity): the client's own timeout or + // No connect timeout here: the API client's own timeout or // the api-gone sweep drives disconnect(). this->state_ = ClientState::CONNECTING; int err = this->backend_->connect(this->address_, this->remote_addr_type_); @@ -38,7 +38,7 @@ void BluetoothConnection::start_connect_() { } void BluetoothConnection::disconnect() { - // Idempotent like the esp32 class: the proxy's teardown loop calls this + // Idempotent: the proxy's teardown loop calls this // every 100 ms while the API subscriber is gone, and a repeat call must not // reach the backend (whose busy error would free the slot mid-teardown). if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { @@ -65,10 +65,10 @@ void BluetoothConnection::disconnect() { } void BluetoothConnection::check_disconnect_timeout_() { - // Safety net mirroring the esp32 base class: if the backend's disconnect - // completion is lost, force the slot free instead of leaking it. - if (this->state_ == ClientState::DISCONNECTING && - millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + // Safety net: if the backend's disconnect completion is lost (or a refusal + // left the teardown unresolved), force the slot free instead of leaking it. + // The caller already gates on DISCONNECTING. + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_); this->reset_connection_(GATT_NOT_CONNECTED); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 03ca4bec23..291391d575 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -7,9 +7,9 @@ #include "bluetooth_connection.h" -// The wrapper exists to serve the proxy's API surface; dedicated-backend -// consumers (radon_eye_rd200) drive the backend directly, so backend-only -// builds compile this header empty. +// The wrapper exists to serve the proxy's API surface; direct consumers +// drive the backend themselves, so backend-only builds compile this header +// empty. #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "esphome/components/ble_device_base/ble_client_state.h" @@ -33,7 +33,7 @@ class BluetoothConnection final { backend->set_sink(ble_device_base::make_gatt_sink(this)); } - // ---- proxy dispatch surface (mirrors the esp32 class) ---- + // ---- proxy dispatch surface ---- conn_err_t read_characteristic(uint16_t handle); conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); conn_err_t read_descriptor(uint16_t handle); @@ -52,8 +52,6 @@ class BluetoothConnection final { bool is_paired() const { return this->paired_; } void set_unpaired() { this->paired_ = false; } conn_err_t pair() { return this->backend_->pair(); } - bool disconnect_pending() const { return this->backend_->disconnect_pending(); } - void cancel_pending_disconnect() { this->backend_->cancel_pending_disconnect(); } void set_address(uint64_t address); uint64_t get_address() const { return this->address_; } @@ -71,8 +69,8 @@ class BluetoothConnection final { } // Latched at discovery completion rather than read from the backend table: // streaming frees the table, and this must stay true for the connection's - // lifetime (esp32 parity — a repeat GetServices is silently ignored there, - // never answered with an authoritative empty database). + // lifetime (a repeat GetServices is silently ignored, never answered with + // an authoritative empty database). bool has_gatt_services() const { return this->services_discovered_; } /// Stream any pending service-discovery batch and police the disconnect diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 912464739d..33181ed748 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -25,7 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // and keeps the scan inhibited, so the engine cancels after 20 s. The // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; -static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; // Can-send windows normally open within a connection interval (tens of ms). static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; @@ -429,7 +428,7 @@ void RP2GattClient::loop() { } } } else if (this->state_ == EngineState::DISCONNECTING) { - if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 1e782cdd3a..89aa31ba5f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -92,10 +92,7 @@ class RP2GattClient final : public Component, public Parented list[str]: @@ -94,7 +94,7 @@ def _esp32_config_schema() -> cv.All: ) elif config[CONF_ACTIVE]: connection_slots: int = config[CONF_CONNECTION_SLOTS] - esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")( + bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)( config ) @@ -154,8 +154,9 @@ def _rp2_config_schema() -> cv.All: # their ids exist for codegen (the esp32 arm's `connections` pattern). if not config[CONF_ACTIVE]: return config - for _ in range(config[CONF_CONNECTION_SLOTS]): - bluetooth_connection.consume_gatt_slot("bluetooth_proxy")(config) + bluetooth_connection.consume_gatt_slot( + "bluetooth_proxy", config[CONF_CONNECTION_SLOTS] + )(config) return { **config, CONF_CONNECTIONS: [ @@ -197,7 +198,9 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: """One wrapper + backend pair per slot; the platform-specific backend registration lives in bluetooth_connection.new_gatt_backend().""" for connection_conf in config.get(CONF_CONNECTIONS, []): - backend = await bluetooth_connection.new_gatt_backend(connection_conf) + backend = await bluetooth_connection.new_gatt_backend( + connection_conf, service_table=False + ) connection = cg.new_Pvariable(connection_conf[CONF_ID]) cg.add(connection.set_backend(backend)) cg.add(var.register_connection(connection)) @@ -381,9 +384,6 @@ async def _to_code_ble_hub(config: ConfigType) -> None: # from the loop below (the define sizes fixed storage in the proxy). slots = len(config.get(CONF_CONNECTIONS, ())) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots) - if not slots: - return - await _connections_to_code(var, config) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 69dea6860f..e4f062812f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -241,12 +241,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_connections_free(); return; } else if (connection->state() == ClientState::CONNECTING) { - if (connection->disconnect_pending()) { - ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", - connection->get_connection_index(), connection->address_str()); - connection->cancel_pending_disconnect(); - return; - } this->log_connection_request_ignored_(connection, connection->state()); return; } else if (connection->state() != ClientState::INIT) { @@ -281,7 +275,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { - // Both connection classes expose the same pairing surface; success is + // The connection wrapper exposes the pairing surface; success is // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { @@ -493,10 +487,10 @@ void BluetoothProxy::loop() { if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { #ifdef BLUETOOTH_CONNECTION_SERVES_PROXY // The API subscriber is gone: tear down any connections it left behind - // (disconnect() on an already-disconnecting backend is a no-op). + // (disconnect() on an already-disconnecting slot is a no-op). for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; - if (connection->get_address() != 0 && !connection->disconnect_pending()) { + if (connection->get_address() != 0) { connection->disconnect(); } } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index ab6687deb9..785cc51fb8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -15,9 +15,7 @@ #include "esphome/components/ble_device_base/ble_hub_impl.h" -#ifdef USE_BLE_GATT_CLIENT #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" -#endif namespace esphome::bluetooth_proxy { diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index 9ed3a927dd..a4ea824e42 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -33,7 +33,7 @@ static const uint16_t MESSAGE_MIN_LEN_V2 = 68; void RadonEyeRD200::update() { // The backends refuse a connect on a non-idle slot, so a poll landing // mid-sequence just logs and retries next interval. - int err = this->backend_->connect(this->address_, ble_device_base::BLE_ADDR_TYPE_PUBLIC); + int err = this->backend_->connect(this->address_, this->address_type_); if (err != 0) { ESP_LOGW(TAG, "Connection in progress (err=%d)", err); return; diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index 1ed00c349d..55b04e5e28 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -25,7 +25,8 @@ namespace esphome::radon_eye_rd200 { class RadonEyeRD200 final : public PollingComponent { public: - RadonEyeRD200(ble_device_base::BLEGattConnection *backend, uint64_t address) : backend_(backend), address_(address) { + RadonEyeRD200(ble_device_base::BLEGattConnection *backend, uint64_t address, uint8_t address_type) + : backend_(backend), address_(address), address_type_(address_type) { backend->set_sink(ble_device_base::make_gatt_sink(this)); } @@ -64,6 +65,7 @@ class RadonEyeRD200 final : public PollingComponent { // Group 4: 1-byte types uint8_t write_command_{0}; + uint8_t address_type_; }; } // namespace esphome::radon_eye_rd200 diff --git a/esphome/components/radon_eye_rd200/sensor.py b/esphome/components/radon_eye_rd200/sensor.py index 2f019184f3..282b55b554 100644 --- a/esphome/components/radon_eye_rd200/sensor.py +++ b/esphome/components/radon_eye_rd200/sensor.py @@ -10,6 +10,8 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_BECQUEREL_PER_CUBIC_METER, ) + +# mac_address and address_type come from gatt_client_config_schema. from esphome.types import ConfigType AUTO_LOAD = ["bluetooth_connection"] @@ -28,7 +30,6 @@ CONFIG_SCHEMA = bluetooth_connection.gatt_client_config_schema( cv.Schema( { cv.GenerateID(): cv.declare_id(RadonEyeRD200), - cv.Required(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_RADON): _SENSOR_SCHEMA, cv.Optional(CONF_RADON_LONG_TERM): _SENSOR_SCHEMA, } @@ -39,7 +40,12 @@ CONFIG_SCHEMA = bluetooth_connection.gatt_client_config_schema( async def to_code(config: ConfigType) -> None: backend = await bluetooth_connection.new_gatt_backend(config) - var = cg.new_Pvariable(config[CONF_ID], backend, config[CONF_MAC_ADDRESS].as_hex) + var = cg.new_Pvariable( + config[CONF_ID], + backend, + config[CONF_MAC_ADDRESS].as_hex, + config[bluetooth_connection.CONF_ADDRESS_TYPE], + ) await cg.register_component(var, config) if (radon := config.get(CONF_RADON)) is not None: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e3c4dd87ce..e87c8044f4 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -307,6 +307,7 @@ #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define USE_ESP32_BLE_TRACKER #define USE_BLE_GATT_CLIENT +#define USE_BLE_GATT_SERVICE_TABLE #define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 0b92933aa6..244c4bdfce 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -203,6 +203,9 @@ def test_every_registered_hub_platform_has_a_schema_arm() -> None: registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # Hub platforms must also be in the backend registry the shared consumer + # helpers dispatch on. + assert registered <= set(bluetooth_connection.GATT_CLIENT_PLATFORMS) # The outer walkable schema's bound must stay the loosest platform cap. assert ( max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index 30cc2d5494..6afa1d41e8 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -53,8 +53,6 @@ class MinimalConnection { } GattServiceTable get_service_table() { return {}; } void release_services() {} - bool disconnect_pending() const { return false; } - void cancel_pending_disconnect() {} void set_connection_type(ConnectionType ct) {} protected: @@ -109,7 +107,8 @@ TEST_F(ServiceTableLookup, FindsServicesAndCharacteristicsByUuid) { EXPECT_EQ(service->start_handle, 0x0010); EXPECT_EQ(find_service(this->table_, ESPBTUUID::from_uint16(0xFFFF)), nullptr); - const GattCharacteristic *characteristic = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525)); + const GattCharacteristic *characteristic = + find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525)); ASSERT_NE(characteristic, nullptr); EXPECT_EQ(characteristic->value_handle, 0x0012); // The lookup is scoped to the service: 0x2A00 lives in the other service. diff --git a/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml index 06ce74dbad..3507c80662 100644 --- a/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml +++ b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml @@ -1,5 +1,5 @@ # Advertisement-only proxy on esp32 by explicit choice: no GATT backend is -# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the +# compiled (USE_BLE_GATT_CLIENT unset), which pins the SERVES_PROXY gating and the # address-scoped maintenance path that a connections build never exercises. packages: common: !include common.yaml