diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 1bcfcf99dc..74548f578f 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -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 #include namespace esphome::ble_device_base { @@ -76,64 +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; +// 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 +concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { + conn.set_listener(sink); + { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; + { conn.disconnect() } -> std::same_as; + { conn.discover_services() } -> std::same_as; + { conn.read_characteristic(uint16_t{}) } -> std::same_as; + { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; + { conn.read_descriptor(uint16_t{}) } -> std::same_as; + { conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { conn.notify_characteristic(uint16_t{}, true) } -> std::same_as; + { conn.pair() } -> std::same_as; + { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; + { conn.get_service_table() } -> std::same_as; + { conn.release_services() } -> std::same_as; }; -/// 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; - 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 +concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { + { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; + { sink.on_service_discovery_done(int{}) } -> std::same_as; + { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; + { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; + { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; + { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { sink.on_pairing_result(int{}) } -> std::same_as; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 712251b157..2125d5b34f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -48,20 +48,29 @@ static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_C // What the platform's connection backend supports beyond GATT operations; // the proxy derives its feature flags and legacy version from these. -#ifdef USE_ESP32 +#if defined(USE_ESP32) static constexpr bool SUPPORTS_PAIRING = true; static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) +// The rp2 BTstack backend pairs (just works + bonding); it has no service +// cache to clear. Keyed on the backend, not the generic client define, so a +// future backend without pairing keeps the stub arm below. +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; #else static constexpr bool SUPPORTS_PAIRING = false; static constexpr bool SUPPORTS_CACHE_CLEARING = false; #endif // Address-scoped (not connection-scoped) maintenance requests. -#ifdef USE_ESP32 +#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)) conn_err_t unpair_device(uint64_t address); -conn_err_t clear_gatt_cache(uint64_t address); #else inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +#endif +#ifdef USE_ESP32 +conn_err_t clear_gatt_cache(uint64_t address); +#else inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } #endif diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h new file mode 100644 index 0000000000..d8792b88c1 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -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, + "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 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 37d6b21dfe..ec03f18e1d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -74,6 +74,16 @@ void BluetoothConnection::check_disconnect_timeout_() { } } +void BluetoothConnection::on_pairing_result(int status) { + if (this->address_ == 0) { + // A drop before completion already answered: reset_connection_slot_ sends + // the connection response, which the client's pair watcher raises on. + return; + } + this->paired_ = status == 0; + this->proxy_->send_device_pairing(this->address_, status == 0, status); +} + void BluetoothConnection::reset_connection_(conn_err_t reason) { if (this->pending_error_ != 0) { reason = this->pending_error_; @@ -81,11 +91,12 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { } this->state_ = ClientState::IDLE; this->services_discovered_ = false; + this->paired_ = false; this->backend_->release_services(); 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) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 83fbd24e4c..e79ee9e7a8 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -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) { @@ -49,6 +49,9 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene this->start_connect_(); } void disconnect(); + bool is_paired() const { return this->paired_; } + void set_unpaired() { this->paired_ = false; } + conn_err_t pair() { return this->backend_->pair(); } // A backend disconnect() is a single call that also cancels an in-progress // connect; there is no deferred-disconnect state to track. bool disconnect_pending() const { return false; } @@ -80,13 +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; + // ---- 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; @@ -98,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}; @@ -118,12 +121,16 @@ class BluetoothConnection final : public ble_device_base::GattClientEventListene // Group 5: 1-byte types ClientState state_{ClientState::IDLE}; + bool paired_{false}; ConnectionType connection_type_{ConnectionType::V1}; uint8_t remote_addr_type_{0}; uint8_t connection_index_{0}; bool services_discovered_{false}; }; +static_assert(ble_device_base::GattClientEventSinkContract, + "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 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 5eb3da0263..dc730659f5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,5 +1,8 @@ #include "bluetooth_connection_rp2.h" +#include "bluetooth_connection_hub.h" +#include "bluetooth_connection.h" + #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) #include "esphome/core/hal.h" @@ -51,6 +54,7 @@ using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; uint8_t RP2GattClient::instance_count = 0; btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { @@ -88,6 +92,8 @@ void RP2GattClient::setup() { if (hci_event_registration.callback == nullptr) { hci_event_registration.callback = &RP2GattClient::hci_packet_handler; hci_add_event_handler(&hci_event_registration); + sm_event_registration.callback = &RP2GattClient::sm_packet_handler; + sm_add_event_handler(&sm_event_registration); } } @@ -157,6 +163,38 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t * } } +void RP2GattClient::sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + switch (hci_event_packet_get_type(packet)) { + case SM_EVENT_JUST_WORKS_REQUEST: + // Confirming from the SM callback is the intended BTstack pattern. + // Unscoped on purpose: no peripheral role exists in-tree, and scoping + // would drop a request racing the queued CONNECTED event. + sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); + break; + case SM_EVENT_PAIRING_COMPLETE: { + RP2GattClient *inst = instance_for_con_handle(sm_event_pairing_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_pairing_complete_get_status(packet), 0); + } + break; + } + case SM_EVENT_REENCRYPTION_COMPLETE: { + // A bonded peer re-encrypts instead of pairing; BTstack emits only this + // event on that path, so it answers the PAIR request too. + RP2GattClient *inst = instance_for_con_handle(sm_event_reencryption_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_reencryption_complete_get_status(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; @@ -447,6 +485,11 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::WRITE_NO_RSP_DONE: this->finish_write_no_rsp_(event.status); break; + case RP2GattEvent::PAIRING_RESULT: + if (this->listener_ != nullptr) { + this->listener_->on_pairing_result(event.status); + } + break; } } @@ -1019,6 +1062,15 @@ int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16 return 0; } +int RP2GattClient::pair() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + sm_request_pairing(this->con_handle_); // void API; completion via SM events + return 0; +} + int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { if (this->state_ != EngineState::READY) { return GATT_ERR_NOT_CONNECTED; @@ -1064,6 +1116,30 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_ return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); } +conn_err_t unpair_device(uint64_t address) { + uint8_t mac[6]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + bool found = false; + BluetoothLock lock; + // Exhaustive: the db keys on (type, address), so stale entries can share + // the same address bytes under different types. + for (int i = 0; i < le_device_db_max_count(); i++) { + int addr_type = 0; + bd_addr_t addr; + le_device_db_info(i, &addr_type, addr, nullptr); + if (addr_type != BD_ADDR_TYPE_UNKNOWN && memcmp(addr, mac, sizeof(bd_addr_t)) == 0) { + le_device_db_remove(i); + found = true; + } + } + if (found) { + return CONN_OK; + } + // No bond for this address; the shared error domain has no closer code + // (esp32 parity: its remove-bond call also errors for an unknown address). + return GATT_NOT_CONNECTED; +} + } // namespace esphome::bluetooth_connection #endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index 0c1bc95fe9..d5bf76e6ee 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -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 @@ -51,6 +52,7 @@ struct RP2GattEvent { MTU_EXCHANGED, // value = negotiated MTU QUERY_COMPLETE, // status = ATT status of the finished query WRITE_NO_RSP_DONE, // status = result of the deferred write + PAIRING_RESULT, // status = SM pairing status (0 = bonded) }; Type type; uint8_t status; @@ -71,28 +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 { +class RP2GattClient final : public Component, public Parented { 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; + 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 — @@ -120,6 +122,7 @@ class RP2GattClient final : public Component, // 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 void sm_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); @@ -147,6 +150,7 @@ class RP2GattClient final : public Component, } // Group 1: containers / large storage + BluetoothConnection *listener_{nullptr}; ServiceArena *arena_{nullptr}; esphome::LockFreeQueue event_queue_; esphome::EventPool event_pool_; @@ -205,6 +209,8 @@ class RP2GattClient final : public Component, static uint8_t instance_count; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) static btstack_packet_callback_registration_t hci_event_registration; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t sm_event_registration; }; } // namespace esphome::bluetooth_connection diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 0902aad924..e4b9cd5100 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -92,6 +92,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint16_t get_conn_id() const { return this->conn_id_; } uint64_t get_address() const { return this->address_; } bool is_paired() const { return this->paired_; } + // The proxy clears this when a bond is removed while the link is up. + void set_unpaired() { this->paired_ = false; } uint8_t get_connection_index() const { return this->connection_index_; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ad24d27369..4b50553ed8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -252,6 +252,7 @@ // platforms whose API/network types the proxy header cannot assume. #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_RP2) #define USE_BLUETOOTH_PROXY +#define USE_BLE_SCANNER_STATE_CALLBACK // Mirror the codegen values per platform: _to_code_esp32() emits the connection // count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits // the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index fb743b2699..25b6cbf002 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -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,47 +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, "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}; }; +static_assert(BLEGattConnectionContract, + "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_); diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..eb6e174c0c --- /dev/null +++ b/tests/components/bluetooth_connection/__init__.py @@ -0,0 +1,19 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # close_service_batch compiles only under BLUETOOTH_CONNECTION_HAS_GATT; + # emit the backend define so the host build exercises it. + async def to_code_testing(config): + # 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) + + manifest.to_code = to_code_testing + # The batcher sizes api protobuf messages. + manifest.dependencies = manifest.dependencies + ["api"] diff --git a/tests/components/bluetooth_connection/test_close_service_batch.cpp b/tests/components/bluetooth_connection/test_close_service_batch.cpp new file mode 100644 index 0000000000..601ff9202b --- /dev/null +++ b/tests/components/bluetooth_connection/test_close_service_batch.cpp @@ -0,0 +1,58 @@ +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" + +#include + +#include "esphome/components/api/api_pb2.h" + +namespace esphome::bluetooth_connection { + +// The three cursor behaviors: a fitting service advances and continues, an +// overflowing batch with >1 service pops and retries it, and a single +// oversized service is force-advanced so the stream cannot wedge. + +static void add_service(api::BluetoothGATTGetServicesResponse &resp, uint16_t characteristics) { + resp.services.emplace_back(); + auto &svc = resp.services.back(); + svc.handle = resp.services.size(); + svc.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + svc.characteristics.init(characteristics); + for (uint16_t i = 0; i < characteristics; i++) { + auto &chr = svc.characteristics.emplace_back(); + chr.handle = 100 + i; + chr.properties = 0x12; + chr.uuid = {0x1234567890ABCDEFULL, 0xFEDCBA0987654321ULL}; + } +} + +TEST(CloseServiceBatch, FittingServiceAdvancesAndContinues) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + size_t current_size = 0; + int16_t cursor = 0; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::CONTINUE); + EXPECT_EQ(cursor, 1); + EXPECT_GT(current_size, 0u); +} + +TEST(CloseServiceBatch, OverflowPopsAndRetriesWithoutAdvancing) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 1); + add_service(resp, 1); + size_t current_size = MAX_PACKET_SIZE - 10; // any service is bigger than 10 bytes + int16_t cursor = 5; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(resp.services.size(), 1u); // popped for the next batch + EXPECT_EQ(cursor, 5); // not advanced: retried next batch +} + +TEST(CloseServiceBatch, SingleOversizedServiceForceAdvances) { + api::BluetoothGATTGetServicesResponse resp; + add_service(resp, 60); // ~30 bytes per characteristic, far past the budget + ASSERT_GT(resp.services.back().calculate_size(), MAX_PACKET_SIZE); + size_t current_size = 0; + int16_t cursor = 7; + EXPECT_EQ(close_service_batch(resp, current_size, cursor, 0, "AA:BB"), BatchClose::SEND); + EXPECT_EQ(cursor, 8); // advanced despite not fitting, so the stream moves on +} + +} // namespace esphome::bluetooth_connection