[bluetooth_connection] Add BTstack GATT client backend for rp2 (#18131)

This commit is contained in:
J. Nick Koston
2026-08-07 12:53:09 -05:00
committed by GitHub
parent 77bfda6f1f
commit d7b5ad77da
9 changed files with 1238 additions and 24 deletions
@@ -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,
+1 -1
View File
@@ -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
@@ -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},
}
)
@@ -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;
@@ -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 <BluetoothLock.h>
#include <cstring>
#include <new>
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<uint8_t>(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<ServiceArena> allocator(RAMAllocator<ServiceArena>::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<ServiceArena> allocator(RAMAllocator<ServiceArena>::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<uint8_t *>(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
@@ -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 <btstack.h>
#include <array>
#include <cstdint>
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<rp2040_ble::RP2040BLE> {
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<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
esphome::LockFreeQueue<RP2GattNotifyEvent, RP2_GATT_NOTIFY_QUEUE_SIZE> notify_queue_;
esphome::EventPool<RP2GattNotifyEvent, RP2_GATT_NOTIFY_QUEUE_SIZE - 1> 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<uint16_t, RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS> 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
@@ -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"]
@@ -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,
@@ -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.