From ad43d6a5bcb53441edd18cd3f1907dcc47e97452 Mon Sep 17 00:00:00 2001 From: Jeff Eberl Date: Mon, 7 Feb 2022 20:32:37 -0700 Subject: [PATCH] Added RadonEye RD200 Component (#3119) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 2 + esphome/components/radon_eye_ble/__init__.py | 23 +++ .../radon_eye_ble/radon_eye_listener.cpp | 25 +++ .../radon_eye_ble/radon_eye_listener.h | 19 ++ .../components/radon_eye_rd200/__init__.py | 1 + .../radon_eye_rd200/radon_eye_rd200.cpp | 179 ++++++++++++++++++ .../radon_eye_rd200/radon_eye_rd200.h | 59 ++++++ esphome/components/radon_eye_rd200/sensor.py | 55 ++++++ tests/test2.yaml | 11 ++ 9 files changed, 374 insertions(+) create mode 100644 esphome/components/radon_eye_ble/__init__.py create mode 100644 esphome/components/radon_eye_ble/radon_eye_listener.cpp create mode 100644 esphome/components/radon_eye_ble/radon_eye_listener.h create mode 100644 esphome/components/radon_eye_rd200/__init__.py create mode 100644 esphome/components/radon_eye_rd200/radon_eye_rd200.cpp create mode 100644 esphome/components/radon_eye_rd200/radon_eye_rd200.h create mode 100644 esphome/components/radon_eye_rd200/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 5fa3090aaf..a353906da2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -139,6 +139,8 @@ esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @stevebaxter esphome/components/pvvx_mithermometer/* @pasiz esphome/components/qr_code/* @wjtje +esphome/components/radon_eye_ble/* @jeffeb3 +esphome/components/radon_eye_rd200/* @jeffeb3 esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet diff --git a/esphome/components/radon_eye_ble/__init__.py b/esphome/components/radon_eye_ble/__init__.py new file mode 100644 index 0000000000..ffe434d19b --- /dev/null +++ b/esphome/components/radon_eye_ble/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import esp32_ble_tracker +from esphome.const import CONF_ID + +DEPENDENCIES = ["esp32_ble_tracker"] +CODEOWNERS = ["@jeffeb3"] + +radon_eye_ble_ns = cg.esphome_ns.namespace("radon_eye_ble") +RadonEyeListener = radon_eye_ble_ns.class_( + "RadonEyeListener", esp32_ble_tracker.ESPBTDeviceListener +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(RadonEyeListener), + } +).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield esp32_ble_tracker.register_ble_device(var, config) diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.cpp b/esphome/components/radon_eye_ble/radon_eye_listener.cpp new file mode 100644 index 0000000000..b10986c9cb --- /dev/null +++ b/esphome/components/radon_eye_ble/radon_eye_listener.cpp @@ -0,0 +1,25 @@ +#include "radon_eye_listener.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +namespace esphome { +namespace radon_eye_ble { + +static const char *const TAG = "radon_eye_ble"; + +bool RadonEyeListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { + if (not device.get_name().empty()) { + if (device.get_name().rfind("FR:R20:SN", 0) == 0) { + // This is an RD200, I think + ESP_LOGD(TAG, "Found Radon Eye RD200 device Name: %s (MAC: %s)", device.get_name().c_str(), + device.address_str().c_str()); + } + } + return false; +} + +} // namespace radon_eye_ble +} // namespace esphome + +#endif diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.h b/esphome/components/radon_eye_ble/radon_eye_listener.h new file mode 100644 index 0000000000..26d0233c56 --- /dev/null +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -0,0 +1,19 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" + +namespace esphome { +namespace radon_eye_ble { + +class RadonEyeListener : public esp32_ble_tracker::ESPBTDeviceListener { + public: + bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; +}; + +} // namespace radon_eye_ble +} // namespace esphome + +#endif diff --git a/esphome/components/radon_eye_rd200/__init__.py b/esphome/components/radon_eye_rd200/__init__.py new file mode 100644 index 0000000000..0740f6967b --- /dev/null +++ b/esphome/components/radon_eye_rd200/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@jeffeb3"] diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp new file mode 100644 index 0000000000..6bb17f0508 --- /dev/null +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -0,0 +1,179 @@ +#include "radon_eye_rd200.h" + +#ifdef USE_ESP32 + +namespace esphome { +namespace radon_eye_rd200 { + +static const char *const TAG = "radon_eye_rd200"; + +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; + } + + case ESP_GATTC_DISCONNECT_EVT: { + ESP_LOGW(TAG, "Disconnected!"); + break; + } + + case ESP_GATTC_SEARCH_CMPL_EVT: { + this->read_handle_ = 0; + auto *chr = this->parent()->get_characteristic(service_uuid_, sensors_read_characteristic_uuid_); + if (chr == nullptr) { + ESP_LOGW(TAG, "No sensor read characteristic found at service %s char %s", service_uuid_.to_string().c_str(), + sensors_read_characteristic_uuid_.to_string().c_str()); + break; + } + this->read_handle_ = chr->handle; + + // Write a 0x50 to the write characteristic. + auto *write_chr = this->parent()->get_characteristic(service_uuid_, sensors_write_characteristic_uuid_); + if (write_chr == nullptr) { + ESP_LOGW(TAG, "No sensor write characteristic found at service %s char %s", service_uuid_.to_string().c_str(), + sensors_read_characteristic_uuid_.to_string().c_str()); + break; + } + this->write_handle_ = write_chr->handle; + + this->node_state = esp32_ble_tracker::ClientState::ESTABLISHED; + + write_query_message_(); + + request_read_values_(); + break; + } + + case ESP_GATTC_READ_CHAR_EVT: { + if (param->read.conn_id != this->parent()->conn_id) + break; + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "Error reading char at handle %d, status=%d", param->read.handle, param->read.status); + break; + } + if (param->read.handle == this->read_handle_) { + read_sensors_(param->read.value, param->read.value_len); + } + break; + } + + default: + break; + } +} + +void RadonEyeRD200::read_sensors_(uint8_t *value, uint16_t value_len) { + if (value_len < 20) { + ESP_LOGD(TAG, "Invalid read"); + return; + } + + // Example data + // [13:08:47][D][radon_eye_rd200:107]: result bytes: 5010 85EBB940 00000000 00000000 2200 2500 0000 + ESP_LOGV(TAG, "result bytes: %02X%02X %02X%02X%02X%02X %02X%02X%02X%02X %02X%02X%02X%02X %02X%02X %02X%02X %02X%02X", + value[0], value[1], value[2], value[3], value[4], value[5], value[6], value[7], value[8], value[9], + value[10], value[11], value[12], value[13], value[14], value[15], value[16], value[17], value[18], + value[19]); + + if (value[0] != 0x50) { + // This isn't a sensor reading. + return; + } + + // Convert from pCi/L to Bq/m³ + constexpr float convert_to_bwpm3 = 37.0; + + RadonValue radon_value; + radon_value.chars[0] = value[2]; + radon_value.chars[1] = value[3]; + radon_value.chars[2] = value[4]; + radon_value.chars[3] = value[5]; + float radon_now = radon_value.number * convert_to_bwpm3; + if (is_valid_radon_value_(radon_now)) { + radon_sensor_->publish_state(radon_now); + } + + radon_value.chars[0] = value[6]; + radon_value.chars[1] = value[7]; + radon_value.chars[2] = value[8]; + radon_value.chars[3] = value[9]; + float radon_day = radon_value.number * convert_to_bwpm3; + + radon_value.chars[0] = value[10]; + radon_value.chars[1] = value[11]; + radon_value.chars[2] = value[12]; + radon_value.chars[3] = value[13]; + float radon_month = radon_value.number * convert_to_bwpm3; + + if (is_valid_radon_value_(radon_month)) { + ESP_LOGV(TAG, "Radon Long Term based on month"); + radon_long_term_sensor_->publish_state(radon_month); + } else if (is_valid_radon_value_(radon_day)) { + ESP_LOGV(TAG, "Radon Long Term based on day"); + radon_long_term_sensor_->publish_state(radon_day); + } + + ESP_LOGV(TAG, " Measurements (Bq/m³) now: %0.03f, day: %0.03f, month: %0.03f", radon_now, radon_day, radon_month); + + ESP_LOGV(TAG, " Measurements (pCi/L) now: %0.03f, day: %0.03f, month: %0.03f", 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); +} + +bool RadonEyeRD200::is_valid_radon_value_(float radon) { return radon > 0.0 and radon < 37000; } + +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); + parent()->connect(); + } else { + ESP_LOGW(TAG, "Connection in progress"); + } + } +} + +void RadonEyeRD200::write_query_message_() { + ESP_LOGV(TAG, "writing 0x50 to write service"); + int request = 0x50; + auto status = esp_ble_gattc_write_char_descr(this->parent()->gattc_if, this->parent()->conn_id, this->write_handle_, + sizeof(request), (uint8_t *) &request, ESP_GATT_WRITE_TYPE_NO_RSP, + ESP_GATT_AUTH_REQ_NONE); + if (status) { + ESP_LOGW(TAG, "Error sending write request for sensor, status=%d", status); + } +} + +void RadonEyeRD200::request_read_values_() { + auto status = esp_ble_gattc_read_char(this->parent()->gattc_if, this->parent()->conn_id, this->read_handle_, + ESP_GATT_AUTH_REQ_NONE); + if (status) { + ESP_LOGW(TAG, "Error sending read request for sensor, status=%d", status); + } +} + +void RadonEyeRD200::dump_config() { + LOG_SENSOR(" ", "Radon", this->radon_sensor_); + LOG_SENSOR(" ", "Radon Long Term", this->radon_long_term_sensor_); +} + +RadonEyeRD200::RadonEyeRD200() + : PollingComponent(10000), + service_uuid_(esp32_ble_tracker::ESPBTUUID::from_raw(SERVICE_UUID)), + sensors_write_characteristic_uuid_(esp32_ble_tracker::ESPBTUUID::from_raw(WRITE_CHARACTERISTIC_UUID)), + sensors_read_characteristic_uuid_(esp32_ble_tracker::ESPBTUUID::from_raw(READ_CHARACTERISTIC_UUID)) {} + +} // namespace radon_eye_rd200 +} // namespace esphome + +#endif // USE_ESP32 diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h new file mode 100644 index 0000000000..7b29be7bd8 --- /dev/null +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -0,0 +1,59 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace radon_eye_rd200 { + +static const char *const SERVICE_UUID = "00001523-1212-efde-1523-785feabcd123"; +static const char *const WRITE_CHARACTERISTIC_UUID = "00001524-1212-efde-1523-785feabcd123"; +static const char *const READ_CHARACTERISTIC_UUID = "00001525-1212-efde-1523-785feabcd123"; + +class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode { + public: + RadonEyeRD200(); + + 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) { radon_sensor_ = radon; } + void set_radon_long_term(sensor::Sensor *radon_long_term) { radon_long_term_sensor_ = radon_long_term; } + + protected: + bool is_valid_radon_value_(float radon); + + void read_sensors_(uint8_t *value, uint16_t value_len); + void write_query_message_(); + void request_read_values_(); + + sensor::Sensor *radon_sensor_{nullptr}; + sensor::Sensor *radon_long_term_sensor_{nullptr}; + + 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_; + + union RadonValue { + char chars[4]; + float number; + }; +}; + +} // namespace radon_eye_rd200 +} // namespace esphome + +#endif // USE_ESP32 diff --git a/esphome/components/radon_eye_rd200/sensor.py b/esphome/components/radon_eye_rd200/sensor.py new file mode 100644 index 0000000000..a9667869b8 --- /dev/null +++ b/esphome/components/radon_eye_rd200/sensor.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor, ble_client + +from esphome.const import ( + STATE_CLASS_MEASUREMENT, + UNIT_BECQUEREL_PER_CUBIC_METER, + CONF_ID, + CONF_RADON, + CONF_RADON_LONG_TERM, + ICON_RADIOACTIVE, +) + +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 +) + +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), +) + + +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) + + if CONF_RADON in config: + sens = await sensor.new_sensor(config[CONF_RADON]) + cg.add(var.set_radon(sens)) + if CONF_RADON_LONG_TERM in config: + sens = await sensor.new_sensor(config[CONF_RADON_LONG_TERM]) + cg.add(var.set_radon_long_term(sens)) diff --git a/tests/test2.yaml b/tests/test2.yaml index 6d606a3143..2a122b971f 100644 --- a/tests/test2.yaml +++ b/tests/test2.yaml @@ -324,6 +324,13 @@ sensor: bus_voltage: name: "INA260 Voltage" update_interval: 60s + - platform: radon_eye_rd200 + ble_client_id: radon_eye_ble_id + update_interval: 10min + radon: + name: "RD200 Radon" + radon_long_term: + name: "RD200 Radon Long Term" time: - platform: homeassistant @@ -423,10 +430,14 @@ ble_client: id: airthings01 - mac_address: 01:02:03:04:05:06 id: airthingsmini01 + - mac_address: 01:02:03:04:05:06 + id: radon_eye_ble_id airthings_ble: +radon_eye_ble: + ruuvi_ble: xiaomi_ble: