diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h index 58f7d84fad..b0c91397fc 100644 --- a/esphome/components/ble_device_base/ble_client_state.h +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -18,6 +18,24 @@ namespace esphome::ble_device_base { static constexpr int GATT_ERR_NOT_CONNECTED = -1; static constexpr int GATT_ERR_NO_MEMORY = -2; +// Preferred connection parameters shared by every platform's GATT client so +// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency +// 0). FAST covers connection setup and service discovery; MEDIUM is the +// steady state once established. Stack defaults (12.5-15 ms) are too slow for +// stable connections through WiFi-based BLE proxies, causing disconnections; +// MEDIUM balances responsiveness with bandwidth usage. +static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms +static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms +// The timeout value was increased from 6s to 8s to address stability issues observed +// in certain BLE devices when operating through WiFi-based BLE proxies. The longer +// timeout reduces the likelihood of disconnections during periods of high latency. +static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s + +// Fastest connection parameters for devices with short discovery timeouts +static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) +static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms +static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s + enum class ClientState : uint8_t { // Connection is allocated INIT, diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 3870e9833e..b6fcf6f57a 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -58,7 +58,7 @@ struct HubCapabilities { bool merges_scan_response; /// GATT client connections are available: the platform has a /// bluetooth_connection backend implementing ble_device_base::BLEGattConnection - /// (ble_gatt_client.h). Today: esp32; rp2 follows with its BTstack backend. + /// (ble_gatt_client.h). Today: esp32 and rp2. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: /// a passive-only controller (bk72xx) can never switch, and a hub may diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index d4d0a3c3af..1dc1969a6a 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,14 +1,15 @@ """Per-platform GATT connection backends the Bluetooth proxy drives. -Auto-loaded by bluetooth_proxy, no user-facing configuration; the proxy's -codegen declares and registers the connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no +user-facing configuration; the proxy's codegen declares and registers the +connection instances. """ import functools import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform -from esphome.const import PlatformFramework +from esphome.const import PLATFORM_RP2, PlatformFramework from esphome.core import CORE @@ -24,9 +25,17 @@ 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). +# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1; +# raising this needs an upstream change (the layer itself supports N). +RP2_MAX_CONNECTIONS = 1 + +# Hub platforms with a GATT backend, mapped to their slot limit — the single +# registry of which hub platforms run the connection-capable proxy. +HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} + +# The hub-platform wrapper and the rp2 BTstack backend codegen classes. HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") +RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) @functools.cache @@ -53,5 +62,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RP2_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, } ) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 338bf671a8..37d6b21dfe 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -117,8 +117,18 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (connected) { this->mtu_ = mtu; if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { - // The API client has the services cached; never discover them. + // The API client has the services cached; never discover them. No + // discovery phase needs the fast interval, so settle straight into the + // shared steady-state parameters (same lifecycle place as esp32). this->state_ = ClientState::ESTABLISHED; + int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, + ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, + ble_device_base::MEDIUM_CONN_TIMEOUT); + if (param_err != 0) { + // Survivable: the link just stays on the fast interval. + ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_, + param_err); + } this->proxy_->send_device_connection(this->address_, true, mtu); this->proxy_->send_connections_free(); return; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp new file mode 100644 index 0000000000..ecd7a9713a --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -0,0 +1,970 @@ +#include "bluetooth_connection_rp2.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.rp2"; + +using ble_device_base::ESPBTUUID; +using ble_device_base::GATT_ERR_NOT_CONNECTED; +using ble_device_base::GATT_ERR_NO_MEMORY; + +// Engine-owned timeouts: BTstack has a 30 s ATT transaction timeout but no +// connect timeout — a stuck LE_CONNECTING both blocks future gap_connect calls +// and keeps the scan inhibited, so the engine cancels after 20 s. The +// disconnect timeout mirrors the esp32 CLOSE_EVT safety net. +static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; +static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; + +// HCI "connection timeout" reason, reported when a teardown had to be forced. +static constexpr uint8_t HCI_REASON_CONNECTION_TIMEOUT = 0x08; + +// Initiating-scan parameters and connection-event lengths for outgoing +// connections (BTstack-specific knobs; the connection intervals themselves are +// the shared FAST/MEDIUM parameters from ble_device_base/ble_client_state.h, +// used in the same lifecycle places as esp32: FAST for connect and service +// discovery, MEDIUM once established). +static constexpr uint16_t CONN_SCAN_INTERVAL = 96; // 60 ms in 0.625 ms units +static constexpr uint16_t CONN_SCAN_WINDOW = 48; // 30 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MIN = 16; // 10 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MAX = 48; // 30 ms in 0.625 ms units + +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; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; +uint8_t RP2GattClient::instance_count = 0; +btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { + if (uuid16 != 0) { + return ESPBTUUID::from_uint16(uuid16); + } + // BTstack structs carry the 128-bit form big-endian (printable order). + return ESPBTUUID::from_raw_reversed(uuid128); +} + +void RP2GattClient::setup() { + // Pre-create every pool entry so the packet handlers' allocate() calls are + // always a free-list pop -- the IRQ path must never reach malloc(). + if (!this->event_pool_.warm() || !this->notify_pool_.warm()) { + ESP_LOGE(TAG, "GATT event pool warm-up failed"); + this->mark_failed(); + return; + } + + // Register this engine for IRQ-context event routing. + if (instance_count >= ESPHOME_BLE_GATT_CLIENT_COUNT) { + // Cannot happen with codegen-sized storage; refuse loudly if it ever does. + ESP_LOGE(TAG, "GATT client registry full"); + this->mark_failed(); + return; + } + { + // One locked section: the slot store lands before the count bump, and a + // live HCI handler (N > 1 builds) cannot read a half-written registry. + BluetoothLock lock; + instances[instance_count] = this; + instance_count++; + // One HCI event handler for all engine instances (BTstack supports + // multiple registrations, so rp2040_ble's own handler is unaffected). + if (hci_event_registration.callback == nullptr) { + hci_event_registration.callback = &RP2GattClient::hci_packet_handler; + hci_add_event_handler(&hci_event_registration); + } + } + + this->disable_loop(); +} + +float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } + +void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); } + +// ---- IRQ-context handlers: copy-and-enqueue only ---- + +RP2GattClient *RP2GattClient::instance_for_con_handle(hci_con_handle_t con_handle) { + for (uint8_t i = 0; i < instance_count; i++) { + if (instances[i]->con_handle_ == con_handle) { + return instances[i]; + } + } + return nullptr; +} + +void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + switch (event_type) { + case HCI_EVENT_META_GAP: { + if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) { + break; + } + bd_addr_t peer; + gap_subevent_le_connection_complete_get_peer_address(packet, peer); + uint8_t status = gap_subevent_le_connection_complete_get_status(packet); + hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet); + // Route to the engine that is waiting for this peer. + for (uint8_t i = 0; i < instance_count; i++) { + RP2GattClient *inst = instances[i]; + if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) { + inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); + break; + } + } + break; + } + case HCI_EVENT_DISCONNECTION_COMPLETE: { + hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet); + RP2GattClient *inst = instance_for_con_handle(con_handle); + if (inst == nullptr && instance_count == 1) { + // The main loop may not have recorded the handle yet (the CONNECTED + // event is still queued); with a single engine the connecting + // instance is unambiguous, so route there to close the + // accept-then-drop window. With multiple engines the event has no + // address to match on, so it must be dropped instead of guessed. + RP2GattClient *candidate = instances[0]; + if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) { + inst = candidate; + } + } + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0); + } + break; + } + default: + break; + } +} + +void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + // Every GATT event carries the connection handle in the same position via + // its accessor; route on it. + hci_con_handle_t con_handle; + switch (event_type) { + case GATT_EVENT_MTU: + con_handle = gatt_event_mtu_get_handle(packet); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: + con_handle = gatt_event_service_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: + con_handle = gatt_event_characteristic_query_result_get_handle(packet); + break; + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: + con_handle = gatt_event_all_characteristic_descriptors_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: + con_handle = gatt_event_characteristic_value_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + con_handle = gatt_event_characteristic_descriptor_query_result_get_handle(packet); + break; + case GATT_EVENT_NOTIFICATION: + con_handle = gatt_event_notification_get_handle(packet); + break; + case GATT_EVENT_INDICATION: + con_handle = gatt_event_indication_get_handle(packet); + break; + case GATT_EVENT_QUERY_COMPLETE: + con_handle = gatt_event_query_complete_get_handle(packet); + break; + default: + return; + } + RP2GattClient *inst = instance_for_con_handle(con_handle); + if (inst != nullptr) { + inst->handle_gatt_event_irq_(event_type, packet); + } +} + +void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet) { + switch (event_type) { + case GATT_EVENT_MTU: + this->enqueue_event_irq_(RP2GattEvent::MTU_EXCHANGED, 0, gatt_event_mtu_get_MTU(packet)); + break; + case GATT_EVENT_QUERY_COMPLETE: + this->enqueue_event_irq_(RP2GattEvent::QUERY_COMPLETE, gatt_event_query_complete_get_att_status(packet), 0); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->service_count_ >= RP2_GATT_MAX_SERVICES) { + this->truncated_ = true; + break; + } + gatt_client_service_t service; + gatt_event_service_query_result_get_service(packet, &service); + auto &dst = this->arena_->services[this->service_count_]; + dst.uuid = uuid_from_btstack(service.uuid16, service.uuid128); + dst.start_handle = service.start_group_handle; + dst.end_handle = service.end_group_handle; + dst.first_characteristic = 0; + dst.characteristic_count = 0; + this->service_count_++; + break; + } + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->char_count_ >= RP2_GATT_MAX_CHARACTERISTICS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_t characteristic; + gatt_event_characteristic_query_result_get_characteristic(packet, &characteristic); + auto &dst = this->arena_->characteristics[this->char_count_]; + dst.uuid = uuid_from_btstack(characteristic.uuid16, characteristic.uuid128); + dst.value_handle = characteristic.value_handle; + dst.end_handle = characteristic.end_handle; + dst.properties = static_cast(characteristic.properties); + dst.first_descriptor = 0; + dst.descriptor_count = 0; + this->char_count_++; + break; + } + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->desc_count_ >= RP2_GATT_MAX_DESCRIPTORS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_descriptor_t descriptor; + gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor); + auto &dst = this->arena_->descriptors[this->desc_count_]; + dst.uuid = uuid_from_btstack(descriptor.uuid16, descriptor.uuid128); + dst.handle = descriptor.handle; + this->desc_count_++; + break; + } + case GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT: { + uint16_t len = gatt_event_characteristic_value_query_result_get_value_length(packet); + if (len > RP2_GATT_MAX_ATTR_LEN) { + len = RP2_GATT_MAX_ATTR_LEN; + } + memcpy(this->op_buffer_, gatt_event_characteristic_value_query_result_get_value(packet), len); + this->op_len_ = len; + break; + } + case GATT_EVENT_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: { + uint16_t len = gatt_event_characteristic_descriptor_query_result_get_descriptor_length(packet); + if (len > RP2_GATT_MAX_ATTR_LEN) { + len = RP2_GATT_MAX_ATTR_LEN; + } + memcpy(this->op_buffer_, gatt_event_characteristic_descriptor_query_result_get_descriptor(packet), len); + this->op_len_ = len; + break; + } + case GATT_EVENT_NOTIFICATION: + this->enqueue_notify_irq_(gatt_event_notification_get_value_handle(packet), + gatt_event_notification_get_value(packet), + gatt_event_notification_get_value_length(packet)); + break; + case GATT_EVENT_INDICATION: + // BTstack auto-confirms indications; deliver like a notification. + this->enqueue_notify_irq_(gatt_event_indication_get_value_handle(packet), gatt_event_indication_get_value(packet), + gatt_event_indication_get_value_length(packet)); + break; + default: + break; + } +} + +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2GattClient::enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value) { + RP2GattEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + this->event_queue_.increment_dropped_count(); + return; + } + event->type = type; + event->status = status; + event->value = value; + this->event_queue_.push(event); +} + +void RP2GattClient::enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len) { + RP2GattNotifyEvent *event = this->notify_pool_.allocate(); + if (event == nullptr) { + this->notify_queue_.increment_dropped_count(); + return; + } + event->handle = handle; + event->len = len > RP2_GATT_MAX_ATTR_LEN ? RP2_GATT_MAX_ATTR_LEN : len; + memcpy(event->data, data, event->len); + this->notify_queue_.push(event); +} +// NOLINTEND(clang-analyzer-unix.Malloc) + +// ---- Main-loop state machine ---- + +void RP2GattClient::loop() { + RP2GattEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + RP2GattEvent copy = *event; + this->event_pool_.release(event); + this->handle_event_(copy); + } + + RP2GattNotifyEvent *notify; + while ((notify = this->notify_queue_.pop()) != nullptr) { + if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { + this->listener_->on_notify_data(notify->handle, notify->data, notify->len); + } + this->notify_pool_.release(notify); + } + + uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + // Control events must not be lost; the connection state is no longer + // trustworthy — recover with a forced teardown. + ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); + this->disconnect(); + } + uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); + if (notify_dropped > 0) { + ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped); + } + + if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + uint32_t now = millis(); + if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connect timeout"); + if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) { + if (!this->connect_cancel_attempted_) { + this->connect_cancel_attempted_ = true; + BluetoothLock lock; + gap_connect_cancel(); + // The cancel produces a connection-complete event with a failure + // status, which drives the normal failure path; restart the timer + // so a lost event escalates below instead of wedging here. + this->connect_started_ = now; + } else { + // The cancel's completion never arrived: reclaim the slot and the + // scan rather than cancelling forever. + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } + } else { + // The link is up (MTU exchange stalled): tear it down properly so the + // controller frees its side; the DISCONNECTING safety net below + // reclaims state if the disconnection event is lost. Dropping engine + // state without gap_disconnect would leak the live link and the + // single GATT slot for the rest of the boot. + this->disconnect(); + } + } + } else if (this->state_ == EngineState::DISCONNECTING) { + if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + } else if (this->state_ == EngineState::IDLE) { + this->disable_loop(); + } +} + +void RP2GattClient::handle_event_(const RP2GattEvent &event) { + switch (event.type) { + case RP2GattEvent::CONNECTED: + this->handle_connected_(event.status, event.value); + break; + case RP2GattEvent::DISCONNECTED: + this->handle_disconnected_(event.status); + break; + case RP2GattEvent::MTU_EXCHANGED: + if (this->state_ == EngineState::MTU_EXCHANGE) { + this->mtu_ = event.value; + ESP_LOGD(TAG, "MTU %u", this->mtu_); + this->state_ = EngineState::READY; + // Scanning resumes and runs alongside the established connection. + this->release_scan_inhibit_(); + if (this->listener_ != nullptr) { + this->listener_->on_connection_state(true, this->mtu_, 0); + } + } + break; + case RP2GattEvent::QUERY_COMPLETE: + this->handle_query_complete_(event.status); + break; + } +} + +void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { + if (this->state_ != EngineState::CONNECTING) { + return; + } + if (status != 0) { + ESP_LOGW(TAG, "Connect failed, status=0x%02x", status); + this->fail_connection_(status); + return; + } + if (this->cancel_requested_) { + // A disconnect request raced the connection complete and lost; finish + // the teardown instead of reporting a connection nobody wants. + this->con_handle_ = con_handle; + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + uint8_t disc_status; + { + BluetoothLock lock; + disc_status = gap_disconnect(this->con_handle_); + } + if (disc_status != 0) { + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + return; + } + this->con_handle_ = con_handle; + this->state_ = EngineState::MTU_EXCHANGE; + ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle); + BluetoothLock lock; + // One wildcard listener covers notifications/indications for every + // characteristic on this connection; the CCCD writes come from the API + // client as plain descriptor writes. + gatt_client_listen_for_characteristic_value_updates(&this->notification_registration_, + &RP2GattClient::gatt_packet_handler, this->con_handle_, nullptr); + // Auto MTU negotiation is disabled (see rp2040_ble enable hooks), so the + // exchange is kicked explicitly; GATT_EVENT_MTU completes it. Without the + // explicit kick the MTU would only be exchanged on the first GATT query, + // which never happens on a V3_WITH_CACHE connection. + // Both registration calls above return void (BTstack 075a078, arduino-pico + // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // the connect timeout in loop(). + gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); +} + +void RP2GattClient::release_scan_inhibit_() { + if (this->holds_scan_inhibit_) { + this->holds_scan_inhibit_ = false; + this->parent_->release_scan_inhibit(); + } +} + +void RP2GattClient::fail_connection_(uint8_t reason) { + this->cleanup_link_state_(); + this->release_scan_inhibit_(); + this->state_ = EngineState::IDLE; + if (this->listener_ != nullptr) { + this->listener_->on_connection_state(false, 0, reason); + } +} + +void RP2GattClient::cleanup_link_state_() { + // Drop notifications queued behind the disconnect so they cannot emit + // against a freed slot (address 0) on the next loop. + RP2GattNotifyEvent *stale; + while ((stale = this->notify_queue_.pop()) != nullptr) { + this->notify_pool_.release(stale); + } + // The wildcard listener is registered on the normal connect path right + // after con_handle_ is recorded; the cancel branch tears down before + // registering, where stop_listening on an unregistered entry is a no-op. + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + BluetoothLock lock; + gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + } + this->con_handle_ = HCI_CON_HANDLE_INVALID; + this->notify_subscription_count_ = 0; + this->cancel_requested_ = false; + this->op_type_ = OpType::NONE; + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); +} + +void RP2GattClient::handle_disconnected_(uint8_t reason) { + if (this->state_ == EngineState::IDLE) { + return; + } + ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason); + this->fail_connection_(reason); +} + +void RP2GattClient::handle_query_complete_(uint8_t att_status) { + // Stale completions cannot cross connections: the loop drains the whole + // event queue every iteration, teardown resets op/discovery state, and a + // new discovery is only issued after the new link's MTU event — which in + // this BTstack emits no QUERY_COMPLETE (the MTU state machine is separate + // from the query state machine). Completions with nothing in flight are + // dropped below. + if (this->op_type_ != OpType::NONE) { + OpType op = this->op_type_; + this->op_type_ = OpType::NONE; + if (this->listener_ == nullptr) { + return; + } + switch (op) { + case OpType::READ_CHAR: + case OpType::READ_DESC: + this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, + att_status); + break; + case OpType::WRITE_CHAR: + case OpType::WRITE_DESC: + this->listener_->on_write_result(this->op_handle_, att_status); + break; + default: + break; + } + return; + } + if (this->discovery_phase_ != DiscoveryPhase::NONE) { + this->advance_discovery_(att_status); + } +} + +// ---- Service discovery ---- + +int RP2GattClient::discover_services() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (this->arena_ == nullptr) { + // Transient: freed in release_services() right after the table streams + // to the API client (mirrors Bluedroid's own per-connection GATT DB + // lifetime on esp32). Checked: a fragmented heap must surface as a + // stack error the proxy can report, not a device reset. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_ = allocator.allocate(1); + if (this->arena_ == nullptr) { + ESP_LOGE(TAG, "Service table allocation failed"); + return ble_device_base::GATT_ERR_NO_MEMORY; + } + new (this->arena_) ServiceArena(); + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; + this->discovery_phase_ = DiscoveryPhase::SERVICES; + BluetoothLock lock; + uint8_t status = gatt_client_discover_primary_services(&RP2GattClient::gatt_packet_handler, this->con_handle_); + if (status != 0) { + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); + return status; + } + return 0; +} + +int RP2GattClient::issue_characteristic_query_(uint16_t service_index) { + auto &service = this->arena_->services[service_index]; + gatt_client_service_t btstack_service = {}; + btstack_service.start_group_handle = service.start_handle; + btstack_service.end_group_handle = service.end_handle; + service.first_characteristic = this->char_count_; + BluetoothLock lock; + return gatt_client_discover_characteristics_for_service(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_service); +} + +int RP2GattClient::issue_descriptor_query_(uint16_t char_index) { + auto &chr = this->arena_->characteristics[char_index]; + gatt_client_characteristic_t btstack_characteristic = {}; + btstack_characteristic.value_handle = chr.value_handle; + btstack_characteristic.end_handle = chr.end_handle; + chr.first_descriptor = this->desc_count_; + BluetoothLock lock; + return gatt_client_discover_characteristic_descriptors(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_characteristic); +} + +void RP2GattClient::advance_discovery_(uint8_t att_status) { + if (this->arena_ == nullptr) { + // release_services() is publicly callable; a table freed mid-discovery + // must end the discovery instead of dereferencing a null arena. + this->finish_discovery_(GATT_ERR_NOT_CONNECTED); + return; + } + if (att_status != 0) { + this->finish_discovery_(att_status); + return; + } + switch (this->discovery_phase_) { + case DiscoveryPhase::SERVICES: + if (this->service_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::CHARACTERISTICS; + this->disc_service_cursor_ = 0; + if (int err = this->issue_characteristic_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + case DiscoveryPhase::CHARACTERISTICS: { + auto &service = this->arena_->services[this->disc_service_cursor_]; + service.characteristic_count = this->char_count_ - service.first_characteristic; + this->disc_service_cursor_++; + if (this->disc_service_cursor_ < this->service_count_) { + if (int err = this->issue_characteristic_query_(this->disc_service_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + if (this->char_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::DESCRIPTORS; + this->disc_char_cursor_ = 0; + if (int err = this->issue_descriptor_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + } + case DiscoveryPhase::DESCRIPTORS: { + auto &chr = this->arena_->characteristics[this->disc_char_cursor_]; + chr.descriptor_count = this->desc_count_ - chr.first_descriptor; + this->disc_char_cursor_++; + if (this->disc_char_cursor_ < this->char_count_) { + if (int err = this->issue_descriptor_query_(this->disc_char_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + this->finish_discovery_(0); + break; + } + default: + break; + } +} + +void RP2GattClient::finish_discovery_(int error) { + this->discovery_phase_ = DiscoveryPhase::NONE; + ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_, + this->char_count_, this->desc_count_); + if (error == 0 && this->truncated_) { + // A partial table must not stream: V3 clients cache the database + // permanently, so an incomplete one would be wrong forever. + error = ATT_ERROR_INSUFFICIENT_RESOURCES; + } + if (error == 0) { + // Discovery no longer needs the fast interval; settle into the shared + // steady-state parameters (same lifecycle place as esp32). Status + // discarded: BTstack fails this only for an already-gone handle. + BluetoothLock lock; + gap_update_connection_parameters(this->con_handle_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, + MEDIUM_CONN_TIMEOUT); + } + if (this->truncated_) { + ESP_LOGE(TAG, "Service table truncated (device exceeds %u services / %u characteristics / %u descriptors)", + RP2_GATT_MAX_SERVICES, RP2_GATT_MAX_CHARACTERISTICS, RP2_GATT_MAX_DESCRIPTORS); + } + if (error != 0) { + this->release_services(); + } + if (this->listener_ != nullptr) { + this->listener_->on_service_discovery_done(error); + } +} + +ble_device_base::GattServiceTable RP2GattClient::get_service_table() { + ble_device_base::GattServiceTable table; + if (this->arena_ != nullptr) { + table.services = this->arena_->services; + table.characteristics = this->arena_->characteristics; + table.descriptors = this->arena_->descriptors; + table.service_count = this->service_count_; + table.characteristic_count = this->char_count_; + table.descriptor_count = this->desc_count_; + } + return table; +} + +void RP2GattClient::release_services() { + if (this->arena_ != nullptr) { + // Under BluetoothLock so a discovery result landing in the BTstack + // context cannot write into the arena mid-free. + BluetoothLock lock; + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_->~ServiceArena(); + allocator.deallocate(this->arena_, 1); + this->arena_ = nullptr; + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; +} + +// ---- Connection control ---- + +int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->is_failed()) { + // setup() failed: nothing is registered for event routing and loop() + // never runs, so a connect could not complete or time out. + return GATT_ERR_NOT_CONNECTED; + } + if (this->state_ != EngineState::IDLE) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (!this->parent_->is_active()) { + return GATT_ERR_NOT_CONNECTED; + } + ble_device_base::uint64_to_mac_msb_first(address, this->peer_addr_); + // BLE_ADDR_TYPE_* code space: bit 0 distinguishes public from random + // (resolved RPA types 2/3 connect with the underlying kind). + this->peer_addr_type_ = (addr_type & 1) != 0 ? BD_ADDR_TYPE_LE_RANDOM : BD_ADDR_TYPE_LE_PUBLIC; + + // Stop the shared radio's scan for the duration of the connect attempt + // (esp32 parity: initiating and scanning contend for the radio). + this->holds_scan_inhibit_ = true; + this->parent_->inhibit_scan(); + this->connect_cancel_attempted_ = false; + this->cancel_requested_ = false; + uint8_t status; + { + BluetoothLock lock; + gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, + 0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); + status = gap_connect(this->peer_addr_, this->peer_addr_type_); + } + if (status != 0) { + ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status); + this->release_scan_inhibit_(); + return status; + } + this->state_ = EngineState::CONNECTING; + this->connect_started_ = millis(); + this->enable_loop(); + return 0; +} + +int RP2GattClient::disconnect() { + switch (this->state_) { + case EngineState::IDLE: + return GATT_ERR_NOT_CONNECTED; + case EngineState::DISCONNECTING: + return 0; // already on its way down + case EngineState::CONNECTING: { + if (this->con_handle_ == HCI_CON_HANDLE_INVALID) { + // The cancel can lose the race against a successful connection + // complete; handle_connected_ checks this flag and finishes the + // teardown instead of proceeding. It also counts as the one cancel + // attempt, so a lost completion escalates on the next timeout tick. + this->cancel_requested_ = true; + this->connect_cancel_attempted_ = true; + BluetoothLock lock; + gap_connect_cancel(); + // Completion arrives as a failed connection-complete event. + return 0; + } + break; + } + default: + break; + } + uint8_t status; + { + BluetoothLock lock; + status = gap_disconnect(this->con_handle_); + } + if (status != 0) { + // Refused (handle already gone): complete via the event queue so the + // listener cannot re-enter disconnect() mid-call. BluetoothLock stops + // the IRQ producer, so this main-loop push is SPSC-safe. + ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status); + { + BluetoothLock lock; + this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); + } + } + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + this->enable_loop(); + return 0; +} + +// ---- GATT operations (single outstanding op) ---- + +int RP2GattClient::read_characteristic(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_CHAR; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + uint8_t status = gatt_client_read_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, + this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + if (!response) { + // Synchronous in BTstack: the data is copied into the L2CAP buffer before + // the call returns, and no completion event exists — synthesize one so + // the wire behavior matches esp32 (which reports write-no-response too). + uint8_t status; + { + BluetoothLock lock; + status = gatt_client_write_value_of_characteristic_without_response(this->con_handle_, handle, len, + const_cast(data)); + } + if (status == 0 && this->listener_ != nullptr) { + this->listener_->on_write_result(handle, 0); + } + return status; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + // BTstack keeps the caller's pointer until the request is sent; the payload + // must live in engine-owned storage across the async operation. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status; + if (len <= this->mtu_ - 3) { + status = gatt_client_write_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, handle, + len, this->op_buffer_); + } else { + status = gatt_client_write_long_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, + handle, len, this->op_buffer_); + } + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::read_descriptor(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_DESC; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + uint8_t status = gatt_client_read_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_DESC; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status = gatt_client_write_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle, len, this->op_buffer_); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + // The CCCD write arrives separately as a descriptor write (V3 semantics); + // this call only gates local delivery via the subscription list. + if (enable) { + if (!this->notify_subscribed_(handle)) { + if (this->notify_subscription_count_ >= RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS) { + return GATT_ERR_NO_MEMORY; + } + this->notify_subscriptions_[this->notify_subscription_count_++] = handle; + } + } else { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + this->notify_subscriptions_[i] = this->notify_subscriptions_[--this->notify_subscription_count_]; + break; + } + } + } + if (this->listener_ != nullptr) { + this->listener_->on_notify_state(handle, enable, 0); + } + return 0; +} + +bool RP2GattClient::notify_subscribed_(uint16_t handle) const { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + return true; + } + } + return false; +} + +int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h new file mode 100644 index 0000000000..145508bdf6 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -0,0 +1,206 @@ +// RP2 (Pico W / Pico 2 W) GATT client backend over BTstack. +// +// Implements ble_device_base::BLEGattConnection for the hub BluetoothConnection +// wrapper. BTstack packet handlers run in the CYW43 async-context low-priority +// IRQ (or on the main-loop stack during BluetoothLock release), so handlers +// only copy into per-instance lock-free queues/storage; loop() drains them and +// drives the state machine. Every BTstack call issued from the main loop is +// wrapped in BluetoothLock. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/rp2040_ble/rp2040_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +// Caps for the transient service table. Sized generously for real devices +// (typical peripherals expose < 8 services / < 30 characteristics); a peer +// exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than +// streaming an incomplete database a V3 client would cache permanently. +static constexpr uint16_t RP2_GATT_MAX_SERVICES = 16; +static constexpr uint16_t RP2_GATT_MAX_CHARACTERISTICS = 96; +static constexpr uint16_t RP2_GATT_MAX_DESCRIPTORS = 96; + +// Concurrent notify subscriptions per connection (enable fails with +// GATT_ERR_NO_MEMORY when exceeded; real clients subscribe to a handful). +static constexpr uint8_t RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS = 16; + +// ATT spec maximum attribute value length; bounds the op buffer and +// notification payloads. +static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512; + +// Control events from the BTstack handlers to loop(). +struct RP2GattEvent { + enum Type : uint8_t { + CONNECTED, // status + con_handle (value) + DISCONNECTED, // status = HCI reason + MTU_EXCHANGED, // value = negotiated MTU + QUERY_COMPLETE, // status = ATT status of the finished query + }; + Type type; + uint8_t status; + uint16_t value; + void release() {} +}; + +// One notification/indication from the peer. +struct RP2GattNotifyEvent { + uint16_t handle; + uint16_t len; + uint8_t data[RP2_GATT_MAX_ATTR_LEN]; + void release() {} +}; + +static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; +// Depth 4: the queue is drained every main-loop iteration and each slot is a +// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. +static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; + +class RP2GattClient final : public Component, + public ble_device_base::BLEGattConnection, + public Parented { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + // ---- ble_device_base::BLEGattConnection ---- + int connect(uint64_t address, uint8_t addr_type) override; + int disconnect() override; + int discover_services() override; + int read_characteristic(uint16_t handle) override; + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override; + int read_descriptor(uint16_t handle) override; + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override; + int notify_characteristic(uint16_t handle, bool enable) override; + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) override; + ble_device_base::GattServiceTable get_service_table() override; + void release_services() override; + + protected: + // Link/engine state. Discovery and GATT ops have their own cursors below — + // the link stays READY while they run. + enum class EngineState : uint8_t { + IDLE, + CONNECTING, // gap_connect issued, waiting for connection complete + MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU + READY, // on_connection_state(true) delivered + DISCONNECTING, + }; + + enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS }; + + enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, READ_DESC, WRITE_DESC }; + + // The whole table in one transient allocation (RAMAllocator, checked), + // freed after streaming. + struct ServiceArena { + ble_device_base::GattService services[RP2_GATT_MAX_SERVICES]; + ble_device_base::GattCharacteristic characteristics[RP2_GATT_MAX_CHARACTERISTICS]; + ble_device_base::GattDescriptor descriptors[RP2_GATT_MAX_DESCRIPTORS]; + }; + + // BTstack packet handlers (IRQ context: copy-and-enqueue only). + static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle); + + void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); + void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value); + void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len); + + // Main-loop state machine. + void handle_event_(const RP2GattEvent &event); + void handle_connected_(uint8_t status, uint16_t con_handle); + void handle_disconnected_(uint8_t reason); + void handle_query_complete_(uint8_t att_status); + void advance_discovery_(uint8_t att_status); + int issue_characteristic_query_(uint16_t service_index); + int issue_descriptor_query_(uint16_t char_index); + void finish_discovery_(int error); + void fail_connection_(uint8_t reason); + void cleanup_link_state_(); + bool notify_subscribed_(uint16_t handle) const; + void release_scan_inhibit_(); + bool op_in_flight_() const { + return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE; + } + + // Group 1: containers / large storage + ServiceArena *arena_{nullptr}; + esphome::LockFreeQueue event_queue_; + esphome::EventPool event_pool_; + esphome::LockFreeQueue notify_queue_; + esphome::EventPool notify_pool_; + + // Shared buffer for the single outstanding GATT op: write payloads (BTstack + // keeps the caller's pointer until the request is sent) and read results + // (written from the handler, read after QUERY_COMPLETE is drained). + uint8_t op_buffer_[RP2_GATT_MAX_ATTR_LEN]; + + // BTstack registrations + gatt_client_notification_t notification_registration_{}; + + // Group 3: 4-byte types + uint32_t connect_started_{0}; + uint32_t disconnecting_started_{0}; + + // Group 4: 2-byte types (table counters written from the handler during + // discovery, read from the main loop after the phase's QUERY_COMPLETE) + hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID}; + uint16_t mtu_{23}; + uint16_t op_handle_{0}; + uint16_t op_len_{0}; + uint16_t service_count_{0}; + uint16_t char_count_{0}; + uint16_t desc_count_{0}; + uint16_t disc_service_cursor_{0}; + uint16_t disc_char_cursor_{0}; + + // Group 5: arrays / 1-byte types + // Subscribed notify handles; the loop() drain filters the wildcard + // listener's deliveries on this list (esp32 parity for enable=false). + std::array notify_subscriptions_{}; + uint8_t notify_subscription_count_{0}; + bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects + bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC}; + EngineState state_{EngineState::IDLE}; + DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE}; + OpType op_type_{OpType::NONE}; + bool truncated_{false}; + // One cancel attempt per connect: the second timeout escalates to failure. + bool connect_cancel_attempted_{false}; + // A disconnect request raced an in-flight connect; finish teardown on link-up. + bool cancel_requested_{false}; + // This engine's own hold on the shared scan inhibit, so the pairing stays + // one-to-one per connection even with multiple slots. + bool holds_scan_inhibit_{false}; + + // Instance registry for routing BTstack events (IRQ context) to engines. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static RP2GattClient *instances[ESPHOME_BLE_GATT_CLIENT_COUNT]; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static uint8_t instance_count; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t hci_event_registration; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index a0706ae4ae..ed6dbfe557 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -48,7 +48,7 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # 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. +# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index bd80f71a49..e6cdde9cda 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -13,20 +13,14 @@ namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; -// Intermediate connection parameters for standard operation -// ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, -// causing disconnections. These medium parameters balance responsiveness with bandwidth usage. -static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms -static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms -// The timeout value was increased from 6s to 8s to address stability issues observed -// in certain BLE devices when operating through WiFi-based BLE proxies. The longer -// timeout reduces the likelihood of disconnections during periods of high latency. -static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s - -// Fastest connection parameters for devices with short discovery timeouts -static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) -static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms -static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s +// Connection parameters are shared with the other GATT client backends +// (ble_device_base/ble_client_state.h) so the platforms cannot drift. +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; static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 70ececb528..8106763489 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -63,8 +63,14 @@ class RP2BLETracker : public Component, // BTstack delivers scan responses as separate advertisement reports rather // than merging them into the advertisement — consumers relying on // scan-response fields (device names) get them only where the receiver - // merges per address (Home Assistant does). No GATT path yet. - return {.active_scan = true, .merges_scan_response = false, .gatt = false, .scan_mode_switch = true}; + // merges per address (Home Assistant does). GATT is available when the + // BTstack connection backend is compiled in (bluetooth_proxy active). +#ifdef USE_BLE_GATT_CLIENT + constexpr bool has_gatt = true; +#else + constexpr bool has_gatt = false; +#endif + return {.active_scan = true, .merges_scan_response = false, .gatt = has_gatt, .scan_mode_switch = true}; } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants.