diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index ef28f7672a..1bcfcf99dc 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -128,7 +128,8 @@ class BLEGattConnection { /// Backend-owned service table (see GattServiceTable lifetime). virtual GattServiceTable get_service_table() = 0; - /// Free the transient service table storage. Call after streaming. + /// Free the transient service table storage. Call after streaming; + /// idempotent (a call with no table held is a no-op). virtual void release_services() = 0; protected: diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 1e85c4b8f9..d4d0a3c3af 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -24,6 +24,10 @@ CODEOWNERS = ["@bdraco", "@jesserockz"] bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") +# The hub-platform wrapper codegen class (drives a ble_device_base +# BLEGattConnection backend; see bluetooth_connection_hub.h). +HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") + @functools.cache def esp32_connection_class() -> cg.MockObjClass: @@ -42,5 +46,12 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( 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. + "bluetooth_connection_hub.cpp": { + PlatformFramework.RP2_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, } ) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp new file mode 100644 index 0000000000..57833edbd2 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -0,0 +1,42 @@ +#include "bluetooth_connection.h" + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + +#include "esphome/components/api/api_pb2.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str) { + // Calculate the actual size of just this service (+1 for the field tag) + size_t service_size = resp.services.back().calculate_size() + 1; + + if (current_size + service_size > MAX_PACKET_SIZE) { + if (resp.services.size() > 1) { + // We would go over -- pop the last service and retry it in the next batch + resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %u + service: %u > %u), sending current batch", + connection_index, address_str, send_service, (unsigned) current_size, (unsigned) service_size, + (unsigned) MAX_PACKET_SIZE); + // Don't advance send_service -- the popped service goes into the next batch + } else { + // This single service is too large, but we have to send it anyway; + // advance so we don't get stuck + ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%u bytes) but sending anyway", connection_index, address_str, + send_service, (unsigned) service_size); + send_service++; + } + return BatchClose::SEND; + } + + current_size += service_size; + send_service++; + return BatchClose::CONTINUE; +} + +} // 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 f63fb93492..712251b157 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -1,16 +1,32 @@ -// Shared types for the per-platform GATT connection backends and the -// Bluetooth proxy that drives them. +// Shared types and helpers for the per-platform GATT connection backends and +// the Bluetooth proxy that drives them. #pragma once #include "esphome/core/defines.h" #include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_device.h" + +#include +#include +#include #ifdef USE_ESP32 #include #endif +// A GATT connection backend exists in this build: esp32 (Bluedroid) or a hub +// platform with the neutral GATT client compiled in. Single-sourced here so +// the proxy and this component cannot drift. +#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT) +#define BLUETOOTH_CONNECTION_HAS_GATT +#endif + +namespace esphome::api { +class BluetoothGATTGetServicesResponse; +} // namespace esphome::api + namespace esphome::bluetooth_connection { // Connection-owned error type for the API error fields, which are plain @@ -30,8 +46,99 @@ static constexpr conn_err_t CONN_OK = 0; // GATT contract so backend and wrapper cannot drift. static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; +// What the platform's connection backend supports beyond GATT operations; +// the proxy derives its feature flags and legacy version from these. +#ifdef USE_ESP32 +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#else +static constexpr bool SUPPORTS_PAIRING = false; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; +#endif + +// Address-scoped (not connection-scoped) maintenance requests. +#ifdef USE_ESP32 +conn_err_t unpair_device(uint64_t address); +conn_err_t clear_gatt_cache(uint64_t address); +#else +inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } +#endif + // send_service_ cursor states; >= 0 is the next service index to stream. static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; +// ---- Service-streaming size budget, shared by every platform's streamer ---- + +// Conservative MTU limit for API messages (accounts for WPA3 overhead) +static constexpr size_t MAX_PACKET_SIZE = 1360; + +// Constants for size estimation +static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) +static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) +static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic + +/// Estimate the wire size of a service (service overhead + its characteristics, +/// assuming 128-bit UUIDs and one 128-bit descriptor per characteristic to be +/// safe) before fetching/packing the full data. +inline size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { + size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; + return service_overhead + (CHAR_SIZE_128BIT + DESC_SIZE_128BIT * DESC_PER_CHAR) * char_count; +} + +// ---- UUID wire packing, shared by every platform's streamer ---- + +// This function is allocation-free and directly packs UUIDs into the output +// array using precalculated constants for the Bluetooth base UUID. ESPBTUUID +// stores its 128-bit form little-endian (same as Bluedroid). +inline void fill_128bit_uuid_array(std::array &out, const ble_device_base::ESPBTUUID &uuid) { + using ble_device_base::ESPBTUUID; + if (uuid.type() == ESPBTUUID::Type::UUID128) { + const uint8_t *u = uuid.uuid128(); + // out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian) + out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) | + ((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]); + out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) | + ((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]); + return; + } + // 16/32-bit UUID inserted into the Bluetooth base UUID: + // 00000000-0000-1000-8000-00805F9B34FB + uint32_t value = uuid.type() == ESPBTUUID::Type::UUID16 ? uuid.uuid16() : uuid.uuid32(); + out[0] = ((uint64_t) value << 32) | 0x00001000ULL; // Base UUID bytes 8-11 + out[1] = 0x800000805F9B34FBULL; // Base UUID bytes 0-7 +} + +/// Fill the UUID in the appropriate wire format based on client support and +/// UUID type (128-bit array for old clients or 128-bit UUIDs, short form +/// otherwise). +inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, + const ble_device_base::ESPBTUUID &uuid, bool use_efficient_uuids) { + using ble_device_base::ESPBTUUID; + if (!use_efficient_uuids || uuid.type() == ESPBTUUID::Type::UUID128) { + fill_128bit_uuid_array(uuid_128, uuid); + } else if (uuid.type() == ESPBTUUID::Type::UUID16) { + short_uuid = uuid.uuid16(); + } else { + short_uuid = uuid.uuid32(); + } +} + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +/// 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. +enum class BatchClose : uint8_t { CONTINUE, SEND }; + +/// Close out the service just packed into resp (account its actual wire size, +/// advance the cursor) and decide whether the batch must be sent now. Shared +/// tail of both platform streamers so the budget logic and its log lines +/// 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 + } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp index 5274637b66..7c62d3766c 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp @@ -12,81 +12,20 @@ namespace esphome::bluetooth_connection { namespace espbt = esphome::esp32_ble_tracker; +using ble_device_base::ESPBTUUID; + static const char *const TAG = "bluetooth_connection"; -// This function is allocation-free and directly packs UUIDs into the output array -// using precalculated constants for the Bluetooth base UUID -static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { - // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB - // out[0] = bytes 8-15 (big-endian) - // - For 128-bit UUIDs: use bytes 8-15 as-is - // - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11 - out[0] = uuid_source.len == ESP_UUID_LEN_128 - ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8])) - : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32) - << 32) | - 0x00001000ULL); // Base UUID bytes 8-11 - // out[1] = bytes 0-7 (big-endian) - // - For 128-bit UUIDs: use bytes 0-7 as-is - // - For 16/32-bit UUIDs: use precalculated base UUID constant - out[1] = uuid_source.len == ESP_UUID_LEN_128 - ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0]) - : 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bd_addr; + ble_device_base::uint64_to_mac_msb_first(address, bd_addr); + return esp_ble_remove_bond_device(bd_addr); } -// Helper to fill UUID in the appropriate format based on client support and UUID type -static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid, - bool use_efficient_uuids) { - if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) { - // Use 128-bit format for old clients or when UUID is already 128-bit - fill_128bit_uuid_array(uuid_128, uuid); - } else if (uuid.len == ESP_UUID_LEN_16) { - short_uuid = uuid.uuid.uuid16; - } else if (uuid.len == ESP_UUID_LEN_32) { - short_uuid = uuid.uuid.uuid32; - } -} - -// Constants for size estimation -static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) -static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) -static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) -static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) -static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) -static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic - -// Helper to estimate service size before fetching all data -/** - * Estimate the size of a Bluetooth service based on the number of characteristics and UUID format. - * - * @param char_count The number of characteristics in the service. - * @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions. - * @return The estimated size of the service in bytes. - * - * This function calculates the size of a Bluetooth service by considering: - * - A service overhead, which depends on whether efficient UUIDs are used. - * - The size of each characteristic, assuming 128-bit UUIDs for safety. - * - The size of descriptors, assuming one 128-bit descriptor per characteristic. - */ -static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { - size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; - // Always assume 128-bit UUIDs for characteristics to be safe - size_t char_size = CHAR_SIZE_128BIT; - // Assume one 128-bit descriptor per characteristic - size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR; - - return service_overhead + (char_size + desc_size) * char_count; -} - -bool BluetoothConnection::supports_efficient_uuids_() const { - auto *api_conn = this->proxy_->get_api_connection(); - return api_conn && api_conn->client_supports_api_version(1, 12); +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bd_addr; + ble_device_base::uint64_to_mac_msb_first(address, bd_addr); + return esp_ble_gattc_cache_clean(bd_addr); } void BluetoothConnection::dump_config() { @@ -94,28 +33,9 @@ void BluetoothConnection::dump_config() { BLEClientBase::dump_config(); } -void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { - auto &allocated = this->proxy_->connections_free_response_.allocated; - for (auto &slot : allocated) { - if (slot == find_value) { - slot = set_value; - return; - } - } -} - void BluetoothConnection::set_address(uint64_t address) { - // If we're clearing an address (disconnecting), update the pre-allocated message - if (address == 0 && this->address_ != 0) { - this->proxy_->connections_free_response_.free++; - this->update_allocated_slot_(this->address_, 0); - } - // If we're setting a new address (connecting), update the pre-allocated message - else if (address != 0 && this->address_ == 0) { - this->proxy_->connections_free_response_.free--; - this->update_allocated_slot_(0, address); - } - + // Keep the proxy's pre-allocated connections-free message in step + this->proxy_->update_address_slot_(this->address_, address); // Call parent implementation to actually set the address BLEClientBase::set_address(address); } @@ -157,20 +77,7 @@ void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { this->reset_connection_(reason); } -void BluetoothConnection::reset_connection_(esp_err_t reason) { - // Send disconnection notification - this->proxy_->send_device_connection(this->address_, false, 0, reason); - - // Important: If we were in the middle of sending services, we do NOT send - // send_gatt_services_done() here. This ensures the client knows that - // the service discovery was interrupted and can retry. The client - // (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT) - // to detect incomplete service discovery rather than relying on us to - // tell them about a partial list. - this->set_address(0); - this->send_service_ = INIT_SENDING_SERVICES; - this->proxy_->send_connections_free(); -} +void BluetoothConnection::reset_connection_(esp_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } void BluetoothConnection::send_service_for_discovery_() { if (this->send_service_ >= this->service_count_) { @@ -188,18 +95,16 @@ void BluetoothConnection::send_service_for_discovery_() { } // Check if client supports efficient UUIDs - bool use_efficient_uuids = this->supports_efficient_uuids_(); + bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); // Prepare response api::BluetoothGATTGetServicesResponse resp; resp.address = this->address_; // Dynamic batching based on actual size - // Conservative MTU limit for API messages (accounts for WPA3 overhead) - static constexpr size_t MAX_PACKET_SIZE = 1360; - // Keep running total of actual message size size_t current_size = resp.calculate_size(); + int16_t batch_start = this->send_service_; while (this->send_service_ < this->service_count_) { esp_gattc_service_elem_t service_result; @@ -238,7 +143,8 @@ void BluetoothConnection::send_service_for_discovery_() { resp.services.emplace_back(); auto &service_resp = resp.services.back(); - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, ESPBTUUID::from_uuid(service_result.uuid), + use_efficient_uuids); service_resp.handle = service_result.start_handle; @@ -268,7 +174,8 @@ void BluetoothConnection::send_service_for_discovery_() { service_resp.characteristics.emplace_back(); auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, ESPBTUUID::from_uuid(char_result.uuid), + use_efficient_uuids); characteristic_resp.handle = char_result.char_handle; characteristic_resp.properties = char_result.properties; char_offset++; @@ -309,44 +216,26 @@ void BluetoothConnection::send_service_for_discovery_() { characteristic_resp.descriptors.emplace_back(); auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, ESPBTUUID::from_uuid(desc_result.uuid), + use_efficient_uuids); descriptor_resp.handle = desc_result.handle; desc_offset++; } } } // end if (total_char_count > 0) - // Calculate the actual size of just this service - size_t service_size = service_resp.calculate_size() + 1; // +1 for field tag - - // Check if adding this service would exceed the limit - if (current_size + service_size > MAX_PACKET_SIZE) { - // We would go over - pop the last service if we have more than one - if (resp.services.size() > 1) { - resp.services.pop_back(); - ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", - this->connection_index_, this->address_str(), this->send_service_, current_size, service_size, - MAX_PACKET_SIZE); - // Don't increment send_service_ - we'll retry this service in next batch - } else { - // This single service is too large, but we have to send it anyway - ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, - this->address_str(), this->send_service_, service_size); - // Increment so we don't get stuck - this->send_service_++; - } - // Send what we have + if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str()) != + BatchClose::CONTINUE) { break; } - - // Now we know we're keeping this service, add its size - current_size += service_size; - // Successfully added this service, increment counter - this->send_service_++; } - // Send the message with dynamically batched services - api_conn->send_message(resp); + // Send the message with dynamically batched services; on a failed send, + // rewind the cursor so the batch is retried instead of silently skipped. + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->send_service_ = batch_start; + } } void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h index 65e2d0777e..531ff311a7 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h @@ -34,6 +34,15 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); } + bool has_gatt_services() const { return this->service_count_ != 0; } + + /// Start connecting: record the API address type and hand the client to the + /// tracker's promote loop (it pauses the scan and opens the connection). + void initiate_connection(uint8_t address_type) { + this->set_remote_addr_type(static_cast(address_type)); + this->set_state(esp32_ble_tracker::ClientState::DISCOVERED); + } + void set_address(uint64_t address) override; protected: @@ -41,10 +50,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase { void on_disconnect_complete(esp_err_t reason) override; - bool supports_efficient_uuids_() const; void send_service_for_discovery_(); void reset_connection_(esp_err_t reason); - void update_allocated_slot_(uint64_t find_value, uint64_t set_value); void log_connection_error_(const char *operation, esp_gatt_status_t status); void log_connection_warning_(const char *operation, esp_err_t err); void log_gatt_not_connected_(const char *action, const char *type); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp new file mode 100644 index 0000000000..338bf671a8 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -0,0 +1,415 @@ +// Hub-platform connection wrapper (USE_RP2 hub builds today). +#include "bluetooth_connection_hub.h" + +#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +void BluetoothConnection::set_address(uint64_t address) { + // Keep the proxy's pre-allocated connections-free message in step + this->proxy_->update_address_slot_(this->address_, address); + this->address_ = address; + if (address == 0) { + this->address_str_[0] = '\0'; + return; + } + uint8_t mac[6]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + format_mac_addr_upper(mac, this->address_str_); +} + +void BluetoothConnection::start_connect_() { + // No connect timeout here (esp32 parity): the 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_); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); + this->reset_connection_(err); + } +} + +void BluetoothConnection::disconnect() { + // Idempotent like the esp32 class: 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) { + return; + } + int err = this->backend_->disconnect(); + if (err == GATT_NOT_CONNECTED) { + // Backend already idle: free the slot so the client is not stuck. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_); + this->reset_connection_(err); + return; + } + if (err != 0) { + // Transient refusal: stay DISCONNECTING and let the safety timeout + // arbitrate rather than freeing a slot whose teardown is unresolved. + // Latch the refusal unless a GATT cause is already recorded (first wins). + ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err); + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + this->state_ = ClientState::DISCONNECTING; + this->disconnecting_started_ = millis(); +} + +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. + static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; + if (this->state_ == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_); + this->reset_connection_(GATT_NOT_CONNECTED); + } +} + +void BluetoothConnection::reset_connection_(conn_err_t reason) { + if (this->pending_error_ != 0) { + reason = this->pending_error_; + this->pending_error_ = 0; + } + this->state_ = ClientState::IDLE; + this->services_discovered_ = false; + this->backend_->release_services(); + this->proxy_->reset_connection_slot_(this, reason); +} + +// ---- GattClientEventListener ---- + +void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { + if (connected && this->address_ == 0) { + // Late completion for a slot that was already freed: nothing to report, + // and the api-gone sweep or a new reservation owns the slot now. + int err = this->backend_->disconnect(); + if (err != 0 && err != GATT_NOT_CONNECTED) { + // Log only: re-arming a freed slot could clobber a new reservation. + ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err); + } + return; + } + if (connected && this->state_ == ClientState::DISCONNECTING) { + // The link came up after a disconnect request won the race; finish the + // teardown instead of reporting a connection the client no longer wants. + int err = this->backend_->disconnect(); + // Fresh teardown attempt: give it the full safety window. + this->disconnecting_started_ = millis(); + if (err == GATT_NOT_CONNECTED) { + // Nothing left to tear down after all. + this->reset_connection_(err); + } else if (err != 0) { + // Transient refusal while the link is up: keep DISCONNECTING and let + // the safety timeout arbitrate (same policy as disconnect()). + ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err); + } + return; + } + if (connected) { + this->mtu_ = mtu; + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + // The API client has the services cached; never discover them. + this->state_ = ClientState::ESTABLISHED; + this->proxy_->send_device_connection(this->address_, true, mtu); + this->proxy_->send_connections_free(); + return; + } + // V3_WITHOUT_CACHE: discover services first — the connected response is + // sent when discovery completes, mirroring the esp32 flow (MTU + services + // before the response). + this->state_ = ClientState::CONNECTED; + int err = this->backend_->discover_services(); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err); + // Latch the real cause for the disconnect report. + this->pending_error_ = err; + this->disconnect(); + } + return; + } + // Disconnected, connect failed, or teardown complete + if (this->address_ == 0) { + return; // Slot already freed + } + ESP_LOGD(TAG, "[%d] [%s] Disconnected, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, + error); + this->reset_connection_(error); +} + +void BluetoothConnection::on_service_discovery_done(int error) { + if (error != 0) { + ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error); + // Carry the GATT error into the disconnection report so the client sees + // the real cause instead of a generic HCI reason. + this->pending_error_ = error; + this->disconnect(); + return; + } + ESP_LOGD(TAG, "[%d] [%s] Discovery finished, sending connected (mtu=%u)", this->connection_index_, this->address_str_, + this->mtu_); + this->state_ = ClientState::ESTABLISHED; + this->services_discovered_ = true; + this->proxy_->send_device_connection(this->address_, true, this->mtu_); + this->proxy_->send_connections_free(); +} + +void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) { + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_, + operation, handle, status); +} + +void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) { + // Late completion for a freed slot; nothing to report. + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("reading char/descriptor", handle, error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTReadResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_write_result(uint16_t handle, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("writing char/descriptor", handle, error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTWriteResponse resp; + resp.address = this->address_; + resp.handle = handle; + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle, + error); + this->proxy_->send_gatt_error(this->address_, handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTNotifyResponse resp; + resp.address = this->address_; + resp.handle = handle; + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->address_ == 0) + return; + ESP_LOGV(TAG, "[%d] [%s] Notify: handle=0x%2X", this->connection_index_, this->address_str_, handle); + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTNotifyDataResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_); + } +} + +// ---- GATT operations ---- + +conn_err_t BluetoothConnection::check_connected_op_(const char *action, const char *type) const { + if (this->connected()) { + return CONN_OK; + } + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str_, action, + type); + return GATT_NOT_CONNECTED; +} + +conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { + if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_characteristic(handle); +} + +conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, + bool response) { + if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_characteristic(handle, data, static_cast(length), response); +} + +conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { + if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_descriptor(handle); +} + +// The neutral backend contract performs descriptor writes acknowledged, so +// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP). +conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, + bool /*response*/) { + if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_descriptor(handle, data, static_cast(length)); +} + +conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { + if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_, + enable ? "Registering for" : "Unregistering for", handle); + return this->backend_->notify_characteristic(handle, enable); +} + +conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (conn_err_t err = this->check_connected_op_("update params of", "connection"); err != CONN_OK) + return err; + return this->backend_->update_connection_params(min_interval, max_interval, latency, timeout); +} + +// ---- Service streaming ---- + +void BluetoothConnection::send_service_for_discovery_() { + auto table = this->backend_->get_service_table(); + if (this->send_service_ >= table.service_count) { + this->send_service_ = DONE_SENDING_SERVICES; + this->proxy_->send_gatt_services_done(this->address_); + this->backend_->release_services(); + return; + } + + // The subscriber vanished mid-stream: park the cursor at done WITHOUT + // sending services-done (esp32 parity — a resubscribing client gets + // silence and its 30 s timeout, never an authoritative partial list) and + // free the table; the api-gone sweep tears the connection down anyway. + auto *api_conn = this->proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, + this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + this->backend_->release_services(); + return; + } + + // Check if client supports efficient UUIDs + bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); + + // Prepare response + api::BluetoothGATTGetServicesResponse resp; + resp.address = this->address_; + + // Dynamic batching based on actual size, same contract as the esp32 streamer + size_t current_size = resp.calculate_size(); + int16_t batch_start = this->send_service_; + + while (this->send_service_ < table.service_count) { + const auto &service = table.services[this->send_service_]; + + // If this service likely won't fit, send current batch (unless it's the first) + size_t estimated_size = estimate_service_size(service.characteristic_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service.uuid, use_efficient_uuids); + service_resp.handle = service.start_handle; + + // Bounds-check the backend's index ranges against the table totals rather + // than trusting its discovery bookkeeping blindly. A miscounted non-empty + // range must not stream a truncated database as authoritative (V3 clients + // cache it permanently): abort and tear the connection down; the client + // times out and retries. Empty ranges are tolerated regardless of index. + uint16_t char_count = service.characteristic_count; + if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) { + ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); + return; + } + if (char_count > 0) { + service_resp.characteristics.init(char_count); + for (uint16_t ci = 0; ci < char_count; ci++) { + const auto &chr = table.characteristics[service.first_characteristic + ci]; + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, chr.uuid, use_efficient_uuids); + characteristic_resp.handle = chr.value_handle; + characteristic_resp.properties = chr.properties; + uint16_t desc_count = chr.descriptor_count; + if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) { + ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); + return; + } + if (desc_count == 0) { + continue; + } + characteristic_resp.descriptors.init(desc_count); + for (uint16_t di = 0; di < desc_count; di++) { + const auto &desc = table.descriptors[chr.first_descriptor + di]; + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc.uuid, use_efficient_uuids); + descriptor_resp.handle = desc.handle; + } + } + } + + if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // Send the message with dynamically batched services; on a failed send, + // rewind the cursor so the batch is retried instead of silently skipped + // (bounded: a subscriber that stays gone ends streaming via the api-lost + // rewind above). + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->send_service_ = batch_start; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h new file mode 100644 index 0000000000..83fbd24e4c --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -0,0 +1,129 @@ +// Hub-platform BluetoothConnection: drives a platform GATT client backend +// through the neutral ble_device_base::BLEGattConnection interface 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. + +#pragma once + +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) + +#include "bluetooth_connection.h" + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/core/helpers.h" + +namespace esphome::bluetooth_proxy { +class BluetoothProxy; +} // namespace esphome::bluetooth_proxy + +namespace esphome::bluetooth_connection { + +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; + +class BluetoothConnection final : public ble_device_base::GattClientEventListener { + public: + /// Wire the platform backend. Called from codegen before setup. + void set_backend(ble_device_base::BLEGattConnection *backend) { + this->backend_ = backend; + backend->set_listener(this); + } + + // ---- proxy dispatch surface (mirrors the esp32 class) ---- + 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); + conn_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t notify_characteristic(uint16_t handle, bool enable); + conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + + /// Start connecting: record the API address type (BLE_ADDR_TYPE_* code + /// space) and open the connection through the backend. Failures report + /// through the same reset path a failed open takes on esp32. + void initiate_connection(uint8_t address_type) { + this->remote_addr_type_ = address_type; + this->start_connect_(); + } + void disconnect(); + // 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() {} + + void set_address(uint64_t address); + uint64_t get_address() const { return this->address_; } + const char *address_str() const { return this->address_str_; } + uint8_t get_connection_index() const { return this->connection_index_; } + + 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; } + // 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). + 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). + void process_pending_services() { + if (this->send_service_ >= 0) { + this->send_service_for_discovery_(); + } + this->check_disconnect_timeout_(); + } + + // ---- ble_device_base::GattClientEventListener ---- + void on_connection_state(bool connected, uint16_t mtu, int error) override; + void on_service_discovery_done(int error) override; + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; + void on_write_result(uint16_t handle, int error) override; + void on_notify_state(uint16_t handle, bool enabled, int error) override; + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + + protected: + friend class bluetooth_proxy::BluetoothProxy; + + void start_connect_(); + void send_service_for_discovery_(); + void check_disconnect_timeout_(); + void reset_connection_(conn_err_t reason); + conn_err_t check_connected_op_(const char *action, const char *type) const; + void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); + + // Memory optimized layout for 32-bit systems (a vptr precedes: pointers and + // 2-byte members first fill to an 8-byte boundary before address_) + // Group 1: Pointers (4 bytes each, naturally aligned) + bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; + ble_device_base::BLEGattConnection *backend_{nullptr}; + + // Group 2: 2-byte types + int16_t send_service_{INIT_SENDING_SERVICES}; + uint16_t mtu_{23}; + + // Group 3: 8-byte and 4-byte types + uint64_t address_{0}; + uint32_t disconnecting_started_{0}; + conn_err_t pending_error_{0}; + + // Group 4: Arrays + char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; + + // Group 5: 1-byte types + ClientState state_{ClientState::IDLE}; + ConnectionType connection_type_{ConnectionType::V1}; + uint8_t remote_addr_type_{0}; + uint8_t connection_index_{0}; + bool services_discovered_{false}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 5916132ab2..a0706ae4ae 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -47,6 +47,8 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # Assistant) assumes an ESPHome proxy can scan actively, so a passive-only # proxy would be misdriven — bk72xx follows once the API carries a feature # flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). +# Coupled to bluetooth_connection: platforms with a GATT backend are also +# listed in its FILTER_SOURCE_FILES hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] @@ -160,16 +162,14 @@ _BLE_HUB_CONFIG_SCHEMA = cv.All( cv.Schema( { **_COMMON_SCHEMA_KEYS, - # Declared directly (BLE_DEVICE_SCHEMA-style): appending a validator - # after a strict schema rejects an explicit `ble_hub_id` before it - # runs, and that key is the documented way to disambiguate once a - # platform has two trackers. - cv.GenerateID(ble_device_base.CONF_BLE_HUB_ID): cv.use_id( - ble_device_base.BLEHub - ), cv.Optional(CONF_ACTIVE, default=False): cv.boolean, } - ).extend(cv.COMPONENT_SCHEMA), + ) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) + .extend(cv.COMPONENT_SCHEMA), _validate_no_active, ) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 66f22c9a90..a25c9d9608 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -54,8 +54,9 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta #else // !USE_ESP32 void BluetoothProxy::setup() { - this->connections_free_response_.limit = 0; - this->connections_free_response_.free = 0; + // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->hub_->scan_active(); @@ -111,16 +112,16 @@ void BluetoothProxy::send_bluetooth_scanner_state_() { #endif // USE_ESP32 -#ifdef USE_ESP32 -void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { +#ifdef BLUETOOTH_CONNECTION_HAS_GATT +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(), espbt::client_state_to_string(state)); + connection->address_str(), ble_device_base::client_state_to_string(state)); } void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } -#endif // USE_ESP32 +#endif // BLUETOOTH_CONNECTION_HAS_GATT void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); @@ -188,19 +189,29 @@ void BluetoothProxy::dump_config() { " Connections: %d", YESNO(this->active_), this->connection_count_); #else - // Advertisement-only: print configured facts. dump_config runs right after - // setup, before the radio is up, so live scan state would always read - // "stopped" here — the loop's BluetoothScannerStateResponse carries the - // changing value instead. + // Print configured facts. dump_config runs right after setup, before the + // radio is up, so live scan state would always read "stopped" here — the + // loop's BluetoothScannerStateResponse carries the changing value instead. char mac_str[18]; this->get_bluetooth_mac_address_pretty(mac_str); + 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 + ESP_LOGCONFIG(TAG, + "Bluetooth Proxy:\n" + " Active: %s\n" + " Connections: %d\n" + " Configured scan: %s\n" + " Adapter MAC: %s", + YESNO(this->active_), this->connection_count_, scan_mode, mac_out); +#else ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Mode: advertisement-only (no GATT connections)\n" " Configured scan: %s\n" " Adapter MAC: %s", - this->configured_scan_active_ ? "active" : "passive", - mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"); + scan_mode, mac_out); +#endif #endif } @@ -229,6 +240,51 @@ esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_par return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } +#endif // USE_ESP32 + +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + +// 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) { +// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) { + // Cannot happen with codegen-sized registration; a silent drop would + // surface later as a null proxy_ dereference, so refuse loudly. + ESP_LOGE(TAG, "Connection registry full, dropping registration"); + return; + } +#ifndef USE_ESP32 + // esp32 assigns connection_index_ in BLEClientBase::setup(); the hub + // class has no Component lifecycle, so the index is assigned here. + connection->connection_index_ = this->connection_count_; +#endif + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; +#endif +} + +void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); } + +void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_value) { + for (auto &slot : this->connections_free_response_.allocated) { + if (slot == find_value) { + slot = set_value; + return; + } + } + // The accounting arrays are only mutated here and sized to the slot count, + // so a miss means the bookkeeping already drifted — say so. + ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); +} + +void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { + this->send_device_connection(connection->get_address(), false, 0, reason); + connection->set_address(0); + connection->send_service_ = INIT_SENDING_SERVICES; + this->send_connections_free(); +} + BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; @@ -244,7 +300,7 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese // We only set the state if we allocate the connection // to avoid a race where multiple connection attempts // are made. - connection->set_state(espbt::ClientState::INIT); + connection->set_state(ClientState::INIT); return connection; } } @@ -267,13 +323,12 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, false); return; } - if (connection->state() == espbt::ClientState::CONNECTED || - connection->state() == espbt::ClientState::ESTABLISHED) { + if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == espbt::ClientState::CONNECTING) { + } 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()); @@ -282,19 +337,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } this->log_connection_request_ignored_(connection, connection->state()); return; - } else if (connection->state() != espbt::ClientState::INIT) { + } else if (connection->state() != ClientState::INIT) { this->log_connection_request_ignored_(connection, connection->state()); return; } if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { - connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITH_CACHE); this->log_connection_info_(connection, "v3 with cache"); } else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE - connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - connection->set_remote_addr_type(static_cast(msg.address_type)); - connection->set_state(espbt::ClientState::DISCOVERED); + connection->initiate_connection(static_cast(msg.address_type)); this->send_connections_free(); break; } @@ -305,7 +359,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_connections_free(); return; } - if (connection->state() != espbt::ClientState::IDLE) { + if (connection->state() != ClientState::IDLE) { connection->disconnect(); } else { connection->set_address(0); @@ -315,6 +369,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { +#ifdef USE_ESP32 auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { if (!connection->is_paired()) { @@ -326,21 +381,21 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_pairing(msg.address, true); } } +#else + // Explicit pairing is not offered (FEATURE_PAIRING is not advertised); + // peripheral-initiated security still works through the platform's SM. + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); +#endif break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_remove_bond_device(address); - this->send_device_unpairing(msg.address, ret == ESP_OK, ret); + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_gattc_cache_clean(address); - // Shares the sender with the neutral path, which also null-checks api_connection_. - this->send_device_clear_cache(msg.address, ret == ESP_OK, ret); + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { @@ -359,7 +414,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms } auto err = connection->read_characteristic(msg.handle); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -372,7 +427,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & } auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -385,7 +440,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead } auto err = connection->read_descriptor(msg.handle); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -398,7 +453,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri } auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -409,8 +464,8 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); return; } - if (!connection->service_count_) { - ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str()); + if (!connection->has_gatt_services()) { + ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); this->send_gatt_services_done(msg.address); return; } @@ -426,7 +481,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest } auto err = connection->notify_characteristic(msg.handle, msg.enable); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_gatt_error(msg.address, msg.handle, err); } } @@ -434,6 +489,7 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; + // Send results unchecked (esp32 parity): a drop resolves via the client timeout. auto *connection = this->get_connection_(msg.address, false); api::BluetoothSetConnectionParamsResponse resp; @@ -441,7 +497,7 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn if (connection == nullptr || !connection->connected()) { ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected", - connection ? static_cast(connection->connection_index_) : -1, + connection ? static_cast(connection->get_connection_index()) : -1, connection ? connection->address_str() : "unknown"); resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); @@ -458,6 +514,10 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn this->api_connection_->send_message(resp); } +#endif // BLUETOOTH_CONNECTION_HAS_GATT + +#ifdef USE_ESP32 + void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { if (this->parent_->get_scan_active() == active) { return; @@ -471,21 +531,35 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #else // !USE_ESP32 -// Advertisement-only proxy. GATT client connections are excluded at compile -// time — this whole arm is selected by #ifdef USE_ESP32, and nothing consults -// HubCapabilities at runtime today — so every connection-oriented request is -// answered with a clean error instead of silence, and Home Assistant treats -// the proxy as passive. - void BluetoothProxy::loop() { +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Stream pending service-discovery batches every iteration (esp32 parity: + // its connections stream from their own per-iteration Component loop). + // send_service_for_discovery_() handles a vanished API connection itself. + for (uint8_t i = 0; i < this->connection_count_; i++) { + this->connections_[i]->process_pending_services(); + } +#endif + // Run advertisement flush / scanner-state poll every 100ms uint32_t now = App.get_loop_component_start_time(); if (now - this->last_advertisement_flush_time_ < 100) return; this->last_advertisement_flush_time_ = now; - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) + 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). + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0) { + connection->disconnect(); + } + } +#endif return; + } // The hub has no scanner-state listener interface; poll and report on change. if (this->hub_->scan_running() != this->last_scan_running_) { @@ -495,6 +569,13 @@ void BluetoothProxy::loop() { this->flush_pending_advertisements_(); } +#ifndef BLUETOOTH_CONNECTION_HAS_GATT + +// Advertisement-only proxy. GATT client connections are excluded at compile +// time (no connection backend on this platform, or active: false), so every +// connection-oriented request is answered with a clean error instead of +// silence, and Home Assistant treats the proxy as passive. + void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { switch (msg.request_type) { case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: @@ -547,12 +628,15 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; + // Send results unchecked (esp32 parity): a drop resolves via the client timeout. api::BluetoothSetConnectionParamsResponse resp; resp.address = msg.address; resp.error = GATT_NOT_CONNECTED; this->api_connection_->send_message(resp); } +#endif // !BLUETOOTH_CONNECTION_HAS_GATT + void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { if (this->hub_->scan_active() != active) { ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index dbfc119d98..b8c8ab15f6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -16,7 +16,6 @@ #include "esphome/components/bluetooth_connection/bluetooth_connection.h" #ifdef USE_ESP32 -#include "esphome/components/esp32_ble_client/ble_client_base.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" @@ -27,6 +26,9 @@ #include #else #include "esphome/components/ble_device_base/ble_hub.h" +#ifdef USE_BLE_GATT_CLIENT +#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" +#endif #endif // USE_ESP32 namespace esphome::bluetooth_proxy { @@ -39,9 +41,9 @@ using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::INIT_SENDING_SERVICES; -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT using BluetoothConnection = bluetooth_connection::BluetoothConnection; -using namespace esp32_ble_client; +using ClientState = ble_device_base::ClientState; #endif // Legacy versions: @@ -51,6 +53,8 @@ using namespace esp32_ble_client; // Version 4: Pairing support // Version 5: Cache clear support static constexpr uint32_t LEGACY_ACTIVE_CONNECTIONS_VERSION = 5; +static constexpr uint32_t LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION = 4; +static constexpr uint32_t LEGACY_ACTIVE_NO_PAIRING_VERSION = 3; static constexpr uint32_t LEGACY_PASSIVE_ONLY_VERSION = 1; enum BluetoothProxyFeature : uint32_t { @@ -72,10 +76,12 @@ enum BluetoothProxySubscriptionFlag : uint32_t { class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public esp32_ble_tracker::BLEScannerStateListener, public Component { - // Allow the connection to update connections_free_response_ - friend bluetooth_connection::BluetoothConnection; #else class BluetoothProxy final : public Component { +#endif +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + // Allow the connection to update connections_free_response_ + friend bluetooth_connection::BluetoothConnection; #endif public: BluetoothProxy(); @@ -90,25 +96,17 @@ class BluetoothProxy final : public Component { void setup() override; void loop() override; -#ifdef USE_ESP32 - // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. - void register_connection([[maybe_unused]] BluetoothConnection *connection) { - // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 - if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { - this->connections_[this->connection_count_++] = connection; - connection->proxy_ = this; - } -#endif - } -#else +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + void register_connection(BluetoothConnection *connection); +#endif // BLUETOOTH_CONNECTION_HAS_GATT +#ifndef USE_ESP32 void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and // the BLEHub contract does not promise those are settled any earlier than // the hub's own setup(). float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } -#endif // USE_ESP32 +#endif // !USE_ESP32 void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); @@ -122,6 +120,10 @@ class BluetoothProxy final : public Component { void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } + /// Whether the subscribed API client understands 16/32-bit UUID fields. + bool client_supports_efficient_uuids() const { + return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); + } void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); @@ -134,17 +136,6 @@ class BluetoothProxy final : public Component { void bluetooth_scanner_set_mode(bool active); -#ifdef USE_ESP32 - static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) { - bd_addr[0] = (address >> 40) & 0xff; - bd_addr[1] = (address >> 32) & 0xff; - bd_addr[2] = (address >> 24) & 0xff; - bd_addr[3] = (address >> 16) & 0xff; - bd_addr[4] = (address >> 8) & 0xff; - bd_addr[5] = (address >> 0) & 0xff; - } -#endif - void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } @@ -154,10 +145,17 @@ class BluetoothProxy final : public Component { #endif uint32_t get_legacy_version() const { - if (this->active_) { + if (!this->active_) { + return LEGACY_PASSIVE_ONLY_VERSION; + } + // Legacy clients (which predate the feature flags) map versions to + // capability sets: 5 adds cache clearing, 4 adds pairing, 3 is active + // connections only. + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { return LEGACY_ACTIVE_CONNECTIONS_VERSION; } - return LEGACY_PASSIVE_ONLY_VERSION; + return bluetooth_connection::SUPPORTS_PAIRING ? LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION + : LEGACY_ACTIVE_NO_PAIRING_VERSION; } uint32_t get_feature_flags() const { @@ -176,11 +174,18 @@ class BluetoothProxy final : public Component { } #endif if (this->active_) { + // REMOTE_CACHING is mandatory for active connections: API clients + // refuse to connect without it (it selects which V3 connect request + // they send, not device-side caching). flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS; flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING; - flags |= BluetoothProxyFeature::FEATURE_PAIRING; - flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; flags |= BluetoothProxyFeature::FEATURE_CONNECTION_PARAMS_SETTING; + if (bluetooth_connection::SUPPORTS_PAIRING) { + flags |= BluetoothProxyFeature::FEATURE_PAIRING; + } + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { + flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; + } } return flags; @@ -231,22 +236,59 @@ class BluetoothProxy final : public Component { } void log_advertisement_flush_(); -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT BluetoothConnection *get_connection_(uint64_t address, bool reserve); - void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); + void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); #endif void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + /// Keep the pre-allocated connections-free message in step when a + /// connection slot changes address (0 = free). Called from the connection + /// classes' set_address(). + // maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the + // body is removed, and the free < MAX compare would trip -Wtype-limits. + void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) { +#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + auto &resp = this->connections_free_response_; + if (new_address == 0 && old_address != 0) { + if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + resp.free++; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(old_address, 0); + } else if (new_address != 0 && old_address == 0) { + if (resp.free > 0) { + resp.free--; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(0, new_address); + } +#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + } + void replace_allocated_slot_(uint64_t find_value, uint64_t set_value); + void log_slot_accounting_mismatch_(); + /// Free a connection slot after teardown: notify the API client and reset + /// the streaming cursor. Important: does NOT send send_gatt_services_done() + /// when service streaming was interrupted -- the client (aioesphomeapi) has + /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service + /// discovery and retry, rather than being told a partial list is complete. + void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); +#endif + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; -#ifdef USE_ESP32 +#ifdef BLUETOOTH_CONNECTION_HAS_GATT // Group 2: Fixed-size array of connection pointers std::array connections_{}; -#else +#endif +#ifndef USE_ESP32 ble_device_base::BLEHub *hub_{nullptr}; #endif diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index c474b5fa81..036530d942 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -5,12 +5,14 @@ advertisement-only arm applies its own defaults.""" import pytest from esphome import config_validation as cv -from esphome.components import bluetooth_connection, bluetooth_proxy +from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy from esphome.const import ( CONF_ACTIVE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_LN882X, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE @@ -22,12 +24,18 @@ HUB_PLATFORM_FRAMEWORKS = [ PlatformFramework.RP2_ARDUINO, ] +HUB_TRACKERS = { + PLATFORM_LN882X: "ln882h_ble_tracker", + PLATFORM_RP2: "rp2_ble_tracker", +} + def test_hub_platform_list_covers_every_hub_platform() -> None: # A platform added to _HUB_PLATFORMS (bk72xx is planned) would otherwise # get no gate coverage at all. covered = {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} assert covered == set(bluetooth_proxy._HUB_PLATFORMS) + assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS) def _set_platform(platform: str | None) -> None: @@ -35,6 +43,14 @@ def _set_platform(platform: str | None) -> None: CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform +def _register_tracker(platform: str) -> None: + # The ble_hub_id guard needs a loaded tracker, normally registered as an + # import side effect of the tracker module. + tracker = HUB_TRACKERS[platform] + ble_device_base.register_hub_provider(tracker) + CORE.loaded_integrations.add(tracker) + + def test_ble_less_platform_gets_the_real_reason( set_core_config: SetCoreConfigCallable, ) -> None: @@ -68,6 +84,7 @@ def test_hub_platform_rejects_active( platform_framework: PlatformFramework, ) -> None: set_core_config(platform_framework) + _register_tracker(platform_framework.value[0]) with pytest.raises(cv.Invalid, match="Active connections are not supported"): bluetooth_proxy.CONFIG_SCHEMA({"active": True}) @@ -100,6 +117,7 @@ def test_hub_platform_accepts_the_advertisement_only_shape( platform_framework: PlatformFramework, ) -> None: set_core_config(platform_framework) + _register_tracker(platform_framework.value[0]) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is False diff --git a/tests/components/bluetooth_connection/test_gatt_uuid.cpp b/tests/components/bluetooth_connection/test_gatt_uuid.cpp new file mode 100644 index 0000000000..b3596a4364 --- /dev/null +++ b/tests/components/bluetooth_connection/test_gatt_uuid.cpp @@ -0,0 +1,49 @@ +// Pins the shared UUID wire packing and the size-estimate budget the service +// streamers rely on, in both efficient and legacy client modes. +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +namespace esphome::bluetooth_connection::testing { + +using ble_device_base::ESPBTUUID; + +TEST(GattUuidPacking, ShortUuidUsedWhenClientSupportsIt) { + std::array uuid128{}; + uint32_t short_uuid = 0; + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), true); + EXPECT_EQ(short_uuid, 0x180Fu); + EXPECT_EQ(uuid128[0], 0u); + EXPECT_EQ(uuid128[1], 0u); +} + +TEST(GattUuidPacking, LegacyClientGetsBaseUuidExpansion) { + // 0000180F-0000-1000-8000-00805F9B34FB + std::array uuid128{}; + uint32_t short_uuid = 0; + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_uint16(0x180F), false); + EXPECT_EQ(short_uuid, 0u); + EXPECT_EQ(uuid128[0], 0x0000180F00001000ULL); + EXPECT_EQ(uuid128[1], 0x800000805F9B34FBULL); +} + +TEST(GattUuidPacking, FullUuidPassesThroughBigEndian) { + // 12345678-90AB-CDEF-1122-334455667788, stored little-endian in ESPBTUUID. + const uint8_t big_endian[16] = {0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; + std::array uuid128{}; + uint32_t short_uuid = 0; + // Efficient mode must still use the 128-bit form for 128-bit UUIDs. + fill_gatt_uuid(uuid128, short_uuid, ESPBTUUID::from_raw_reversed(big_endian), true); + EXPECT_EQ(short_uuid, 0u); + EXPECT_EQ(uuid128[0], 0x1234567890ABCDEFULL); + EXPECT_EQ(uuid128[1], 0x1122334455667788ULL); +} + +TEST(GattUuidPacking, EstimateGrowsWithCharacteristicsAndMode) { + // The estimate only gates batching; pin its shape, not exact bytes. + EXPECT_LT(estimate_service_size(0, true), estimate_service_size(0, false)); + EXPECT_LT(estimate_service_size(1, false), estimate_service_size(2, false)); +} + +} // namespace esphome::bluetooth_connection::testing