mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 03:16:37 +00:00
[bluetooth_connection] Add BTstack GATT client backend for rp2 (#18131)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user