From 8a88120143df6a647cf528f73c3f9569e9fd9012 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2026 23:53:07 -0500 Subject: [PATCH] Simplify: flash vtable sink, shared table walker, one proxy predicate, slot ledger --- .../ble_device_base/ble_gatt_client.h | 87 +++++---- .../bluetooth_connection/__init__.py | 88 +++++++-- .../bluetooth_connection.cpp | 4 +- .../bluetooth_connection.h | 12 +- .../bluetooth_connection_bluedroid.cpp | 168 ++++++++++-------- .../bluetooth_connection_bluedroid.h | 8 +- .../bluetooth_connection_hub.cpp | 4 +- .../bluetooth_connection_hub.h | 8 +- .../bluetooth_connection_rp2.cpp | 4 +- .../components/bluetooth_proxy/__init__.py | 18 +- .../radon_eye_rd200/radon_eye_rd200.cpp | 114 ++++++------ .../radon_eye_rd200/radon_eye_rd200.h | 15 +- .../bluetooth_proxy/test_platform_gates.py | 2 +- .../radon_eye_rd200/test.esp32-idf.yaml | 3 +- 14 files changed, 302 insertions(+), 233 deletions(-) diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 9a5cdc3969..4aa3581d49 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -97,60 +97,69 @@ concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { { 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. +/// One trampoline per event, shared by every instance of a consumer type. +struct GattEventVTable { + void (*connection_state)(void *, bool, uint16_t, int); + void (*service_discovery_done)(void *, int); + void (*read_result)(void *, uint16_t, const uint8_t *, uint16_t, int); + void (*write_result)(void *, uint16_t, int); + void (*notify_state)(void *, uint16_t, bool, int); + void (*notify_data)(void *, uint16_t, const uint8_t *, uint16_t); + void (*pairing_result)(void *, int); +}; + +// The per-consumer-type table lives in flash (constexpr), so a sink costs +// two pointers of RAM regardless of how many events the surface carries. +template +inline constexpr GattEventVTable GATT_EVENT_VTABLE{ + [](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); }, +}; + +/// Type-erased consumer handle a backend delivers events through: an +/// instance pointer plus the consumer type's trampoline table. Built with +/// make_gatt_sink() from any type satisfying GattClientEventSinkContract; +/// call sites read the same as a direct listener call. No virtuals, no heap — +/// the cost of supporting several consumer types in one build is one +/// indirect call per event. Codegen wires the sink before setup(), so +/// backends may call without a null check. struct GattEventSink { void *instance{nullptr}; - 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}; + const GattEventVTable *vtable{nullptr}; void on_connection_state(bool connected, uint16_t mtu, int error) const { - this->connection_state(this->instance, connected, mtu, error); + this->vtable->connection_state(this->instance, connected, mtu, error); } - void on_service_discovery_done(int error) const { this->service_discovery_done(this->instance, error); } + void on_service_discovery_done(int error) const { this->vtable->service_discovery_done(this->instance, error); } void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) const { - this->read_result(this->instance, handle, data, len, error); + this->vtable->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_write_result(uint16_t handle, int error) const { this->vtable->write_result(this->instance, handle, error); } void on_notify_state(uint16_t handle, bool enabled, int error) const { - this->notify_state(this->instance, handle, enabled, error); + this->vtable->notify_state(this->instance, handle, enabled, error); } void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) const { - this->notify_data(this->instance, handle, data, len); + this->vtable->notify_data(this->instance, handle, data, len); } - void on_pairing_result(int status) const { this->pairing_result(this->instance, status); } + void on_pairing_result(int status) const { this->vtable->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); }, - }; + return {consumer, &GATT_EVENT_VTABLE}; } // The BLEGattConnection op surface, asserted where the alias binds diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 77031ea253..7936c12b62 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -7,6 +7,8 @@ owning a dedicated backend (e.g. radon_eye_rd200) through gatt_client_schema() + new_gatt_backend(). """ +from dataclasses import dataclass, field + import esphome.codegen as cg from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -15,8 +17,10 @@ from esphome.core import CORE from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.types import ConfigType +DOMAIN = "bluetooth_connection" -def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: + +def AUTO_LOAD() -> list[str]: """ble_device_base plus the platform BLE stack the build's backend registers with, so consumers stay platform-blind. The platform-less arm serves tooling that resolves the manifest without a target.""" @@ -42,7 +46,9 @@ RP2_MAX_CONNECTIONS = 1 HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} # Every platform with a GATT backend; gates dedicated-backend consumers. -GATT_CLIENT_PLATFORMS = [PLATFORM_ESP32, PLATFORM_RP2] +# Derived from the hub registry so a platform gaining a backend is admitted +# everywhere at once (esp32 is the non-hub arm). +GATT_CLIENT_PLATFORMS = [PLATFORM_ESP32, *HUB_MAX_CONNECTIONS] # The hub-platform wrapper and the rp2 BTstack backend codegen classes. HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") @@ -78,25 +84,79 @@ def gatt_client_schema() -> cv.Schema: ) -def gatt_client_config_schema(base_schema: cv.Schema, consumer: str) -> cv.All: - """Wrap a dedicated-backend consumer's schema so the consumer stays - platform-blind: gates on the platforms with a backend, folds in - gatt_client_schema(), and does the esp32 controller-slot bookkeeping. - `consumer` names the component in slot-exhaustion errors.""" +def hub_connection_schema() -> cv.Schema: + """Per-slot schema for the proxy's connection wrappers: the wrapper id on + top of the backend fragment. Same call-time constraint as + gatt_client_schema().""" + return gatt_client_schema().extend( + {cv.GenerateID(): cv.declare_id(HubBluetoothConnection)} + ) - def apply(config: ConfigType) -> ConfigType: - if config is SCHEMA_EXTRACT: - # The language-schema dumper runs without a platform; expose the - # consumer's own keys. - return base_schema - config = base_schema.extend(gatt_client_schema())(config) + +@dataclass +class _SlotLedger: + """GATT connection slots claimed this run, for the platform cap check.""" + + consumers: list[str] = field(default_factory=list) + + +def _ledger() -> _SlotLedger: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _SlotLedger() + return CORE.data[DOMAIN] + + +def consume_gatt_slot(consumer: str): + """Validator claiming one GATT connection slot: the neutral ledger feeds + the platform cap check in FINAL_VALIDATE_SCHEMA, and esp32 additionally + charges the controller's connection budget.""" + + def validator(config: ConfigType) -> ConfigType: + _ledger().consumers.append(consumer) if CORE.is_esp32: from esphome.components import esp32_ble esp32_ble.consume_connection_slots(1, consumer)(config) return config - return cv.All(cv.only_on(GATT_CLIENT_PLATFORMS), apply) + return validator + + +def _validate_slot_totals(config: ConfigType) -> ConfigType: + # esp32 has its own controller budget (esp32_ble); the hub platforms cap + # at the prebuilt stack's client count, and nothing else counts claims + # across components (e.g. a proxy plus a radon_eye_rd200 on rp2). + if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None: + return config + claimed = _ledger().consumers + if len(claimed) > cap: + raise cv.Invalid( + f"{CORE.target_platform} supports at most {cap} GATT client " + f"connection(s); {len(claimed)} requested by: {', '.join(claimed)}" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _validate_slot_totals + + +def gatt_client_config_schema(base_schema: cv.Schema, consumer: str): + """Wrap a dedicated-backend consumer's schema so the consumer stays + platform-blind: gates on the platforms with a backend, folds in + gatt_client_schema(), and claims the connection slot. + `consumer` names the component in slot-exhaustion errors.""" + + def apply(config: ConfigType) -> ConfigType: + if config is SCHEMA_EXTRACT: + # The language-schema dumper runs without a platform; expose the + # consumer's own keys. Checked before the platform gate so the + # dumper is not rejected by only_on. + return base_schema + cv.only_on(GATT_CLIENT_PLATFORMS)(config) + config = base_schema.extend(gatt_client_schema())(config) + return consume_gatt_slot(consumer)(config) + + return apply async def new_gatt_backend(config: ConfigType) -> cg.MockObj: diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 57833edbd2..652ecf8d5e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -1,6 +1,6 @@ #include "bluetooth_connection.h" -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "esphome/components/api/api_pb2.h" #include "esphome/core/log.h" @@ -39,4 +39,4 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 70c3ff4617..bacfd693a4 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,14 +16,16 @@ #include #endif -// The connection-aware API request handlers are compiled: a proxy is present -// and a GATT backend is wired by codegen (one slot per connection). +// The proxy-serving surface is compiled: a proxy is present and a GATT +// backend is wired by codegen (one slot per connection). This is the single +// spelling of that predicate - the hub wrapper, the connection-aware API +// request handlers, and the Bluedroid in-place streamer all gate on it. // Advertisement-only builds get the clean-error handlers; address-scoped // maintenance (unpair, cache clear) still works there through the // per-platform free functions below. Backend-only builds (a dedicated-backend // consumer without bluetooth_proxy) compile none of this API surface. #if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) -#define BLUETOOTH_CONNECTION_HAS_GATT +#define BLUETOOTH_CONNECTION_SERVES_PROXY #endif namespace esphome::api { @@ -139,7 +141,7 @@ inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } -#ifdef BLUETOOTH_CONNECTION_HAS_GATT +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY /// Result of close_service_batch: keep filling the batch or send it now. /// An oversized service is packed alone; a failed (backpressured) send is /// retried from the batch start, so no service is silently skipped. @@ -151,6 +153,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND }; /// cannot drift. BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, uint8_t connection_index, const char *address_str); -#endif // BLUETOOTH_CONNECTION_HAS_GATT +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index db70f04411..d6f4061091 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -2,10 +2,11 @@ #if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) +#include "bluetooth_connection.h" + // The in-place streamer serves the proxy's service-discovery API; backend-only // builds compile without the proxy headers or the streamer. -#ifdef USE_BLUETOOTH_PROXY -#include "bluetooth_connection.h" +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "bluetooth_connection_hub.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -273,12 +274,11 @@ void BluedroidGattClient::free_service_table_() { 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; +template +bool BluedroidGattClient::walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc) { + // Shared enumeration for both table-build passes: an identical walk order + // is what lets the counting pass size the block the filling pass fills. + // INVALID_OFFSET/NOT_FOUND mean end-of-range; anything else is a failure. for (uint16_t s = 0; s < this->service_total_; s++) { esp_gattc_service_elem_t svc; uint16_t svc_count = 1; @@ -286,6 +286,9 @@ bool BluedroidGattClient::build_service_table_() { svc_count == 0) { return false; } + if (!on_service(s, svc)) { + return false; + } uint16_t svc_chars = 0; if (esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, svc.start_handle, svc.end_handle, 0, &svc_chars) != ESP_GATT_OK) { @@ -302,13 +305,45 @@ bool BluedroidGattClient::build_service_table_() { 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; + if (!on_char(svc, chr)) { + return false; + } + for (uint16_t d = 0;; d++) { + esp_gattc_descr_elem_t desc; + uint16_t desc_count = 1; + auto desc_status = + esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, chr.char_handle, &desc, &desc_count, d); + if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { + break; + } + if (desc_status != ESP_GATT_OK || desc_count == 0) { + return false; + } + if (!on_desc(chr, desc)) { + return false; + } + } } } + return true; +} + +bool BluedroidGattClient::build_service_table_() { + // Pass 1: count, so one exact-size block holds the whole table. + uint16_t char_total = 0; + uint16_t desc_total = 0; + bool counted = this->walk_database_([](uint16_t, const esp_gattc_service_elem_t &) { return true; }, + [&](const esp_gattc_service_elem_t &, const esp_gattc_char_elem_t &) { + char_total++; + return true; + }, + [&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &) { + desc_total++; + return true; + }); + if (!counted) { + return false; + } // The arrays share one block; carving stays aligned because each struct's // strictest member is the UUID and array sizes are multiples of it. @@ -327,74 +362,55 @@ bool BluedroidGattClient::build_service_table_() { 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. + // Pass 2: fill, bounded by the pass-1 totals. A bound trip or a shortfall + // means the cached database changed between the passes; fail the build + // rather than serve an inconsistent table (the consumer retries). uint16_t char_index = 0; uint16_t desc_index = 0; - 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; + ble_device_base::GattService *cur_service = nullptr; + ble_device_base::GattCharacteristic *cur_char = nullptr; + bool filled = this->walk_database_( + [&](uint16_t s, const esp_gattc_service_elem_t &svc) { + cur_service = &services[s]; + cur_service->uuid = ble_device_base::ESPBTUUID::from_uuid(svc.uuid); + cur_service->start_handle = svc.start_handle; + cur_service->end_handle = svc.end_handle; + cur_service->first_characteristic = char_index; + cur_service->characteristic_count = 0; + return true; + }, + [&](const esp_gattc_service_elem_t &svc, const esp_gattc_char_elem_t &chr) { + if (char_index >= char_total) { + return false; } - if (desc_status != ESP_GATT_OK || desc_count == 0) { - this->free_service_table_(); + cur_char = &characteristics[char_index++]; + cur_char->uuid = ble_device_base::ESPBTUUID::from_uuid(chr.uuid); + cur_char->value_handle = chr.char_handle; + // Bluedroid addresses descriptors by characteristic handle, so the + // table's end_handle only needs the service-bounded upper bound. + cur_char->end_handle = svc.end_handle; + cur_char->properties = chr.properties; + cur_char->first_descriptor = desc_index; + cur_char->descriptor_count = 0; + cur_service->characteristic_count++; + return true; + }, + [&](const esp_gattc_char_elem_t &, const esp_gattc_descr_elem_t &desc) { + if (desc_index >= desc_total) { return false; } descriptors[desc_index].uuid = ble_device_base::ESPBTUUID::from_uuid(desc.uuid); descriptors[desc_index].handle = desc.handle; desc_index++; - } - characteristic.descriptor_count = desc_index - characteristic.first_descriptor; - char_index++; - } - service.characteristic_count = char_index - service.first_characteristic; + cur_char->descriptor_count++; + return true; + }); + if (!filled || char_index != char_total || desc_index != desc_total) { + this->free_service_table_(); + return false; } - 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; + this->table_char_total_ = char_total; + this->table_desc_total_ = desc_total; return true; } @@ -459,16 +475,14 @@ void BluedroidGattClient::handle_search_cmpl_() { // clients cache the streamed result permanently. auto status = primary_status != ESP_GATT_OK ? primary_status : secondary_status; this->log_gattc_warning_("esp_ble_gattc_get_attr_count", status); - if (this->listener_ != nullptr) { - this->listener_->on_service_discovery_done(status); - } + this->sink_.on_service_discovery_done(status); return; } this->service_total_ = primary + secondary; this->sink_.on_service_discovery_done(0); } -#ifdef USE_BLUETOOTH_PROXY +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { if (this->services_released_ || conn.send_service_ >= this->service_total_) { conn.send_service_ = DONE_SENDING_SERVICES; @@ -607,7 +621,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { conn.send_service_ = batch_start; } } -#endif // USE_BLUETOOTH_PROXY +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY // ---- events ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 94f2483c38..a1bdc32176 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -13,6 +13,8 @@ #if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) +#include "bluetooth_connection.h" + #include "esphome/components/ble_device_base/ble_gatt_client.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/core/component.h" @@ -23,7 +25,7 @@ namespace esphome::bluetooth_connection { class BluedroidGattClient; -#ifdef USE_BLUETOOTH_PROXY +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY class BluetoothConnection; #endif @@ -77,7 +79,7 @@ class BluedroidGattClient final : public Component { ble_device_base::GattServiceTable get_service_table(); void release_services(); -#ifdef USE_BLUETOOTH_PROXY +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY /// In-place service streamer (the proxy wrapper detects and prefers it): /// builds one api response batch directly from Bluedroid's cached database, /// so the streaming peak is the response itself - the old esp32 model. @@ -108,6 +110,8 @@ 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); + template + bool walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc); bool build_service_table_(); void free_service_table_(); ble_device_base::GattServiceTable table_view_() const; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 73b85c7010..fe8af7c86b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,7 @@ // Hub-platform connection wrapper (USE_RP2 hub builds today). #include "bluetooth_connection_hub.h" -#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -436,4 +436,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index e6f81e1f0c..03ca4bec23 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -5,14 +5,12 @@ #pragma once -#include "esphome/core/defines.h" +#include "bluetooth_connection.h" // The wrapper exists to serve the proxy's API surface; dedicated-backend // consumers (radon_eye_rd200) drive the backend directly, so backend-only // builds compile this header empty. -#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) - -#include "bluetooth_connection.h" +#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" @@ -154,4 +152,4 @@ static_assert(ble_device_base::GattClientEventSinkContract, } // namespace esphome::bluetooth_connection -#endif // USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY +#endif // BLUETOOTH_CONNECTION_SERVES_PROXY diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 103eab1599..912464739d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,6 +1,5 @@ #include "bluetooth_connection_rp2.h" -#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -621,8 +620,7 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { this->op_len_ > 0) { att_status = 0; } - this->sink_.on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, - att_status); + this->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: diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index f7e02e8e52..2bf62d4fc5 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -84,17 +84,7 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - CONNECTION_SCHEMA = ( - bluetooth_connection.gatt_client_schema() - .extend( - { - cv.GenerateID(): cv.declare_id( - bluetooth_connection.HubBluetoothConnection - ) - } - ) - .extend(cv.COMPONENT_SCHEMA) - ) + CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema() def validate_connections(config): if CONF_CONNECTIONS in config: @@ -157,15 +147,15 @@ 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.""" - connection_schema = bluetooth_connection.gatt_client_schema().extend( - {cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection)} - ) + connection_schema = bluetooth_connection.hub_connection_schema() def populate_connections(config: ConfigType) -> ConfigType: # One wrapper + backend pair per slot, declared during validation so # their ids exist for codegen (the esp32 arm's `connections` pattern). if not config[CONF_ACTIVE]: return config + for _ in range(config[CONF_CONNECTION_SLOTS]): + bluetooth_connection.consume_gatt_slot("bluetooth_proxy")(config) return { **config, CONF_CONNECTIONS: [ diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index 83ab0885d6..9ed3a927dd 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -13,36 +13,32 @@ static const char *const TAG = "radon_eye_rd200"; 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. +// V1 (RD200 firmware < 2.0) exposes a vendor service; V2 (>= 2.0) moved to +// Bluetooth-base (16-bit) UUIDs 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 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 uint16_t SERVICE_UUID_V2 = 0x1523; +static const uint16_t WRITE_CHARACTERISTIC_UUID_V2 = 0x1524; +static const uint16_t READ_CHARACTERISTIC_UUID_V2 = 0x1525; static const uint8_t WRITE_COMMAND_V2 = 0x40; // Minimum notification payload carrying all three measurements. static const uint16_t MESSAGE_MIN_LEN_V1 = 20; static const uint16_t MESSAGE_MIN_LEN_V2 = 68; -// BLE public address type (shared code space with the API/backends). -static const uint8_t BLE_ADDR_TYPE_PUBLIC = 0; - void RadonEyeRD200::update() { - if (this->busy_) { - ESP_LOGW(TAG, "Connection in progress"); + // The backends refuse a connect on a non-idle slot, so a poll landing + // mid-sequence just logs and retries next interval. + int err = this->backend_->connect(this->address_, ble_device_base::BLE_ADDR_TYPE_PUBLIC); + if (err != 0) { + ESP_LOGW(TAG, "Connection in progress (err=%d)", err); 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::on_connection_state(bool connected, uint16_t mtu, int error) { @@ -50,82 +46,84 @@ void RadonEyeRD200::on_connection_state(bool connected, uint16_t mtu, int error) 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_(); + this->backend_->disconnect(); } } void RadonEyeRD200::on_service_discovery_done(int error) { if (error != 0) { ESP_LOGW(TAG, "Service discovery failed, status=%d", error); - this->abort_connection_(); + this->backend_->disconnect(); 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_(); + this->backend_->disconnect(); 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_(); + this->backend_->disconnect(); } } 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; + struct Variant { + ESPBTUUID service; + ESPBTUUID write_chr; + ESPBTUUID read_chr; + uint8_t command; + }; + // Built on the stack per (cold) discovery so the UUID objects stay out of + // static RAM; the V1 strings live in flash. + const Variant variants[] = { + {ESPBTUUID::from_raw(SERVICE_UUID_V1), ESPBTUUID::from_raw(WRITE_CHARACTERISTIC_UUID_V1), + ESPBTUUID::from_raw(READ_CHARACTERISTIC_UUID_V1), WRITE_COMMAND_V1}, + {ESPBTUUID::from_uint16(SERVICE_UUID_V2), ESPBTUUID::from_uint16(WRITE_CHARACTERISTIC_UUID_V2), + ESPBTUUID::from_uint16(READ_CHARACTERISTIC_UUID_V2), WRITE_COMMAND_V2}, + }; + for (const auto &variant : variants) { + const auto *service = ble_device_base::find_service(table, variant.service); + if (service == nullptr) { + continue; + } + const auto *read_chr = ble_device_base::find_characteristic(table, *service, variant.read_chr); + const auto *write_chr = ble_device_base::find_characteristic(table, *service, variant.write_chr); + if (read_chr == nullptr || write_chr == nullptr) { + ESP_LOGW(TAG, "Service found but a sensor characteristic is missing"); + 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; + this->write_command_ = variant.command; + return true; } - - 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; + ESP_LOGW(TAG, "No supported device has been found, disconnecting"); + return false; } 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_(); + this->backend_->disconnect(); 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_(); + this->backend_->disconnect(); } } @@ -137,14 +135,14 @@ void RadonEyeRD200::on_write_result(uint16_t handle, int error) { } if (error != 0) { ESP_LOGE(TAG, "write descr failed, error status = %x", error); - this->abort_connection_(); + this->backend_->disconnect(); 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_(); + this->backend_->disconnect(); } } @@ -156,8 +154,6 @@ void RadonEyeRD200::on_notify_data(uint16_t handle, const uint8_t *data, uint16_ 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"); diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index ae3fd35d6e..1ed00c349d 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -25,7 +25,7 @@ namespace esphome::radon_eye_rd200 { class RadonEyeRD200 final : public PollingComponent { public: - RadonEyeRD200(ble_device_base::BLEGattConnection *backend, uint64_t address) : address_(address), backend_(backend) { + RadonEyeRD200(ble_device_base::BLEGattConnection *backend, uint64_t address) : backend_(backend), address_(address) { backend->set_sink(ble_device_base::make_gatt_sink(this)); } @@ -47,16 +47,16 @@ class RadonEyeRD200 final : public PollingComponent { protected: 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 + // Group 1: pointers first - PollingComponent's size is 4 mod 8, so three + // pointers bring the 8-byte address to a naturally aligned offset. ble_device_base::BLEGattConnection *backend_; sensor::Sensor *radon_sensor_{nullptr}; sensor::Sensor *radon_long_term_sensor_{nullptr}; + // Group 2: 8-byte types + uint64_t address_; + // Group 3: 2-byte types uint16_t read_handle_{0}; uint16_t write_handle_{0}; @@ -64,9 +64,6 @@ class RadonEyeRD200 final : public PollingComponent { // 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 diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index ec059bf588..0b92933aa6 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -177,7 +177,7 @@ def test_rp2_rejects_esp32_only_keys_by_name( bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) -def test_bluetooth_connection_auto_load_covers_its_includes() -> None: +def test_bluetooth_connection_auto_load_matches_the_platform_stack() -> None: # The backend registers with its platform BLE stack, so that dependency # lives here and consumers (proxy, radon_eye_rd200) stay platform-blind; # the platform-less arm is the union for manifest-resolving tooling. diff --git a/tests/components/radon_eye_rd200/test.esp32-idf.yaml b/tests/components/radon_eye_rd200/test.esp32-idf.yaml index a0fd2398d9..3c5ffa1ea8 100644 --- a/tests/components/radon_eye_rd200/test.esp32-idf.yaml +++ b/tests/components/radon_eye_rd200/test.esp32-idf.yaml @@ -1,3 +1,4 @@ +# The tracker and the Bluedroid backend are auto-loaded through +# bluetooth_connection; no bus package or hub config is needed. packages: - ble: !include ../../test_build_components/common/ble/esp32-idf.yaml radon_eye_rd200: !include common.yaml