diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index de5bd3d8d5..923f6c7f4b 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -1,123 +1,150 @@ #include "radon_eye_rd200.h" -#include "esphome/components/esp32_ble/ble_uuid.h" + +#if defined(USE_BLE_CLIENT_GATT_NODES) || (defined(USE_BLE_GATT_CLIENT) && !defined(USE_ESP32)) + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include -#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 || (USE_BLE_GATT_CLIENT && !USE_ESP32) diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index 401402a137..30045f292a 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -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 -#include -#include -#include "esphome/components/ble_client/ble_client.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#if defined(USE_BLE_CLIENT_GATT_NODES) || (defined(USE_BLE_GATT_CLIENT) && !defined(USE_ESP32)) + +#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 || (USE_BLE_GATT_CLIENT && !USE_ESP32) diff --git a/esphome/components/radon_eye_rd200/sensor.py b/esphome/components/radon_eye_rd200/sensor.py index da04328218..f77d796ab9 100644 --- a/esphome/components/radon_eye_rd200/sensor.py +++ b/esphome/components/radon_eye_rd200/sensor.py @@ -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]) diff --git a/tests/components/radon_eye_rd200/common.yaml b/tests/components/radon_eye_rd200/common.yaml index d06979be6f..46610d145b 100644 --- a/tests/components/radon_eye_rd200/common.yaml +++ b/tests/components/radon_eye_rd200/common.yaml @@ -1,5 +1,3 @@ -esp32_ble_tracker: - ble_client: - mac_address: 01:02:03:04:05:06 id: radon_eye_blec diff --git a/tests/components/radon_eye_rd200/test.esp32-idf.yaml b/tests/components/radon_eye_rd200/test.esp32-idf.yaml index 7a6541ae76..a0fd2398d9 100644 --- a/tests/components/radon_eye_rd200/test.esp32-idf.yaml +++ b/tests/components/radon_eye_rd200/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: ble: !include ../../test_build_components/common/ble/esp32-idf.yaml - -<<: !include common.yaml + radon_eye_rd200: !include common.yaml diff --git a/tests/components/radon_eye_rd200/test.rp2040-ard.yaml b/tests/components/radon_eye_rd200/test.rp2040-ard.yaml new file mode 100644 index 0000000000..2161414268 --- /dev/null +++ b/tests/components/radon_eye_rd200/test.rp2040-ard.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: