WIP: radon_eye_rd200 onto the neutral GATT contract

This commit is contained in:
J. Nick Koston
2026-08-08 23:08:32 -05:00
parent b2c22b5802
commit a21d71ddbe
17 changed files with 747 additions and 332 deletions
@@ -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<typename S>
concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) {
{ sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_service_discovery_done(int{}) } -> std::same_as<void>;
{ sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_write_result(uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as<void>;
{ sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as<void>;
{ sink.on_pairing_result(int{}) } -> std::same_as<void>;
};
/// 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<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); },
};
}
// 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<typename T, typename Sink>
concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) {
conn.set_listener(sink);
template<typename T>
concept BLEGattConnectionContract = requires(T conn, GattEventSink sink, const uint8_t *data) {
conn.set_sink(sink);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.disconnect() } -> std::same_as<int>;
{ conn.discover_services() } -> std::same_as<int>;
@@ -113,21 +190,50 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *
{ conn.release_services() } -> std::same_as<void>;
};
// 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<typename S>
concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) {
{ sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_service_discovery_done(int{}) } -> std::same_as<void>;
{ sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_write_result(uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as<void>;
{ sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as<void>;
{ sink.on_pairing_result(int{}) } -> std::same_as<void>;
};
// ---- 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
@@ -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(
{
@@ -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<const ble_device_base::GattService *>(this->table_storage_),
reinterpret_cast<const ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes),
reinterpret_cast<const ble_device_base::GattDescriptor *>(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<uint8_t> allocator(RAMAllocator<uint8_t>::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<uint8_t> allocator(RAMAllocator<uint8_t>::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<unsigned>(total_bytes));
return false;
}
auto *services = reinterpret_cast<ble_device_base::GattService *>(this->table_storage_);
auto *characteristics = reinterpret_cast<ble_device_base::GattCharacteristic *>(this->table_storage_ + svc_bytes);
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.
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;
}
@@ -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.
@@ -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<BLEGattConnection, bluetooth_connection::BluetoothConnection>,
static_assert(BLEGattConnectionContract<BLEGattConnection>,
"The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)");
#undef ESPHOME_BLE_GATT_CONNECTION_TYPE
@@ -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) ----
@@ -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;
}
@@ -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 Parented<rp2040_ble::RP2040
void dump_config() override;
float get_setup_priority() const override;
void set_listener(BluetoothConnection *listener) { this->listener_ = 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<rp2040_ble::RP2040
}
// Group 1: containers / large storage
BluetoothConnection *listener_{nullptr};
ble_device_base::GattEventSink sink_;
ServiceArena *arena_{nullptr};
esphome::LockFreeQueue<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
+22 -54
View File
@@ -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:
@@ -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 <cstring>
#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
@@ -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 <esp_gattc_api.h>
#include <algorithm>
#include <iterator>
#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
+67 -31
View File
@@ -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))
@@ -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 (
@@ -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<RecordingSink>, "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<MinimalConnection, RecordingSink>,
static_assert(BLEGattConnectionContract<MinimalConnection>,
"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
+1 -7
View File
@@ -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:
@@ -1,4 +1,3 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
<<: !include common.yaml
radon_eye_rd200: !include common.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