[ble_device_base] Bind the GATT backend at compile time (#18185)

This commit is contained in:
J. Nick Koston
2026-08-08 15:59:32 -05:00
committed by GitHub
parent 7218aa4803
commit 04384e0f5b
9 changed files with 199 additions and 137 deletions
@@ -2,12 +2,13 @@
//
// Platform-neutral GATT client connection contract.
//
// A platform's GATT client backend (bluetooth_connection/esp32,
// bluetooth_connection/rp2) implements BLEGattConnection; consumers
// (bluetooth_proxy) drive it through this interface and receive
// completions through GattClientEventListener. All listener callbacks are
// delivered on the ESPHome main loop; borrowed data pointers are valid only
// for the duration of the call.
// 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
// calls are delivered on the ESPHome main loop; borrowed data pointers are
// valid only for the duration of the call.
//
// Error domain (plain int, forwarded to the API without translation):
// 0 success
@@ -29,6 +30,7 @@
#include "ble_client_state.h"
#include "ble_device.h"
#include <concepts>
#include <cstdint>
namespace esphome::ble_device_base {
@@ -76,67 +78,53 @@ struct GattServiceTable {
uint16_t descriptor_count{0};
};
/// Completion/event sink for a GATT connection. Implemented by the consumer
/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop.
class GattClientEventListener {
public:
virtual ~GattClientEventListener() = default;
/// Connected (with negotiated MTU) or disconnected/connect-failed
/// (error = HCI status or disconnect reason).
virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0;
/// Service discovery finished; on success the service table is populated.
virtual void on_service_discovery_done(int error) = 0;
/// Characteristic or descriptor read finished. data/len valid during the call.
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0;
/// Characteristic write-with-response or descriptor write finished.
virtual void on_write_result(uint16_t handle, int error) = 0;
/// Notification/indication registration state changed.
virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0;
/// Notification/indication data from the peer. data/len valid during the call.
virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0;
virtual void on_pairing_result(int status) {}
// 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
// rejection); one operation may be outstanding at a time. Semantics beyond
// the signatures:
// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h).
// - disconnect: also cancels a connect in progress.
// - 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).
// - 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);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.disconnect() } -> std::same_as<int>;
{ conn.discover_services() } -> std::same_as<int>;
{ conn.read_characteristic(uint16_t{}) } -> std::same_as<int>;
{ conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as<int>;
{ conn.read_descriptor(uint16_t{}) } -> std::same_as<int>;
{ conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as<int>;
{ conn.notify_characteristic(uint16_t{}, true) } -> std::same_as<int>;
{ conn.pair() } -> std::same_as<int>;
{ conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as<int>;
{ conn.get_service_table() } -> std::same_as<GattServiceTable>;
{ conn.release_services() } -> std::same_as<void>;
};
/// One GATT client connection slot. Operations return 0 when accepted
/// (completion arrives via the listener) or a synchronous error code
/// (busy, not connected, stack rejection). One operation may be outstanding
/// at a time; callers see a synchronous error otherwise.
class BLEGattConnection {
public:
virtual ~BLEGattConnection() = default;
void set_listener(GattClientEventListener *listener) { this->listener_ = listener; }
/// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant
/// (ble_device.h). Completion: on_connection_state().
virtual int connect(uint64_t address, uint8_t addr_type) = 0;
/// Disconnect (or cancel a connect in progress). Completion: on_connection_state().
virtual int disconnect() = 0;
/// Discover the peer's services/characteristics/descriptors into the
/// service table. Completion: on_service_discovery_done().
virtual int discover_services() = 0;
virtual int read_characteristic(uint16_t handle) = 0;
virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0;
virtual int read_descriptor(uint16_t handle) = 0;
virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0;
/// Enable/disable delivery of on_notify_data() for a characteristic value
/// handle. Local registration only — the CCCD write is the API client's
/// responsibility (it arrives as a plain write_descriptor).
virtual int notify_characteristic(uint16_t handle, bool enable) = 0;
/// Initiate pairing on the live link. Completion: on_pairing_result().
virtual int pair() { return GATT_ERR_NOT_CONNECTED; }
virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) = 0;
/// Backend-owned service table (see GattServiceTable lifetime).
virtual GattServiceTable get_service_table() = 0;
/// Free the transient service table storage. Call after streaming;
/// idempotent (a call with no table held is a no-op).
virtual void release_services() = 0;
protected:
GattClientEventListener *listener_{nullptr};
// 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>;
};
} // namespace esphome::ble_device_base
+3 -2
View File
@@ -58,8 +58,9 @@ struct HubCapabilities {
/// may only see them where the receiver merges per address (Home Assistant does).
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 and rp2.
/// bluetooth_connection backend (rp2 binds the BLEGattConnection alias in
/// bluetooth_connection_gatt_backend.h; esp32 uses its Bluedroid client).
/// 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
@@ -0,0 +1,64 @@
// bluetooth_connection_gatt_backend.h
//
// Binds ble_device_base::BLEGattConnection to the build's one GATT backend.
// Backend and consumer both live in this component, so the ladder does too;
// backends implement ble_gatt_client.h (the neutral contract).
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#if defined(USE_RP2040_BLE)
#include "bluetooth_connection_rp2.h"
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient
#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND)
// Emitted only by the host unit-test manifest: the tests compile the hub
// wrapper standalone, so bind a do-nothing backend. Every other backend-less
// build hits the #error below.
namespace esphome::bluetooth_connection {
class BluetoothConnection;
class StubGattBackend {
public:
void set_listener(BluetoothConnection *listener) {}
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; }
int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
int read_descriptor(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
int notify_characteristic(uint16_t handle, bool enable) { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int pair() { return ble_device_base::GATT_ERR_NOT_CONNECTED; }
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
ble_device_base::GattServiceTable get_service_table() { return {}; }
void release_services() {}
};
} // namespace esphome::bluetooth_connection
#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::StubGattBackend
#else
#error "USE_BLE_GATT_CLIENT is set but this build has no GATT backend; add an alias arm here"
#endif
namespace esphome::ble_device_base {
using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE;
static_assert(BLEGattConnectionContract<BLEGattConnection, bluetooth_connection::BluetoothConnection>,
"The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)");
#undef ESPHOME_BLE_GATT_CONNECTION_TYPE
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
@@ -96,7 +96,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) {
this->proxy_->reset_connection_slot_(this, reason);
}
// ---- GattClientEventListener ----
// ---- backend event sink ----
void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected && this->address_ == 0) {
@@ -1,6 +1,6 @@
// Hub-platform BluetoothConnection: drives a platform GATT client backend
// through the neutral ble_device_base::BLEGattConnection interface and
// translates its events into the same API messages the esp32 class emits.
// Hub-platform BluetoothConnection: drives the build's GATT backend (the
// ble_device_base::BLEGattConnection alias) and translates its events into
// the same API messages the esp32 class emits.
// Presents the identical method surface, so the proxy's GATT dispatch
// compiles against either class unchanged.
@@ -13,7 +13,7 @@
#include "bluetooth_connection.h"
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "bluetooth_connection_gatt_backend.h"
#include "esphome/core/helpers.h"
namespace esphome::bluetooth_proxy {
@@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection {
using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
class BluetoothConnection final : public ble_device_base::GattClientEventListener {
class BluetoothConnection final {
public:
/// Wire the platform backend. Called from codegen before setup.
void set_backend(ble_device_base::BLEGattConnection *backend) {
@@ -83,14 +83,14 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene
this->check_disconnect_timeout_();
}
// ---- ble_device_base::GattClientEventListener ----
void on_connection_state(bool connected, uint16_t mtu, int error) override;
void on_service_discovery_done(int error) override;
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
void on_write_result(uint16_t handle, int error) override;
void on_notify_state(uint16_t handle, bool enabled, int error) override;
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_pairing_result(int status) override;
// ---- backend event sink (called directly by the backend, main loop) ----
void on_connection_state(bool connected, uint16_t mtu, int error);
void on_service_discovery_done(int 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_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);
protected:
friend class bluetooth_proxy::BluetoothProxy;
@@ -102,8 +102,7 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene
conn_err_t check_connected_op_(const char *action, const char *type) const;
void log_gatt_operation_error_(const char *operation, uint16_t handle, int status);
// Memory optimized layout for 32-bit systems (a vptr precedes: pointers and
// 2-byte members first fill to an 8-byte boundary before address_)
// Memory optimized layout for 32-bit systems
// Group 1: Pointers (4 bytes each, naturally aligned)
bluetooth_proxy::BluetoothProxy *proxy_{nullptr};
ble_device_base::BLEGattConnection *backend_{nullptr};
@@ -129,6 +128,9 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene
bool services_discovered_{false};
};
static_assert(ble_device_base::GattClientEventSinkContract<BluetoothConnection>,
"The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)");
} // namespace esphome::bluetooth_connection
#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT
@@ -1,4 +1,6 @@
#include "bluetooth_connection_rp2.h"
#include "bluetooth_connection_hub.h"
#include "bluetooth_connection.h"
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)
@@ -1,11 +1,10 @@
// 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.
// The build's ble_device_base::BLEGattConnection backend (bound by alias in
// bluetooth_connection_gatt_backend.h) 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
@@ -27,6 +26,8 @@
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
@@ -72,29 +73,28 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8;
// 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> {
class RP2GattClient final : public Component, 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 pair() 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;
void set_listener(BluetoothConnection *listener) { this->listener_ = listener; }
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
int disconnect();
int discover_services();
int read_characteristic(uint16_t handle);
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
int read_descriptor(uint16_t handle);
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
int notify_characteristic(uint16_t handle, bool enable);
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
ble_device_base::GattServiceTable get_service_table();
void release_services();
protected:
// Link/engine state. Discovery and GATT ops have their own cursors below —
@@ -150,6 +150,7 @@ class RP2GattClient final : public Component,
}
// Group 1: containers / large storage
BluetoothConnection *listener_{nullptr};
ServiceArena *arena_{nullptr};
esphome::LockFreeQueue<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
@@ -1,5 +1,8 @@
// The GATT client contract compiles in no real build until a hub backend is
// 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.
#define USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
@@ -8,57 +11,57 @@
namespace esphome::ble_device_base::testing {
class RecordingListener : public GattClientEventListener {
public:
void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; }
void on_service_discovery_done(int error) override { this->discovery_error_ = error; }
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override {}
void on_write_result(uint16_t handle, int error) override {}
void on_notify_state(uint16_t handle, bool enabled, int error) override {}
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override {}
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_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};
};
class MinimalConnection : public BLEGattConnection {
static_assert(GattClientEventSinkContract<RecordingSink>, "the recording sink must cover the full event-sink surface");
class MinimalConnection {
public:
int connect(uint64_t address, uint8_t addr_type) override {
void set_listener(RecordingSink *listener) { this->listener_ = listener; }
int connect(uint64_t address, uint8_t addr_type) {
if (this->listener_ != nullptr)
this->listener_->on_connection_state(true, 517, 0);
return 0;
}
int disconnect() override { return 0; }
int discover_services() override {
int disconnect() { return 0; }
int discover_services() {
if (this->listener_ != nullptr)
this->listener_->on_service_discovery_done(0);
return 0;
}
int read_characteristic(uint16_t handle) override { return GATT_ERR_NOT_CONNECTED; }
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override { return 0; }
int read_descriptor(uint16_t handle) override { return 0; }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override { return 0; }
int notify_characteristic(uint16_t handle, bool enable) override { return 0; }
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency,
uint16_t timeout) override {
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 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; }
int pair() { return GATT_ERR_NOT_CONNECTED; }
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) {
return 0;
}
GattServiceTable get_service_table() override { return {}; }
void release_services() override {}
GattServiceTable get_service_table() { return {}; }
void release_services() {}
protected:
RecordingSink *listener_{nullptr};
};
TEST(BleGattClientContract, PairingDefaultsAreSafeForNonPairingBackends) {
// pair() defaults to not-connected and on_pairing_result() to a no-op, so
// a backend without pairing still answers the client through the dispatch.
MinimalConnection conn;
RecordingListener listener;
conn.set_listener(&listener);
EXPECT_EQ(conn.pair(), GATT_ERR_NOT_CONNECTED);
listener.on_pairing_result(0); // must not crash: default body
}
static_assert(BLEGattConnectionContract<MinimalConnection, RecordingSink>,
"a minimal backend must satisfy the contract the alias asserts");
TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
MinimalConnection connection;
RecordingListener listener;
RecordingSink listener;
connection.set_listener(&listener);
EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0);
EXPECT_TRUE(listener.connected_);
@@ -9,6 +9,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# These defines are global to the merged host test binary; safe
# because no co-compiled test observes them.
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND")
cg.add_define("USE_BLUETOOTH_PROXY")
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1)