Deliver completions through a GattClientListener interface

This commit is contained in:
J. Nick Koston
2026-08-09 00:44:35 -05:00
parent 9db31db24d
commit 7af211ab54
9 changed files with 83 additions and 145 deletions
@@ -8,9 +8,9 @@
// 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
// completions through the GattClientListener interface (one build can hold
// several consumer types while the backend stays a single non-virtual
// class). All listener
// calls are delivered on the ESPHome main loop; borrowed data pointers are
// valid only for the duration of the call.
//
@@ -82,90 +82,31 @@ 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
/// The event surface a backend delivers completions through. Genuine runtime
/// polymorphism lives here - one build can hold several consumer types (the
/// proxy's connection wrapper, dedicated-backend components) against the one
/// non-virtual backend class - so this is a plain virtual interface: every
/// method defaults to a no-op, consumers override what they consume (override
/// makes a misspelled name a compile error), and adding an event touches no
/// existing consumer. No destructor: components are never destroyed, and
/// nothing deletes through this base.
/// on_connection_state carries the negotiated MTU and an HCI
/// status/disconnect reason. Codegen wires the listener 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); }
class GattClientListener {
public:
virtual void on_connection_state(bool connected, uint16_t mtu, int error) {}
virtual void on_service_discovery_done(int error) {}
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
virtual void on_write_result(uint16_t handle, int error) {}
virtual void on_notify_state(uint16_t handle, bool enabled, int error) {}
virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {}
virtual void on_pairing_result(int 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
// through the listener) 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).
@@ -184,8 +125,8 @@ template<typename T> GattEventSink make_gatt_sink(T *consumer) {
// on_notify_state, characteristic writes with response and descriptor
// writes in on_write_result.
template<typename T>
concept BLEGattConnectionContract = requires(T conn, GattEventSink sink, const uint8_t *data) {
conn.set_sink(sink);
concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) {
conn.set_listener(listener);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.disconnect() } -> std::same_as<int>;
{ conn.discover_services() } -> std::same_as<int>;
@@ -436,7 +436,7 @@ void BluedroidGattClient::set_disconnecting_() {
}
void BluedroidGattClient::report_connection_state_(bool connected, int error) {
this->sink_.on_connection_state(connected, this->mtu_, error);
this->listener_->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,
@@ -478,11 +478,11 @@ 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);
this->sink_.on_service_discovery_done(status);
this->listener_->on_service_discovery_done(status);
return;
}
this->service_total_ = primary + secondary;
this->sink_.on_service_discovery_done(0);
this->listener_->on_service_discovery_done(0);
}
#ifdef USE_BLUETOOTH_PROXY
@@ -765,32 +765,34 @@ 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->sink_.on_read_result(param->read.handle, ok ? param->read.value : nullptr, ok ? param->read.value_len : 0,
ok ? 0 : param->read.status);
this->listener_->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->sink_.on_write_result(param->write.handle, param->write.status == ESP_GATT_OK ? 0 : param->write.status);
this->listener_->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->sink_.on_notify_state(param->reg_for_notify.handle, true,
param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status);
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);
break;
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
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);
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);
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->sink_.on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len);
break;
}
default:
@@ -811,8 +813,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->sink_.on_pairing_result(param->ble_security.auth_cmpl.success ? 0
: param->ble_security.auth_cmpl.fail_reason);
this->listener_->on_pairing_result(
param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason);
break;
}
default:
@@ -55,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_sink(ble_device_base::GattEventSink sink) { this->sink_ = sink; }
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
esp32_ble_tracker::ESPBTClient *tracker_client() { return &this->shim_; }
// ---- ble_device_base::BLEGattConnection contract ----
@@ -123,7 +123,7 @@ class BluedroidGattClient final : public Component {
// Group 1: pointers / composed objects
BluedroidTrackerShim shim_{this};
ble_device_base::GattEventSink sink_;
ble_device_base::GattClientListener *listener_{nullptr};
#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
@@ -26,7 +26,7 @@ namespace esphome::bluetooth_connection {
class StubGattBackend {
public:
void set_sink(ble_device_base::GattEventSink sink) {}
void set_listener(ble_device_base::GattClientListener *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; }
@@ -96,7 +96,7 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) {
this->proxy_->reset_connection_slot_(this, reason);
}
// ---- backend event sink ----
// ---- backend event listener ----
void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected && this->address_ == 0) {
@@ -24,12 +24,12 @@ namespace esphome::bluetooth_connection {
using ClientState = ble_device_base::ClientState;
using ConnectionType = ble_device_base::ConnectionType;
class BluetoothConnection final {
class BluetoothConnection final : public ble_device_base::GattClientListener {
public:
/// Wire the platform backend. Called from codegen before setup.
void set_backend(ble_device_base::BLEGattConnection *backend) {
this->backend_ = backend;
backend->set_sink(ble_device_base::make_gatt_sink(this));
backend->set_listener(this);
}
// ---- proxy dispatch surface ----
@@ -88,14 +88,14 @@ class BluetoothConnection final {
}
}
// ---- 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);
// ---- backend event listener (called directly by the backend, main loop) ----
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;
protected:
friend class bluetooth_proxy::BluetoothProxy;
@@ -146,9 +146,6 @@ class BluetoothConnection final {
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_BLE_GATT_CLIENT
@@ -383,7 +383,7 @@ void RP2GattClient::loop() {
RP2GattNotifyEvent *notify;
while ((notify = this->notify_queue_.pop()) != nullptr) {
if (this->notify_subscribed_(notify->handle)) {
this->sink_.on_notify_data(notify->handle, notify->data, notify->len);
this->listener_->on_notify_data(notify->handle, notify->data, notify->len);
}
this->notify_pool_.release(notify);
}
@@ -446,7 +446,7 @@ void RP2GattClient::loop() {
}
if (timed_out) {
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
this->sink_.on_write_result(this->op_handle_, GATT_CLIENT_BUSY);
this->listener_->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())) {
@@ -470,7 +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_();
this->sink_.on_connection_state(true, this->mtu_, 0);
this->listener_->on_connection_state(true, this->mtu_, 0);
}
break;
case RP2GattEvent::QUERY_COMPLETE:
@@ -480,7 +480,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
this->finish_write_no_rsp_(event.status);
break;
case RP2GattEvent::PAIRING_RESULT:
this->sink_.on_pairing_result(event.status);
this->listener_->on_pairing_result(event.status);
break;
}
}
@@ -506,7 +506,7 @@ void RP2GattClient::finish_write_no_rsp_(uint8_t status) {
return;
}
this->op_type_ = OpType::NONE;
this->sink_.on_write_result(this->op_handle_, status);
this->listener_->on_write_result(this->op_handle_, status);
}
void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
@@ -566,7 +566,7 @@ void RP2GattClient::fail_connection_(uint8_t reason) {
this->cleanup_link_state_();
this->release_scan_inhibit_();
this->state_ = EngineState::IDLE;
this->sink_.on_connection_state(false, 0, reason);
this->listener_->on_connection_state(false, 0, reason);
}
void RP2GattClient::cleanup_link_state_() {
@@ -619,11 +619,12 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) {
this->op_len_ > 0) {
att_status = 0;
}
this->sink_.on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, att_status);
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->sink_.on_write_result(this->op_handle_, att_status);
this->listener_->on_write_result(this->op_handle_, att_status);
break;
default:
break;
@@ -780,7 +781,7 @@ void RP2GattClient::finish_discovery_(int error) {
if (error != 0) {
this->release_services();
}
this->sink_.on_service_discovery_done(error);
this->listener_->on_service_discovery_done(error);
}
ble_device_base::GattServiceTable RP2GattClient::get_service_table() {
@@ -973,7 +974,7 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui
}
}
if (status == 0) {
this->sink_.on_write_result(handle, 0);
this->listener_->on_write_result(handle, 0);
}
return status;
}
@@ -1074,7 +1075,7 @@ int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) {
}
}
}
this->sink_.on_notify_state(handle, enable, 0);
this->listener_->on_notify_state(handle, enable, 0);
return 0;
}
@@ -78,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_sink(ble_device_base::GattEventSink sink) { this->sink_ = sink; }
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
@@ -153,7 +153,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
}
// Group 1: containers / large storage
ble_device_base::GattEventSink sink_;
ble_device_base::GattClientListener *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_;
@@ -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 type-erased GattEventSink the way a real backend does.
// events through the GattClientListener interface the way a real backend does.
#define USE_BLE_GATT_CLIENT
#include "esphome/components/ble_device_base/ble_gatt_client.h"
@@ -11,37 +11,34 @@
namespace esphome::ble_device_base::testing {
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) { 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) {}
// Overrides only what it records; the interface's defaults cover the rest.
class RecordingListener : public GattClientListener {
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_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; }
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_sink(GattEventSink sink) { this->sink_ = sink; }
void set_listener(GattClientListener *listener) { this->listener_ = listener; }
int connect(uint64_t address, uint8_t addr_type) {
this->sink_.on_connection_state(true, 517, 0);
this->listener_->on_connection_state(true, 517, 0);
return 0;
}
int disconnect() { return 0; }
int discover_services() {
this->sink_.on_service_discovery_done(0);
this->listener_->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) {
this->sink_.on_write_result(handle, 0);
this->listener_->on_write_result(handle, 0);
return 0;
}
int read_descriptor(uint16_t handle) { return 0; }
@@ -58,7 +55,7 @@ class MinimalConnection {
void set_connection_type(ConnectionType ct) {}
protected:
GattEventSink sink_;
GattClientListener *listener_{nullptr};
};
static_assert(BLEGattConnectionContract<MinimalConnection>,
@@ -66,8 +63,8 @@ static_assert(BLEGattConnectionContract<MinimalConnection>,
TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) {
MinimalConnection connection;
RecordingSink listener;
connection.set_sink(make_gatt_sink(&listener));
RecordingListener listener;
connection.set_listener(&listener);
EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0);
EXPECT_TRUE(listener.connected_);
EXPECT_EQ(connection.discover_services(), 0);