Simplify: flash vtable sink, shared table walker, one proxy predicate, slot ledger

This commit is contained in:
J. Nick Koston
2026-08-08 23:53:07 -05:00
parent 49191c1ed8
commit 8a88120143
14 changed files with 302 additions and 233 deletions
@@ -97,60 +97,69 @@ concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) {
{ sink.on_pairing_result(int{}) } -> std::same_as<void>;
};
/// 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<typename T>
inline constexpr GattEventVTable GATT_EVENT_VTABLE{
[](void *p, bool connected, uint16_t mtu, int error) {
static_cast<T *>(p)->on_connection_state(connected, mtu, error);
},
[](void *p, int error) { static_cast<T *>(p)->on_service_discovery_done(error); },
[](void *p, uint16_t handle, const uint8_t *data, uint16_t len, int error) {
static_cast<T *>(p)->on_read_result(handle, data, len, error);
},
[](void *p, uint16_t handle, int error) { static_cast<T *>(p)->on_write_result(handle, error); },
[](void *p, uint16_t handle, bool enabled, int error) {
static_cast<T *>(p)->on_notify_state(handle, enabled, error);
},
[](void *p, uint16_t handle, const uint8_t *data, uint16_t len) {
static_cast<T *>(p)->on_notify_data(handle, data, len);
},
[](void *p, int status) { static_cast<T *>(p)->on_pairing_result(status); },
};
/// Type-erased consumer handle a backend delivers events through: an
/// instance pointer plus the consumer type's trampoline table. Built with
/// make_gatt_sink() from any type satisfying GattClientEventSinkContract;
/// call sites read the same as a direct listener call. No virtuals, no heap —
/// the cost of supporting several consumer types in one build is one
/// indirect call per event. Codegen wires the sink before setup(), so
/// 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<typename T> GattEventSink make_gatt_sink(T *consumer) {
static_assert(GattClientEventSinkContract<T>, "the consumer is missing part of the event-sink surface");
return GattEventSink{
consumer,
[](void *p, bool connected, uint16_t mtu, int error) {
static_cast<T *>(p)->on_connection_state(connected, mtu, error);
},
[](void *p, int error) { static_cast<T *>(p)->on_service_discovery_done(error); },
[](void *p, uint16_t handle, const uint8_t *data, uint16_t len, int error) {
static_cast<T *>(p)->on_read_result(handle, data, len, error);
},
[](void *p, uint16_t handle, int error) { static_cast<T *>(p)->on_write_result(handle, error); },
[](void *p, uint16_t handle, bool enabled, int error) {
static_cast<T *>(p)->on_notify_state(handle, enabled, error);
},
[](void *p, uint16_t handle, const uint8_t *data, uint16_t len) {
static_cast<T *>(p)->on_notify_data(handle, data, len);
},
[](void *p, int status) { static_cast<T *>(p)->on_pairing_result(status); },
};
return {consumer, &GATT_EVENT_VTABLE<T>};
}
// The BLEGattConnection op surface, asserted where the alias binds
@@ -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:
@@ -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
@@ -16,14 +16,16 @@
#include <esp_err.h>
#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<uint64_t, 2> &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 &current_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
@@ -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<typename ServiceFn, typename CharFn, typename DescFn>
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<ble_device_base::GattDescriptor *>(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 ----
@@ -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<typename ServiceFn, typename CharFn, typename DescFn>
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;
@@ -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
@@ -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<BluetoothConnection>,
} // namespace esphome::bluetooth_connection
#endif // USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
@@ -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:
+4 -14
View File
@@ -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: [
@@ -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");
@@ -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
@@ -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.
@@ -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