Compare commits

..
Author SHA1 Message Date
J. Nick Koston ec681bb4fb Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-20 00:47:07 -05:00
J. Nick Koston 3ee3d1b353 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-19 10:30:50 -05:00
J. Nick Koston 3fb5aa401b Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-19 09:18:06 -05:00
J. Nick Koston ae8760ded8 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:28:25 -05:00
J. Nick Koston 588ca0de3c Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:12:12 -05:00
J. Nick Koston 674215f7f7 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 19:03:19 -05:00
J. Nick Koston b2dd602cef Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:59:00 -05:00
J. Nick Koston 6f9b5993b9 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:42:07 -05:00
J. Nick Koston 93d352df83 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:38:08 -05:00
J. Nick Koston 014cccb67e Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:16:44 -05:00
J. Nick Koston bbe643e16b Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 18:07:48 -05:00
J. Nick Koston 63ab68b58a Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:56:33 -05:00
J. Nick Koston e5cc588ab5 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:47:09 -05:00
J. Nick Koston 8ba15fa7af Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:24:38 -05:00
J. Nick Koston 527ba05474 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:09:40 -05:00
J. Nick Koston f2bae23b4e Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 17:04:51 -05:00
J. Nick Koston 7bc00c8eda Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:58:38 -05:00
J. Nick Koston dd76743909 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:38:37 -05:00
J. Nick Koston 313127d3dd Gate on the unified neutral-node-surface define 2026-08-12 16:33:50 -05:00
J. Nick Koston 50f2eb4459 Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:33:21 -05:00
J. Nick Koston 349ba6a02f Merge branch 'neutral-ble-client' into radon-eye-single-node 2026-08-12 16:11:36 -05:00
J. Nick Koston f9993aa010 Migrate radon_eye_rd200 onto the neutral ble_client node interface
One implementation replaces the raw-gattc one and runs on esp32 and rp2
alike; user YAML is unchanged. The node resolves the V1/V2 variant from the
service table, owns its CCCD write, and keeps the drop-the-link-between-polls
protocol.
2026-08-12 16:09:44 -05:00
6 changed files with 183 additions and 150 deletions
@@ -1,123 +1,150 @@
#include "radon_eye_rd200.h"
#include "esphome/components/esp32_ble/ble_uuid.h"
#ifdef USE_BLE_CLIENT_GATT_NODES
#include "esphome/core/helpers.h"
#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
// Bluetooth-base (16-bit) UUIDs with a different command byte and payload
// layout.
static const char *const SERVICE_UUID_V1 = "00001523-1212-efde-1523-785feabcd123";
static const char *const WRITE_CHARACTERISTIC_UUID_V1 = "00001524-1212-efde-1523-785feabcd123";
static const char *const READ_CHARACTERISTIC_UUID_V1 = "00001525-1212-efde-1523-785feabcd123";
static const uint8_t WRITE_COMMAND_V1 = 0x50;
static const 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 uint16_t SERVICE_UUID_V2 = 0x1523;
static const uint16_t WRITE_CHARACTERISTIC_UUID_V2 = 0x1524;
static const uint16_t READ_CHARACTERISTIC_UUID_V2 = 0x1525;
static const uint8_t WRITE_COMMAND_V2 = 0x40;
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;
}
// Minimum notification payload carrying all three measurements.
static const uint16_t MESSAGE_MIN_LEN_V1 = 20;
static const uint16_t MESSAGE_MIN_LEN_V2 = 68;
case ESP_GATTC_DISCONNECT_EVT: {
ESP_LOGW(TAG, "Disconnected!");
break;
}
RadonEyeRD200::RadonEyeRD200() : PollingComponent(10000) {}
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->parent()->connected())
return;
if (!this->parent()->enabled) {
ESP_LOGW(TAG, "Reconnecting to device");
this->parent()->set_enabled(true);
} else {
ESP_LOGW(TAG, "Connection in progress");
}
}
void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) {
void RadonEyeRD200::on_connected(const ble_device_base::GattServiceTable &table) {
if (!this->resolve_handles_(table)) {
// Retried on the next poll (update() re-enables the client).
this->parent()->set_enabled(false);
return;
}
// Local notification registration; the CCCD write follows in
// on_notify_state (the contract leaves the CCCD to the node).
if (this->parent()->notify_characteristic(this->read_handle_, true) != 0) {
this->parent()->set_enabled(false);
}
}
bool RadonEyeRD200::resolve_handles_(const ble_device_base::GattServiceTable &table) {
struct Variant {
ESPBTUUID service;
ESPBTUUID write_chr;
ESPBTUUID read_chr;
uint8_t command;
};
// Built on the stack per (cold) discovery so the UUID objects stay out of
// static RAM; the V1 strings live in flash.
const Variant variants[] = {
{ESPBTUUID::from_raw(SERVICE_UUID_V1), ESPBTUUID::from_raw(WRITE_CHARACTERISTIC_UUID_V1),
ESPBTUUID::from_raw(READ_CHARACTERISTIC_UUID_V1), WRITE_COMMAND_V1},
{ESPBTUUID::from_uint16(SERVICE_UUID_V2), ESPBTUUID::from_uint16(WRITE_CHARACTERISTIC_UUID_V2),
ESPBTUUID::from_uint16(READ_CHARACTERISTIC_UUID_V2), WRITE_COMMAND_V2},
};
for (const auto &variant : variants) {
const auto *service = ble_device_base::find_service(table, variant.service);
if (service == nullptr) {
continue;
}
const auto *read_chr = ble_device_base::find_characteristic(table, *service, variant.read_chr);
const auto *write_chr = ble_device_base::find_characteristic(table, *service, variant.write_chr);
if (read_chr == nullptr || write_chr == nullptr) {
ESP_LOGW(TAG, "Service found but a sensor characteristic is missing");
return false;
}
this->cccd_handle_ = ble_device_base::find_cccd(table, *read_chr);
if (this->cccd_handle_ == 0) {
ESP_LOGW(TAG, "Sensor read characteristic has no CCCD");
return false;
}
this->read_handle_ = read_chr->value_handle;
this->write_handle_ = write_chr->value_handle;
this->write_command_ = variant.command;
return true;
}
ESP_LOGW(TAG, "No supported device has been found, disconnecting");
return false;
}
void RadonEyeRD200::on_notify_state(uint16_t handle, bool enabled, int error) {
if (handle != this->read_handle_) {
return;
}
if (error != 0) {
ESP_LOGW(TAG, "Error registering for sensor notify, status=%d", error);
this->parent()->set_enabled(false);
return;
}
if (!enabled) {
return;
}
static const uint8_t ENABLE_NOTIFY[2] = {0x01, 0x00};
if (this->parent()->write_descriptor(this->cccd_handle_, ENABLE_NOTIFY, sizeof(ENABLE_NOTIFY)) != 0) {
this->parent()->set_enabled(false);
}
}
void RadonEyeRD200::on_write_result(uint16_t handle, int error) {
// The command write (no response) 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->parent()->set_enabled(false);
return;
}
ESP_LOGV(TAG, "Write descr success, writing 0x%02X at write_handle=%d", this->write_command_, this->write_handle_);
if (this->parent()->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->parent()->set_enabled(false);
}
}
void RadonEyeRD200::on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {
if (handle != this->read_handle_) {
return;
}
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->parent()->set_enabled(false);
}
void RadonEyeRD200::read_sensors_(const uint8_t *value, uint16_t value_len) {
if (value_len < 1) {
ESP_LOGW(TAG, "Unexpected empty message");
return;
@@ -125,7 +152,8 @@ void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) {
uint8_t command = value[0];
if ((command == WRITE_COMMAND_V1 && value_len < 20) || (command == WRITE_COMMAND_V2 && value_len < 68)) {
if ((command == WRITE_COMMAND_V1 && value_len < MESSAGE_MIN_LEN_V1) ||
(command == WRITE_COMMAND_V2 && value_len < MESSAGE_MIN_LEN_V2)) {
ESP_LOGW(TAG, "Unexpected command 0x%02X message length %d", command, value_len);
return;
}
@@ -134,8 +162,11 @@ void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) {
// 501085EBB9400000000000000000220025000000
// Example data V2:
// 4042323230313033525532303338330652443230304e56322e302e3200014a00060a00080000000300010079300000e01108001c00020000003822005c8f423fa4709d3f
ESP_LOGV(TAG, "radon sensors raw bytes");
ESP_LOG_BUFFER_HEX_LEVEL(TAG, value, value_len, ESP_LOG_VERBOSE);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
// Sized for the longest supported message; format_hex_to truncates longer.
char hex_buf[format_hex_size(MESSAGE_MIN_LEN_V2)];
ESP_LOGV(TAG, "radon sensors raw bytes: %s", format_hex_to(hex_buf, value, value_len));
#endif
// Convert from pCi/L to Bq/m³
constexpr float convert_to_bwpm3 = 37.0;
@@ -185,22 +216,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 +223,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_CLIENT_GATT_NODES
@@ -1,15 +1,29 @@
// RD200 radon sensor on the platform-neutral ble_client node interface -
// one implementation for every platform with a GATT client engine (esp32
// and rp2 / Pico W today).
//
// Poll cycle: enable the client (it connects on the peer's next sighting) →
// resolve the V1/V2 variant and handles from the service table during
// on_connected() → local notify registration → explicit CCCD write (the
// contract makes the CCCD the node's job) → write the read command → parse
// the notification → disable. 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_CLIENT_GATT_NODES
#include "esphome/components/ble_client/ble_client_node.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include "esphome/components/ble_client/ble_client.h"
#else
#include "esphome/components/ble_client/ble_client_gatt.h"
#endif
namespace esphome::radon_eye_rd200 {
@@ -20,26 +34,28 @@ class RadonEyeRD200 final : public PollingComponent, public ble_client::BLEClien
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; }
// ---- ble_client::BLEClientNode (unused events keep the no-op defaults) ----
void on_connected(const ble_device_base::GattServiceTable &table) override;
void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) override;
void on_notify_state(uint16_t handle, bool enabled, int error) override;
void on_write_result(uint16_t handle, int error) override;
protected:
void read_sensors_(uint8_t *value, uint16_t value_len);
bool resolve_handles_(const ble_device_base::GattServiceTable &table);
void read_sensors_(const uint8_t *value, uint16_t value_len);
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_;
uint16_t read_handle_{0};
uint16_t write_handle_{0};
uint16_t cccd_handle_{0};
uint8_t write_command_{0};
};
} // namespace esphome::radon_eye_rd200
#endif // USE_ESP32
#endif // USE_BLE_CLIENT_GATT_NODES
+2 -2
View File
@@ -36,7 +36,7 @@ CONFIG_SCHEMA = cv.All(
}
)
.extend(cv.polling_component_schema("5min"))
.extend(ble_client.BLE_CLIENT_SCHEMA),
.extend(ble_client.NODE_BLE_CLIENT_SCHEMA),
)
@@ -44,7 +44,7 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
await ble_client.register_gatt_node(var, config)
if CONF_RADON in config:
sens = await sensor.new_sensor(config[CONF_RADON])
@@ -1,5 +1,3 @@
esp32_ble_tracker:
ble_client:
- mac_address: 01:02:03:04:05:06
id: radon_eye_blec
@@ -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,7 @@
# The neutral node interface: the BTstack backend and rp2040_ble come in
# through bluetooth_connection's auto-load; the tracker hub supplies the
# sightings.
packages:
radon_eye_rd200: !include common.yaml
rp2_ble_tracker: