Merge branch 'esp32-gatt-backend' into esp32-proxy-flip

# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
This commit is contained in:
J. Nick Koston
2026-08-09 00:20:09 -05:00
9 changed files with 474 additions and 113 deletions
@@ -5,8 +5,12 @@
// Exactly one GATT backend exists per build, so BLEGattConnection is a
// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract
// interface.
// The hub BluetoothConnection wrapper drives it and receives completions
// through its event-sink methods, which the backend calls directly. All sink
// A consumer - a streaming consumer that forwards the raw database (the hub
// BluetoothConnection wrapper) or a direct consumer owning a dedicated
// backend and resolving handles by UUID - drives it and receives
// completions through a GattEventSink — a pointer-sized-entry function table
// rather than a concrete consumer type, because one build can hold several
// consumer types while the backend stays a single non-virtual class. All sink
// calls are delivered on the ESPHome main loop; borrowed data pointers are
// valid only for the duration of the call.
//
@@ -78,6 +82,87 @@ struct GattServiceTable {
uint16_t descriptor_count{0};
};
// The event sink the backend calls directly, asserted where each consumer is
// defined: on_connection_state carries the negotiated MTU and an HCI
// status/disconnect reason. The requirements check call validity, not exact
// parameter types; keep sink parameters at the documented widths (uint16_t
// handles and lengths).
template<typename S>
concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) {
{ sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_service_discovery_done(int{}) } -> std::same_as<void>;
{ sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_write_result(uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as<void>;
{ sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as<void>;
{ sink.on_pairing_result(int{}) } -> std::same_as<void>;
};
/// One trampoline per event, shared by every instance of a consumer type.
struct GattEventVTable {
void (*connection_state)(void *, bool, uint16_t, int);
void (*service_discovery_done)(void *, int);
void (*read_result)(void *, uint16_t, const uint8_t *, uint16_t, int);
void (*write_result)(void *, uint16_t, int);
void (*notify_state)(void *, uint16_t, bool, int);
void (*notify_data)(void *, uint16_t, const uint8_t *, uint16_t);
void (*pairing_result)(void *, int);
};
// The per-consumer-type table lives in flash (constexpr), so a sink costs
// two pointers of RAM regardless of how many events the surface carries.
template<typename T>
inline constexpr GattEventVTable GATT_EVENT_VTABLE{
[](void *p, bool connected, uint16_t mtu, int error) {
static_cast<T *>(p)->on_connection_state(connected, mtu, error);
},
[](void *p, int error) { static_cast<T *>(p)->on_service_discovery_done(error); },
[](void *p, uint16_t handle, const uint8_t *data, uint16_t len, int error) {
static_cast<T *>(p)->on_read_result(handle, data, len, error);
},
[](void *p, uint16_t handle, int error) { static_cast<T *>(p)->on_write_result(handle, error); },
[](void *p, uint16_t handle, bool enabled, int error) {
static_cast<T *>(p)->on_notify_state(handle, enabled, error);
},
[](void *p, uint16_t handle, const uint8_t *data, uint16_t len) {
static_cast<T *>(p)->on_notify_data(handle, data, len);
},
[](void *p, int status) { static_cast<T *>(p)->on_pairing_result(status); },
};
/// Type-erased consumer handle a backend delivers events through: an
/// instance pointer plus the consumer type's trampoline table. Built with
/// make_gatt_sink() from any type satisfying GattClientEventSinkContract;
/// call sites read the same as a direct listener call. No virtuals, no heap —
/// the cost of supporting several consumer types in one build is one
/// indirect call per event. Codegen wires the sink before setup(), so
/// backends may call without a null check.
struct GattEventSink {
void *instance{nullptr};
const GattEventVTable *vtable{nullptr};
void on_connection_state(bool connected, uint16_t mtu, int error) const {
this->vtable->connection_state(this->instance, connected, mtu, error);
}
void on_service_discovery_done(int error) const { this->vtable->service_discovery_done(this->instance, error); }
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) const {
this->vtable->read_result(this->instance, handle, data, len, error);
}
void on_write_result(uint16_t handle, int error) const { this->vtable->write_result(this->instance, handle, error); }
void on_notify_state(uint16_t handle, bool enabled, int error) const {
this->vtable->notify_state(this->instance, handle, enabled, error);
}
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) const {
this->vtable->notify_data(this->instance, handle, data, len);
}
void on_pairing_result(int status) const { this->vtable->pairing_result(this->instance, status); }
};
template<typename T> GattEventSink make_gatt_sink(T *consumer) {
static_assert(GattClientEventSinkContract<T>, "the consumer is missing part of the event-sink surface");
return {consumer, &GATT_EVENT_VTABLE<T>};
}
// The BLEGattConnection op surface, asserted where the alias binds
// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives
// through the sink) or a synchronous error (busy, not connected, stack
@@ -88,17 +173,19 @@ struct GattServiceTable {
// - notify_characteristic: local registration only; the CCCD write is the
// API client's responsibility (a plain write_descriptor).
// - get_service_table/release_services: backend-owned transient storage,
// released after streaming (release is idempotent). A backend may instead
// provide its own service streamer (stream_service_batch on the concrete
// type, detected by the consumer at compile time) and keep the table empty.
// released after streaming (release is idempotent). A backend may
// additionally provide its own service streamer (stream_service_batch on
// the concrete type, detected by the consumer at compile time) for
// arbitrary-size databases; the table then materializes only for consumers
// that ask for it.
// - completions: connect and disconnect land in on_connection_state,
// discover_services in on_service_discovery_done, pair in
// on_pairing_result, reads in on_read_result, notify_characteristic in
// on_notify_state, characteristic writes with response and descriptor
// writes in on_write_result.
template<typename T, typename Sink>
concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) {
conn.set_listener(sink);
template<typename T>
concept BLEGattConnectionContract = requires(T conn, GattEventSink sink, const uint8_t *data) {
conn.set_sink(sink);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.disconnect() } -> std::same_as<int>;
{ conn.discover_services() } -> std::same_as<int>;
@@ -118,21 +205,50 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
};
// The event sink the backend calls directly (the hub BluetoothConnection
// wrapper), asserted where the wrapper is defined: on_connection_state
// carries the negotiated MTU and an HCI status/disconnect reason. The
// requirements check call validity, not exact parameter types; keep sink
// parameters at the documented widths (uint16_t handles and lengths).
template<typename S>
concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) {
{ sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_service_discovery_done(int{}) } -> std::same_as<void>;
{ sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_write_result(uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as<void>;
{ sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as<void>;
{ sink.on_pairing_result(int{}) } -> std::same_as<void>;
};
// ---- service table lookup helpers ----
//
// Neutral, bounds-checked walks over a materialized GattServiceTable for
// direct consumers that resolve a known device's handles by UUID (streaming
// consumers forward the raw database and never need these). Linear search:
// the table exists only between discovery and release_services(), for one
// small known device.
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
static constexpr uint16_t CCCD_UUID = 0x2902;
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
for (uint16_t i = 0; i < table.service_count; i++) {
if (table.services[i].uuid == uuid)
return &table.services[i];
}
return nullptr;
}
inline const GattCharacteristic *find_characteristic(const GattServiceTable &table, const GattService &service,
const ESPBTUUID &uuid) {
uint16_t end = service.first_characteristic + service.characteristic_count;
if (end > table.characteristic_count)
return nullptr;
for (uint16_t i = service.first_characteristic; i < end; i++) {
if (table.characteristics[i].uuid == uuid)
return &table.characteristics[i];
}
return nullptr;
}
/// Handle of the characteristic's Client Characteristic Configuration
/// descriptor (0x2902), or 0 when it has none.
inline uint16_t find_cccd(const GattServiceTable &table, const GattCharacteristic &characteristic) {
uint16_t end = characteristic.first_descriptor + characteristic.descriptor_count;
if (end > table.descriptor_count)
return 0;
const ESPBTUUID cccd_uuid = ESPBTUUID::from_uint16(CCCD_UUID);
for (uint16_t i = characteristic.first_descriptor; i < end; i++) {
if (table.descriptors[i].uuid == cccd_uuid)
return table.descriptors[i].handle;
}
return 0;
}
} // namespace esphome::ble_device_base
@@ -2,10 +2,14 @@
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
// The in-place streamer serves the proxy's service-discovery API; backend-only
// builds compile without the proxy headers or the streamer.
#ifdef USE_BLUETOOTH_PROXY
#include "bluetooth_connection.h"
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
#endif
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/core/hal.h"
@@ -229,6 +233,9 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
void BluedroidGattClient::release_services() {
this->service_total_ = 0;
#ifdef USE_BLE_GATT_SERVICE_TABLE
this->free_service_table_();
#endif
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
// Only the cache clean makes the stack's database unsafe to walk.
this->services_released_ = true;
@@ -236,6 +243,180 @@ void BluedroidGattClient::release_services() {
#endif
}
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
if (this->table_storage_ == nullptr &&
(this->services_released_ || this->service_total_ == 0 || !this->build_service_table_())) {
return {};
}
return this->table_view_();
}
// The view is carved from the storage block and the counts on each call
// (a cold path) rather than cached, saving a per-instance table member.
ble_device_base::GattServiceTable BluedroidGattClient::table_view_() const {
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = this->table_char_total_ * sizeof(ble_device_base::GattCharacteristic);
return {reinterpret_cast<const ble_device_base::GattService *>(this->table_storage_),
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes),
reinterpret_cast<const ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes),
this->service_total_,
this->table_char_total_,
this->table_desc_total_};
}
void BluedroidGattClient::free_service_table_() {
if (this->table_storage_ == nullptr) {
return;
}
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
allocator.deallocate(this->table_storage_, 0);
this->table_storage_ = nullptr;
this->table_char_total_ = 0;
this->table_desc_total_ = 0;
}
template<typename ServiceFn, typename CharFn, typename DescFn>
bool BluedroidGattClient::walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) {
// Shared enumeration for both table-build passes: an identical walk order
// is what lets the counting pass size the block the filling pass fills.
// INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure.
for (uint16_t s = 0; s < this->service_total_; s++) {
esp_gattc_service_elem_t svc;
uint16_t svc_count = 1;
if (esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &svc, &svc_count, s) != ESP_GATT_OK ||
svc_count == 0) {
return false;
}
if (!on_service(s, svc)) {
return false;
}
uint16_t svc_chars = 0;
if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, svc.start_handle,
svc.end_handle, 0, &svc_chars) != ESP_GATT_OK) {
return false;
}
for (uint16_t c = 0; c < svc_chars; c++) {
esp_gattc_char_elem_t chr;
uint16_t char_count = 1;
auto status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, svc.start_handle, svc.end_handle, &chr,
&char_count, c);
if (status == ESP_GATT_INVALID_OFFSET || status == ESP_GATT_NOT_FOUND) {
break;
}
if (status != ESP_GATT_OK || char_count == 0) {
return false;
}
if (!on_char(svc, chr)) {
return false;
}
for (uint16_t d = 0;; d++) {
esp_gattc_descr_elem_t desc;
uint16_t desc_count = 1;
auto desc_status =
esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d);
if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) {
break;
}
if (desc_status != ESP_GATT_OK || desc_count == 0) {
return false;
}
if (!on_desc(chr, desc)) {
return false;
}
}
}
}
return true;
}
bool BluedroidGattClient::build_service_table_() {
// Pass 1: count, so one exact-size block holds the whole table.
uint16_t char_total = 0;
uint16_t desc_total = 0;
bool counted = this->walk_database_([](uint16_t, const esp_gattc_service_elem_t &) { return true; },
[&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) {
char_total++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) {
desc_total++;
return true;
});
if (!counted) {
return false;
}
// The arrays share one block; carving stays aligned because each struct's
// strictest member is the UUID and array sizes are multiples of it.
size_t svc_bytes = this->service_total_ * sizeof(ble_device_base::GattService);
size_t char_bytes = char_total * sizeof(ble_device_base::GattCharacteristic);
size_t total_bytes = svc_bytes + char_bytes + desc_total * sizeof(ble_device_base::GattDescriptor);
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
this->table_storage_ = allocator.allocate(total_bytes);
if (this->table_storage_ == nullptr) {
ESP_LOGW(TAG, "[%d] Service table allocation failed (%u bytes)", this->connection_index_,
static_cast<unsigned>(total_bytes));
return false;
}
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->table_storage_);
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes);
auto *descriptors =
reinterpret_cast<ble_device_base::GattDescriptor *>(this->table_storage_ + svc_bytes + char_bytes);
// Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall
// means the cached database changed between the passes; fail the build
// rather than serve an inconsistent table (the consumer retries).
uint16_t char_index = 0;
uint16_t desc_index = 0;
ble_device_base::GattService *cur_service = nullptr;
ble_device_base::GattCharacteristic *cur_char = nullptr;
bool filled = this->walk_database_(
[&](uint16_t s, const esp_gattc_service_elem_t &svc) {
cur_service = &services[s];
cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid);
cur_service->start_handle = svc.start_handle;
cur_service->end_handle = svc.end_handle;
cur_service->first_characteristic = char_index;
cur_service->characteristic_count = 0;
return true;
},
[&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) {
if (char_index >= char_total) {
return false;
}
cur_char = &characteristics[char_index++];
cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid);
cur_char->value_handle = chr.char_handle;
// Bluedroid addresses descriptors by characteristic handle, so the
// table's end_handle only needs the service-bounded upper bound.
cur_char->end_handle = svc.end_handle;
cur_char->properties = chr.properties;
cur_char->first_descriptor = desc_index;
cur_char->descriptor_count = 0;
cur_service->characteristic_count++;
return true;
},
[&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) {
if (desc_index >= desc_total) {
return false;
}
descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid);
descriptors[desc_index].handle = desc.handle;
desc_index++;
cur_char->descriptor_count++;
return true;
});
if (!filled || char_index != char_total || desc_index != desc_total) {
this->free_service_table_();
return false;
}
this->table_char_total_ = char_total;
this->table_desc_total_ = desc_total;
return true;
}
#endif // USE_BLE_GATT_SERVICE_TABLE
// ---- internals ----
bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
@@ -255,7 +436,7 @@ void BluedroidGattClient::set_disconnecting_() {
}
void BluedroidGattClient::report_connection_state_(bool connected, int error) {
this->listener_->on_connection_state(connected, this->mtu_, error);
this->sink_.on_connection_state(connected, this->mtu_, error);
}
esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
@@ -297,15 +478,14 @@ void BluedroidGattClient::handle_search_cmpl_() {
// clients cache the streamed result permanently.
auto status = primary_status != ESP_GATT_OK ? primary_status : secondary_status;
this->log_gattc_warning_("esp_ble_gattc_get_attr_count", status);
if (this->listener_ != nullptr) {
this->listener_->on_service_discovery_done(status);
}
this->sink_.on_service_discovery_done(status);
return;
}
this->service_total_ = primary + secondary;
this->listener_->on_service_discovery_done(0);
this->sink_.on_service_discovery_done(0);
}
#ifdef USE_BLUETOOTH_PROXY
void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
if (this->services_released_ || conn.send_service_ >= this->service_total_) {
conn.send_service_ = DONE_SENDING_SERVICES;
@@ -444,6 +624,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
conn.send_service_ = batch_start;
}
}
#endif // USE_BLUETOOTH_PROXY
// ---- events ----
@@ -479,6 +660,9 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
// matching the previous esp32 behavior).
this->seen_mtu_ = true;
this->report_connection_state_(true, 0);
// Settled: only the disconnect safety net needs the loop, and
// set_disconnecting_() re-enables it.
this->disable_loop();
}
}
@@ -572,6 +756,8 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga
ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_);
this->set_state_(ClientState::ESTABLISHED);
this->handle_search_cmpl_();
// Settled (see the V3_WITH_CACHE arm in handle_open_evt_).
this->disable_loop();
break;
}
case ESP_GATTC_READ_CHAR_EVT:
@@ -579,34 +765,32 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga
if (this->conn_id_ != param->read.conn_id)
return false;
bool ok = param->read.status == ESP_GATT_OK;
this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr,
ok ? param->read.value_len : 0, ok ? 0 : param->read.status);
this->sink_.on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0,
ok ? 0 : param->read.status);
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
case ESP_GATTC_WRITE_DESCR_EVT: {
if (this->conn_id_ != param->write.conn_id)
return false;
this->listener_->on_write_result(param->write.handle,
param->write.status == ESP_GATT_OK ? 0 : param->write.status);
this->sink_.on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
this->listener_->on_notify_state(param->reg_for_notify.handle, true,
param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status);
this->sink_.on_notify_state(param->reg_for_notify.handle, true,
param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status);
break;
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
this->listener_->on_notify_state(
param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
this->sink_.on_notify_state(param->unreg_for_notify.handle, false,
param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (this->conn_id_ != param->notify.conn_id)
return false;
ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle);
this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
this->sink_.on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
break;
}
default:
@@ -627,8 +811,8 @@ void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_bl
case ESP_GAP_BLE_AUTH_CMPL_EVT: {
if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr))
break;
this->listener_->on_pairing_result(
param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason);
this->sink_.on_pairing_result(param->ble_security.auth_cmpl.success ? 0
: param->ble_security.auth_cmpl.fail_reason);
break;
}
default:
@@ -22,8 +22,10 @@
namespace esphome::bluetooth_connection {
class BluetoothConnection;
class BluedroidGattClient;
#ifdef USE_BLUETOOTH_PROXY
class BluetoothConnection;
#endif
// The tracker-facing half: owns the ClientState the promote loop reads and
// forwards events/commands to the engine.
@@ -53,7 +55,7 @@ class BluedroidGattClient final : public Component {
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Wired by codegen before setup and invariant for the device lifetime.
void set_listener(BluetoothConnection *listener) { this->listener_ = listener; }
void set_sink(ble_device_base::GattEventSink sink) { this->sink_ = sink; }
esp32_ble_tracker::ESPBTClient *tracker_client() { return &this->shim_; }
// ---- ble_device_base::BLEGattConnection contract ----
@@ -67,17 +69,27 @@ class BluedroidGattClient final : public Component {
int notify_characteristic(uint16_t handle, bool enable);
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
// Never called: this backend streams in place (stream_service_batch), so
// the table stays empty. Satisfies the contract concept.
// Materialized on demand from Bluedroid's cached database for direct
// consumers that resolve handles by UUID. The streaming consumer (the
// proxy wrapper) never calls this - it uses stream_service_batch - so the
// materializer only compiles when codegen declares a direct consumer
// (USE_BLE_GATT_SERVICE_TABLE) and proxy-only builds keep the old
// footprint; a direct consumer's peak is bounded by its one known device.
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable get_service_table();
#else
ble_device_base::GattServiceTable get_service_table() { return {}; }
#endif
void release_services();
/// In-place service streamer (the wrapper detects and prefers it): builds
/// one api response batch directly from Bluedroid's cached database, so the
/// streaming peak is the response itself - the old esp32 model.
#ifdef USE_BLUETOOTH_PROXY
/// In-place service streamer (the proxy wrapper detects and prefers it):
/// builds one api response batch directly from Bluedroid's cached database,
/// so the streaming peak is the response itself - the old esp32 model.
void stream_service_batch(BluetoothConnection &conn);
#endif
void set_connection_type(esp32_ble_tracker::ConnectionType ct) { this->connection_type_ = ct; }
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
bool disconnect_pending() const { return this->shim_.disconnect_pending(); }
void cancel_pending_disconnect() { this->shim_.cancel_pending_disconnect(); }
@@ -101,10 +113,24 @@ class BluedroidGattClient final : public Component {
const char *param_type);
int check_and_log_error_(const char *operation, esp_err_t err);
void log_gattc_warning_(const char *operation, int code);
#ifdef USE_BLE_GATT_SERVICE_TABLE
template<typename ServiceFn, typename CharFn, typename DescFn>
bool walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
bool build_service_table_();
void free_service_table_();
ble_device_base::GattServiceTable table_view_() const;
#endif
// Group 1: pointers / composed objects
BluedroidTrackerShim shim_{this};
BluetoothConnection *listener_{nullptr};
ble_device_base::GattEventSink sink_;
#ifdef USE_BLE_GATT_SERVICE_TABLE
// One exact-size block carved into the table's three arrays; owned here,
// freed by release_services(). Null when no table is materialized. The
// GattServiceTable view is rebuilt from this pointer and the counts on
// each (cold) get_service_table() call instead of being cached.
uint8_t *table_storage_{nullptr};
#endif
// Group 2: 4-byte types
int gattc_if_{ESP_GATT_IF_NONE};
uint32_t disconnecting_started_{0};
@@ -116,6 +142,11 @@ class BluedroidGattClient final : public Component {
uint16_t conn_id_{0xFFFF};
uint16_t mtu_{23};
uint16_t service_total_{0};
#ifdef USE_BLE_GATT_SERVICE_TABLE
// Filled element counts of the materialized table (0 when none).
uint16_t table_char_total_{0};
uint16_t table_desc_total_{0};
#endif
// Group 5: 1-byte types
// Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call.
@@ -24,11 +24,9 @@
// build hits the #error below.
namespace esphome::bluetooth_connection {
class BluetoothConnection;
class StubGattBackend {
public:
void set_listener(BluetoothConnection *listener) {}
void set_sink(ble_device_base::GattEventSink sink) {}
int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
@@ -61,7 +59,7 @@ class StubGattBackend {
namespace esphome::ble_device_base {
using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE;
static_assert(BLEGattConnectionContract<BLEGattConnection, bluetooth_connection::BluetoothConnection>,
static_assert(BLEGattConnectionContract<BLEGattConnection>,
"The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)");
#undef ESPHOME_BLE_GATT_CONNECTION_TYPE
@@ -1,4 +1,4 @@
// Hub-platform connection wrapper (USE_RP2 hub builds today).
// The proxy's per-slot connection wrapper, shared by every platform.
#include "bluetooth_connection_hub.h"
#ifdef USE_BLE_GATT_CLIENT
@@ -27,7 +27,7 @@ void BluetoothConnection::set_address(uint64_t address) {
}
void BluetoothConnection::start_connect_() {
// No connect timeout here (esp32 parity): the client's own timeout or
// No connect timeout here: the API client's own timeout or
// the api-gone sweep drives disconnect().
this->state_ = ClientState::CONNECTING;
int err = this->backend_->connect(this->address_, this->remote_addr_type_);
@@ -38,7 +38,7 @@ void BluetoothConnection::start_connect_() {
}
void BluetoothConnection::disconnect() {
// Idempotent like the esp32 class: the proxy's teardown loop calls this
// Idempotent: the proxy's teardown loop calls this
// every 100 ms while the API subscriber is gone, and a repeat call must not
// reach the backend (whose busy error would free the slot mid-teardown).
if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) {
@@ -65,10 +65,10 @@ void BluetoothConnection::disconnect() {
}
void BluetoothConnection::check_disconnect_timeout_() {
// Safety net mirroring the esp32 base class: if the backend's disconnect
// completion is lost, force the slot free instead of leaking it.
if (this->state_ == ClientState::DISCONNECTING &&
millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
// Safety net: if the backend's disconnect completion is lost (or a refusal
// left the teardown unresolved), force the slot free instead of leaking it.
// The caller already gates on DISCONNECTING.
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_);
this->reset_connection_(GATT_NOT_CONNECTED);
}
@@ -29,10 +29,10 @@ class BluetoothConnection final {
/// Wire the platform backend. Called from codegen before setup.
void set_backend(ble_device_base::BLEGattConnection *backend) {
this->backend_ = backend;
backend->set_listener(this);
backend->set_sink(ble_device_base::make_gatt_sink(this));
}
// ---- proxy dispatch surface (mirrors the esp32 class) ----
// ---- proxy dispatch surface ----
conn_err_t read_characteristic(uint16_t handle);
conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response);
conn_err_t read_descriptor(uint16_t handle);
@@ -70,8 +70,8 @@ class BluetoothConnection final {
}
// Latched at discovery completion rather than read from the backend table:
// streaming frees the table, and this must stay true for the connection's
// lifetime (esp32 parity — a repeat GetServices is silently ignored there,
// never answered with an authoritative empty database).
// lifetime (a repeat GetServices is silently ignored, never answered with
// an authoritative empty database).
bool has_gatt_services() const { return this->services_discovered_; }
/// Stream any pending service-discovery batch and police the disconnect
@@ -1,6 +1,5 @@
#include "bluetooth_connection_rp2.h"
#include "bluetooth_connection_hub.h"
#include "bluetooth_connection.h"
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
@@ -26,7 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY;
// 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;
// Can-send windows normally open within a connection interval (tens of ms).
static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500;
@@ -384,8 +382,8 @@ void RP2GattClient::loop() {
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);
if (this->notify_subscribed_(notify->handle)) {
this->sink_.on_notify_data(notify->handle, notify->data, notify->len);
}
this->notify_pool_.release(notify);
}
@@ -430,7 +428,7 @@ void RP2GattClient::loop() {
}
}
} else if (this->state_ == EngineState::DISCONNECTING) {
if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) {
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Disconnect timeout, forcing idle");
this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT);
}
@@ -448,9 +446,7 @@ void RP2GattClient::loop() {
}
if (timed_out) {
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
if (this->listener_ != nullptr) {
this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
}
this->sink_.on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
}
} else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() &&
this->event_queue_.empty() && this->notify_queue_.empty())) {
@@ -474,9 +470,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
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);
}
this->sink_.on_connection_state(true, this->mtu_, 0);
}
break;
case RP2GattEvent::QUERY_COMPLETE:
@@ -486,9 +480,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
this->finish_write_no_rsp_(event.status);
break;
case RP2GattEvent::PAIRING_RESULT:
if (this->listener_ != nullptr) {
this->listener_->on_pairing_result(event.status);
}
this->sink_.on_pairing_result(event.status);
break;
}
}
@@ -514,9 +506,7 @@ void RP2GattClient::finish_write_no_rsp_(uint8_t status) {
return;
}
this->op_type_ = OpType::NONE;
if (this->listener_ != nullptr) {
this->listener_->on_write_result(this->op_handle_, status);
}
this->sink_.on_write_result(this->op_handle_, status);
}
void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
@@ -576,9 +566,7 @@ 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);
}
this->sink_.on_connection_state(false, 0, reason);
}
void RP2GattClient::cleanup_link_state_() {
@@ -621,9 +609,6 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) {
if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) {
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:
@@ -634,12 +619,11 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) {
this->op_len_ > 0) {
att_status = 0;
}
this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0,
att_status);
this->sink_.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);
this->sink_.on_write_result(this->op_handle_, att_status);
break;
default:
break;
@@ -796,9 +780,7 @@ void RP2GattClient::finish_discovery_(int error) {
if (error != 0) {
this->release_services();
}
if (this->listener_ != nullptr) {
this->listener_->on_service_discovery_done(error);
}
this->sink_.on_service_discovery_done(error);
}
ble_device_base::GattServiceTable RP2GattClient::get_service_table() {
@@ -990,8 +972,8 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui
return 0;
}
}
if (status == 0 && this->listener_ != nullptr) {
this->listener_->on_write_result(handle, 0);
if (status == 0) {
this->sink_.on_write_result(handle, 0);
}
return status;
}
@@ -1092,9 +1074,7 @@ int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) {
}
}
}
if (this->listener_ != nullptr) {
this->listener_->on_notify_state(handle, enable, 0);
}
this->sink_.on_notify_state(handle, enable, 0);
return 0;
}
@@ -26,8 +26,6 @@
namespace esphome::bluetooth_connection {
class BluetoothConnection;
// 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
@@ -80,7 +78,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
void dump_config() override;
float get_setup_priority() const override;
void set_listener(BluetoothConnection *listener) { this->listener_ = listener; }
void set_sink(ble_device_base::GattEventSink sink) { this->sink_ = sink; }
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
@@ -155,7 +153,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
}
// Group 1: containers / large storage
BluetoothConnection *listener_{nullptr};
ble_device_base::GattEventSink sink_;
ServiceArena *arena_{nullptr};
esphome::LockFreeQueue<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
@@ -2,7 +2,7 @@
// configured; this TU pins it on the host so the header cannot rot unseen.
// The contract is a concept (BLEGattConnection is a per-platform alias), so
// the minimal backend here proves the concept stays satisfiable and routes
// events through the duck-typed sink the way a real backend does.
// events through the type-erased GattEventSink the way a real backend does.
#define USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
@@ -15,33 +15,35 @@ struct RecordingSink {
void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; }
void on_service_discovery_done(int error) { this->discovery_error_ = error; }
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
void on_write_result(uint16_t handle, int error) {}
void on_write_result(uint16_t handle, int error) { this->write_handle_ = handle; }
void on_notify_state(uint16_t handle, bool enabled, int error) {}
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {}
void on_pairing_result(int status) {}
bool connected_{false};
int discovery_error_{0};
uint16_t write_handle_{0};
};
static_assert(GattClientEventSinkContract<RecordingSink>, "the recording sink must cover the full event-sink surface");
class MinimalConnection {
public:
void set_listener(RecordingSink *listener) { this->listener_ = listener; }
void set_sink(GattEventSink sink) { this->sink_ = sink; }
int connect(uint64_t address, uint8_t addr_type) {
if (this->listener_ != nullptr)
this->listener_->on_connection_state(true, 517, 0);
this->sink_.on_connection_state(true, 517, 0);
return 0;
}
int disconnect() { return 0; }
int discover_services() {
if (this->listener_ != nullptr)
this->listener_->on_service_discovery_done(0);
this->sink_.on_service_discovery_done(0);
return 0;
}
int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
this->sink_.on_write_result(handle, 0);
return 0;
}
int read_descriptor(uint16_t handle) { return 0; }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; }
int notify_characteristic(uint16_t handle, bool enable) { return 0; }
@@ -56,21 +58,23 @@ class MinimalConnection {
void set_connection_type(ConnectionType ct) {}
protected:
RecordingSink *listener_{nullptr};
GattEventSink sink_;
};
static_assert(BLEGattConnectionContract<MinimalConnection, RecordingSink>,
static_assert(BLEGattConnectionContract<MinimalConnection>,
"a minimal backend must satisfy the contract the alias asserts");
TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
MinimalConnection connection;
RecordingSink listener;
connection.set_listener(&listener);
connection.set_sink(make_gatt_sink(&listener));
EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0);
EXPECT_TRUE(listener.connected_);
EXPECT_EQ(connection.discover_services(), 0);
EXPECT_EQ(listener.discovery_error_, 0);
EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED);
EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0);
EXPECT_EQ(listener.write_handle_, 7);
// A default table is empty and safe to walk.
GattServiceTable table = connection.get_service_table();
@@ -79,4 +83,54 @@ TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
EXPECT_EQ(table.descriptor_count, 0);
}
// A radon_eye_rd200-shaped table: two services, the second holding a
// notifying characteristic with a CCCD and a bare write characteristic.
class ServiceTableLookup : public ::testing::Test {
protected:
void SetUp() override {
this->services_[0] = {ESPBTUUID::from_uint16(0x1800), 0x0001, 0x0005, 0, 1};
this->services_[1] = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 1, 2};
this->characteristics_[0] = {ESPBTUUID::from_uint16(0x2A00), 0x0003, 0x0003, 0x02, 0, 0};
this->characteristics_[1] = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 1};
this->characteristics_[2] = {ESPBTUUID::from_uint16(0x1524), 0x0016, 0x0016, 0x04, 1, 0};
this->descriptors_[0] = {ESPBTUUID::from_uint16(0x2902), 0x0013};
this->table_ = {this->services_, this->characteristics_, this->descriptors_, 2, 3, 1};
}
GattService services_[2];
GattCharacteristic characteristics_[3];
GattDescriptor descriptors_[1];
GattServiceTable table_;
};
TEST_F(ServiceTableLookup, FindsServicesAndCharacteristicsByUuid) {
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
ASSERT_NE(service, nullptr);
EXPECT_EQ(service->start_handle, 0x0010);
EXPECT_EQ(find_service(this->table_, ESPBTUUID::from_uint16(0xFFFF)), nullptr);
const GattCharacteristic *characteristic =
find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
ASSERT_NE(characteristic, nullptr);
EXPECT_EQ(characteristic->value_handle, 0x0012);
// The lookup is scoped to the service: 0x2A00 lives in the other service.
EXPECT_EQ(find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x2A00)), nullptr);
}
TEST_F(ServiceTableLookup, FindsTheCccdAndReportsItsAbsence) {
const GattService *service = find_service(this->table_, ESPBTUUID::from_uint16(0x1523));
const GattCharacteristic *notify_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
EXPECT_EQ(find_cccd(this->table_, *notify_char), 0x0013);
const GattCharacteristic *write_char = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1524));
EXPECT_EQ(find_cccd(this->table_, *write_char), 0);
}
TEST_F(ServiceTableLookup, RejectsRangesThatOverrunTheTable) {
// A corrupt index range must fail the lookup, not walk out of bounds.
GattService bad_service = {ESPBTUUID::from_uint16(0x1523), 0x0010, 0x0020, 2, 5};
EXPECT_EQ(find_characteristic(this->table_, bad_service, ESPBTUUID::from_uint16(0x1524)), nullptr);
GattCharacteristic bad_char = {ESPBTUUID::from_uint16(0x1525), 0x0012, 0x0014, 0x10, 0, 9};
EXPECT_EQ(find_cccd(this->table_, bad_char), 0);
}
} // namespace esphome::ble_device_base::testing