diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 5087b112a8d..dabf16d760b 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -22,6 +22,9 @@ static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; static constexpr uint32_t FAN_CLEANING_DELAY = 10000; static constexpr uint32_t SHT_HEATER_DELAY = 20000; static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +// Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation +static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; +static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; @@ -42,6 +45,9 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; +static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; +static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; static inline void set_read_command_and_words(SEN6XComponent::Sen6xType type, uint16_t &read_cmd, uint8_t &read_words) { read_cmd = SEN6X_CMD_READ_MEASUREMENT; @@ -168,32 +174,54 @@ void SEN6XComponent::setup() { }); } -// One configuration write per invocation, spaced by CMD_EXEC_DELAY. Cases without a -// configured value fall through; each taken case must advance setup_step_index_ so the -// next invocation resumes at the following step. These writes are optional, so a failure -// only warns and the chain continues to the mandatory start-measurements write. +// One configuration write per invocation, spaced by CMD_EXEC_DELAY, then the start command. +// These writes are optional, so a failure only warns and the chain carries on. void SEN6XComponent::run_next_setup_step_() { - switch (this->setup_step_index_) { - // Tuning writes are skipped when setup() disabled the sensor for this variant - case 0: - this->setup_step_index_++; - if (this->voc_sensor_ != nullptr && this->voc_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); - break; - } - [[fallthrough]]; - case 1: - this->setup_step_index_++; - if (this->nox_sensor_ != nullptr && this->nox_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); - break; - } - [[fallthrough]]; - default: - this->finish_setup_(); + while (this->setup_step_index_ < SETUP_STEP_COUNT) { + if (this->write_setup_step_(static_cast(this->setup_step_index_++))) { + this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); return; + } } - this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); + this->finish_setup_(); +} + +// Returns false when the step has nothing to write: the setting is not configured, or setup() +// disabled its sensor for this variant +bool SEN6XComponent::write_setup_step_(SetupStep step) { + switch (step) { + case SETUP_STEP_VOC_TUNING: + if (this->voc_sensor_ == nullptr || !this->voc_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); + return true; + case SETUP_STEP_NOX_TUNING: + if (this->nox_sensor_ == nullptr || !this->nox_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); + return true; + case SETUP_STEP_CO2_ASC: + if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) + return false; + this->write_setup_register_(SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL, this->co2_asc_.value() ? 1 : 0); + return true; + case SETUP_STEP_ALTITUDE: + if (this->co2_sensor_ == nullptr || this->altitude_compensation_ == ALTITUDE_UNSET) + return false; + this->write_setup_register_(SEN6X_CMD_SENSOR_ALTITUDE, this->altitude_compensation_); + return true; + case SETUP_STEP_AMBIENT_PRESSURE: + if (this->co2_sensor_ == nullptr || this->ambient_pressure_ == PRESSURE_UNSET) + return false; + this->write_ambient_pressure_(this->ambient_pressure_); + return true; + default: + return false; + } +} + +bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { + return this->write_config_words_(i2c_command, &value, 1); } void SEN6XComponent::finish_setup_() { @@ -208,14 +236,15 @@ void SEN6XComponent::finish_setup_() { ESP_LOGD(TAG, "Initialized"); } -// Writes one optional configuration command. A failure warns and returns false, but does -// not stop setup: the sensor still measures with that setting left at its default. +// Writes one optional configuration command and reserves its execution time. A failure warns and +// returns false, but does not stop setup: the sensor still measures with that setting left at its default. bool SEN6XComponent::write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len) { if (!this->write_command(i2c_command, data, len)) { ESP_LOGE(TAG, "Write 0x%04X failed, error %d", i2c_command, this->last_error_); this->status_set_warning(); return false; } + this->set_command_wait_(CMD_EXEC_DELAY); return true; } @@ -239,6 +268,22 @@ void SEN6XComponent::dump_config() { this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, this->firmware_version_minor_, this->address_); LOG_UPDATE_INTERVAL(this); + // setup() clears co2_sensor_ once it detects a variant without CO2 + if (this->co2_sensor_ != nullptr) { + if (this->co2_asc_.has_value()) { + ESP_LOGCONFIG(TAG, " CO2 automatic self-calibration: %s", ONOFF(this->co2_asc_.value())); + } + if (this->altitude_compensation_ != ALTITUDE_UNSET) { + ESP_LOGCONFIG(TAG, " Altitude compensation: %u m", this->altitude_compensation_); + } + if (this->ambient_pressure_ != PRESSURE_UNSET) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %u hPa", this->ambient_pressure_); + } + if (this->ambient_pressure_source_ != nullptr) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation source: %s", + this->ambient_pressure_source_->get_name().c_str()); + } + } 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_); @@ -255,9 +300,17 @@ void SEN6XComponent::update() { if (!this->initialized_ || !this->measuring_) { return; } - - // Cancel any in-flight polling from a previous update() cycle. + // Cancel any in-flight polling from a previous update() cycle before touching the bus. this->cancel_timeout(TIMEOUT_POLL); + // A command is still executing (an action moments ago): run this cycle once its window has passed + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->update(); }); + return; + } + + if (this->ambient_pressure_source_ != nullptr && this->co2_sensor_ != nullptr) { + this->update_ambient_pressure_compensation_(this->ambient_pressure_source_->state); + } set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_); @@ -286,6 +339,11 @@ void SEN6XComponent::poll_data_ready_() { ESP_LOGD(TAG, "Data not ready"); return; } + // A set command (the pressure write above all) gets its execution time before the chain writes again + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->poll_data_ready_(); }); + return; + } ESP_LOGV(TAG, "Data ready polling attempt %u", static_cast(POLL_RETRIES - this->poll_retries_remaining_ + 1)); this->poll_retries_remaining_--; @@ -454,8 +512,35 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } -// True while the previous command's execution or settling time has not elapsed -bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } +void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { + if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) + this->last_ambient_pressure_ = pressure_hpa; +} + +void SEN6XComponent::update_ambient_pressure_compensation_(float pressure_hpa) { + if (std::isnan(pressure_hpa)) + return; + // Range-check before narrowing so out-of-unit sources (e.g. Pa) can't wrap into range + if (pressure_hpa < AMBIENT_PRESSURE_MIN_HPA || pressure_hpa > AMBIENT_PRESSURE_MAX_HPA) { + if (!this->pressure_range_warned_) { + ESP_LOGW(TAG, "Ambient pressure out of range: %.0f hPa", pressure_hpa); + this->pressure_range_warned_ = true; + } + return; + } + this->pressure_range_warned_ = false; + const auto value = static_cast(lroundf(pressure_hpa)); + if (value != this->last_ambient_pressure_) + this->write_ambient_pressure_(value); +} + +// Time left in the previous command's execution or settling window, 0 once it has passed +uint32_t SEN6XComponent::command_wait_remaining_() const { + const uint32_t elapsed = millis() - this->command_started_at_; + return elapsed < this->command_wait_ ? this->command_wait_ - elapsed : 0; +} + +bool SEN6XComponent::command_blocked_() const { return this->command_wait_remaining_() != 0; } void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { this->command_started_at_ = millis(); diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 588a466d714..43d0ce00120 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,10 @@ struct GasTuning { uint16_t gain_factor; }; +// The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa +inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; +inline constexpr uint16_t PRESSURE_UNSET = 0; + class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) @@ -58,17 +62,35 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } + void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } + void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } + void set_ambient_pressure_source(sensor::Sensor *pressure) { this->ambient_pressure_source_ = pressure; } void start_measurement(); void stop_measurement(); void start_fan_cleaning(); void activate_sht_heater(); protected: + enum SetupStep : uint8_t { + SETUP_STEP_VOC_TUNING, + SETUP_STEP_NOX_TUNING, + SETUP_STEP_CO2_ASC, + SETUP_STEP_ALTITUDE, + SETUP_STEP_AMBIENT_PRESSURE, + SETUP_STEP_COUNT, + }; + Sen6xType infer_type_from_product_name_(const std::string &product_name); void run_next_setup_step_(); + bool write_setup_step_(SetupStep step); void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool write_setup_register_(uint16_t i2c_command, uint16_t value); + void write_ambient_pressure_(uint16_t pressure_hpa); + void update_ambient_pressure_compensation_(float pressure_hpa); + uint32_t command_wait_remaining_() const; bool command_blocked_() const; void set_command_wait_(uint32_t wait_ms); void on_measurement_started_(); @@ -80,6 +102,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + sensor::Sensor *ambient_pressure_source_{nullptr}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; @@ -87,12 +110,18 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; + uint16_t altitude_compensation_{ALTITUDE_UNSET}; + uint16_t ambient_pressure_{PRESSURE_UNSET}; + // Last pressure written to the device, used to skip redundant writes + uint16_t last_ambient_pressure_{PRESSURE_UNSET}; + optional co2_asc_; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; uint8_t firmware_version_minor_{0}; uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool pressure_range_warned_{false}; bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 9893ef80233..b219caafa09 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -6,6 +6,10 @@ from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, + CONF_ALTITUDE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, + CONF_AUTOMATIC_SELF_CALIBRATION, CONF_CO2, CONF_FORMALDEHYDE, CONF_GAIN_FACTOR, @@ -155,6 +159,22 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, + ).extend( + { + cv.Optional(CONF_AUTOMATIC_SELF_CALIBRATION): cv.boolean, + cv.Optional(CONF_ALTITUDE_COMPENSATION): cv.All( + cv.float_with_unit( + "altitude", "(m|m a.s.l.|MAMSL|MASL)", optional_unit=True + ), + cv.int_range(min=0, max=3000), + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.int_range( + min=700, max=1200 + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( + sensor.Sensor + ), + } ), cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema( unit_of_measurement="ppb", @@ -209,6 +229,18 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + if co2_cfg := config.get(CONF_CO2): + if (asc := co2_cfg.get(CONF_AUTOMATIC_SELF_CALIBRATION)) is not None: + cg.add(var.set_automatic_self_calibration(asc)) + if (altitude := co2_cfg.get(CONF_ALTITUDE_COMPENSATION)) is not None: + cg.add(var.set_altitude_compensation(altitude)) + if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: + cg.add(var.set_ambient_pressure_compensation(pressure)) + if ( + source := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE) + ) is not None: + sens = await cg.get_variable(source) + cg.add(var.set_ambient_pressure_source(sens)) SEN6X_ACTION_SCHEMA = maybe_simple_id( diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6ab80a76b21..eed0dd51d67 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -1,4 +1,8 @@ sensor: + - platform: template + id: pressure_source + name: Pressure Source + unit_of_measurement: hPa # Test with explicit type parameter - platform: sen6x id: sen6x_sensor @@ -45,6 +49,10 @@ sensor: gain_factor: 180 co2: name: Carbon Dioxide + automatic_self_calibration: true + altitude_compensation: 100 + ambient_pressure_compensation: 1013 + ambient_pressure_compensation_source: pressure_source formaldehyde: name: Formaldehyde address: 0x6B