From 2483621500f929b2a926b73d437437ae7053d2c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 21:31:09 -0500 Subject: [PATCH] Bluedroid backend for the GATT client contract --- .../bluetooth_connection/__init__.py | 7 + .../bluetooth_connection_bluedroid.cpp | 637 ++++++++++++++++++ .../bluetooth_connection_bluedroid.h | 137 ++++ .../bluetooth_connection_gatt_backend.h | 3 + 4 files changed, 784 insertions(+) create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp create mode 100644 esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 1dc1969a6a..b4d1a7a762 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -36,6 +36,9 @@ HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} # The hub-platform wrapper and the rp2 BTstack 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 +) @functools.cache @@ -51,6 +54,10 @@ def esp32_connection_class() -> cg.MockObjClass: FILTER_SOURCE_FILES = filter_source_files_from_platform( { + "bluetooth_connection_bluedroid.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, "bluetooth_connection_esp32.cpp": { PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp new file mode 100644 index 0000000000..8fa622b034 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -0,0 +1,637 @@ +#include "bluetooth_connection_bluedroid.h" + +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) + +#include "bluetooth_connection_hub.h" + +#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 + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.bluedroid"; + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; +using esp32_ble_tracker::ClientState; +using esp32_ble_tracker::ConnectionType; + +static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; +static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; + +// ---- shim forwarders ---- + +bool BluedroidTrackerShim::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + return this->engine_->handle_gattc_event_(event, gattc_if, param); +} +void BluedroidTrackerShim::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + this->engine_->handle_gap_event_(event, param); +} +void BluedroidTrackerShim::connect() { this->engine_->tracker_connect_(); } +void BluedroidTrackerShim::disconnect() { this->engine_->tracker_disconnect_(); } + +// ---- component ---- + +void BluedroidGattClient::setup() { + static uint8_t connection_index = 0; + this->connection_index_ = connection_index++; + this->conn_id_ = UNSET_CONN_ID; +} + +void BluedroidGattClient::loop() { + if (!esp32_ble::global_ble->is_active()) { + // Stack down: re-register the app on the next enable. + this->set_state_(ClientState::INIT); + return; + } + auto st = this->state_(); + if (st == ClientState::INIT) { + auto ret = esp_ble_gattc_app_register(this->shim_.app_id); + if (ret) { + ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->shim_.app_id, ret); + this->mark_failed(); + } + // Do not wait for REG_EVT; a dropped event must not wedge the slot. + this->set_state_(ClientState::IDLE); + } else if (st == ClientState::IDLE) { + // The loop only drives the bootstrap and the disconnect safety timeout. + this->disable_loop(); + } else if (st == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > DISCONNECTING_TIMEOUT) { + ESP_LOGE(TAG, "[%d] Timeout waiting for CLOSE_EVT, forcing IDLE", this->connection_index_); + // Release before idling: unconditional disconnect does not release, and a + // lost CLOSE/DISCONNECT would otherwise leak the table and the cache. + this->release_services(); + this->set_idle_(); + this->report_connection_state_(false, ESP_GATT_CONN_TIMEOUT); + } +} + +void BluedroidGattClient::dump_config() { + ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); + if (this->is_failed()) { + ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots"); + } +} + +// ---- contract ops ---- + +int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + this->address_ = address; + this->remote_bda_[0] = (address >> 40) & 0xFF; + this->remote_bda_[1] = (address >> 32) & 0xFF; + this->remote_bda_[2] = (address >> 24) & 0xFF; + this->remote_bda_[3] = (address >> 16) & 0xFF; + this->remote_bda_[4] = (address >> 8) & 0xFF; + this->remote_bda_[5] = (address >> 0) & 0xFF; + this->remote_addr_type_ = static_cast(addr_type); + // Hand the request to the tracker's promote loop: it stops the scan, raises + // coex, and calls tracker_connect_() - the tracker owns connect timing here. + this->set_state_(ClientState::DISCOVERED); + return 0; +} + +void BluedroidGattClient::tracker_connect_() { + auto st = this->state_(); + if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) { + ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_); + return; + } + if (st == ClientState::DISCONNECTING) { + ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_); + return; + } + ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_); + this->services_released_ = false; + this->seen_mtu_ = false; + this->enable_loop(); + this->set_state_(ClientState::CONNECTING); + if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) { + // Fast params for the discovery phase; stepped down at SEARCH_CMPL. + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0, + FAST_CONN_TIMEOUT); + } else { + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, + MEDIUM_CONN_TIMEOUT); + } + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, this->remote_addr_type_, true); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + // CONNECT_EVT never fired, so conn_id_ is legitimately unset: plain IDLE. + this->set_state_(ClientState::IDLE); + this->report_connection_state_(false, ret); + } +} + +int BluedroidGattClient::disconnect() { + auto st = this->state_(); + if (st == ClientState::IDLE || st == ClientState::DISCONNECTING) { + return 0; + } + if (st == ClientState::DISCOVERED) { + // Never opened: nothing to close. + this->set_state_(ClientState::IDLE); + return 0; + } + if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { + ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_); + this->shim_.schedule_disconnect(); + return 0; + } + this->unconditional_disconnect_(); + return 0; +} + +void BluedroidGattClient::unconditional_disconnect_() { + ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_); + if (this->conn_id_ == UNSET_CONN_ID) { + ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_); + return; + } + auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); + if (err != ESP_OK) { + // The stack is now in an indeterminate state for this link. + ESP_LOGE(TAG, "[%d] esp_ble_gattc_close error: %d", this->connection_index_, err); + } + this->set_disconnecting_(); +} + +int BluedroidGattClient::discover_services() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + this->service_count_ = 0; + auto err = esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gattc_search_service", err); + } + return err; +} + +int BluedroidGattClient::read_characteristic(uint16_t handle) { + return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, + handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + // The BTC layer copies the payload immediately, so the const_cast is safe. + return this->check_and_log_error_( + "esp_ble_gattc_write_char", + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, + ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::read_descriptor(uint16_t handle) { + return this->check_and_log_error_( + "esp_ble_gattc_read_char_descr", + esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + return this->check_and_log_error_( + "esp_ble_gattc_write_char_descr", + esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) { + // Local registration only; the CCCD write is the API client's responsibility. + if (enable) { + return this->check_and_log_error_("esp_ble_gattc_register_for_notify", + esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle)); + } + return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", + esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle)); +} + +int BluedroidGattClient::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); } + +int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); +} + +ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() { return this->table_; } + +void BluedroidGattClient::release_services() { + if (this->arena_ != nullptr) { + RAMAllocator allocator; + allocator.deallocate(this->arena_, 0); + this->arena_ = nullptr; + } + this->table_ = {}; +#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // Only the cache clean makes the stack's database unsafe to walk. + this->services_released_ = true; + esp_ble_gattc_cache_clean(this->remote_bda_); +#endif +} + +// ---- internals ---- + +bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const { + return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0; +} + +void BluedroidGattClient::set_idle_() { + this->set_state_(ClientState::IDLE); + this->conn_id_ = UNSET_CONN_ID; +} + +void BluedroidGattClient::set_disconnecting_() { + this->disconnecting_started_ = millis(); + this->set_state_(ClientState::DISCONNECTING); + // The loop may be disabled while idle; the safety timeout needs it. + this->enable_loop(); +} + +void BluedroidGattClient::report_connection_state_(bool connected, int error) { + if (this->listener_ != nullptr) { + this->listener_->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, + uint16_t timeout, const char *param_type) { + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + ESP_LOGD(TAG, "[%d] %s conn params", this->connection_index_, param_type); + auto err = esp_ble_gap_update_conn_params(&conn_params); + if (err != ESP_OK) { + this->log_gattc_warning_("esp_ble_gap_update_conn_params", err); + } + return err; +} + +int BluedroidGattClient::check_and_log_error_(const char *operation, esp_err_t err) { + if (err != ESP_OK) { + this->log_gattc_warning_(operation, err); + } + return err; +} + +void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) { + ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->connection_index_, operation, code); +} + +// ---- table materialization ---- + +bool BluedroidGattClient::materialize_table_() { + if (this->services_released_) { + ESP_LOGW(TAG, "[%d] Services released, cannot walk the GATT cache", this->connection_index_); + return false; + } + // Pass 1: exact counts for a single allocation. + uint16_t total_chars = 0; + uint16_t total_descs = 0; + for (uint16_t s = 0; s < this->service_count_; s++) { + esp_gattc_service_elem_t service_result; + uint16_t count = 1; + if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, &count, s) != + ESP_GATT_OK || + count == 0) { + return false; + } + uint16_t char_count = 0; + if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, + service_result.start_handle, service_result.end_handle, 0, + &char_count) != ESP_GATT_OK) { + return false; + } + total_chars += char_count; + uint16_t char_offset = 0; + while (char_offset < char_count) { + esp_gattc_char_elem_t char_result; + uint16_t cc = 1; + auto st = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &cc, char_offset); + if (st == ESP_GATT_INVALID_OFFSET || st == ESP_GATT_NOT_FOUND || cc == 0) { + break; + } + if (st != ESP_GATT_OK) { + return false; + } + uint16_t desc_count = 0; + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, + char_result.char_handle, &desc_count); + total_descs += desc_count; + char_offset++; + } + } + + const size_t svc_bytes = this->service_count_ * sizeof(ble_device_base::GattService); + const size_t chr_bytes = total_chars * sizeof(ble_device_base::GattCharacteristic); + const size_t dsc_bytes = total_descs * sizeof(ble_device_base::GattDescriptor); + RAMAllocator allocator; + this->arena_ = allocator.allocate(svc_bytes + chr_bytes + dsc_bytes); + if (this->arena_ == nullptr) { + ESP_LOGE(TAG, "[%d] Service table allocation failed", this->connection_index_); + return false; + } + auto *services = reinterpret_cast(this->arena_); + auto *chars = reinterpret_cast(this->arena_ + svc_bytes); + auto *descs = reinterpret_cast(this->arena_ + svc_bytes + chr_bytes); + + // Pass 2: fill. Enumeration stays bounded by the pass-1 totals - a + // misbehaving peripheral can return more entries than it reported. + uint16_t chr_index = 0; + uint16_t dsc_index = 0; + for (uint16_t s = 0; s < this->service_count_; s++) { + esp_gattc_service_elem_t service_result; + uint16_t count = 1; + if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, &count, s) != + ESP_GATT_OK || + count == 0) { + break; + } + auto &service = services[s]; + service.uuid = esp32_ble_tracker::ESPBTUUID::from_uuid(service_result.uuid); + service.start_handle = service_result.start_handle; + service.end_handle = service_result.end_handle; + service.first_characteristic = chr_index; + service.characteristic_count = 0; + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + while (chr_index < total_chars) { + uint16_t cc = 1; + auto st = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &cc, char_offset); + if (st == ESP_GATT_INVALID_OFFSET || st == ESP_GATT_NOT_FOUND || st != ESP_GATT_OK || cc == 0) { + break; + } + auto &chr = chars[chr_index]; + chr.uuid = esp32_ble_tracker::ESPBTUUID::from_uuid(char_result.uuid); + chr.value_handle = char_result.char_handle; + chr.end_handle = char_result.char_handle; + chr.properties = char_result.properties; + chr.first_descriptor = dsc_index; + chr.descriptor_count = 0; + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (dsc_index < total_descs) { + uint16_t dc = 1; + auto dst = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, + &dc, desc_offset); + if (dst == ESP_GATT_INVALID_OFFSET || dst == ESP_GATT_NOT_FOUND || dst != ESP_GATT_OK || dc == 0) { + break; + } + descs[dsc_index].uuid = esp32_ble_tracker::ESPBTUUID::from_uuid(desc_result.uuid); + descs[dsc_index].handle = desc_result.handle; + dsc_index++; + chr.descriptor_count++; + desc_offset++; + } + chr_index++; + service.characteristic_count++; + char_offset++; + } + } + + this->table_.services = services; + this->table_.characteristics = chars; + this->table_.descriptors = descs; + this->table_.service_count = this->service_count_; + this->table_.characteristic_count = chr_index; + this->table_.descriptor_count = dsc_index; + return true; +} + +void BluedroidGattClient::handle_search_cmpl_() { + // Step down from the fast discovery params. + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + bool ok = this->materialize_table_(); + if (this->listener_ != nullptr) { + this->listener_->on_service_discovery_done(ok ? 0 : ble_device_base::GATT_ERR_NO_MEMORY); + } +} + +// ---- events ---- + +void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { + this->service_count_ = 0; + auto st = this->state_(); + if (st == ClientState::IDLE) { + // IDF can deliver OPEN_EVT after esp_ble_gattc_open already returned an + // error and the slot went IDLE; do not resurrect it. + ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, param->open.status); + return; + } + if (st != ClientState::CONNECTING) { + ESP_LOGE(TAG, "[%d] OPEN_EVT in unexpected state", this->connection_index_); + } + if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + this->log_gattc_warning_("Connection open", param->open.status); + // Never established, CLOSE_EVT may not follow. + this->set_idle_(); + this->report_connection_state_(false, param->open.status); + return; + } + if (this->shim_.disconnect_scheduled()) { + // Earliest point conn_id_ exists; keep it set so CLOSE_EVT still matches. + this->unconditional_disconnect_(); + return; + } + this->set_state_(ClientState::CONNECTED); + ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_); + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + this->set_state_(ClientState::ESTABLISHED); + // No discovery phase: report immediately; the MTU report below is + // suppressed by seen_mtu_ (HA tolerates a post-connect MTU of 23 here, + // matching the previous esp32 behavior). + this->seen_mtu_ = true; + this->report_connection_state_(true, 0); + } +} + +void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) { + if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state_() == ClientState::CONNECTED) { + ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_); + } else { + ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason); + } + if (this->state_() == ClientState::IDLE) { + // 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. + 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, + esp_ble_gattc_cb_param_t *param) { + if (event == ESP_GATTC_REG_EVT && this->shim_.app_id != param->reg.app_id) + return false; + if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_) + return false; + + switch (event) { + case ESP_GATTC_REG_EVT: { + if (param->reg.status == ESP_GATT_OK) { + this->gattc_if_ = esp_gattc_if; + } else { + ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status); + this->mark_failed(); + } + break; + } + case ESP_GATTC_CONNECT_EVT: { + if (!this->check_addr_(param->connect.remote_bda)) + return false; + this->conn_id_ = param->connect.conn_id; + // MTU request here rather than OPEN_EVT, matching the IDF examples. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret); + } + break; + } + case ESP_GATTC_OPEN_EVT: { + if (!this->check_addr_(param->open.remote_bda)) + return false; + this->handle_open_evt_(param); + break; + } + case ESP_GATTC_CFG_MTU_EVT: { + if (this->conn_id_ != param->cfg_mtu.conn_id) + return false; + if (param->cfg_mtu.status != ESP_GATT_OK) { + // Warn only; a disconnect will follow if the link is dead. + this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status); + } else { + this->mtu_ = param->cfg_mtu.mtu; + } + if (!this->seen_mtu_) { + this->seen_mtu_ = true; + // The connected report waited for the MTU so HA never sees 23. + this->report_connection_state_(true, 0); + } + break; + } + case ESP_GATTC_DISCONNECT_EVT: { + if (!this->check_addr_(param->disconnect.remote_bda)) + return false; + this->handle_disconnect_evt_(param); + break; + } + case ESP_GATTC_CLOSE_EVT: { + if (this->conn_id_ != param->close.conn_id) + 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). + this->report_connection_state_(false, param->close.reason); + break; + } + case ESP_GATTC_SEARCH_RES_EVT: { + if (this->conn_id_ != param->search_res.conn_id) + return false; + this->service_count_++; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + if (this->conn_id_ != param->search_cmpl.conn_id) + return false; + ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_); + this->set_state_(ClientState::ESTABLISHED); + this->handle_search_cmpl_(); + 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); + } + 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); + } + 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); + } + 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); + } + 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); + } + break; + } + default: + break; + } + return true; +} + +void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + // Always accept a server-initiated security request. + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true); + break; + } + 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); + } + break; + } + default: + break; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h new file mode 100644 index 0000000000..f109f2e23b --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -0,0 +1,137 @@ +// Bluedroid (esp32) GATT client backend: the esp32 arm of the +// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection +// wrapper. Not a BLEClientBase: the tracker's promote loop owns +// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only +// parks the address in DISCOVERED and the real esp_ble_gattc_open happens in +// the tracker-invoked shim connect(). The shim exists because the tracker's +// ESPBTClient::disconnect() returns void while the contract's returns int - +// one class cannot carry both. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) + +#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" + +#include +#include + +namespace esphome::bluetooth_connection { + +class BluetoothConnection; +class BluedroidGattClient; + +// The tracker-facing half: owns the ClientState the promote loop reads and +// forwards events/commands to the engine. +class BluedroidTrackerShim final : public esp32_ble_tracker::ESPBTClient { + public: + explicit BluedroidTrackerShim(BluedroidGattClient *engine) : engine_(engine) {} + bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void connect() override; + void disconnect() override; + bool wants_parsed_advertisements() override { return false; } + void on_scan_end() override {} + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } + + void schedule_disconnect() { this->want_disconnect_ = true; } + bool disconnect_scheduled() const { return this->want_disconnect_; } + + protected: + BluedroidGattClient *engine_; +}; + +class BluedroidGattClient final : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + + void set_listener(BluetoothConnection *listener) { this->listener_ = listener; } + esp32_ble_tracker::ESPBTClient *tracker_client() { return &this->shim_; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + 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); + ble_device_base::GattServiceTable get_service_table(); + void release_services(); + + void set_connection_type(esp32_ble_tracker::ConnectionType ct) { this->connection_type_ = ct; } + bool disconnect_pending() const { return this->shim_.disconnect_scheduled(); } + void cancel_pending_disconnect() { this->shim_.cancel_pending_disconnect(); } + + protected: + friend class BluedroidTrackerShim; + + esp32_ble_tracker::ClientState state_() const { return this->shim_.state(); } + void set_state_(esp32_ble_tracker::ClientState st) { this->shim_.set_state(st); } + bool check_addr_(const esp_bd_addr_t &addr) const; + void tracker_connect_(); + void tracker_disconnect_() { this->disconnect(); } + bool handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); + void handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); + void handle_open_evt_(esp_ble_gattc_cb_param_t *param); + void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param); + void handle_search_cmpl_(); + bool materialize_table_(); + void unconditional_disconnect_(); + void set_idle_(); + void set_disconnecting_(); + void report_connection_state_(bool connected, int error); + esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + 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); + + // Group 1: pointers / composed objects + BluedroidTrackerShim shim_{this}; + BluetoothConnection *listener_{nullptr}; + // One exact-size allocation holding services, then characteristics, then + // descriptors; freed by release_services(). + uint8_t *arena_{nullptr}; + ble_device_base::GattServiceTable table_{}; + + // Group 2: 8-byte types + uint64_t address_{0}; + + // Group 3: 4-byte types + int gattc_if_{ESP_GATT_IF_NONE}; + uint32_t disconnecting_started_{0}; + + // Group 4: arrays + esp_bd_addr_t remote_bda_{}; + + // Group 5: 2-byte types + uint16_t conn_id_; + uint16_t mtu_{23}; + + // Group 6: 1-byte types + esp_ble_addr_type_t remote_addr_type_{BLE_ADDR_TYPE_PUBLIC}; + esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE}; + uint8_t connection_index_; + uint8_t service_count_{0}; + // Set only when release_services() cleans the stack's GATT cache, which no + // walk may then touch (Bluedroid asserts rather than erroring). + bool services_released_{false}; + // The connected report waits for the MTU exchange; OPEN_EVT alone would + // hand HA the default 23. + bool seen_mtu_{false}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h index d8792b88c1..32ddf5e577 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -15,6 +15,9 @@ #if defined(USE_RP2040_BLE) #include "bluetooth_connection_rp2.h" #define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_ESP32) +#include "bluetooth_connection_bluedroid.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient #elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) // Emitted only by the host unit-test manifest: the tests compile the hub // wrapper standalone, so bind a do-nothing backend. Every other backend-less