diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 81d879a704..eb6e87d970 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -5,8 +5,11 @@ // 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 (the hub BluetoothConnection wrapper, or a component owning a +// dedicated backend instance such as radon_eye_rd200) 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 +81,78 @@ 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 +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; +}; + +/// Type-erased consumer handle a backend delivers events through: one +/// instance pointer plus a trampoline per event. 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}; + void (*connection_state)(void *, bool, uint16_t, int){nullptr}; + void (*service_discovery_done)(void *, int){nullptr}; + void (*read_result)(void *, uint16_t, const uint8_t *, uint16_t, int){nullptr}; + void (*write_result)(void *, uint16_t, int){nullptr}; + void (*notify_state)(void *, uint16_t, bool, int){nullptr}; + void (*notify_data)(void *, uint16_t, const uint8_t *, uint16_t){nullptr}; + void (*pairing_result)(void *, int){nullptr}; + + void on_connection_state(bool connected, uint16_t mtu, int error) const { + this->connection_state(this->instance, connected, mtu, error); + } + void on_service_discovery_done(int error) const { this->service_discovery_done(this->instance, error); } + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) const { + this->read_result(this->instance, handle, data, len, error); + } + void on_write_result(uint16_t handle, int error) const { this->write_result(this->instance, handle, error); } + void on_notify_state(uint16_t handle, bool enabled, int error) const { + this->notify_state(this->instance, handle, enabled, error); + } + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) const { + this->notify_data(this->instance, handle, data, len); + } + void on_pairing_result(int status) const { this->pairing_result(this->instance, status); } +}; + +template GattEventSink make_gatt_sink(T *consumer) { + static_assert(GattClientEventSinkContract, "the consumer is missing part of the event-sink surface"); + return GattEventSink{ + consumer, + [](void *p, bool connected, uint16_t mtu, int error) { + static_cast(p)->on_connection_state(connected, mtu, error); + }, + [](void *p, int error) { static_cast(p)->on_service_discovery_done(error); }, + [](void *p, uint16_t handle, const uint8_t *data, uint16_t len, int error) { + static_cast(p)->on_read_result(handle, data, len, error); + }, + [](void *p, uint16_t handle, int error) { static_cast(p)->on_write_result(handle, error); }, + [](void *p, uint16_t handle, bool enabled, int error) { + static_cast(p)->on_notify_state(handle, enabled, error); + }, + [](void *p, uint16_t handle, const uint8_t *data, uint16_t len) { + static_cast(p)->on_notify_data(handle, data, len); + }, + [](void *p, int status) { static_cast(p)->on_pairing_result(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 @@ -88,17 +163,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 -concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { - conn.set_listener(sink); +template +concept BLEGattConnectionContract = requires(T conn, GattEventSink sink, const uint8_t *data) { + conn.set_sink(sink); { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; { conn.disconnect() } -> std::same_as; { conn.discover_services() } -> std::same_as; @@ -113,21 +190,50 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t * { conn.release_services() } -> std::same_as; }; -// 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; -}; +// ---- service table lookup helpers ---- +// +// Neutral, bounds-checked walks over a materialized GattServiceTable for +// consumers that resolve a known device's handles by UUID (the proxy streams +// the whole table to HA instead and never needs 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 diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index a9800ab81c..d5089ae7e5 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,13 +1,18 @@ -"""Per-platform GATT connection backends the Bluetooth proxy drives. +"""Per-platform GATT connection backends and the helpers to embed one. -Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no -user-facing configuration; the proxy's codegen declares and registers the -connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a +consumer's codegen declares and registers the backend instances — the +Bluetooth proxy through its per-slot connection wrappers, and components +owning a dedicated backend (e.g. radon_eye_rd200) through +gatt_client_schema() + new_gatt_backend(). """ import esphome.codegen as cg +import esphome.config_validation as cv from esphome.config_helpers import filter_source_files_from_platform from esphome.const import PLATFORM_RP2, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType def AUTO_LOAD() -> list[str]: @@ -33,6 +38,56 @@ BluedroidGattClient = bluetooth_connection_ns.class_( "BluedroidGattClient", cg.Component ) +CONF_BACKEND_ID = "backend_id" + + +def gatt_client_schema() -> cv.Schema: + """Schema fragment for one GATT backend instance: its generated id plus + the platform-stack reference new_gatt_backend() resolves. Platform + dispatch happens at call time, so call this from inside a validator or a + per-platform schema builder, never at module import. + """ + if CORE.is_esp32: + from esphome.components import esp32_ble_tracker + + return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( + {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(BluedroidGattClient)} + ) + from esphome.components import rp2040_ble + + return cv.Schema( + { + cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(RP2GattClient), + cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( + rp2040_ble.RP2040BLE + ), + } + ) + + +async def new_gatt_backend(config: ConfigType) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema(), register it + with its platform stack, and claim one neutral GATT client slot. + + On esp32 the tracker's promote loop owns connect timing, so the backend's + tracker-facing shim registers as a raw client; on rp2 the backend parents + on the BTstack controller. + """ + from esphome.components import ble_device_base + + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(config[CONF_BACKEND_ID]) + await cg.register_component(backend, config) + if CORE.is_esp32: + from esphome.components import esp32_ble_tracker + + await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config) + else: + from esphome.components import rp2040_ble + + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + return backend + FILTER_SOURCE_FILES = filter_source_files_from_platform( { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 0bcaae7717..4e8a2f17db 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -220,6 +220,7 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_ void BluedroidGattClient::release_services() { this->service_total_ = 0; + this->free_service_table_(); #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH // Only the cache clean makes the stack's database unsafe to walk. this->services_released_ = true; @@ -227,6 +228,165 @@ void BluedroidGattClient::release_services() { #endif } +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(this->table_storage_), + reinterpret_cast(this->table_storage_ + svc_bytes), + reinterpret_cast(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 allocator(RAMAllocator::ALLOC_INTERNAL); + allocator.deallocate(this->table_storage_, 0); + this->table_storage_ = nullptr; + this->table_char_total_ = 0; + this->table_desc_total_ = 0; +} + +bool BluedroidGattClient::build_service_table_() { + // Pass 1: count characteristics and descriptors so one exact-size block + // holds the whole table (descriptor counts need the characteristic handles, + // so this pass already enumerates characteristics). + uint16_t char_total = 0; + uint16_t desc_total = 0; + 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; + } + 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; + } + char_total++; + uint16_t chr_descs = 0; + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, chr.char_handle, + &chr_descs); + desc_total += chr_descs; + } + } + + // 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 allocator(RAMAllocator::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(total_bytes)); + return false; + } + auto *services = reinterpret_cast(this->table_storage_); + auto *characteristics = reinterpret_cast(this->table_storage_ + svc_bytes); + auto *descriptors = + reinterpret_cast(this->table_storage_ + svc_bytes + char_bytes); + + // Pass 2: fill. Every write is bounded by the pass-1 totals, so a + // misbehaving peripheral returning extra entries cannot overrun the block. + uint16_t char_index = 0; + uint16_t desc_index = 0; + 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) { + this->free_service_table_(); + return false; + } + auto &service = services[s]; + service.uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid); + service.start_handle = svc.start_handle; + service.end_handle = svc.end_handle; + service.first_characteristic = char_index; + for (uint16_t c = 0; char_index < char_total; 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) { + this->free_service_table_(); + return false; + } + auto &characteristic = characteristics[char_index]; + characteristic.uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid); + characteristic.value_handle = chr.char_handle; + // Bluedroid addresses descriptors by characteristic handle, so the + // table's end_handle only needs the service-bounded upper bound. + characteristic.end_handle = svc.end_handle; + characteristic.properties = chr.properties; + characteristic.first_descriptor = desc_index; + uint16_t chr_descs = 0; + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, chr.char_handle, + &chr_descs); + for (uint16_t d = 0; d < chr_descs && desc_index < desc_total; 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) { + this->free_service_table_(); + return false; + } + descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid); + descriptors[desc_index].handle = desc.handle; + desc_index++; + } + characteristic.descriptor_count = desc_index - characteristic.first_descriptor; + char_index++; + } + service.characteristic_count = char_index - service.first_characteristic; + } + if (char_index < char_total) { + // Fewer characteristics enumerated than counted: close the gap so the + // view's descriptor offset can be derived from the filled count alone. + memmove(characteristics + char_index, descriptors, desc_index * sizeof(ble_device_base::GattDescriptor)); + } + this->table_char_total_ = char_index; + this->table_desc_total_ = desc_index; + return true; +} + // ---- internals ---- bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const { @@ -246,7 +406,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, @@ -284,7 +444,7 @@ void BluedroidGattClient::handle_search_cmpl_() { esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE, 0x0001, 0xFFFF, 0, &secondary); this->service_total_ = primary + secondary; - this->listener_->on_service_discovery_done(0); + this->sink_.on_service_discovery_done(0); } void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { @@ -551,7 +711,7 @@ 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, + 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; } @@ -559,17 +719,17 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga case ESP_GATTC_WRITE_DESCR_EVT: { if (this->conn_id_ != param->write.conn_id) return false; - this->listener_->on_write_result(param->write.handle, + 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, + 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( + 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; @@ -578,7 +738,7 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga 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: @@ -599,7 +759,7 @@ 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( + this->sink_.on_pairing_result( param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason); break; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 3e0494f371..f7ee9b5403 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -53,7 +53,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,9 +67,12 @@ 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. - ble_device_base::GattServiceTable get_service_table() { return {}; } + // Materialized on demand from Bluedroid's cached database for direct + // consumers that resolve handles by UUID (e.g. radon_eye_rd200). The proxy + // wrapper never calls this - it streams through stream_service_batch - so + // proxy peak RAM is unchanged; a direct consumer's peak is bounded by its + // one known device's table. + ble_device_base::GattServiceTable get_service_table(); void release_services(); /// In-place service streamer (the wrapper detects and prefers it): builds @@ -101,10 +104,18 @@ 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); + bool build_service_table_(); + void free_service_table_(); + ble_device_base::GattServiceTable table_view_() const; // Group 1: pointers / composed objects BluedroidTrackerShim shim_{this}; - BluetoothConnection *listener_{nullptr}; + ble_device_base::GattEventSink sink_; + // 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}; // Group 2: 4-byte types int gattc_if_{ESP_GATT_IF_NONE}; uint32_t disconnecting_started_{0}; @@ -116,6 +127,9 @@ class BluedroidGattClient final : public Component { uint16_t conn_id_{0xFFFF}; uint16_t mtu_{23}; uint16_t service_total_{0}; + // Filled element counts of the materialized table (0 when none). + uint16_t table_char_total_{0}; + uint16_t table_desc_total_{0}; // Group 5: 1-byte types // Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call. diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h index 0a54622d8e..a425b644db 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -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, +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 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index fa903685f4..433f034f05 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -29,7 +29,7 @@ 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) ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc730659f5..103eab1599 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -384,8 +384,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); } @@ -448,9 +448,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 +472,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 +482,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 +508,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 +568,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 +611,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 +621,12 @@ 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, + 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 +783,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 +975,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 +1077,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; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index f3d9ecc11e..1e782cdd3a 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -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 Parentedlistener_ = 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 event_queue_; esphome::EventPool event_pool_; diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 49c6712f80..f7e02e8e52 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -58,7 +58,6 @@ _LOGGER = logging.getLogger(__name__) CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" -CONF_BACKEND_ID = "backend_id" DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") @@ -85,14 +84,17 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( - bluetooth_connection.BluedroidGattClient - ), - } - ).extend(cv.COMPONENT_SCHEMA) + CONNECTION_SCHEMA = ( + bluetooth_connection.gatt_client_schema() + .extend( + { + cv.GenerateID(): cv.declare_id( + bluetooth_connection.HubBluetoothConnection + ) + } + ) + .extend(cv.COMPONENT_SCHEMA) + ) def validate_connections(config): if CONF_CONNECTIONS in config: @@ -155,15 +157,8 @@ def _rp2_config_schema() -> cv.All: """Full proxy on the rp2 BLE hub: active connections through the BTstack GATT client backend in bluetooth_connection. The slot limit comes from the prebuilt BTstack library (one connection today); the code is built for N.""" - from esphome.components import rp2040_ble - - connection_schema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( - bluetooth_connection.RP2GattClient - ), - } + connection_schema = bluetooth_connection.gatt_client_schema().extend( + {cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection)} ) def populate_connections(config: ConfigType) -> ConfigType: @@ -183,11 +178,6 @@ def _rp2_config_schema() -> cv.All: cv.Schema( { **_COMMON_SCHEMA_KEYS, - # The GATT backend drives the controller directly (connect, GATT - # ops), not through the tracker hub. - cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( - rp2040_ble.RP2040BLE - ), cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, @@ -213,36 +203,20 @@ def _rp2_config_schema() -> cv.All: return cv.All(schema, populate_connections) -async def _connections_to_code( - var: cg.MockObj, config: ConfigType, register_backend -) -> None: - """One wrapper + backend pair per slot; register_backend supplies the - platform's backend registration (tracker client on esp32, controller - parent on rp2).""" +async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: + """One wrapper + backend pair per slot; the platform-specific backend + registration lives in bluetooth_connection.new_gatt_backend().""" for connection_conf in config.get(CONF_CONNECTIONS, []): - ble_device_base.request_gatt_client() - backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) - await cg.register_component(backend, connection_conf) - await register_backend(backend, connection_conf, config) + backend = await bluetooth_connection.new_gatt_backend(connection_conf) connection = cg.new_Pvariable(connection_conf[CONF_ID]) cg.add(connection.set_backend(backend)) cg.add(var.register_connection(connection)) -async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble - - async def register_backend(backend, connection_conf, config): - await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) - - await _connections_to_code(var, config, register_backend) - - -# Per-platform schema builders and connection codegen; every key of -# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by -# tests/component_tests/bluetooth_proxy/). +# Per-platform schema builders; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by +# tests/component_tests/bluetooth_proxy/). Connection codegen is shared. _GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} -_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code} # Keys every platform arm declares identically; each arm spreads this dict so @@ -397,13 +371,7 @@ async def _to_code_esp32(config: ConfigType) -> None: connection_count = len(config.get(CONF_CONNECTIONS, [])) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) - async def register_backend(backend, connection_conf, _config): - # The tracker promote loop drives connect timing through the shim. - await esp32_ble_tracker.register_raw_client( - backend.tracker_client(), connection_conf - ) - - await _connections_to_code(var, config, register_backend) + await _connections_to_code(var, config) if config.get(CONF_CACHE_SERVICES): add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) @@ -426,7 +394,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None: if not slots: return - await _GATT_HUB_TO_CODE[CORE.target_platform](var, config) + await _connections_to_code(var, config) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index de5bd3d8d5..5aad76ea21 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -1,123 +1,159 @@ #include "radon_eye_rd200.h" -#include "esphome/components/esp32_ble/ble_uuid.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "esphome/core/log.h" #include -#ifdef USE_ESP32 - namespace esphome::radon_eye_rd200 { static const char *const TAG = "radon_eye_rd200"; -static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID_V1 = - esp32_ble_tracker::ESPBTUUID::from_raw("00001523-1212-efde-1523-785feabcd123"); -static const esp32_ble_tracker::ESPBTUUID WRITE_CHARACTERISTIC_UUID_V1 = - esp32_ble_tracker::ESPBTUUID::from_raw("00001524-1212-efde-1523-785feabcd123"); -static const esp32_ble_tracker::ESPBTUUID READ_CHARACTERISTIC_UUID_V1 = - esp32_ble_tracker::ESPBTUUID::from_raw("00001525-1212-efde-1523-785feabcd123"); +using ble_device_base::ESPBTUUID; + +// V1 (RD200 firmware < 2.0) exposes a vendor service; V2 (>= 2.0) moved to a +// 16-bit-based service with a different command byte and payload layout. +static const char *const SERVICE_UUID_V1 = "00001523-1212-efde-1523-785feabcd123"; +static const char *const WRITE_CHARACTERISTIC_UUID_V1 = "00001524-1212-efde-1523-785feabcd123"; +static const char *const READ_CHARACTERISTIC_UUID_V1 = "00001525-1212-efde-1523-785feabcd123"; static const uint8_t WRITE_COMMAND_V1 = 0x50; -static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID_V2 = - esp32_ble_tracker::ESPBTUUID::from_raw("00001523-0000-1000-8000-00805f9b34fb"); -static const esp32_ble_tracker::ESPBTUUID WRITE_CHARACTERISTIC_UUID_V2 = - esp32_ble_tracker::ESPBTUUID::from_raw("00001524-0000-1000-8000-00805f9b34fb"); -static const esp32_ble_tracker::ESPBTUUID READ_CHARACTERISTIC_UUID_V2 = - esp32_ble_tracker::ESPBTUUID::from_raw("00001525-0000-1000-8000-00805f9b34fb"); +static const char *const SERVICE_UUID_V2 = "00001523-0000-1000-8000-00805f9b34fb"; +static const char *const WRITE_CHARACTERISTIC_UUID_V2 = "00001524-0000-1000-8000-00805f9b34fb"; +static const char *const READ_CHARACTERISTIC_UUID_V2 = "00001525-0000-1000-8000-00805f9b34fb"; static const uint8_t WRITE_COMMAND_V2 = 0x40; -void RadonEyeRD200::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) { - switch (event) { - case ESP_GATTC_OPEN_EVT: { - if (param->open.status == ESP_GATT_OK) { - ESP_LOGI(TAG, "Connected successfully!"); - } - break; - } +// BLE public address type (shared code space with the API/backends). +static const uint8_t BLE_ADDR_TYPE_PUBLIC = 0; - case ESP_GATTC_DISCONNECT_EVT: { - ESP_LOGW(TAG, "Disconnected!"); - break; - } - - case ESP_GATTC_SEARCH_CMPL_EVT: { - if (this->parent()->get_service(SERVICE_UUID_V1) != nullptr) { - service_uuid_ = SERVICE_UUID_V1; - sensors_write_characteristic_uuid_ = WRITE_CHARACTERISTIC_UUID_V1; - sensors_read_characteristic_uuid_ = READ_CHARACTERISTIC_UUID_V1; - write_command_ = WRITE_COMMAND_V1; - } else if (this->parent()->get_service(SERVICE_UUID_V2) != nullptr) { - service_uuid_ = SERVICE_UUID_V2; - sensors_write_characteristic_uuid_ = WRITE_CHARACTERISTIC_UUID_V2; - sensors_read_characteristic_uuid_ = READ_CHARACTERISTIC_UUID_V2; - write_command_ = WRITE_COMMAND_V2; - } else { - ESP_LOGW(TAG, "No supported device has been found, disconnecting"); - parent()->set_enabled(false); - break; - } - - this->read_handle_ = 0; - auto *chr = this->parent()->get_characteristic(service_uuid_, sensors_read_characteristic_uuid_); - if (chr == nullptr) { - char service_buf[esp32_ble::UUID_STR_LEN]; - char char_buf[esp32_ble::UUID_STR_LEN]; - ESP_LOGW(TAG, "No sensor read characteristic found at service %s char %s", service_uuid_.to_str(service_buf), - sensors_read_characteristic_uuid_.to_str(char_buf)); - break; - } - this->read_handle_ = chr->handle; - - auto *write_chr = this->parent()->get_characteristic(service_uuid_, sensors_write_characteristic_uuid_); - if (write_chr == nullptr) { - char service_buf[esp32_ble::UUID_STR_LEN]; - char char_buf[esp32_ble::UUID_STR_LEN]; - ESP_LOGW(TAG, "No sensor write characteristic found at service %s char %s", service_uuid_.to_str(service_buf), - sensors_write_characteristic_uuid_.to_str(char_buf)); - break; - } - this->write_handle_ = write_chr->handle; - - esp_err_t status = - esp_ble_gattc_register_for_notify(gattc_if, this->parent()->get_remote_bda(), this->read_handle_); - if (status) { - ESP_LOGW(TAG, "Error registering for sensor notify, status=%d", status); - } - break; - } - - case ESP_GATTC_WRITE_DESCR_EVT: { - if (param->write.status != ESP_GATT_OK) { - ESP_LOGE(TAG, "write descr failed, error status = %x", param->write.status); - break; - } - ESP_LOGV(TAG, "Write descr success, writing 0x%02X at write_handle=%d", this->write_command_, - this->write_handle_); - esp_err_t status = - esp_ble_gattc_write_char(gattc_if, this->parent()->get_conn_id(), this->write_handle_, sizeof(write_command_), - (uint8_t *) &write_command_, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "Error writing 0x%02x command, status=%d", write_command_, status); - } - break; - } - - case ESP_GATTC_NOTIFY_EVT: { - if (param->notify.is_notify) { - ESP_LOGV(TAG, "ESP_GATTC_NOTIFY_EVT, receive notify value, %d bytes", param->notify.value_len); - } else { - ESP_LOGV(TAG, "ESP_GATTC_NOTIFY_EVT, receive indicate value, %d bytes", param->notify.value_len); - } - read_sensors_(param->notify.value, param->notify.value_len); - break; - } - - default: - break; +void RadonEyeRD200::update() { + if (this->busy_) { + ESP_LOGW(TAG, "Connection in progress"); + return; + } + ESP_LOGD(TAG, "Connecting"); + if (this->backend_->connect(this->address_, BLE_ADDR_TYPE_PUBLIC) == 0) { + this->busy_ = true; + } else { + ESP_LOGW(TAG, "Connect request rejected, will retry"); } } -void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) { +void RadonEyeRD200::on_connection_state(bool connected, uint16_t mtu, int error) { + if (!connected) { + if (error != 0) { + ESP_LOGW(TAG, "Disconnected, status=%d", error); + } + this->busy_ = false; + return; + } + ESP_LOGI(TAG, "Connected successfully!"); + if (this->backend_->discover_services() != 0) { + this->abort_connection_(); + } +} + +void RadonEyeRD200::on_service_discovery_done(int error) { + if (error != 0) { + ESP_LOGW(TAG, "Service discovery failed, status=%d", error); + this->abort_connection_(); + return; + } + bool resolved = this->resolve_handles_(); + // The table is backend-owned transient storage; release before continuing. + this->backend_->release_services(); + if (!resolved) { + this->abort_connection_(); + return; + } + // Local notification registration; the CCCD write follows in + // on_notify_state (the contract leaves the CCCD to the client). + if (this->backend_->notify_characteristic(this->read_handle_, true) != 0) { + this->abort_connection_(); + } +} + +bool RadonEyeRD200::resolve_handles_() { + auto table = this->backend_->get_service_table(); + const char *write_uuid; + const char *read_uuid; + const ble_device_base::GattService *service; + if ((service = ble_device_base::find_service(table, ESPBTUUID::from_raw(SERVICE_UUID_V1))) != nullptr) { + write_uuid = WRITE_CHARACTERISTIC_UUID_V1; + read_uuid = READ_CHARACTERISTIC_UUID_V1; + this->write_command_ = WRITE_COMMAND_V1; + } else if ((service = ble_device_base::find_service(table, ESPBTUUID::from_raw(SERVICE_UUID_V2))) != nullptr) { + write_uuid = WRITE_CHARACTERISTIC_UUID_V2; + read_uuid = READ_CHARACTERISTIC_UUID_V2; + this->write_command_ = WRITE_COMMAND_V2; + } else { + ESP_LOGW(TAG, "No supported device has been found, disconnecting"); + return false; + } + + const auto *read_chr = ble_device_base::find_characteristic(table, *service, ESPBTUUID::from_raw(read_uuid)); + if (read_chr == nullptr) { + ESP_LOGW(TAG, "No sensor read characteristic found at char %s", read_uuid); + return false; + } + const auto *write_chr = ble_device_base::find_characteristic(table, *service, ESPBTUUID::from_raw(write_uuid)); + if (write_chr == nullptr) { + ESP_LOGW(TAG, "No sensor write characteristic found at char %s", write_uuid); + return false; + } + this->cccd_handle_ = ble_device_base::find_cccd(table, *read_chr); + if (this->cccd_handle_ == 0) { + ESP_LOGW(TAG, "Sensor read characteristic has no CCCD"); + return false; + } + this->read_handle_ = read_chr->value_handle; + this->write_handle_ = write_chr->value_handle; + return true; +} + +void RadonEyeRD200::on_notify_state(uint16_t handle, bool enabled, int error) { + if (error != 0) { + ESP_LOGW(TAG, "Error registering for sensor notify, status=%d", error); + this->abort_connection_(); + return; + } + static const uint8_t enable_notify[2] = {0x01, 0x00}; + if (this->backend_->write_descriptor(this->cccd_handle_, enable_notify, sizeof(enable_notify)) != 0) { + this->abort_connection_(); + } +} + +void RadonEyeRD200::on_write_result(uint16_t handle, int error) { + // The command write (no response on some backends) also lands here; only + // the CCCD completion advances the sequence. + if (handle != this->cccd_handle_) { + return; + } + if (error != 0) { + ESP_LOGE(TAG, "write descr failed, error status = %x", error); + this->abort_connection_(); + return; + } + ESP_LOGV(TAG, "Write descr success, writing 0x%02X at write_handle=%d", this->write_command_, this->write_handle_); + if (this->backend_->write_characteristic(this->write_handle_, &this->write_command_, sizeof(this->write_command_), + false) != 0) { + ESP_LOGW(TAG, "Error writing 0x%02x command", this->write_command_); + this->abort_connection_(); + } +} + +void RadonEyeRD200::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { + ESP_LOGV(TAG, "Received notify value, %d bytes", len); + this->read_sensors_(data, len); + // This instance must not stay connected so other clients can connect to it + // (e.g. the mobile app). + this->backend_->disconnect(); +} + +void RadonEyeRD200::abort_connection_() { this->backend_->disconnect(); } + +void RadonEyeRD200::read_sensors_(const uint8_t *value, uint16_t value_len) { if (value_len < 1) { ESP_LOGW(TAG, "Unexpected empty message"); return; @@ -185,22 +221,6 @@ void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) { " Measurements (pCi/L) now: %0.03f, day: %0.03f, month: %0.03f", radon_now, radon_day, radon_month, radon_now / convert_to_bwpm3, radon_day / convert_to_bwpm3, radon_month / convert_to_bwpm3); - - // This instance must not stay connected - // so other clients can connect to it (e.g. the - // mobile app). - parent()->set_enabled(false); -} - -void RadonEyeRD200::update() { - if (this->node_state != esp32_ble_tracker::ClientState::ESTABLISHED) { - if (!parent()->enabled) { - ESP_LOGW(TAG, "Reconnecting to device"); - parent()->set_enabled(true); - } else { - ESP_LOGW(TAG, "Connection in progress"); - } - } } void RadonEyeRD200::dump_config() { @@ -208,8 +228,6 @@ void RadonEyeRD200::dump_config() { LOG_SENSOR(" ", "Radon Long Term", this->radon_long_term_sensor_); } -RadonEyeRD200::RadonEyeRD200() : PollingComponent(10000) {} - } // namespace esphome::radon_eye_rd200 -#endif // USE_ESP32 +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index 401402a137..ae3fd35d6e 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -1,45 +1,74 @@ +// RD200 radon sensor over the platform-neutral GATT client contract +// (ble_device_base/ble_gatt_client.h). The component owns a dedicated +// backend instance and is its event sink — the first ble_client-family +// component migrated off the esp32-only BLEClientNode, which also enables it +// on every platform with a GATT backend (esp32 and rp2 today). +// +// Poll cycle: connect → discover → resolve handles by UUID from the service +// table → subscribe (local registration, then an explicit CCCD write — the +// contract makes the CCCD the client's job) → write the read command → parse +// the notification → disconnect. The link is dropped after every reading so +// the vendor mobile app can connect between polls. + #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" -#include -#include -#include -#include "esphome/components/ble_client/ble_client.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#ifdef USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" -#include "esphome/core/log.h" namespace esphome::radon_eye_rd200 { -class RadonEyeRD200 final : public PollingComponent, public ble_client::BLEClientNode { +class RadonEyeRD200 final : public PollingComponent { public: - RadonEyeRD200(); + RadonEyeRD200(ble_device_base::BLEGattConnection *backend, uint64_t address) : address_(address), backend_(backend) { + backend->set_sink(ble_device_base::make_gatt_sink(this)); + } void dump_config() override; void update() override; - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override; + void set_radon(sensor::Sensor *radon) { this->radon_sensor_ = radon; } + void set_radon_long_term(sensor::Sensor *radon_long_term) { this->radon_long_term_sensor_ = radon_long_term; } - void set_radon(sensor::Sensor *radon) { radon_sensor_ = radon; } - void set_radon_long_term(sensor::Sensor *radon_long_term) { radon_long_term_sensor_ = radon_long_term; } + // ---- backend event sink ---- + 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: - void read_sensors_(uint8_t *value, uint16_t value_len); + bool resolve_handles_(); + void read_sensors_(const uint8_t *value, uint16_t value_len); + void abort_connection_(); + // Group 1: 8-byte types + uint64_t address_; + + // Group 2: pointers + ble_device_base::BLEGattConnection *backend_; sensor::Sensor *radon_sensor_{nullptr}; sensor::Sensor *radon_long_term_sensor_{nullptr}; - uint8_t write_command_; - uint16_t read_handle_; - uint16_t write_handle_; - esp32_ble_tracker::ESPBTUUID service_uuid_; - esp32_ble_tracker::ESPBTUUID sensors_write_characteristic_uuid_; - esp32_ble_tracker::ESPBTUUID sensors_read_characteristic_uuid_; + // Group 3: 2-byte types + uint16_t read_handle_{0}; + uint16_t write_handle_{0}; + uint16_t cccd_handle_{0}; + + // Group 4: 1-byte types + uint8_t write_command_{0}; + // A connect was accepted and the link is not torn down yet; cleared by + // on_connection_state(false). + bool busy_{false}; }; } // namespace esphome::radon_eye_rd200 -#endif // USE_ESP32 +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/radon_eye_rd200/sensor.py b/esphome/components/radon_eye_rd200/sensor.py index da04328218..67a22634b9 100644 --- a/esphome/components/radon_eye_rd200/sensor.py +++ b/esphome/components/radon_eye_rd200/sensor.py @@ -1,54 +1,90 @@ +import functools + import esphome.codegen as cg -from esphome.components import ble_client, sensor +from esphome.components import bluetooth_connection, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_MAC_ADDRESS, CONF_RADON, CONF_RADON_LONG_TERM, ICON_RADIOACTIVE, + PLATFORM_ESP32, + PLATFORM_RP2, STATE_CLASS_MEASUREMENT, UNIT_BECQUEREL_PER_CUBIC_METER, ) +from esphome.core import CORE +from esphome.types import ConfigType + + +def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: + """The GATT backend plus the platform BLE stack it registers with; the + union arm serves tooling that resolves the manifest without a target + platform (the bluetooth_proxy pattern).""" + if CORE.is_esp32: + return ["bluetooth_connection", "esp32_ble_tracker"] + if CORE.target_platform == PLATFORM_RP2: + return ["bluetooth_connection", "rp2040_ble"] + return ["bluetooth_connection", "esp32_ble_tracker", "rp2040_ble"] -DEPENDENCIES = ["ble_client"] radon_eye_rd200_ns = cg.esphome_ns.namespace("radon_eye_rd200") -RadonEyeRD200 = radon_eye_rd200_ns.class_( - "RadonEyeRD200", cg.PollingComponent, ble_client.BLEClientNode +RadonEyeRD200 = radon_eye_rd200_ns.class_("RadonEyeRD200", cg.PollingComponent) + +_SENSOR_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + icon=ICON_RADIOACTIVE, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, ) + +@functools.lru_cache(maxsize=None) +def _schema_for_platform(platform: str) -> cv.Schema | cv.All: + """Built per platform (cached): the backend id's class and the esp32 + controller-slot consumption depend on the target.""" + schema = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RadonEyeRD200), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_RADON): _SENSOR_SCHEMA, + cv.Optional(CONF_RADON_LONG_TERM): _SENSOR_SCHEMA, + } + ) + .extend(cv.polling_component_schema("5min")) + .extend(bluetooth_connection.gatt_client_schema()) + ) + if platform == PLATFORM_ESP32: + from esphome.components import esp32_ble + + return cv.All( + schema, esp32_ble.consume_connection_slots(1, "radon_eye_rd200") + ) + return schema + + +def _platform_schema(config: ConfigType) -> ConfigType: + return _schema_for_platform(CORE.target_platform)(config) + + CONFIG_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(RadonEyeRD200), - cv.Optional(CONF_RADON): sensor.sensor_schema( - unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, - icon=ICON_RADIOACTIVE, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_RADON_LONG_TERM): sensor.sensor_schema( - unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, - icon=ICON_RADIOACTIVE, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - ), - } - ) - .extend(cv.polling_component_schema("5min")) - .extend(ble_client.BLE_CLIENT_SCHEMA), + cv.only_on([PLATFORM_ESP32, PLATFORM_RP2]), + _platform_schema, ) -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) +async def to_code(config: ConfigType) -> None: + backend = await bluetooth_connection.new_gatt_backend(config) + var = cg.new_Pvariable( + config[CONF_ID], backend, config[CONF_MAC_ADDRESS].as_hex + ) await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) - - if CONF_RADON in config: - sens = await sensor.new_sensor(config[CONF_RADON]) + if (radon := config.get(CONF_RADON)) is not None: + sens = await sensor.new_sensor(radon) cg.add(var.set_radon(sens)) - if CONF_RADON_LONG_TERM in config: - sens = await sensor.new_sensor(config[CONF_RADON_LONG_TERM]) + if (radon_long_term := config.get(CONF_RADON_LONG_TERM)) is not None: + sens = await sensor.new_sensor(radon_long_term) cg.add(var.set_radon_long_term(sens)) diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index ec3cf8bd4c..8310bd6ded 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -186,12 +186,12 @@ def test_bluetooth_connection_auto_load_covers_its_includes() -> None: def test_every_registered_hub_platform_has_a_schema_arm() -> None: - # A platform added to HUB_MAX_CONNECTIONS without a schema builder, - # codegen arm, or _HUB_PLATFORMS entry would only fail when a config for - # it is validated (or not even then); pin all three couplings here. + # A platform added to HUB_MAX_CONNECTIONS without a schema builder or + # _HUB_PLATFORMS entry would only fail when a config for it is validated + # (or not even then); pin both couplings here. Connection codegen is + # shared (bluetooth_connection.new_gatt_backend), so it needs no arm. registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) - assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE) assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) # The outer walkable schema's bound must stay the loosest platform cap. assert ( 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 25b6cbf002..da12873e48 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -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, "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; } @@ -53,21 +55,23 @@ class MinimalConnection { void release_services() {} protected: - RecordingSink *listener_{nullptr}; + GattEventSink sink_; }; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "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(); @@ -76,4 +80,53 @@ 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 diff --git a/tests/components/radon_eye_rd200/common.yaml b/tests/components/radon_eye_rd200/common.yaml index d06979be6f..3a52846a21 100644 --- a/tests/components/radon_eye_rd200/common.yaml +++ b/tests/components/radon_eye_rd200/common.yaml @@ -1,12 +1,6 @@ -esp32_ble_tracker: - -ble_client: - - mac_address: 01:02:03:04:05:06 - id: radon_eye_blec - sensor: - platform: radon_eye_rd200 - ble_client_id: radon_eye_blec + mac_address: 01:02:03:04:05:06 radon: name: RD200 Radon radon_long_term: diff --git a/tests/components/radon_eye_rd200/test.esp32-idf.yaml b/tests/components/radon_eye_rd200/test.esp32-idf.yaml index 7a6541ae76..a0fd2398d9 100644 --- a/tests/components/radon_eye_rd200/test.esp32-idf.yaml +++ b/tests/components/radon_eye_rd200/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: ble: !include ../../test_build_components/common/ble/esp32-idf.yaml - -<<: !include common.yaml + radon_eye_rd200: !include common.yaml diff --git a/tests/components/radon_eye_rd200/test.rp2040-ard.yaml b/tests/components/radon_eye_rd200/test.rp2040-ard.yaml new file mode 100644 index 0000000000..8d43379602 --- /dev/null +++ b/tests/components/radon_eye_rd200/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +# The BTstack GATT backend and its rp2040_ble controller are auto-loaded; +# no tracker hub is needed for a dedicated GATT client. +packages: + radon_eye_rd200: !include common.yaml