From 9db31db24d203f9d4f1c02d8dfaea800564a9826 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2026 00:19:39 -0500 Subject: [PATCH 1/4] Multi-consumer sink, table materializer, and review fixes for the backend --- .../ble_device_base/ble_gatt_client.h | 167 +++++++++-- .../bluetooth_connection_bluedroid.cpp | 265 +++++++++++++++--- .../bluetooth_connection_bluedroid.h | 50 +++- .../bluetooth_connection_gatt_backend.h | 9 +- .../bluetooth_connection_hub.cpp | 23 +- .../bluetooth_connection_hub.h | 43 +-- .../bluetooth_connection_rp2.cpp | 48 +--- .../bluetooth_connection_rp2.h | 11 +- .../test_gatt_client_contract.cpp | 79 +++++- 9 files changed, 535 insertions(+), 160 deletions(-) diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 81d879a704..97322ec27e 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -5,8 +5,12 @@ // Exactly one GATT backend exists per build, so BLEGattConnection is a // compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract // interface. -// The hub BluetoothConnection wrapper drives it and receives completions -// through its event-sink methods, which the backend calls directly. All sink +// 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 // calls are delivered on the ESPHome main loop; borrowed data pointers are // valid only for the duration of the call. // @@ -78,6 +82,87 @@ struct GattServiceTable { uint16_t descriptor_count{0}; }; +// The event sink the backend calls directly, asserted where each consumer is +// defined: on_connection_state carries the negotiated MTU and an HCI +// status/disconnect reason. The requirements check call validity, not exact +// parameter types; keep sink parameters at the documented widths (uint16_t +// handles and lengths). +template +concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { + { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; + { sink.on_service_discovery_done(int{}) } -> std::same_as; + { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; + { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; + { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; + { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { sink.on_pairing_result(int{}) } -> std::same_as; +}; + +/// One trampoline per event, shared by every instance of a consumer type. +struct GattEventVTable { + void (*connection_state)(void *, bool, uint16_t, int); + void (*service_discovery_done)(void *, int); + void (*read_result)(void *, uint16_t, const uint8_t *, uint16_t, int); + void (*write_result)(void *, uint16_t, int); + void (*notify_state)(void *, uint16_t, bool, int); + void (*notify_data)(void *, uint16_t, const uint8_t *, uint16_t); + void (*pairing_result)(void *, int); +}; + +// The per-consumer-type table lives in flash (constexpr), so a sink costs +// two pointers of RAM regardless of how many events the surface carries. +template +inline constexpr GattEventVTable GATT_EVENT_VTABLE{ + [](void *p, bool connected, uint16_t mtu, int error) { + static_cast(p)->on_connection_state(connected, mtu, error); + }, + [](void *p, int error) { static_cast(p)->on_service_discovery_done(error); }, + [](void *p, uint16_t handle, const uint8_t *data, uint16_t len, int error) { + static_cast(p)->on_read_result(handle, data, len, error); + }, + [](void *p, uint16_t handle, int error) { static_cast(p)->on_write_result(handle, error); }, + [](void *p, uint16_t handle, bool enabled, int error) { + static_cast(p)->on_notify_state(handle, enabled, error); + }, + [](void *p, uint16_t handle, const uint8_t *data, uint16_t len) { + static_cast(p)->on_notify_data(handle, data, len); + }, + [](void *p, int status) { static_cast(p)->on_pairing_result(status); }, +}; + +/// Type-erased consumer handle a backend delivers events through: an +/// instance pointer plus the consumer type's trampoline table. Built with +/// make_gatt_sink() from any type satisfying GattClientEventSinkContract; +/// call sites read the same as a direct listener call. No virtuals, no heap — +/// the cost of supporting several consumer types in one build is one +/// indirect call per event. Codegen wires the sink before setup(), so +/// backends may call without a null check. +struct GattEventSink { + void *instance{nullptr}; + const GattEventVTable *vtable{nullptr}; + + void on_connection_state(bool connected, uint16_t mtu, int error) const { + this->vtable->connection_state(this->instance, connected, mtu, error); + } + void on_service_discovery_done(int error) const { this->vtable->service_discovery_done(this->instance, error); } + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) const { + this->vtable->read_result(this->instance, handle, data, len, error); + } + void on_write_result(uint16_t handle, int error) const { this->vtable->write_result(this->instance, handle, error); } + void on_notify_state(uint16_t handle, bool enabled, int error) const { + this->vtable->notify_state(this->instance, handle, enabled, error); + } + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) const { + this->vtable->notify_data(this->instance, handle, data, len); + } + void on_pairing_result(int status) const { this->vtable->pairing_result(this->instance, status); } +}; + +template GattEventSink make_gatt_sink(T *consumer) { + static_assert(GattClientEventSinkContract, "the consumer is missing part of the event-sink surface"); + return {consumer, &GATT_EVENT_VTABLE}; +} + // The BLEGattConnection op surface, asserted where the alias binds // (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives // through the sink) or a synchronous error (busy, not connected, stack @@ -88,17 +173,19 @@ struct GattServiceTable { // - notify_characteristic: local registration only; the CCCD write is the // API client's responsibility (a plain write_descriptor). // - get_service_table/release_services: backend-owned transient storage, -// released after streaming (release is idempotent). A backend may instead -// provide its own service streamer (stream_service_batch on the concrete -// type, detected by the consumer at compile time) and keep the table empty. +// released after streaming (release is idempotent). A backend may +// additionally provide its own service streamer (stream_service_batch on +// the concrete type, detected by the consumer at compile time) for +// arbitrary-size databases; the table then materializes only for consumers +// that ask for it. // - completions: connect and disconnect land in on_connection_state, // discover_services in on_service_discovery_done, pair in // on_pairing_result, reads in on_read_result, notify_characteristic in // on_notify_state, characteristic writes with response and descriptor // writes in on_write_result. -template -concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { - conn.set_listener(sink); +template +concept BLEGattConnectionContract = requires(T conn, GattEventSink sink, const uint8_t *data) { + conn.set_sink(sink); { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; { conn.disconnect() } -> std::same_as; { conn.discover_services() } -> std::same_as; @@ -111,23 +198,57 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t * { 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; + { conn.set_connection_type(ConnectionType{}) } -> std::same_as; }; -// The event sink the backend calls directly (the hub BluetoothConnection -// wrapper), asserted where the wrapper is defined: on_connection_state -// carries the negotiated MTU and an HCI status/disconnect reason. The -// requirements check call validity, not exact parameter types; keep sink -// parameters at the documented widths (uint16_t handles and lengths). -template -concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { - { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; - { sink.on_service_discovery_done(int{}) } -> std::same_as; - { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; - { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; - { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; - { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; - { sink.on_pairing_result(int{}) } -> std::same_as; -}; +// ---- service table lookup helpers ---- +// +// Neutral, bounds-checked walks over a materialized GattServiceTable for +// 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; + +inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) { + for (uint16_t i = 0; i < table.service_count; i++) { + if (table.services[i].uuid == uuid) + return &table.services[i]; + } + return nullptr; +} + +inline const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service, + const ESPBTUUID &uuid) { + uint16_t end = service.first_characteristic + service.characteristic_count; + if (end > table.characteristic_count) + return nullptr; + for (uint16_t i = service.first_characteristic; i < end; i++) { + if (table.characteristics[i].uuid == uuid) + return &table.characteristics[i]; + } + return nullptr; +} + +/// Handle of the characteristic's Client Characteristic Configuration +/// descriptor (0x2902), or 0 when it has none. +inline uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) { + uint16_t end = characteristic.first_descriptor + characteristic.descriptor_count; + if (end > table.descriptor_count) + return 0; + const ESPBTUUID cccd_uuid = ESPBTUUID::from_uint16(CCCD_UUID); + for (uint16_t i = characteristic.first_descriptor; i < end; i++) { + if (table.descriptors[i].uuid == cccd_uuid) + return table.descriptors[i].handle; + } + return 0; +} } // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index abd37868d0..7fb051b142 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -2,17 +2,20 @@ #if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) +// The in-place streamer serves the proxy's service-discovery API; backend-only +// builds compile without the proxy headers or the streamer. +#ifdef USE_BLUETOOTH_PROXY #include "bluetooth_connection.h" #include "bluetooth_connection_hub.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#endif #include "esphome/components/ble_device_base/ble_client_state.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include #include #include @@ -90,6 +93,13 @@ void BluedroidGattClient::dump_config() { // ---- contract ops ---- int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + // Refuse anything but a fully idle slot. Clobbering DISCONNECTING with + // DISCOVERED would let the tracker open a new link while the old one is + // still closing - the stale CLOSE_EVT then tears the new attempt down. + if (this->state_() != ClientState::IDLE) { + ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_); + return ESP_GATT_BUSY; + } ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_); this->remote_addr_type_ = addr_type; // Hand the request to the tracker's promote loop: it stops the scan, raises @@ -223,6 +233,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; @@ -230,6 +243,180 @@ 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_())) { + return {}; + } + return this->table_view_(); +} + +// The view is carved from the storage block and the counts on each call +// (a cold path) rather than cached, saving a per-instance table member. +ble_device_base::GattServiceTable BluedroidGattClient::table_view_() const { + size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService); + size_t char_bytes = this->table_char_total_ * sizeof(ble_device_base::GattCharacteristic); + return {reinterpret_cast(this->table_storage_), + reinterpret_cast(this->table_storage_ + svc_bytes), + reinterpret_cast(this->table_storage_ + svc_bytes + char_bytes), + this->service_total_, + this->table_char_total_, + this->table_desc_total_}; +} + +void BluedroidGattClient::free_service_table_() { + if (this->table_storage_ == nullptr) { + return; + } + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + allocator.deallocate(this->table_storage_, 0); + this->table_storage_ = nullptr; + this->table_char_total_ = 0; + this->table_desc_total_ = 0; +} + +template +bool BluedroidGattClient::walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) { + // Shared enumeration for both table-build passes: an identical walk order + // is what lets the counting pass size the block the filling pass fills. + // INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure. + for (uint16_t s = 0; s < this->service_total_; s++) { + esp_gattc_service_elem_t svc; + uint16_t svc_count = 1; + if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s) != ESP_GATT_OK || + svc_count == 0) { + return false; + } + if (!on_service(s, svc)) { + return false; + } + uint16_t svc_chars = 0; + if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, svc.start_handle, + svc.end_handle, 0, &svc_chars) != ESP_GATT_OK) { + return false; + } + for (uint16_t c = 0; c < svc_chars; c++) { + esp_gattc_char_elem_t chr; + uint16_t char_count = 1; + auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr, + &char_count, c); + if (status == ESP_GATT_INVALID_OFFSET || status == ESP_GATT_NOT_FOUND) { + break; + } + if (status != ESP_GATT_OK || char_count == 0) { + return false; + } + if (!on_char(svc, chr)) { + return false; + } + for (uint16_t d = 0;; d++) { + esp_gattc_descr_elem_t desc; + uint16_t desc_count = 1; + auto desc_status = + esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK || desc_count == 0) { + return false; + } + if (!on_desc(chr, desc)) { + return false; + } + } + } + } + return true; +} + +bool BluedroidGattClient::build_service_table_() { + // Pass 1: count, so one exact-size block holds the whole table. + uint16_t char_total = 0; + uint16_t desc_total = 0; + bool counted = this->walk_database_([](uint16_t, const esp_gattc_service_elem_t &) { return true; }, + [&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) { + char_total++; + return true; + }, + [&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) { + desc_total++; + return true; + }); + if (!counted) { + return false; + } + + // The arrays share one block; carving stays aligned because each struct's + // strictest member is the UUID and array sizes are multiples of it. + size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService); + size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic); + size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor); + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->table_storage_ = allocator.allocate(total_bytes); + if (this->table_storage_ == nullptr) { + ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->connection_index_, + static_cast(total_bytes)); + return false; + } + auto *services = reinterpret_cast(this->table_storage_); + auto *characteristics = reinterpret_cast(this->table_storage_ + svc_bytes); + auto *descriptors = + reinterpret_cast(this->table_storage_ + svc_bytes + char_bytes); + + // Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall + // means the cached database changed between the passes; fail the build + // rather than serve an inconsistent table (the consumer retries). + uint16_t char_index = 0; + uint16_t desc_index = 0; + ble_device_base::GattService *cur_service = nullptr; + ble_device_base::GattCharacteristic *cur_char = nullptr; + bool filled = this->walk_database_( + [&](uint16_t s, const esp_gattc_service_elem_t &svc) { + cur_service = &services[s]; + cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid); + cur_service->start_handle = svc.start_handle; + cur_service->end_handle = svc.end_handle; + cur_service->first_characteristic = char_index; + cur_service->characteristic_count = 0; + return true; + }, + [&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) { + if (char_index >= char_total) { + return false; + } + cur_char = &characteristics[char_index++]; + cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid); + cur_char->value_handle = chr.char_handle; + // Bluedroid addresses descriptors by characteristic handle, so the + // table's end_handle only needs the service-bounded upper bound. + cur_char->end_handle = svc.end_handle; + cur_char->properties = chr.properties; + cur_char->first_descriptor = desc_index; + cur_char->descriptor_count = 0; + cur_service->characteristic_count++; + return true; + }, + [&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) { + if (desc_index >= desc_total) { + return false; + } + descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid); + descriptors[desc_index].handle = desc.handle; + desc_index++; + cur_char->descriptor_count++; + return true; + }); + if (!filled || char_index != char_total || desc_index != desc_total) { + this->free_service_table_(); + return false; + } + this->table_char_total_ = char_total; + this->table_desc_total_ = desc_total; + return true; +} +#endif // USE_BLE_GATT_SERVICE_TABLE + // ---- internals ---- bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const { @@ -249,9 +436,7 @@ void BluedroidGattClient::set_disconnecting_() { } void BluedroidGattClient::report_connection_state_(bool connected, int error) { - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(connected, this->mtu_, error); - } + this->sink_.on_connection_state(connected, this->mtu_, error); } esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, @@ -293,17 +478,14 @@ void BluedroidGattClient::handle_search_cmpl_() { // clients cache the streamed result permanently. auto status = primary_status != ESP_GATT_OK ? primary_status : secondary_status; this->log_gattc_warning_("esp_ble_gattc_get_attr_count", status); - if (this->listener_ != nullptr) { - this->listener_->on_service_discovery_done(status); - } + this->sink_.on_service_discovery_done(status); return; } this->service_total_ = primary + secondary; - if (this->listener_ != nullptr) { - this->listener_->on_service_discovery_done(0); - } + this->sink_.on_service_discovery_done(0); } +#ifdef USE_BLUETOOTH_PROXY void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { if (this->services_released_ || conn.send_service_ >= this->service_total_) { conn.send_service_ = DONE_SENDING_SERVICES; @@ -357,7 +539,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { resp.services.emplace_back(); auto &service_resp = resp.services.back(); fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, - esp32_ble_tracker::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids); + ble_device_base::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids); service_resp.handle = service_result.start_handle; if (total_char_count > 0) { @@ -384,7 +566,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { service_resp.characteristics.emplace_back(); auto &characteristic_resp = service_resp.characteristics.back(); fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, - esp32_ble_tracker::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids); + ble_device_base::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids); characteristic_resp.handle = char_result.char_handle; characteristic_resp.properties = char_result.properties; @@ -420,7 +602,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { characteristic_resp.descriptors.emplace_back(); auto &descriptor_resp = characteristic_resp.descriptors.back(); fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, - esp32_ble_tracker::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids); + ble_device_base::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids); descriptor_resp.handle = desc_result.handle; desc_offset++; } @@ -442,6 +624,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { conn.send_service_ = batch_start; } } +#endif // USE_BLUETOOTH_PROXY // ---- events ---- @@ -463,7 +646,7 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { this->report_connection_state_(false, param->open.status); return; } - if (this->shim_.disconnect_scheduled()) { + if (this->shim_.disconnect_pending()) { // Earliest point conn_id_ exists; keep it set so CLOSE_EVT still matches. this->unconditional_disconnect_(); return; @@ -477,6 +660,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(); } } @@ -490,11 +676,12 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param // Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING. return; } - // Passive disconnect: report now, but wait for CLOSE_EVT before going IDLE - - // reconnecting earlier makes the controller reject with 133 or assert. + // Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting + // earlier makes the controller reject with 133 or assert) and before + // reporting - the wrapper frees the slot on the report, and a freed slot + // invites a reconnect into the still-closing link. this->release_services(); this->set_disconnecting_(); - this->report_connection_state_(false, param->disconnect.reason); } bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, @@ -558,9 +745,8 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga return false; this->release_services(); this->set_idle_(); - // The wrapper frees the slot on this final report; after a passive - // disconnect this is the second connected=false, matching the previous - // esp32 behavior (report at DISCONNECT, slot free at CLOSE). + // The one connected=false report: the wrapper frees the slot on it, + // so it must not fire before the controller finished closing. this->report_connection_state_(false, param->close.reason); break; } @@ -570,52 +756,41 @@ 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: case ESP_GATTC_READ_DESCR_EVT: { if (this->conn_id_ != param->read.conn_id) return false; - if (this->listener_ != nullptr) { - bool ok = param->read.status == ESP_GATT_OK; - this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr, - ok ? param->read.value_len : 0, ok ? 0 : param->read.status); - } + bool ok = param->read.status == ESP_GATT_OK; + this->sink_.on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0, + ok ? 0 : param->read.status); break; } case ESP_GATTC_WRITE_CHAR_EVT: case ESP_GATTC_WRITE_DESCR_EVT: { if (this->conn_id_ != param->write.conn_id) return false; - if (this->listener_ != nullptr) { - this->listener_->on_write_result(param->write.handle, - param->write.status == ESP_GATT_OK ? 0 : param->write.status); - } + this->sink_.on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status); break; } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { - if (this->listener_ != nullptr) { - this->listener_->on_notify_state( - param->reg_for_notify.handle, true, - param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status); - } + this->sink_.on_notify_state(param->reg_for_notify.handle, true, + param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status); break; } case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { - if (this->listener_ != nullptr) { - this->listener_->on_notify_state( - param->unreg_for_notify.handle, false, - param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status); - } + this->sink_.on_notify_state(param->unreg_for_notify.handle, false, + param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status); break; } case ESP_GATTC_NOTIFY_EVT: { if (this->conn_id_ != param->notify.conn_id) return false; ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle); - if (this->listener_ != nullptr) { - this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len); - } + this->sink_.on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len); break; } default: @@ -636,10 +811,8 @@ void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_bl case ESP_GAP_BLE_AUTH_CMPL_EVT: { if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) break; - if (this->listener_ != nullptr) { - this->listener_->on_pairing_result( - param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason); - } + this->sink_.on_pairing_result(param->ble_security.auth_cmpl.success ? 0 + : param->ble_security.auth_cmpl.fail_reason); break; } default: diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 2c50123b8f..0690fb6df3 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -22,8 +22,10 @@ namespace esphome::bluetooth_connection { -class BluetoothConnection; class BluedroidGattClient; +#ifdef USE_BLUETOOTH_PROXY +class BluetoothConnection; +#endif // The tracker-facing half: owns the ClientState the promote loop reads and // forwards events/commands to the engine. @@ -52,7 +54,8 @@ class BluedroidGattClient final : public Component { void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } - void set_listener(BluetoothConnection *listener) { this->listener_ = listener; } + // Wired by codegen before setup and invariant for the device lifetime. + void set_sink(ble_device_base::GattEventSink sink) { this->sink_ = sink; } esp32_ble_tracker::ESPBTClient *tracker_client() { return &this->shim_; } // ---- ble_device_base::BLEGattConnection contract ---- @@ -66,17 +69,27 @@ class BluedroidGattClient final : public Component { int notify_characteristic(uint16_t handle, bool enable); int pair(); int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); - // Never called: this backend streams in place (stream_service_batch), so - // the table stays empty. Satisfies the contract concept. + // Materialized on demand from Bluedroid's cached database for direct + // 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(); - /// In-place service streamer (the wrapper detects and prefers it): builds - /// one api response batch directly from Bluedroid's cached database, so the - /// streaming peak is the response itself - the old esp32 model. +#ifdef USE_BLUETOOTH_PROXY + /// In-place service streamer (the proxy wrapper detects and prefers it): + /// builds one api response batch directly from Bluedroid's cached database, + /// so the streaming peak is the response itself - the old esp32 model. void stream_service_batch(BluetoothConnection &conn); +#endif - void set_connection_type(esp32_ble_tracker::ConnectionType ct) { this->connection_type_ = ct; } + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } bool disconnect_pending() const { return this->shim_.disconnect_pending(); } void cancel_pending_disconnect() { this->shim_.cancel_pending_disconnect(); } @@ -100,10 +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}; - BluetoothConnection *listener_{nullptr}; + 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}; @@ -115,6 +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_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h index 58102ecfb3..55badf4e60 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -24,11 +24,9 @@ // build hits the #error below. namespace esphome::bluetooth_connection { -class BluetoothConnection; - class StubGattBackend { public: - void set_listener(BluetoothConnection *listener) {} + void set_sink(ble_device_base::GattEventSink sink) {} int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } @@ -46,6 +44,9 @@ 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() {} }; @@ -58,7 +59,7 @@ class StubGattBackend { namespace esphome::ble_device_base { using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); #undef ESPHOME_BLE_GATT_CONNECTION_TYPE diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 41e0ce48f1..374db952bf 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,7 @@ -// 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" -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +#ifdef USE_BLE_GATT_CLIENT #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -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); } @@ -130,7 +130,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { // The API client has the services cached; never discover them. No // discovery phase needs the fast interval, so settle straight into the - // shared steady-state parameters (same lifecycle place as esp32). + // shared steady-state parameters. On esp32 the backend already set the + // same values as prefer-params before opening, so this request is + // usually redundant there - kept because rp2 has no prefer-params and + // the explicit update is its only path to the steady-state interval. this->state_ = ClientState::ESTABLISHED; int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, @@ -433,4 +436,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 95aac31931..269a41f23f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -1,14 +1,13 @@ -// Hub-platform BluetoothConnection: drives the build's GATT backend (the +// BluetoothConnection: drives the build's GATT backend (the // ble_device_base::BLEGattConnection alias) and translates its events into -// the same API messages the esp32 class emits. -// Presents the identical method surface, so the proxy's GATT dispatch -// compiles against either class unchanged. +// the proxy's API messages. One wrapper for every platform; per-backend +// differences live behind the alias and the streamer cut-through. #pragma once #include "esphome/core/defines.h" -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +#ifdef USE_BLE_GATT_CLIENT #include "bluetooth_connection.h" @@ -30,10 +29,10 @@ class BluetoothConnection final { /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { this->backend_ = backend; - backend->set_listener(this); + 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,10 +51,8 @@ class BluetoothConnection final { bool is_paired() const { return this->paired_; } void set_unpaired() { this->paired_ = false; } conn_err_t pair() { return this->backend_->pair(); } - // A backend disconnect() is a single call that also cancels an in-progress - // connect; there is no deferred-disconnect state to track. - bool disconnect_pending() const { return false; } - void cancel_pending_disconnect() {} + 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_; } @@ -65,22 +62,30 @@ class BluetoothConnection final { ClientState state() const { return this->state_; } void set_state(ClientState st) { this->state_ = st; } bool connected() const { return this->state_ == ClientState::ESTABLISHED; } - void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; } + void set_connection_type(ConnectionType ct) { + this->connection_type_ = ct; + // The bluedroid backend branches on the type itself (prefer-params and + // the with-cache report at OPEN_EVT); the others ignore it. + this->backend_->set_connection_type(ct); + } // 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 - /// safety timeout. Called from the proxy's loop — hub connections have no - /// Component loop of their own (the esp32 class streams from its own - /// loop() and has the same 10 s safety net in its base class). + /// safety timeout. Called from the proxy's loop — the wrapper has no + /// Component loop of its own. void process_pending_services() { if (this->send_service_ >= 0) { this->stream_pending_(this->backend_); } - this->check_disconnect_timeout_(); + // Inline state gate: this runs per loop iteration for every slot, and the + // 10 s safety net only matters while DISCONNECTING. + if (this->state_ == ClientState::DISCONNECTING) { + this->check_disconnect_timeout_(); + } } // ---- backend event sink (called directly by the backend, main loop) ---- @@ -146,4 +151,4 @@ static_assert(ble_device_base::GattClientEventSinkContract, } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc730659f5..33181ed748 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,6 +1,5 @@ #include "bluetooth_connection_rp2.h" -#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -26,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; @@ -384,8 +382,8 @@ void RP2GattClient::loop() { RP2GattNotifyEvent *notify; while ((notify = this->notify_queue_.pop()) != nullptr) { - if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { - this->listener_->on_notify_data(notify->handle, notify->data, notify->len); + if (this->notify_subscribed_(notify->handle)) { + this->sink_.on_notify_data(notify->handle, notify->data, notify->len); } this->notify_pool_.release(notify); } @@ -430,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); } @@ -448,9 +446,7 @@ void RP2GattClient::loop() { } if (timed_out) { ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); - } + this->sink_.on_write_result(this->op_handle_, GATT_CLIENT_BUSY); } } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && this->event_queue_.empty() && this->notify_queue_.empty())) { @@ -474,9 +470,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(true, this->mtu_, 0); - } + this->sink_.on_connection_state(true, this->mtu_, 0); } break; case RP2GattEvent::QUERY_COMPLETE: @@ -486,9 +480,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->finish_write_no_rsp_(event.status); break; case RP2GattEvent::PAIRING_RESULT: - if (this->listener_ != nullptr) { - this->listener_->on_pairing_result(event.status); - } + this->sink_.on_pairing_result(event.status); break; } } @@ -514,9 +506,7 @@ void RP2GattClient::finish_write_no_rsp_(uint8_t status) { return; } this->op_type_ = OpType::NONE; - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, status); - } + this->sink_.on_write_result(this->op_handle_, status); } void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { @@ -576,9 +566,7 @@ void RP2GattClient::fail_connection_(uint8_t reason) { this->cleanup_link_state_(); this->release_scan_inhibit_(); this->state_ = EngineState::IDLE; - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(false, 0, reason); - } + this->sink_.on_connection_state(false, 0, reason); } void RP2GattClient::cleanup_link_state_() { @@ -621,9 +609,6 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { OpType op = this->op_type_; this->op_type_ = OpType::NONE; - if (this->listener_ == nullptr) { - return; - } switch (op) { case OpType::READ_CHAR: case OpType::READ_DESC: @@ -634,12 +619,11 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { this->op_len_ > 0) { att_status = 0; } - this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, - att_status); + this->sink_.on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, att_status); break; case OpType::WRITE_CHAR: case OpType::WRITE_DESC: - this->listener_->on_write_result(this->op_handle_, att_status); + this->sink_.on_write_result(this->op_handle_, att_status); break; default: break; @@ -796,9 +780,7 @@ void RP2GattClient::finish_discovery_(int error) { if (error != 0) { this->release_services(); } - if (this->listener_ != nullptr) { - this->listener_->on_service_discovery_done(error); - } + this->sink_.on_service_discovery_done(error); } ble_device_base::GattServiceTable RP2GattClient::get_service_table() { @@ -990,8 +972,8 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui return 0; } } - if (status == 0 && this->listener_ != nullptr) { - this->listener_->on_write_result(handle, 0); + if (status == 0) { + this->sink_.on_write_result(handle, 0); } return status; } @@ -1092,9 +1074,7 @@ int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { } } } - if (this->listener_ != nullptr) { - this->listener_->on_notify_state(handle, enable, 0); - } + this->sink_.on_notify_state(handle, enable, 0); return 0; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index d5bf76e6ee..1e782cdd3a 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -26,8 +26,6 @@ namespace esphome::bluetooth_connection { -class BluetoothConnection; - // Caps for the transient service table. Sized generously for real devices // (typical peripherals expose < 8 services / < 30 characteristics); a peer // exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than @@ -80,7 +78,7 @@ class RP2GattClient final : public Component, public Parentedlistener_ = listener; } + void set_sink(ble_device_base::GattEventSink sink) { this->sink_ = sink; } // ---- ble_device_base::BLEGattConnection contract ---- int connect(uint64_t address, uint8_t addr_type); @@ -94,6 +92,11 @@ class RP2GattClient final : public Component, public Parented event_queue_; esphome::EventPool event_pool_; 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 25b6cbf002..0206b6ecce 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -2,7 +2,7 @@ // configured; this TU pins it on the host so the header cannot rot unseen. // The contract is a concept (BLEGattConnection is a per-platform alias), so // the minimal backend here proves the concept stays satisfiable and routes -// events through the duck-typed sink the way a real backend does. +// events through the type-erased GattEventSink the way a real backend does. #define USE_BLE_GATT_CLIENT #include "esphome/components/ble_device_base/ble_gatt_client.h" @@ -15,33 +15,35 @@ struct RecordingSink { void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } void on_service_discovery_done(int error) { this->discovery_error_ = error; } void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} - void on_write_result(uint16_t handle, int error) {} + void on_write_result(uint16_t handle, int error) { this->write_handle_ = handle; } void on_notify_state(uint16_t handle, bool enabled, int error) {} void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} void on_pairing_result(int status) {} bool connected_{false}; int discovery_error_{0}; + uint16_t write_handle_{0}; }; static_assert(GattClientEventSinkContract, "the recording sink must cover the full event-sink surface"); class MinimalConnection { public: - void set_listener(RecordingSink *listener) { this->listener_ = listener; } + void set_sink(GattEventSink sink) { this->sink_ = sink; } int connect(uint64_t address, uint8_t addr_type) { - if (this->listener_ != nullptr) - this->listener_->on_connection_state(true, 517, 0); + this->sink_.on_connection_state(true, 517, 0); return 0; } int disconnect() { return 0; } int discover_services() { - if (this->listener_ != nullptr) - this->listener_->on_service_discovery_done(0); + this->sink_.on_service_discovery_done(0); return 0; } int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + this->sink_.on_write_result(handle, 0); + return 0; + } int read_descriptor(uint16_t handle) { return 0; } int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } int notify_characteristic(uint16_t handle, bool enable) { return 0; } @@ -51,23 +53,28 @@ 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: - RecordingSink *listener_{nullptr}; + GattEventSink sink_; }; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "a minimal backend must satisfy the contract the alias asserts"); TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; RecordingSink listener; - connection.set_listener(&listener); + connection.set_sink(make_gatt_sink(&listener)); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_TRUE(listener.connected_); EXPECT_EQ(connection.discover_services(), 0); EXPECT_EQ(listener.discovery_error_, 0); EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); + EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0); + EXPECT_EQ(listener.write_handle_, 7); // A default table is empty and safe to walk. GattServiceTable table = connection.get_service_table(); @@ -76,4 +83,54 @@ TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { EXPECT_EQ(table.descriptor_count, 0); } +// A radon_eye_rd200-shaped table: two services, the second holding a +// notifying characteristic with a CCCD and a bare write characteristic. +class ServiceTableLookup : public ::testing::Test { + protected: + void SetUp() override { + this->services_[0] = {ESPBTUUID::from_uint16(0x1800), 0x0001, 0x0005, 0, 1}; + this->services_[1] = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 1, 2}; + this->characteristics_[0] = {ESPBTUUID::from_uint16(0x2A00), 0x0003, 0x0003, 0x02, 0, 0}; + this->characteristics_[1] = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 1}; + this->characteristics_[2] = {ESPBTUUID::from_uint16(0x1524), 0x0016, 0x0016, 0x04, 1, 0}; + this->descriptors_[0] = {ESPBTUUID::from_uint16(0x2902), 0x0013}; + this->table_ = {this->services_, this->characteristics_, this->descriptors_, 2, 3, 1}; + } + + GattService services_[2]; + GattCharacteristic characteristics_[3]; + GattDescriptor descriptors_[1]; + GattServiceTable table_; +}; + +TEST_F(ServiceTableLookup, FindsServicesAndCharacteristicsByUuid) { + const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523)); + ASSERT_NE(service, nullptr); + 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)); + ASSERT_NE(characteristic, nullptr); + EXPECT_EQ(characteristic->value_handle, 0x0012); + // The lookup is scoped to the service: 0x2A00 lives in the other service. + EXPECT_EQ(find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x2A00)), nullptr); +} + +TEST_F(ServiceTableLookup, FindsTheCccdAndReportsItsAbsence) { + const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523)); + const GattCharacteristic *notify_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525)); + EXPECT_EQ(find_cccd(this->table_, *notify_char), 0x0013); + const GattCharacteristic *write_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1524)); + EXPECT_EQ(find_cccd(this->table_, *write_char), 0); +} + +TEST_F(ServiceTableLookup, RejectsRangesThatOverrunTheTable) { + // A corrupt index range must fail the lookup, not walk out of bounds. + GattService bad_service = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 2, 5}; + EXPECT_EQ(find_characteristic(this->table_, bad_service, ESPBTUUID::from_uint16(0x1524)), nullptr); + GattCharacteristic bad_char = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 9}; + EXPECT_EQ(find_cccd(this->table_, bad_char), 0); +} + } // namespace esphome::ble_device_base::testing From ba567cecc3b8140b789daacb961760526988a2d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2026 00:23:34 -0500 Subject: [PATCH 2/4] Shared backend codegen registry and flip cleanups --- .../bluetooth_connection/__init__.py | 120 ++++++++++++++++-- .../bluetooth_connection.cpp | 22 ++++ .../bluetooth_connection.h | 8 +- .../bluetooth_connection_esp32.cpp | 29 ----- .../bluetooth_connection_hub.cpp | 4 +- .../bluetooth_connection_hub.h | 13 +- .../components/bluetooth_proxy/__init__.py | 81 +++--------- .../bluetooth_proxy/bluetooth_proxy.cpp | 12 +- .../bluetooth_proxy/bluetooth_proxy.h | 2 - .../test_outer_schema_mirror.py | 15 ++- .../bluetooth_proxy/test_platform_gates.py | 11 +- .../test-passive.esp32-c6-idf.yaml | 2 +- 12 files changed, 186 insertions(+), 133 deletions(-) delete mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index a9800ab81c..c93b323772 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,13 +1,21 @@ -"""Per-platform GATT connection backends the Bluetooth proxy drives. +"""Per-platform GATT connection backends and the helpers to embed one. -Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no -user-facing configuration; the proxy's codegen declares and registers the -connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the +Bluetooth proxy's codegen declares and registers the backend instances +through gatt_client_schema()/hub_connection_schema() + new_gatt_backend(). """ +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform -from esphome.const import PLATFORM_RP2, PlatformFramework +import esphome.config_validation as cv +from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + +DOMAIN = "bluetooth_connection" def AUTO_LOAD() -> list[str]: @@ -26,13 +34,109 @@ 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} -# 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_( "BluedroidGattClient", cg.Component ) +CONF_BACKEND_ID = "backend_id" + + +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), +} + + +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: + """Schema fragment for one GATT backend instance: its generated id plus + the platform-stack reference new_gatt_backend() resolves. Platform + dispatch happens at call time, so call this from inside a validator or a + per-platform schema builder, never at module import. + """ + entry = _backend_entry() + return entry.schema_fragment().extend( + {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)} + ) + + +def hub_connection_schema() -> cv.Schema: + """Per-slot schema for the proxy's connection wrappers: the wrapper id on + top of the backend fragment. Same call-time constraint as + gatt_client_schema().""" + return gatt_client_schema().extend( + {cv.GenerateID(): cv.declare_id(HubBluetoothConnection)} + ) + + +async def new_gatt_backend(config: ConfigType) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema() and register + it with its platform stack. The connection slot is claimed at validation + (the proxy's slot validators), not here. + """ + from esphome.components import ble_device_base + + ble_device_base.request_gatt_client() + 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, {}) + await _backend_entry().register(backend, config) + return backend + FILTER_SOURCE_FILES = filter_source_files_from_platform( { @@ -40,10 +144,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 57833edbd2..327f430ab5 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_HAS_GATT #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_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index c7bda55047..cdf7076ab7 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -17,9 +17,11 @@ #endif // The connection-aware API request handlers are compiled: a GATT backend is -// wired by codegen (one slot per connection). Advertisement-only builds get -// the clean-error handlers; address-scoped maintenance (unpair, cache clear) -// still works there through the per-platform free functions below. +// wired by codegen (one slot per connection). This is the single spelling of +// that predicate - the hub wrapper and the API request handlers gate on it. +// Advertisement-only builds get the clean-error handlers; address-scoped +// maintenance (unpair, cache clear) still works there through the +// per-platform free functions below. #ifdef USE_BLE_GATT_CLIENT #define BLUETOOTH_CONNECTION_HAS_GATT #endif 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_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 374db952bf..359aa04d26 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,7 @@ // The proxy's per-slot connection wrapper, shared by every platform. #include "bluetooth_connection_hub.h" -#ifdef USE_BLE_GATT_CLIENT +#ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -436,4 +436,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 269a41f23f..63d08a60ba 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -5,12 +5,13 @@ #pragma once -#include "esphome/core/defines.h" - -#ifdef USE_BLE_GATT_CLIENT - #include "bluetooth_connection.h" +// 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_HAS_GATT + #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" #include "esphome/core/helpers.h" @@ -51,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_; } @@ -151,4 +150,4 @@ static_assert(ble_device_base::GattClientEventSinkContract, } // namespace esphome::bluetooth_connection -#endif // USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 49c6712f80..34397d93a1 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -9,12 +9,12 @@ from esphome.core import CORE from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType -# The esp32 BLE stack (esp32_ble, esp32_ble_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. +# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily +# inside _esp32_config_schema()/_to_code_esp32(): importing those modules +# registers esp32-only automations (ble.enable, ble.disable, ...) as a side +# effect, and a module-scope import would leak them into every platform's +# registry the moment a config declares `bluetooth_proxy:` — degrading +# "Unable to find action" config errors into C++ compile failures. def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: @@ -58,7 +58,6 @@ _LOGGER = logging.getLogger(__name__) CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" -CONF_BACKEND_ID = "backend_id" DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") @@ -85,14 +84,7 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( - bluetooth_connection.BluedroidGattClient - ), - } - ).extend(cv.COMPONENT_SCHEMA) + CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema() def validate_connections(config): if CONF_CONNECTIONS in config: @@ -155,16 +147,7 @@ def _rp2_config_schema() -> cv.All: """Full proxy on the rp2 BLE hub: active connections through the BTstack GATT client backend in bluetooth_connection. The slot limit comes from the prebuilt BTstack library (one connection today); the code is built for N.""" - from esphome.components import rp2040_ble - - connection_schema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( - bluetooth_connection.RP2GattClient - ), - } - ) + connection_schema = bluetooth_connection.hub_connection_schema() def populate_connections(config: ConfigType) -> ConfigType: # One wrapper + backend pair per slot, declared during validation so @@ -183,11 +166,6 @@ def _rp2_config_schema() -> cv.All: cv.Schema( { **_COMMON_SCHEMA_KEYS, - # The GATT backend drives the controller directly (connect, GATT - # ops), not through the tracker hub. - cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( - rp2040_ble.RP2040BLE - ), cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, @@ -213,36 +191,20 @@ def _rp2_config_schema() -> cv.All: return cv.All(schema, populate_connections) -async def _connections_to_code( - var: cg.MockObj, config: ConfigType, register_backend -) -> None: - """One wrapper + backend pair per slot; register_backend supplies the - platform's backend registration (tracker client on esp32, controller - parent on rp2).""" +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, []): - ble_device_base.request_gatt_client() - backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) - await cg.register_component(backend, connection_conf) - await register_backend(backend, connection_conf, config) + backend = await bluetooth_connection.new_gatt_backend(connection_conf) connection = cg.new_Pvariable(connection_conf[CONF_ID]) cg.add(connection.set_backend(backend)) cg.add(var.register_connection(connection)) -async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble - - async def register_backend(backend, connection_conf, config): - await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) - - await _connections_to_code(var, config, register_backend) - - -# Per-platform schema builders and connection codegen; every key of -# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by -# tests/component_tests/bluetooth_proxy/). +# Per-platform schema builders; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by +# tests/component_tests/bluetooth_proxy/). Connection codegen is shared. _GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} -_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code} # Keys every platform arm declares identically; each arm spreads this dict so @@ -397,13 +359,7 @@ async def _to_code_esp32(config: ConfigType) -> None: connection_count = len(config.get(CONF_CONNECTIONS, [])) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) - async def register_backend(backend, connection_conf, _config): - # The tracker promote loop drives connect timing through the shim. - await esp32_ble_tracker.register_raw_client( - backend.tracker_client(), connection_conf - ) - - await _connections_to_code(var, config, register_backend) + await _connections_to_code(var, config) if config.get(CONF_CACHE_SERVICES): add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) @@ -423,10 +379,7 @@ 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 _GATT_HUB_TO_CODE[CORE.target_platform](var, config) + await _connections_to_code(var, config) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 3c4e11ff26..ecaaed774c 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_HAS_GATT // 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 267e29d12e..17e480b671 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/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index 32e5daf4bb..0054b2226c 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -14,6 +14,17 @@ import voluptuous as vol from esphome import config_validation as cv from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP32 +from esphome.core import CORE + + +def _esp32_schema_keys() -> dict[str, object]: + # The builder resolves the backend schema through the platform-dispatched + # bluetooth_connection.gatt_client_schema(), so the platform must be set + # (conftest's autouse reset restores CORE after each test). + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_ESP32 + return _keys(_schema_of(_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), @@ -38,7 +49,7 @@ def _keys(schema: vol.Schema) -> dict[str, object]: def test_outer_scalar_keys_exist_in_esp32_schema() -> None: outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() missing = set(outer) - set(esp32) assert not missing, ( f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema " @@ -51,7 +62,7 @@ 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())) + esp32 = _esp32_schema_keys() scalar = { name for name, key in esp32.items() diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index ec3cf8bd4c..a6f7618b87 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -186,13 +186,16 @@ def test_bluetooth_connection_auto_load_covers_its_includes() -> None: def test_every_registered_hub_platform_has_a_schema_arm() -> None: - # A platform added to HUB_MAX_CONNECTIONS without a schema builder, - # codegen arm, or _HUB_PLATFORMS entry would only fail when a config for - # it is validated (or not even then); pin all three couplings here. + # A platform added to HUB_MAX_CONNECTIONS without a schema builder or + # _HUB_PLATFORMS entry would only fail when a config for it is validated + # (or not even then); pin both couplings here. Connection codegen is + # shared (bluetooth_connection.new_gatt_backend), so it needs no arm. registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) - assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE) assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # Hub platforms must also be in the backend registry the shared codegen + # helpers dispatch on. + assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS) # 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/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 From 20f63026a27f162fdcf455c003a45d84ae84924e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2026 00:27:53 -0500 Subject: [PATCH 3/4] Dedicated-backend consumer support: proxy gate, consumer schema, slot ledger --- .../ble_device_base/ble_gatt_client.h | 6 +- .../bluetooth_connection/__init__.py | 126 +++++++++++++++++- .../bluetooth_connection.cpp | 15 ++- .../bluetooth_connection.h | 18 +-- .../bluetooth_connection_bluedroid.cpp | 9 +- .../bluetooth_connection_bluedroid.h | 8 +- .../bluetooth_connection_gatt_backend.h | 2 - .../bluetooth_connection_hub.cpp | 4 +- .../bluetooth_connection_hub.h | 4 +- .../bluetooth_connection_rp2.h | 5 +- .../components/bluetooth_proxy/__init__.py | 9 +- .../bluetooth_proxy/bluetooth_proxy.cpp | 18 +-- .../bluetooth_proxy/bluetooth_proxy.h | 14 +- esphome/core/defines.h | 1 + .../bluetooth_proxy/test_platform_gates.py | 26 ++-- .../test_gatt_client_contract.cpp | 2 - 16 files changed, 197 insertions(+), 70 deletions(-) diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 97322ec27e..3b8c362d93 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -198,10 +198,8 @@ 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; }; diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index c93b323772..6229e7b2fd 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,24 +1,41 @@ """Per-platform GATT connection backends and the helpers to embed one. -Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the -Bluetooth proxy's codegen declares and registers the backend instances -through gatt_client_schema()/hub_connection_schema() + new_gatt_backend(). +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 (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 +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, schema_extractor from esphome.types import ConfigType DOMAIN = "bluetooth_connection" def AUTO_LOAD() -> list[str]: + """ble_device_base plus the platform BLE stack the build's backend + registers with, so consumers stay platform-blind. The platform-less arm + serves tooling that resolves the manifest without a target.""" + if CORE.is_esp32: + return ["ble_device_base", "esp32_ble_tracker"] + if CORE.target_platform == PLATFORM_RP2: + return ["ble_device_base", "rp2040_ble"] + if CORE.target_platform is None: + return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"] return ["ble_device_base"] @@ -42,6 +59,10 @@ 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: @@ -92,6 +113,9 @@ _PLATFORM_BACKENDS: dict[str, _PlatformBackend] = { 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: @@ -122,14 +146,102 @@ def hub_connection_schema() -> cv.Schema: ) -async def new_gatt_backend(config: ConfigType) -> cg.MockObj: +@dataclass +class _SlotLedger: + """GATT connection slots claimed this run, for the platform cap check.""" + + consumers: list[str] = field(default_factory=list) + + +def _ledger() -> _SlotLedger: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _SlotLedger() + return CORE.data[DOMAIN] + + +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.extend([consumer] * count) + if CORE.is_esp32: + from esphome.components import esp32_ble + + esp32_ble.consume_connection_slots(count, consumer)(config) + return config + + return validator + + +def _validate_slot_totals(config: ConfigType) -> ConfigType: + # esp32 has its own controller budget (esp32_ble); the hub platforms cap + # at the prebuilt stack's client count, and nothing else counts claims + # across components (e.g. a proxy plus a radon_eye_rd200 on rp2). + if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None: + return config + claimed = _ledger().consumers + if len(claimed) > cap: + raise cv.Invalid( + f"{CORE.target_platform} supports at most {cap} GATT client " + f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}" + ) + return config + + +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() 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 keys plus the platform-free peer keys. + return base_schema.extend(_PEER_SCHEMA) + cv.only_on(GATT_CLIENT_PLATFORMS)(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, *, 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 - (the proxy's slot validators), not here. + (gatt_client_config_schema / the proxy's slot validators), not here. + + 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. diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 327f430ab5..c2d089d101 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -5,7 +5,7 @@ #include #endif -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "esphome/components/api/api_pb2.h" #include "esphome/core/log.h" @@ -42,9 +42,16 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size return BatchClose::CONTINUE; } +} // namespace esphome::bluetooth_connection + +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY + #ifdef USE_ESP32 +namespace esphome::bluetooth_connection { + // Address-scoped Bluedroid maintenance shared by every esp32 proxy build, -// including advertisement-only ones where no GATT backend is compiled. +// including advertisement-only ones where no GATT backend (and none of the +// gated surface above) is compiled - so this block sits outside that gate. conn_err_t unpair_device(uint64_t address) { esp_bd_addr_t bda; @@ -57,8 +64,6 @@ conn_err_t clear_gatt_cache(uint64_t address) { 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_HAS_GATT +#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index cdf7076ab7..bacfd693a4 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,14 +16,16 @@ #include #endif -// The connection-aware API request handlers are compiled: a GATT backend is -// wired by codegen (one slot per connection). This is the single spelling of -// that predicate - the hub wrapper and the API request handlers gate on it. +// The proxy-serving surface is compiled: a proxy is present and a GATT +// backend is wired by codegen (one slot per connection). This is the single +// spelling of that predicate - the hub wrapper, the connection-aware API +// request handlers, and the Bluedroid in-place streamer all gate on it. // Advertisement-only builds get the clean-error handlers; address-scoped // maintenance (unpair, cache clear) still works there through the -// per-platform free functions below. -#ifdef USE_BLE_GATT_CLIENT -#define BLUETOOTH_CONNECTION_HAS_GATT +// per-platform free functions below. Backend-only builds (a dedicated-backend +// consumer without bluetooth_proxy) compile none of this API surface. +#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) +#define BLUETOOTH_CONNECTION_SERVES_PROXY #endif namespace esphome::api { @@ -139,7 +141,7 @@ inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY /// Result of close_service_batch: keep filling the batch or send it now. /// An oversized service is packed alone; a failed (backpressured) send is /// retried from the batch start, so no service is silently skipped. @@ -151,6 +153,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND }; /// cannot drift. BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, uint8_t connection_index, const char *address_str); -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 7fb051b142..b8a23b6b93 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -2,10 +2,11 @@ #if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) +#include "bluetooth_connection.h" + // The in-place streamer serves the proxy's service-discovery API; backend-only // builds compile without the proxy headers or the streamer. -#ifdef USE_BLUETOOTH_PROXY -#include "bluetooth_connection.h" +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "bluetooth_connection_hub.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -485,7 +486,7 @@ void BluedroidGattClient::handle_search_cmpl_() { this->sink_.on_service_discovery_done(0); } -#ifdef USE_BLUETOOTH_PROXY +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { if (this->services_released_ || conn.send_service_ >= this->service_total_) { conn.send_service_ = DONE_SENDING_SERVICES; @@ -624,7 +625,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { conn.send_service_ = batch_start; } } -#endif // USE_BLUETOOTH_PROXY +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY // ---- events ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 0690fb6df3..f100c5068e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -13,6 +13,8 @@ #if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) +#include "bluetooth_connection.h" + #include "esphome/components/ble_device_base/ble_gatt_client.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/core/component.h" @@ -23,7 +25,7 @@ namespace esphome::bluetooth_connection { class BluedroidGattClient; -#ifdef USE_BLUETOOTH_PROXY +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY class BluetoothConnection; #endif @@ -82,7 +84,7 @@ class BluedroidGattClient final : public Component { #endif void release_services(); -#ifdef USE_BLUETOOTH_PROXY +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY /// In-place service streamer (the proxy wrapper detects and prefers it): /// builds one api response batch directly from Bluedroid's cached database, /// so the streaming peak is the response itself - the old esp32 model. @@ -90,8 +92,6 @@ class BluedroidGattClient final : public Component { #endif void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } - bool disconnect_pending() const { return this->shim_.disconnect_pending(); } - void cancel_pending_disconnect() { this->shim_.cancel_pending_disconnect(); } protected: friend class BluedroidTrackerShim; 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 359aa04d26..d1719a0617 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,7 @@ // The proxy's per-slot connection wrapper, shared by every platform. #include "bluetooth_connection_hub.h" -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -436,4 +436,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 63d08a60ba..291391d575 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -10,7 +10,7 @@ // 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_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" @@ -150,4 +150,4 @@ static_assert(ble_device_base::GattClientEventSinkContract, } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY 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 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,6 +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 + bluetooth_connection.consume_gatt_slot( + "bluetooth_proxy", config[CONF_CONNECTION_SLOTS] + )(config) return { **config, CONF_CONNECTIONS: [ @@ -195,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)) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index ecaaed774c..e4f062812f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -106,7 +106,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), connection->address_str(), ble_device_base::client_state_to_string(state)); @@ -115,7 +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 // BLUETOOTH_CONNECTION_HAS_GATT +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); @@ -139,7 +139,7 @@ void BluetoothProxy::dump_config() { this->get_bluetooth_mac_address_pretty(mac_str); const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Active: %s\n" @@ -157,7 +157,7 @@ void BluetoothProxy::dump_config() { #endif } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY // maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) { @@ -427,7 +427,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn this->api_connection_->send_message(resp); } -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY #ifdef USE_ESP32 @@ -470,7 +470,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #endif // USE_ESP32 void BluetoothProxy::loop() { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY // Stream pending service-discovery batches every iteration; the streamer // handles a vanished API connection itself. for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -485,7 +485,7 @@ void BluetoothProxy::loop() { this->last_advertisement_flush_time_ = now; if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY // The API subscriber is gone: tear down any connections it left behind // (disconnect() on an already-disconnecting slot is a no-op). for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -509,7 +509,7 @@ void BluetoothProxy::loop() { this->flush_pending_advertisements_(); } -#ifndef BLUETOOTH_CONNECTION_HAS_GATT +#ifndef BLUETOOTH_CONNECTION_SERVES_PROXY // Advertisement-only proxy. GATT client connections are excluded at compile // time (no connection backend on this platform, or active: false), so every @@ -581,7 +581,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn this->api_connection_->send_message(resp); } -#endif // !BLUETOOTH_CONNECTION_HAS_GATT +#endif // !BLUETOOTH_CONNECTION_SERVES_PROXY void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 17e480b671..785cc51fb8 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -26,7 +26,7 @@ using bluetooth_connection::conn_err_t; using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::INIT_SENDING_SERVICES; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY using BluetoothConnection = bluetooth_connection::BluetoothConnection; using ClientState = ble_device_base::ClientState; #endif @@ -58,7 +58,7 @@ enum BluetoothProxySubscriptionFlag : uint32_t { }; class BluetoothProxy final : public Component { -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY // Allow the connection to update connections_free_response_ friend bluetooth_connection::BluetoothConnection; #endif @@ -69,9 +69,9 @@ class BluetoothProxy final : public Component { void setup() override; void loop() override; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY void register_connection(BluetoothConnection *connection); -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY #ifndef USE_ESP32 // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and @@ -189,7 +189,7 @@ class BluetoothProxy final : public Component { } void log_advertisement_flush_(); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); @@ -197,7 +197,7 @@ class BluetoothProxy final : public Component { 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); -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY /// Keep the pre-allocated connections-free message in step when a /// connection slot changes address (0 = free). Called from the connection /// classes' set_address(). @@ -237,7 +237,7 @@ class BluetoothProxy final : public Component { // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY // Group 2: Fixed-size array of connection pointers std::array connections_{}; #endif 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 a6f7618b87..244c4bdfce 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -177,12 +177,22 @@ def test_rp2_rejects_esp32_only_keys_by_name( bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) -def test_bluetooth_connection_auto_load_covers_its_includes() -> None: - # Every backend builds on ble_device_base alone; the Bluedroid backend - # talks to IDF directly, so esp32_ble_client is no longer in the closure. - for platform in ("esp32", "rp2", None): - _set_platform(platform) - assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] +def test_bluetooth_connection_auto_load_matches_the_platform_stack() -> None: + # The backend registers with its platform BLE stack, so that dependency + # lives here and consumers (proxy, radon_eye_rd200) stay platform-blind; + # the platform-less arm is the union for manifest-resolving tooling. + _set_platform("esp32") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"] + _set_platform("rp2") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"] + _set_platform("ln882x") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] + _set_platform(None) + assert bluetooth_connection.AUTO_LOAD() == [ + "ble_device_base", + "esp32_ble_tracker", + "rp2040_ble", + ] def test_every_registered_hub_platform_has_a_schema_arm() -> None: @@ -193,9 +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 codegen + # Hub platforms must also be in the backend registry the shared consumer # helpers dispatch on. - assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS) + 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 0206b6ecce..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: From cc81069d0200eba02bdd6b412e24670691f2e38b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2026 00:28:54 -0500 Subject: [PATCH 4/4] Keep the esp32 maintenance functions outside the gated surface --- .../bluetooth_connection/bluetooth_connection.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 327f430ab5..ed4a6e73ac 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -42,9 +42,16 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size return BatchClose::CONTINUE; } +} // namespace esphome::bluetooth_connection + +#endif // BLUETOOTH_CONNECTION_HAS_GATT + #ifdef USE_ESP32 +namespace esphome::bluetooth_connection { + // Address-scoped Bluedroid maintenance shared by every esp32 proxy build, -// including advertisement-only ones where no GATT backend is compiled. +// including advertisement-only ones where no GATT backend (and none of the +// gated surface above) is compiled - so this block sits outside that gate. conn_err_t unpair_device(uint64_t address) { esp_bd_addr_t bda; @@ -57,8 +64,6 @@ conn_err_t clear_gatt_cache(uint64_t address) { 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_HAS_GATT +#endif // USE_ESP32