From fdda47db6e3103a44b9a20028e67b7e7a4934a36 Mon Sep 17 00:00:00 2001 From: functionpointer Date: Mon, 11 Apr 2022 04:50:56 +0200 Subject: [PATCH] Add integration hydreon_rgxx for rain sensors by Hydreon (#2711) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/hydreon_rgxx/__init__.py | 11 + .../components/hydreon_rgxx/binary_sensor.py | 36 +++ .../components/hydreon_rgxx/hydreon_rgxx.cpp | 211 ++++++++++++++++++ .../components/hydreon_rgxx/hydreon_rgxx.h | 76 +++++++ esphome/components/hydreon_rgxx/sensor.py | 119 ++++++++++ tests/test3.yaml | 22 ++ 7 files changed, 476 insertions(+) create mode 100644 esphome/components/hydreon_rgxx/__init__.py create mode 100644 esphome/components/hydreon_rgxx/binary_sensor.py create mode 100644 esphome/components/hydreon_rgxx/hydreon_rgxx.cpp create mode 100644 esphome/components/hydreon_rgxx/hydreon_rgxx.h create mode 100644 esphome/components/hydreon_rgxx/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 8958aa2928..309f6f4d51 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -82,6 +82,7 @@ esphome/components/hitachi_ac424/* @sourabhjaiswal esphome/components/homeassistant/* @OttoWinter esphome/components/honeywellabp/* @RubyBailey esphome/components/hrxl_maxsonar_wr/* @netmikey +esphome/components/hydreon_rgxx/* @functionpointer esphome/components/i2c/* @esphome/core esphome/components/improv_serial/* @esphome/core esphome/components/ina260/* @MrEditor97 diff --git a/esphome/components/hydreon_rgxx/__init__.py b/esphome/components/hydreon_rgxx/__init__.py new file mode 100644 index 0000000000..5fe050edf2 --- /dev/null +++ b/esphome/components/hydreon_rgxx/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.components import uart + +CODEOWNERS = ["@functionpointer"] +DEPENDENCIES = ["uart"] + +hydreon_rgxx_ns = cg.esphome_ns.namespace("hydreon_rgxx") +RGModel = hydreon_rgxx_ns.enum("RGModel") +HydreonRGxxComponent = hydreon_rgxx_ns.class_( + "HydreonRGxxComponent", cg.PollingComponent, uart.UARTDevice +) diff --git a/esphome/components/hydreon_rgxx/binary_sensor.py b/esphome/components/hydreon_rgxx/binary_sensor.py new file mode 100644 index 0000000000..0d489ebcb7 --- /dev/null +++ b/esphome/components/hydreon_rgxx/binary_sensor.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import binary_sensor +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_COLD, +) + +from . import hydreon_rgxx_ns, HydreonRGxxComponent + +CONF_HYDREON_RGXX_ID = "hydreon_rgxx_id" +CONF_TOO_COLD = "too_cold" + +HydreonRGxxBinarySensor = hydreon_rgxx_ns.class_( + "HydreonRGxxBinaryComponent", cg.Component +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(HydreonRGxxBinarySensor), + cv.GenerateID(CONF_HYDREON_RGXX_ID): cv.use_id(HydreonRGxxComponent), + cv.Optional(CONF_TOO_COLD): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_COLD + ), + } +) + + +async def to_code(config): + main_sensor = await cg.get_variable(config[CONF_HYDREON_RGXX_ID]) + bin_component = cg.new_Pvariable(config[CONF_ID], main_sensor) + await cg.register_component(bin_component, config) + if CONF_TOO_COLD in config: + tc = await binary_sensor.new_binary_sensor(config[CONF_TOO_COLD]) + cg.add(main_sensor.set_too_cold_sensor(tc)) diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp new file mode 100644 index 0000000000..3ed65831ae --- /dev/null +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -0,0 +1,211 @@ +#include "hydreon_rgxx.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace hydreon_rgxx { + +static const char *const TAG = "hydreon_rgxx.sensor"; +static const int MAX_DATA_LENGTH_BYTES = 80; +static const uint8_t ASCII_LF = 0x0A; +#define HYDREON_RGXX_COMMA , +static const char *const PROTOCOL_NAMES[] = {HYDREON_RGXX_PROTOCOL_LIST(, HYDREON_RGXX_COMMA)}; + +void HydreonRGxxComponent::dump_config() { + this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); + ESP_LOGCONFIG(TAG, "hydreon_rgxx:"); + if (this->is_failed()) { + ESP_LOGE(TAG, "Connection with hydreon_rgxx failed!"); + } + LOG_UPDATE_INTERVAL(this); + + int i = 0; +#define HYDREON_RGXX_LOG_SENSOR(s) \ + if (this->sensors_[i++] != nullptr) { \ + LOG_SENSOR(" ", #s, this->sensors_[i - 1]); \ + } + HYDREON_RGXX_PROTOCOL_LIST(HYDREON_RGXX_LOG_SENSOR, ); +} + +void HydreonRGxxComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up hydreon_rgxx..."); + while (this->available() != 0) { + this->read(); + } + this->schedule_reboot_(); +} + +bool HydreonRGxxComponent::sensor_missing_() { + if (this->sensors_received_ == -1) { + // no request sent yet, don't check + return false; + } else { + if (this->sensors_received_ == 0) { + ESP_LOGW(TAG, "No data at all"); + return true; + } + for (int i = 0; i < NUM_SENSORS; i++) { + if (this->sensors_[i] == nullptr) { + continue; + } + if ((this->sensors_received_ >> i & 1) == 0) { + ESP_LOGW(TAG, "Missing %s", PROTOCOL_NAMES[i]); + return true; + } + } + return false; + } +} + +void HydreonRGxxComponent::update() { + if (this->boot_count_ > 0) { + if (this->sensor_missing_()) { + this->no_response_count_++; + ESP_LOGE(TAG, "data missing %d times", this->no_response_count_); + if (this->no_response_count_ > 15) { + ESP_LOGE(TAG, "asking sensor to reboot"); + for (auto &sensor : this->sensors_) { + if (sensor != nullptr) { + sensor->publish_state(NAN); + } + } + this->schedule_reboot_(); + return; + } + } else { + this->no_response_count_ = 0; + } + this->write_str("R\n"); +#ifdef USE_BINARY_SENSOR + if (this->too_cold_sensor_ != nullptr) { + this->too_cold_sensor_->publish_state(this->too_cold_); + } +#endif + this->too_cold_ = false; + this->sensors_received_ = 0; + } +} + +void HydreonRGxxComponent::loop() { + uint8_t data; + while (this->available() > 0) { + if (this->read_byte(&data)) { + buffer_ += (char) data; + if (this->buffer_.back() == static_cast(ASCII_LF) || this->buffer_.length() >= MAX_DATA_LENGTH_BYTES) { + // complete line received + this->process_line_(); + this->buffer_.clear(); + } + } + } +} + +/** + * Communication with the sensor is asynchronous. + * We send requests and let esphome continue doing its thing. + * Once we have received a complete line, we process it. + * + * Catching communication failures is done in two layers: + * + * 1. We check if all requested data has been received + * before we send out the next request. If data keeps + * missing, we escalate. + * 2. Request the sensor to reboot. We retry based on + * a timeout. If the sensor does not respond after + * several boot attempts, we give up. + */ +void HydreonRGxxComponent::schedule_reboot_() { + this->boot_count_ = 0; + this->set_interval("reboot", 5000, [this]() { + if (this->boot_count_ < 0) { + ESP_LOGW(TAG, "hydreon_rgxx failed to boot %d times", -this->boot_count_); + } + this->boot_count_--; + this->write_str("K\n"); + if (this->boot_count_ < -5) { + ESP_LOGE(TAG, "hydreon_rgxx can't boot, giving up"); + for (auto &sensor : this->sensors_) { + if (sensor != nullptr) { + sensor->publish_state(NAN); + } + } + this->mark_failed(); + } + }); +} + +bool HydreonRGxxComponent::buffer_starts_with_(const std::string &prefix) { + return this->buffer_starts_with_(prefix.c_str()); +} + +bool HydreonRGxxComponent::buffer_starts_with_(const char *prefix) { return buffer_.rfind(prefix, 0) == 0; } + +void HydreonRGxxComponent::process_line_() { + ESP_LOGV(TAG, "Read from serial: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + + if (buffer_[0] == ';') { + ESP_LOGI(TAG, "Comment: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + return; + } + if (this->buffer_starts_with_("PwrDays")) { + if (this->boot_count_ <= 0) { + this->boot_count_ = 1; + } else { + this->boot_count_++; + } + this->cancel_interval("reboot"); + this->no_response_count_ = 0; + ESP_LOGI(TAG, "Boot detected: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + this->write_str("P\nH\nM\n"); // set sensor to polling mode, high res mode, metric mode + return; + } + if (this->buffer_starts_with_("SW")) { + std::string::size_type majend = this->buffer_.find('.'); + std::string::size_type endversion = this->buffer_.find(' ', 3); + if (majend == std::string::npos || endversion == std::string::npos || majend > endversion) { + ESP_LOGW(TAG, "invalid version string: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + } + int major = strtol(this->buffer_.substr(3, majend - 3).c_str(), nullptr, 10); + int minor = strtol(this->buffer_.substr(majend + 1, endversion - (majend + 1)).c_str(), nullptr, 10); + + if (major > 10 || minor >= 1000 || minor < 0 || major < 0) { + ESP_LOGW(TAG, "invalid version: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + } + this->sw_version_ = major * 1000 + minor; + ESP_LOGI(TAG, "detected sw version %i", this->sw_version_); + return; + } + bool is_data_line = false; + for (int i = 0; i < NUM_SENSORS; i++) { + if (this->sensors_[i] != nullptr && this->buffer_starts_with_(PROTOCOL_NAMES[i])) { + is_data_line = true; + break; + } + } + if (is_data_line) { + std::string::size_type tc = this->buffer_.find("TooCold"); + this->too_cold_ |= tc != std::string::npos; + if (this->too_cold_) { + ESP_LOGD(TAG, "Received TooCold"); + } + for (int i = 0; i < NUM_SENSORS; i++) { + if (this->sensors_[i] == nullptr) { + continue; + } + std::string::size_type n = this->buffer_.find(PROTOCOL_NAMES[i]); + if (n == std::string::npos) { + continue; + } + int data = strtol(this->buffer_.substr(n + strlen(PROTOCOL_NAMES[i])).c_str(), nullptr, 10); + this->sensors_[i]->publish_state(data); + ESP_LOGD(TAG, "Received %s: %f", PROTOCOL_NAMES[i], this->sensors_[i]->get_raw_state()); + this->sensors_received_ |= (1 << i); + } + } else { + ESP_LOGI(TAG, "Got unknown line: %s", this->buffer_.c_str()); + } +} + +float HydreonRGxxComponent::get_setup_priority() const { return setup_priority::DATA; } + +} // namespace hydreon_rgxx +} // namespace esphome diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h new file mode 100644 index 0000000000..ebe4a35b19 --- /dev/null +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/sensor/sensor.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#include "esphome/components/uart/uart.h" + +namespace esphome { +namespace hydreon_rgxx { + +enum RGModel { + RG9 = 1, + RG15 = 2, +}; + +#ifdef HYDREON_RGXX_NUM_SENSORS +static const uint8_t NUM_SENSORS = HYDREON_RGXX_NUM_SENSORS; +#else +static const uint8_t NUM_SENSORS = 1; +#endif + +#ifndef HYDREON_RGXX_PROTOCOL_LIST +#define HYDREON_RGXX_PROTOCOL_LIST(F, SEP) F("") +#endif + +class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { + public: + void set_sensor(sensor::Sensor *sensor, int index) { this->sensors_[index] = sensor; } +#ifdef USE_BINARY_SENSOR + void set_too_cold_sensor(binary_sensor::BinarySensor *sensor) { this->too_cold_sensor_ = sensor; } +#endif + void set_model(RGModel model) { model_ = model; } + + /// Schedule data readings. + void update() override; + /// Read data once available + void loop() override; + /// Setup the sensor and test for a connection. + void setup() override; + void dump_config() override; + + float get_setup_priority() const override; + + protected: + void process_line_(); + void schedule_reboot_(); + bool buffer_starts_with_(const std::string &prefix); + bool buffer_starts_with_(const char *prefix); + bool sensor_missing_(); + + sensor::Sensor *sensors_[NUM_SENSORS] = {nullptr}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *too_cold_sensor_ = nullptr; +#endif + + int16_t boot_count_ = 0; + int16_t no_response_count_ = 0; + std::string buffer_; + RGModel model_ = RG9; + int sw_version_ = 0; + bool too_cold_ = false; + + // bit field showing which sensors we have received data for + int sensors_received_ = -1; +}; + +class HydreonRGxxBinaryComponent : public Component { + public: + HydreonRGxxBinaryComponent(HydreonRGxxComponent *parent) {} +}; + +} // namespace hydreon_rgxx +} // namespace esphome diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py new file mode 100644 index 0000000000..409500305a --- /dev/null +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -0,0 +1,119 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import uart, sensor +from esphome.const import ( + CONF_ID, + CONF_MODEL, + CONF_MOISTURE, + DEVICE_CLASS_HUMIDITY, + STATE_CLASS_MEASUREMENT, +) + +from . import RGModel, HydreonRGxxComponent + +UNIT_INTENSITY = "intensity" +UNIT_MILLIMETERS = "mm" +UNIT_MILLIMETERS_PER_HOUR = "mm/h" + +CONF_ACC = "acc" +CONF_EVENT_ACC = "event_acc" +CONF_TOTAL_ACC = "total_acc" +CONF_R_INT = "r_int" + +RG_MODELS = { + "RG_9": RGModel.RG9, + "RG_15": RGModel.RG15, + # https://rainsensors.com/wp-content/uploads/sites/3/2020/07/rg-15_instructions_sw_1.000.pdf + # https://rainsensors.com/wp-content/uploads/sites/3/2021/03/2020.08.25-rg-9_instructions.pdf + # https://rainsensors.com/wp-content/uploads/sites/3/2021/03/2021.03.11-rg-9_instructions.pdf +} +SUPPORTED_SENSORS = { + CONF_ACC: ["RG_15"], + CONF_EVENT_ACC: ["RG_15"], + CONF_TOTAL_ACC: ["RG_15"], + CONF_R_INT: ["RG_15"], + CONF_MOISTURE: ["RG_9"], +} +PROTOCOL_NAMES = { + CONF_MOISTURE: "R", + CONF_ACC: "Acc", + CONF_R_INT: "Rint", + CONF_EVENT_ACC: "EventAcc", + CONF_TOTAL_ACC: "TotalAcc", +} + + +def _validate(config): + for conf, models in SUPPORTED_SENSORS.items(): + if conf in config: + if config[CONF_MODEL] not in models: + raise cv.Invalid( + f"{conf} is only available on {' and '.join(models)}, not {config[CONF_MODEL]}" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HydreonRGxxComponent), + cv.Required(CONF_MODEL): cv.enum( + RG_MODELS, + upper=True, + space="_", + ), + cv.Optional(CONF_ACC): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETERS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_EVENT_ACC): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETERS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TOTAL_ACC): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETERS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_R_INT): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETERS_PER_HOUR, + accuracy_decimals=2, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_MOISTURE): sensor.sensor_schema( + unit_of_measurement=UNIT_INTENSITY, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(uart.UART_DEVICE_SCHEMA), + _validate, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + cg.add_define( + "HYDREON_RGXX_PROTOCOL_LIST(F, sep)", + cg.RawExpression( + " sep ".join([f'F("{name}")' for name in PROTOCOL_NAMES.values()]) + ), + ) + cg.add_define("HYDREON_RGXX_NUM_SENSORS", len(PROTOCOL_NAMES)) + + for i, conf in enumerate(PROTOCOL_NAMES): + if conf in config: + sens = await sensor.new_sensor(config[conf]) + cg.add(var.set_sensor(sens, i)) diff --git a/tests/test3.yaml b/tests/test3.yaml index f0975f9918..a6ad6b9e92 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -349,6 +349,24 @@ sensor: name: 'Temperature' humidity: name: 'Humidity' + - platform: hydreon_rgxx + model: "RG 9" + uart_id: uart6 + id: "hydreon_rg9" + moisture: + name: "hydreon_rain" + id: hydreon_rain + - platform: hydreon_rgxx + model: "RG_15" + uart_id: uart6 + acc: + name: "hydreon_acc" + event_acc: + name: "hydreon_event_acc" + total_acc: + name: "hydreon_total_acc" + r_int: + name: "hydreon_r_int" - platform: adc pin: VCC id: my_sensor @@ -796,6 +814,10 @@ binary_sensor: then: - cover.toggle: time_based_cover - cover.toggle: endstop_cover + - platform: hydreon_rgxx + hydreon_rgxx_id: "hydreon_rg9" + too_cold: + name: "rg9_toocold" - platform: template id: 'pzemac_reset_energy' on_press: