diff --git a/CODEOWNERS b/CODEOWNERS index 51719ef1aa..77c9d30c5d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -173,6 +173,7 @@ esphome/components/sdm_meter/* @jesserockz @polyfaces esphome/components/sdp3x/* @Azimath esphome/components/selec_meter/* @sourabhjaiswal esphome/components/select/* @esphome/core +esphome/components/sen5x/* @martgras esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/sgp40/* @SenexCrenshaw diff --git a/esphome/components/sen5x/__init__.py b/esphome/components/sen5x/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h new file mode 100644 index 0000000000..423b942000 --- /dev/null +++ b/esphome/components/sen5x/automation.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "sen5x.h" + +namespace esphome { +namespace sen5x { + +template class StartFanAction : public Action { + public: + explicit StartFanAction(SEN5XComponent *sen5x) : sen5x_(sen5x) {} + + void play(Ts... x) override { this->sen5x_->start_fan_cleaning(); } + + protected: + SEN5XComponent *sen5x_; +}; + +} // namespace sen5x +} // namespace esphome diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp new file mode 100644 index 0000000000..865fae373b --- /dev/null +++ b/esphome/components/sen5x/sen5x.cpp @@ -0,0 +1,413 @@ +#include "sen5x.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace sen5x { + +static const char *const TAG = "sen5x"; + +static const uint16_t SEN5X_CMD_AUTO_CLEANING_INTERVAL = 0x8004; +static const uint16_t SEN5X_CMD_GET_DATA_READY_STATUS = 0x0202; +static const uint16_t SEN5X_CMD_GET_FIRMWARE_VERSION = 0xD100; +static const uint16_t SEN5X_CMD_GET_PRODUCT_NAME = 0xD014; +static const uint16_t SEN5X_CMD_GET_SERIAL_NUMBER = 0xD033; +static const uint16_t SEN5X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static const uint16_t SEN5X_CMD_READ_MEASUREMENT = 0x03C4; +static const uint16_t SEN5X_CMD_RHT_ACCELERATION_MODE = 0x60F7; +static const uint16_t SEN5X_CMD_START_CLEANING_FAN = 0x5607; +static const uint16_t SEN5X_CMD_START_MEASUREMENTS = 0x0021; +static const uint16_t SEN5X_CMD_START_MEASUREMENTS_RHT_ONLY = 0x0037; +static const uint16_t SEN5X_CMD_STOP_MEASUREMENTS = 0x3f86; +static const uint16_t SEN5X_CMD_TEMPERATURE_COMPENSATION = 0x60B2; +static const uint16_t SEN5X_CMD_VOC_ALGORITHM_STATE = 0x6181; +static const uint16_t SEN5X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; + +void SEN5XComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up sen5x..."); + + // the sensor needs 1000 ms to enter the idle state + this->set_timeout(1000, [this]() { + // Check if measurement is ready before reading the value + if (!this->write_command(SEN5X_CMD_GET_DATA_READY_STATUS)) { + ESP_LOGE(TAG, "Failed to write data ready status command"); + this->mark_failed(); + return; + } + + uint16_t raw_read_status; + if (!this->read_data(raw_read_status)) { + ESP_LOGE(TAG, "Failed to read data ready status"); + this->mark_failed(); + return; + } + + uint32_t stop_measurement_delay = 0; + // In order to query the device periodic measurement must be ceased + if (raw_read_status) { + ESP_LOGD(TAG, "Sensor has data available, stopping periodic measurement"); + if (!this->write_command(SEN5X_CMD_STOP_MEASUREMENTS)) { + ESP_LOGE(TAG, "Failed to stop measurements"); + this->mark_failed(); + return; + } + // According to the SEN5x datasheet the sensor will only respond to other commands after waiting 200 ms after + // issuing the stop_periodic_measurement command + stop_measurement_delay = 200; + } + this->set_timeout(stop_measurement_delay, [this]() { + uint16_t raw_serial_number[3]; + if (!this->get_register(SEN5X_CMD_GET_SERIAL_NUMBER, raw_serial_number, 3, 20)) { + ESP_LOGE(TAG, "Failed to read serial number"); + this->error_code_ = SERIAL_NUMBER_IDENTIFICATION_FAILED; + this->mark_failed(); + return; + } + this->serial_number_[0] = static_cast(uint16_t(raw_serial_number[0]) & 0xFF); + this->serial_number_[1] = static_cast(raw_serial_number[0] & 0xFF); + this->serial_number_[2] = static_cast(raw_serial_number[1] >> 8); + ESP_LOGD(TAG, "Serial number %02d.%02d.%02d", serial_number_[0], serial_number_[1], serial_number_[2]); + + uint16_t raw_product_name[16]; + if (!this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) { + ESP_LOGE(TAG, "Failed to read product name"); + this->error_code_ = PRODUCT_NAME_FAILED; + this->mark_failed(); + return; + } + // 2 ASCII bytes are encoded in an int + const uint16_t *current_int = raw_product_name; + char current_char; + uint8_t max = 16; + do { + // first char + current_char = *current_int >> 8; + if (current_char) { + product_name_.push_back(current_char); + // second char + current_char = *current_int & 0xFF; + if (current_char) + product_name_.push_back(current_char); + } + current_int++; + } while (current_char && --max); + + Sen5xType sen5x_type = UNKNOWN; + if (product_name_ == "SEN50") { + sen5x_type = SEN50; + } else { + if (product_name_ == "SEN54") { + sen5x_type = SEN54; + } else { + if (product_name_ == "SEN55") { + sen5x_type = SEN55; + } + } + ESP_LOGD(TAG, "Productname %s", product_name_.c_str()); + } + if (this->humidity_sensor_ && sen5x_type == SEN50) { + ESP_LOGE(TAG, "For Relative humidity a SEN54 OR SEN55 is required. You are using a <%s> sensor", + this->product_name_.c_str()); + this->humidity_sensor_ = nullptr; // mark as not used + } + if (this->temperature_sensor_ && sen5x_type == SEN50) { + ESP_LOGE(TAG, "For Temperature a SEN54 OR SEN55 is required. You are using a <%s> sensor", + this->product_name_.c_str()); + this->temperature_sensor_ = nullptr; // mark as not used + } + if (this->voc_sensor_ && sen5x_type == SEN50) { + ESP_LOGE(TAG, "For VOC a SEN54 OR SEN55 is required. You are using a <%s> sensor", this->product_name_.c_str()); + this->voc_sensor_ = nullptr; // mark as not used + } + if (this->nox_sensor_ && sen5x_type != SEN55) { + ESP_LOGE(TAG, "For NOx a SEN55 is required. You are using a <%s> sensor", this->product_name_.c_str()); + this->nox_sensor_ = nullptr; // mark as not used + } + + if (!this->get_register(SEN5X_CMD_GET_FIRMWARE_VERSION, this->firmware_version_, 20)) { + ESP_LOGE(TAG, "Failed to read firmware version"); + this->error_code_ = FIRMWARE_FAILED; + this->mark_failed(); + return; + } + this->firmware_version_ >>= 8; + ESP_LOGD(TAG, "Firmware version %d", this->firmware_version_); + + if (this->voc_sensor_ && this->store_baseline_) { + // Hash with compilation time + // This ensures the baseline storage is cleared after OTA + uint32_t hash = fnv1_hash(App.get_compilation_time()); + this->pref_ = global_preferences->make_preference(hash, true); + + if (this->pref_.load(&this->voc_baselines_storage_)) { + ESP_LOGI(TAG, "Loaded VOC baseline state0: 0x%04X, state1: 0x%04X", this->voc_baselines_storage_.state0, + voc_baselines_storage_.state1); + } + + // Initialize storage timestamp + this->seconds_since_last_store_ = 0; + + if (this->voc_baselines_storage_.state0 > 0 && this->voc_baselines_storage_.state1 > 0) { + ESP_LOGI(TAG, "Setting VOC baseline from save state0: 0x%04X, state1: 0x%04X", + this->voc_baselines_storage_.state0, voc_baselines_storage_.state1); + uint16_t states[4]; + + states[0] = voc_baselines_storage_.state0 >> 16; + states[1] = voc_baselines_storage_.state0 & 0xFFFF; + states[2] = voc_baselines_storage_.state1 >> 16; + states[3] = voc_baselines_storage_.state1 & 0xFFFF; + + if (!this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE, states, 4)) { + ESP_LOGE(TAG, "Failed to set VOC baseline from saved state"); + } + } + } + bool result; + if (this->auto_cleaning_interval_.has_value()) { + // override default value + result = write_command(SEN5X_CMD_AUTO_CLEANING_INTERVAL, this->auto_cleaning_interval_.value()); + } else { + result = write_command(SEN5X_CMD_AUTO_CLEANING_INTERVAL); + } + if (result) { + delay(20); + uint16_t secs[2]; + if (this->read_data(secs, 2)) { + auto_cleaning_interval_ = secs[0] << 16 | secs[1]; + } + } + if (acceleration_mode_.has_value()) { + result = this->write_command(SEN5X_CMD_RHT_ACCELERATION_MODE, acceleration_mode_.value()); + } else { + result = this->write_command(SEN5X_CMD_RHT_ACCELERATION_MODE); + } + if (!result) { + ESP_LOGE(TAG, "Failed to set rh/t acceleration mode"); + this->error_code_ = COMMUNICATION_FAILED; + this->mark_failed(); + return; + } + delay(20); + if (!acceleration_mode_.has_value()) { + uint16_t mode; + if (this->read_data(mode)) { + this->acceleration_mode_ = RhtAccelerationMode(mode); + } else { + ESP_LOGE(TAG, "Failed to read RHT Acceleration mode"); + } + } + if (this->voc_tuning_params_.has_value()) + this->write_tuning_parameters_(SEN5X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); + if (this->nox_tuning_params_.has_value()) + this->write_tuning_parameters_(SEN5X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); + + if (this->temperature_compensation_.has_value()) + this->write_temperature_compensation_(this->temperature_compensation_.value()); + + // Finally start sensor measurements + auto cmd = SEN5X_CMD_START_MEASUREMENTS_RHT_ONLY; + if (this->pm_1_0_sensor_ || this->pm_2_5_sensor_ || this->pm_4_0_sensor_ || this->pm_10_0_sensor_) { + // if any of the gas sensors are active we need a full measurement + cmd = SEN5X_CMD_START_MEASUREMENTS; + } + + if (!this->write_command(cmd)) { + ESP_LOGE(TAG, "Error starting continuous measurements."); + this->error_code_ = MEASUREMENT_INIT_FAILED; + this->mark_failed(); + return; + } + initialized_ = true; + ESP_LOGD(TAG, "Sensor initialized"); + }); + }); +} + +void SEN5XComponent::dump_config() { + ESP_LOGCONFIG(TAG, "sen5x:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + switch (this->error_code_) { + case COMMUNICATION_FAILED: + ESP_LOGW(TAG, "Communication failed! Is the sensor connected?"); + break; + case MEASUREMENT_INIT_FAILED: + ESP_LOGW(TAG, "Measurement Initialization failed!"); + break; + case SERIAL_NUMBER_IDENTIFICATION_FAILED: + ESP_LOGW(TAG, "Unable to read sensor serial id"); + break; + case PRODUCT_NAME_FAILED: + ESP_LOGW(TAG, "Unable to read product name"); + break; + case FIRMWARE_FAILED: + ESP_LOGW(TAG, "Unable to read sensor firmware version"); + break; + default: + ESP_LOGW(TAG, "Unknown setup error!"); + break; + } + } + ESP_LOGCONFIG(TAG, " Productname: %s", this->product_name_.c_str()); + ESP_LOGCONFIG(TAG, " Firmware version: %d", this->firmware_version_); + ESP_LOGCONFIG(TAG, " Serial number %02d.%02d.%02d", serial_number_[0], serial_number_[1], serial_number_[2]); + if (this->auto_cleaning_interval_.has_value()) { + ESP_LOGCONFIG(TAG, " Auto auto cleaning interval %d seconds", auto_cleaning_interval_.value()); + } + if (this->acceleration_mode_.has_value()) { + switch (this->acceleration_mode_.value()) { + case LOW_ACCELERATION: + ESP_LOGCONFIG(TAG, " Low RH/T acceleration mode"); + break; + case MEDIUM_ACCELERATION: + ESP_LOGCONFIG(TAG, " Medium RH/T accelertion mode"); + break; + case HIGH_ACCELERATION: + ESP_LOGCONFIG(TAG, " High RH/T accelertion mode"); + break; + } + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); + LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); + LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); + LOG_SENSOR(" ", "PM 10.0", this->pm_10_0_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "VOC", this->voc_sensor_); // SEN54 and SEN55 only + LOG_SENSOR(" ", "NOx", this->nox_sensor_); // SEN55 only +} + +void SEN5XComponent::update() { + if (!initialized_) { + return; + } + + // Store baselines after defined interval or if the difference between current and stored baseline becomes too + // much + if (this->store_baseline_ && this->seconds_since_last_store_ > SHORTEST_BASELINE_STORE_INTERVAL) { + if (this->write_command(SEN5X_CMD_VOC_ALGORITHM_STATE)) { + // run it a bit later to avoid adding a delay here + this->set_timeout(550, [this]() { + uint16_t states[4]; + if (this->read_data(states, 4)) { + uint32_t state0 = states[0] << 16 | states[1]; + uint32_t state1 = states[2] << 16 | states[3]; + if ((uint32_t) std::abs(static_cast(this->voc_baselines_storage_.state0 - state0)) > + MAXIMUM_STORAGE_DIFF || + (uint32_t) std::abs(static_cast(this->voc_baselines_storage_.state1 - state1)) > + MAXIMUM_STORAGE_DIFF) { + this->seconds_since_last_store_ = 0; + this->voc_baselines_storage_.state0 = state0; + this->voc_baselines_storage_.state1 = state1; + + if (this->pref_.save(&this->voc_baselines_storage_)) { + ESP_LOGI(TAG, "Stored VOC baseline state0: 0x%04X ,state1: 0x%04X", this->voc_baselines_storage_.state0, + voc_baselines_storage_.state1); + } else { + ESP_LOGW(TAG, "Could not store VOC baselines"); + } + } + } + }); + } + } + + if (!this->write_command(SEN5X_CMD_READ_MEASUREMENT)) { + this->status_set_warning(); + ESP_LOGD(TAG, "write error read measurement (%d)", this->last_error_); + return; + } + this->set_timeout(20, [this]() { + uint16_t measurements[8]; + + if (!this->read_data(measurements, 8)) { + this->status_set_warning(); + ESP_LOGD(TAG, "read data error (%d)", this->last_error_); + return; + } + float pm_1_0 = measurements[0] / 10.0; + if (measurements[0] == 0xFFFF) + pm_1_0 = NAN; + float pm_2_5 = measurements[1] / 10.0; + if (measurements[1] == 0xFFFF) + pm_2_5 = NAN; + float pm_4_0 = measurements[2] / 10.0; + if (measurements[2] == 0xFFFF) + pm_4_0 = NAN; + float pm_10_0 = measurements[3] / 10.0; + if (measurements[3] == 0xFFFF) + pm_10_0 = NAN; + float humidity = measurements[4] / 100.0; + if (measurements[4] == 0xFFFF) + humidity = NAN; + float temperature = measurements[5] / 200.0; + if (measurements[5] == 0xFFFF) + temperature = NAN; + float voc = measurements[6] / 10.0; + if (measurements[6] == 0xFFFF) + voc = NAN; + float nox = measurements[7] / 10.0; + if (measurements[7] == 0xFFFF) + nox = NAN; + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5); + if (this->pm_4_0_sensor_ != nullptr) + this->pm_4_0_sensor_->publish_state(pm_4_0); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(humidity); + if (this->voc_sensor_ != nullptr) + this->voc_sensor_->publish_state(voc); + if (this->nox_sensor_ != nullptr) + this->nox_sensor_->publish_state(nox); + this->status_clear_warning(); + }); +} + +bool SEN5XComponent::write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning) { + uint16_t params[6]; + params[0] = tuning.index_offset; + params[1] = tuning.learning_time_offset_hours; + params[2] = tuning.learning_time_gain_hours; + params[3] = tuning.gating_max_duration_minutes; + params[4] = tuning.std_initial; + params[5] = tuning.gain_factor; + auto result = write_command(i2c_command, params, 6); + if (!result) { + ESP_LOGE(TAG, "set tuning parameters failed. i2c command=%0xX, err=%d", i2c_command, this->last_error_); + } + return result; +} + +bool SEN5XComponent::write_temperature_compensation_(const TemperatureCompensation &compensation) { + uint16_t params[3]; + params[0] = compensation.offset; + params[1] = compensation.normalized_offset_slope; + params[2] = compensation.time_constant; + if (!write_command(SEN5X_CMD_TEMPERATURE_COMPENSATION, params, 3)) { + ESP_LOGE(TAG, "set temperature_compensation failed. Err=%d", this->last_error_); + return false; + } + return true; +} + +bool SEN5XComponent::start_fan_cleaning() { + if (!write_command(SEN5X_CMD_START_CLEANING_FAN)) { + this->status_set_warning(); + ESP_LOGE(TAG, "write error start fan (%d)", this->last_error_); + return false; + } else { + ESP_LOGD(TAG, "Fan auto clean started"); + } + return true; +} + +} // namespace sen5x +} // namespace esphome diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h new file mode 100644 index 0000000000..f306003a82 --- /dev/null +++ b/esphome/components/sen5x/sen5x.h @@ -0,0 +1,128 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/sensirion_common/i2c_sensirion.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome { +namespace sen5x { + +enum ERRORCODE { + COMMUNICATION_FAILED, + SERIAL_NUMBER_IDENTIFICATION_FAILED, + MEASUREMENT_INIT_FAILED, + PRODUCT_NAME_FAILED, + FIRMWARE_FAILED, + UNKNOWN +}; + +// Shortest time interval of 3H for storing baseline values. +// Prevents wear of the flash because of too many write operations +const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 10800; +// Store anyway if the baseline difference exceeds the max storage diff value +const uint32_t MAXIMUM_STORAGE_DIFF = 50; + +struct Sen5xBaselines { + int32_t state0; + int32_t state1; +} PACKED; // NOLINT + +enum RhtAccelerationMode : uint16_t { LOW_ACCELERATION = 0, MEDIUM_ACCELERATION = 1, HIGH_ACCELERATION = 2 }; + +struct GasTuning { + uint16_t index_offset; + uint16_t learning_time_offset_hours; + uint16_t learning_time_gain_hours; + uint16_t gating_max_duration_minutes; + uint16_t std_initial; + uint16_t gain_factor; +}; + +struct TemperatureCompensation { + uint16_t offset; + uint16_t normalized_offset_slope; + uint16_t time_constant; +}; + +class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { + public: + float get_setup_priority() const override { return setup_priority::DATA; } + void setup() override; + void dump_config() override; + void update() override; + + enum Sen5xType { SEN50, SEN54, SEN55, UNKNOWN }; + + void set_pm_1_0_sensor(sensor::Sensor *pm_1_0) { pm_1_0_sensor_ = pm_1_0; } + void set_pm_2_5_sensor(sensor::Sensor *pm_2_5) { pm_2_5_sensor_ = pm_2_5; } + void set_pm_4_0_sensor(sensor::Sensor *pm_4_0) { pm_4_0_sensor_ = pm_4_0; } + void set_pm_10_0_sensor(sensor::Sensor *pm_10_0) { pm_10_0_sensor_ = pm_10_0; } + + void set_voc_sensor(sensor::Sensor *voc_sensor) { voc_sensor_ = voc_sensor; } + void set_nox_sensor(sensor::Sensor *nox_sensor) { nox_sensor_ = nox_sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } + void set_store_baseline(bool store_baseline) { store_baseline_ = store_baseline; } + void set_acceleration_mode(RhtAccelerationMode mode) { acceleration_mode_ = mode; } + void set_auto_cleaning_interval(uint32_t auto_cleaning_interval) { auto_cleaning_interval_ = auto_cleaning_interval; } + void set_voc_algorithm_tuning(uint16_t index_offset, uint16_t learning_time_offset_hours, + uint16_t learning_time_gain_hours, uint16_t gating_max_duration_minutes, + uint16_t std_initial, uint16_t gain_factor) { + voc_tuning_params_.value().index_offset = index_offset; + voc_tuning_params_.value().learning_time_offset_hours = learning_time_offset_hours; + voc_tuning_params_.value().learning_time_gain_hours = learning_time_gain_hours; + voc_tuning_params_.value().gating_max_duration_minutes = gating_max_duration_minutes; + voc_tuning_params_.value().std_initial = std_initial; + voc_tuning_params_.value().gain_factor = gain_factor; + } + void set_nox_algorithm_tuning(uint16_t index_offset, uint16_t learning_time_offset_hours, + uint16_t learning_time_gain_hours, uint16_t gating_max_duration_minutes, + uint16_t gain_factor) { + nox_tuning_params_.value().index_offset = index_offset; + nox_tuning_params_.value().learning_time_offset_hours = learning_time_offset_hours; + nox_tuning_params_.value().learning_time_gain_hours = learning_time_gain_hours; + nox_tuning_params_.value().gating_max_duration_minutes = gating_max_duration_minutes; + nox_tuning_params_.value().std_initial = 50; + nox_tuning_params_.value().gain_factor = gain_factor; + } + void set_temperature_compensation(float offset, float normalized_offset_slope, uint16_t time_constant) { + temperature_compensation_.value().offset = offset * 200; + temperature_compensation_.value().normalized_offset_slope = normalized_offset_slope * 100; + temperature_compensation_.value().time_constant = time_constant; + } + bool start_fan_cleaning(); + + protected: + bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool write_temperature_compensation_(const TemperatureCompensation &compensation); + ERRORCODE error_code_; + bool initialized_{false}; + sensor::Sensor *pm_1_0_sensor_{nullptr}; + sensor::Sensor *pm_2_5_sensor_{nullptr}; + sensor::Sensor *pm_4_0_sensor_{nullptr}; + sensor::Sensor *pm_10_0_sensor_{nullptr}; + // SEN54 and SEN55 only + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + sensor::Sensor *voc_sensor_{nullptr}; + // SEN55 only + sensor::Sensor *nox_sensor_{nullptr}; + + std::string product_name_; + uint8_t serial_number_[4]; + uint16_t firmware_version_; + Sen5xBaselines voc_baselines_storage_; + bool store_baseline_; + uint32_t seconds_since_last_store_; + ESPPreferenceObject pref_; + optional acceleration_mode_; + optional auto_cleaning_interval_; + optional voc_tuning_params_; + optional nox_tuning_params_; + optional temperature_compensation_; +}; + +} // namespace sen5x +} // namespace esphome diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py new file mode 100644 index 0000000000..489fda8335 --- /dev/null +++ b/esphome/components/sen5x/sensor.py @@ -0,0 +1,241 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import i2c, sensor, sensirion_common +from esphome import automation +from esphome.automation import maybe_simple_id + +from esphome.const import ( + CONF_HUMIDITY, + CONF_ID, + CONF_OFFSET, + CONF_PM_1_0, + CONF_PM_10_0, + CONF_PM_2_5, + CONF_PM_4_0, + CONF_STORE_BASELINE, + CONF_TEMPERATURE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_NITROUS_OXIDE, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ICON_CHEMICAL_WEAPON, + ICON_RADIATOR, + ICON_THERMOMETER, + ICON_WATER_PERCENT, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_PERCENT, +) + +CODEOWNERS = ["@martgras"] +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["sensirion_common"] + +sen5x_ns = cg.esphome_ns.namespace("sen5x") +SEN5XComponent = sen5x_ns.class_( + "SEN5XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice +) +RhtAccelerationMode = sen5x_ns.enum("RhtAccelerationMode") + +CONF_ACCELERATION_MODE = "acceleration_mode" +CONF_ALGORITHM_TUNING = "algorithm_tuning" +CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" +CONF_GAIN_FACTOR = "gain_factor" +CONF_GATING_MAX_DURATION_MINUTES = "gating_max_duration_minutes" +CONF_INDEX_OFFSET = "index_offset" +CONF_LEARNING_TIME_GAIN_HOURS = "learning_time_gain_hours" +CONF_LEARNING_TIME_OFFSET_HOURS = "learning_time_offset_hours" +CONF_NORMALIZED_OFFSET_SLOPE = "normalized_offset_slope" +CONF_NOX = "nox" +CONF_STD_INITIAL = "std_initial" +CONF_TEMPERATURE_COMPENSATION = "temperature_compensation" +CONF_TIME_CONSTANT = "time_constant" +CONF_VOC = "voc" +CONF_VOC_BASELINE = "voc_baseline" + + +# Actions +StartFanAction = sen5x_ns.class_("StartFanAction", automation.Action) + +ACCELERATION_MODES = { + "low": RhtAccelerationMode.LOW_ACCELERATION, + "medium": RhtAccelerationMode.MEDIUM_ACCELERATION, + "high": RhtAccelerationMode.HIGH_ACCELERATION, +} + +GAS_SENSOR = cv.Schema( + { + cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( + { + cv.Optional(CONF_INDEX_OFFSET, default=100): cv.int_range(1, 250), + cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_range( + 1, 1000 + ), + cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_range( + 1, 1000 + ), + cv.Optional( + CONF_GATING_MAX_DURATION_MINUTES, default=720 + ): cv.int_range(0, 3000), + cv.Optional(CONF_STD_INITIAL, default=50): cv.int_, + cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_range(1, 1000), + } + ) + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SEN5XComponent), + cv.Optional(CONF_PM_1_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM1, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PM_2_5): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM25, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PM_4_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PM_10_0): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=2, + device_class=DEVICE_CLASS_PM10, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_AUTO_CLEANING_INTERVAL): cv.time_period_in_seconds_, + cv.Optional(CONF_VOC): sensor.sensor_schema( + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(GAS_SENSOR), + cv.Optional(CONF_NOX): sensor.sensor_schema( + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_NITROUS_OXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(GAS_SENSOR), + cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, + cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_WATER_PERCENT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_COMPENSATION): cv.Schema( + { + cv.Optional(CONF_OFFSET, default=0): cv.float_, + cv.Optional(CONF_NORMALIZED_OFFSET_SLOPE, default=0): cv.percentage, + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_, + } + ), + cv.Optional(CONF_ACCELERATION_MODE): cv.enum(ACCELERATION_MODES), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x69)) +) + +SENSOR_MAP = { + CONF_PM_1_0: "set_pm_1_0_sensor", + CONF_PM_2_5: "set_pm_2_5_sensor", + CONF_PM_4_0: "set_pm_4_0_sensor", + CONF_PM_10_0: "set_pm_10_0_sensor", + CONF_VOC: "set_voc_sensor", + CONF_NOX: "set_nox_sensor", + CONF_TEMPERATURE: "set_temperature_sensor", + CONF_HUMIDITY: "set_humidity_sensor", +} + +SETTING_MAP = { + CONF_AUTO_CLEANING_INTERVAL: "set_auto_cleaning_interval", + CONF_ACCELERATION_MODE: "set_acceleration_mode", +} + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + for key, funcName in SETTING_MAP.items(): + if key in config: + cg.add(getattr(var, funcName)(config[key])) + + for key, funcName in SENSOR_MAP.items(): + if key in config: + sens = await sensor.new_sensor(config[key]) + cg.add(getattr(var, funcName)(sens)) + + if CONF_VOC in config and CONF_ALGORITHM_TUNING in config[CONF_VOC]: + cfg = config[CONF_VOC][CONF_ALGORITHM_TUNING] + cg.add( + var.set_voc_algorithm_tuning( + cfg[CONF_INDEX_OFFSET], + cfg[CONF_LEARNING_TIME_OFFSET_HOURS], + cfg[CONF_LEARNING_TIME_GAIN_HOURS], + cfg[CONF_GATING_MAX_DURATION_MINUTES], + cfg[CONF_STD_INITIAL], + cfg[CONF_GAIN_FACTOR], + ) + ) + if CONF_NOX in config and CONF_ALGORITHM_TUNING in config[CONF_NOX]: + cfg = config[CONF_NOX][CONF_ALGORITHM_TUNING] + cg.add( + var.set_nox_algorithm_tuning( + cfg[CONF_INDEX_OFFSET], + cfg[CONF_LEARNING_TIME_OFFSET_HOURS], + cfg[CONF_LEARNING_TIME_GAIN_HOURS], + cfg[CONF_GATING_MAX_DURATION_MINUTES], + cfg[CONF_GAIN_FACTOR], + ) + ) + if CONF_TEMPERATURE_COMPENSATION in config: + cg.add( + var.set_temperature_compensation( + config[CONF_TEMPERATURE_COMPENSATION][CONF_OFFSET], + config[CONF_TEMPERATURE_COMPENSATION][CONF_NORMALIZED_OFFSET_SLOPE], + config[CONF_TEMPERATURE_COMPENSATION][CONF_TIME_CONSTANT], + ) + ) + + +SEN5X_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(SEN5XComponent), + } +) + + +@automation.register_action( + "sen5x.start_fan_autoclean", StartFanAction, SEN5X_ACTION_SCHEMA +) +async def sen54_fan_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/tests/test5.yaml b/tests/test5.yaml index ffda860377..35f6b14f2a 100644 --- a/tests/test5.yaml +++ b/tests/test5.yaml @@ -285,6 +285,49 @@ sensor: address: 0x77 iir_filter: 2X + - platform: sen5x + id: sen54 + temperature: + name: "Temperature" + accuracy_decimals: 1 + humidity: + name: "Humidity" + accuracy_decimals: 0 + pm_1_0: + name: " PM <1µm Weight concentration" + id: pm_1_0 + accuracy_decimals: 1 + pm_2_5: + name: " PM <2.5µm Weight concentration" + id: pm_2_5 + accuracy_decimals: 1 + pm_4_0: + name: " PM <4µm Weight concentration" + id: pm_4_0 + accuracy_decimals: 1 + pm_10_0: + name: " PM <10µm Weight concentration" + id: pm_10_0 + accuracy_decimals: 1 + nox: + name: "NOx" + voc: + name: "VOC" + algorithm_tuning: + index_offset: 100 + learning_time_offset_hours: 12 + learning_time_gain_hours: 12 + gating_max_duration_minutes: 180 + std_initial: 50 + gain_factor: 230 + temperature_compensation: + offset: 0 + normalized_offset_slope: 0 + time_constant: 0 + acceleration_mode: low + store_baseline: true + address: 0x69 + script: - id: automation_test then: