mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 01:56:01 +00:00
WIP: radon_eye_rd200 onto the neutral GATT contract
This commit is contained in:
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
Reference in New Issue
Block a user