diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index dabf16d760b..42bc3aea6a6 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -1,6 +1,7 @@ #include "sen6x.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include #include namespace esphome::sen6x { @@ -25,7 +26,6 @@ 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; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -45,6 +45,8 @@ 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_TEMPERATURE_COMPENSATION = 0x60B2; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_ACCELERATION = 0x6100; 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; @@ -200,6 +202,16 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { return false; this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); return true; + case SETUP_STEP_TEMPERATURE_COMPENSATION: + if (!this->temperature_compensation_.has_value()) + return false; + this->write_temperature_compensation_(this->temperature_compensation_.value()); + return true; + case SETUP_STEP_TEMPERATURE_ACCELERATION: + if (!this->temperature_acceleration_.has_value()) + return false; + this->write_temperature_acceleration_(this->temperature_acceleration_.value()); + return true; case SETUP_STEP_CO2_ASC: if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) return false; @@ -220,6 +232,18 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { } } +bool SEN6XComponent::write_temperature_compensation_(const TemperatureCompensation &compensation) { + // Word 4 selects compensation slot 0; other slots are not exposed + uint16_t params[4] = {static_cast(compensation.offset), + static_cast(compensation.normalized_offset_slope), compensation.time_constant, 0}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_COMPENSATION, params, 4); +} + +bool SEN6XComponent::write_temperature_acceleration_(const TemperatureAcceleration &acceleration) { + uint16_t params[4] = {acceleration.k, acceleration.p, acceleration.t1, acceleration.t2}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_ACCELERATION, params, 4); +} + bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { return this->write_config_words_(i2c_command, &value, 1); } @@ -264,10 +288,21 @@ void SEN6XComponent::dump_config() { " Product: %s\n" " Serial: %s\n" " Firmware: %u.%u\n" - " Address: 0x%02X", + " Address: 0x%02X\n" + " Startup delay: %" PRIu32 " ms", this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, - this->firmware_version_minor_, this->address_); + this->firmware_version_minor_, this->address_, this->startup_delay_ms_); LOG_UPDATE_INTERVAL(this); + if (this->temperature_compensation_.has_value()) { + const auto &comp = this->temperature_compensation_.value(); + ESP_LOGCONFIG(TAG, " Temperature compensation: offset=%.2f slope=%.4f time_constant=%us", comp.offset / 200.0f, + comp.normalized_offset_slope / 10000.0f, comp.time_constant); + } + if (this->temperature_acceleration_.has_value()) { + const auto &accel = this->temperature_acceleration_.value(); + ESP_LOGCONFIG(TAG, " Temperature acceleration: K=%.1f P=%.1f T1=%.1f T2=%.1f", accel.k / 10.0f, accel.p / 10.0f, + accel.t1 / 10.0f, accel.t2 / 10.0f); + } // setup() clears co2_sensor_ once it detects a variant without CO2 if (this->co2_sensor_ != nullptr) { if (this->co2_asc_.has_value()) { @@ -557,7 +592,7 @@ void SEN6XComponent::on_measurement_started_() { } // Values need the warm-up period again after every start this->startup_complete_ = false; - this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); + this->set_timeout(TIMEOUT_STARTUP, this->startup_delay_ms_, [this]() { this->startup_complete_ = true; }); } void SEN6XComponent::start_measurement() { diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 43d0ce00120..8cc187ebf2b 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,21 @@ struct GasTuning { uint16_t gain_factor; }; +// Raw values in the device's fixed-point encoding (offset x200, slope x10000) +struct TemperatureCompensation { + int16_t offset; + int16_t normalized_offset_slope; + uint16_t time_constant; +}; + +// Raw values in the device's fixed-point encoding (all x10) +struct TemperatureAcceleration { + uint16_t k; + uint16_t p; + uint16_t t1; + uint16_t t2; +}; + // 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; @@ -62,6 +77,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_startup_delay(uint32_t delay_ms) { this->startup_delay_ms_ = delay_ms; } + void set_temperature_compensation(int16_t offset, int16_t normalized_offset_slope, uint16_t time_constant) { + this->temperature_compensation_ = TemperatureCompensation{offset, normalized_offset_slope, time_constant}; + } + void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { + this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; + } 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; } @@ -75,6 +97,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S enum SetupStep : uint8_t { SETUP_STEP_VOC_TUNING, SETUP_STEP_NOX_TUNING, + SETUP_STEP_TEMPERATURE_COMPENSATION, + SETUP_STEP_TEMPERATURE_ACCELERATION, SETUP_STEP_CO2_ASC, SETUP_STEP_ALTITUDE, SETUP_STEP_AMBIENT_PRESSURE, @@ -88,6 +112,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S 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); + bool write_temperature_compensation_(const TemperatureCompensation &compensation); + bool write_temperature_acceleration_(const TemperatureAcceleration &acceleration); void write_ambient_pressure_(uint16_t pressure_hpa); void update_ambient_pressure_compensation_(float pressure_hpa); uint32_t command_wait_remaining_() const; @@ -102,14 +128,17 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + optional temperature_acceleration_; + optional temperature_compensation_; + uint16_t read_cmd_{0}; sensor::Sensor *ambient_pressure_source_{nullptr}; + uint32_t startup_delay_ms_{60000}; 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}; uint32_t command_wait_{0}; 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 diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b219caafa09..c62aa5af4cd 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -19,13 +19,18 @@ from esphome.const import ( CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NORMALIZED_OFFSET_SLOPE, CONF_NOX, + CONF_OFFSET, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, + CONF_TEMPERATURE_COMPENSATION, + CONF_TIME_CONSTANT, CONF_TYPE, CONF_VOC, DEVICE_CLASS_CARBON_DIOXIDE, @@ -47,6 +52,17 @@ from esphome.const import ( ) from esphome.types import ConfigType +CONF_TEMPERATURE_ACCELERATION = "temperature_acceleration" +CONF_K = "k" +CONF_P = "p" +CONF_T1 = "t1" +CONF_T2 = "t2" + +# The device takes these as fixed-point words: offset x200, slope x10000, acceleration x10 +TEMPERATURE_OFFSET_SCALE = 200 +TEMPERATURE_SLOPE_SCALE = 10000 +TEMPERATURE_ACCELERATION_SCALE = 10 + CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] @@ -93,6 +109,8 @@ def _gas_index_schema( ).extend({cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(tuning_schema)}) +_acceleration_parameter = cv.float_range(min=0.1, max=6553.5) + CONFIG_SCHEMA = cv.All( cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen6x"), cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen6x"), @@ -182,6 +200,31 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_STARTUP_DELAY, default="60s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=1)), + ), + cv.Optional(CONF_TEMPERATURE_COMPENSATION): cv.Schema( + { + cv.Optional(CONF_OFFSET, default=0): cv.float_range( + min=-163.84, max=163.835 + ), + cv.Optional( + CONF_NORMALIZED_OFFSET_SLOPE, default=0 + ): cv.float_range(min=-3.2768, max=3.2767), + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_range( + min=0, max=65535 + ), + } + ), + cv.Optional(CONF_TEMPERATURE_ACCELERATION): cv.Schema( + { + cv.Optional(CONF_K, default=20.0): _acceleration_parameter, + cv.Optional(CONF_P, default=20.0): _acceleration_parameter, + cv.Optional(CONF_T1, default=100.0): _acceleration_parameter, + cv.Optional(CONF_T2, default=300.0): _acceleration_parameter, + } + ), } ) .extend(cv.polling_component_schema("60s")) @@ -208,6 +251,26 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) + + if (comp := config.get(CONF_TEMPERATURE_COMPENSATION)) is not None: + cg.add( + var.set_temperature_compensation( + round(comp[CONF_OFFSET] * TEMPERATURE_OFFSET_SCALE), + round(comp[CONF_NORMALIZED_OFFSET_SLOPE] * TEMPERATURE_SLOPE_SCALE), + comp[CONF_TIME_CONSTANT], + ) + ) + if (accel := config.get(CONF_TEMPERATURE_ACCELERATION)) is not None: + cg.add( + var.set_temperature_acceleration( + *( + round(accel[key] * TEMPERATURE_ACCELERATION_SCALE) + for key in (CONF_K, CONF_P, CONF_T1, CONF_T2) + ) + ) + ) + for key, func_name in SENSOR_MAP.items(): if cfg := config.get(key): sens = await sensor.new_sensor(cfg) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index eed0dd51d67..d276cecbc7c 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -8,6 +8,16 @@ sensor: id: sen6x_sensor type: SEN69C i2c_id: i2c_bus + startup_delay: 30s + temperature_compensation: + offset: -2.5 + normalized_offset_slope: 0.01 + time_constant: 60 + temperature_acceleration: + k: 25.0 + p: 15.0 + t1: 120.0 + t2: 240.0 temperature: name: Temperature accuracy_decimals: 1 diff --git a/tests/components/sen6x/validate.esp32-idf.yaml b/tests/components/sen6x/validate.esp32-idf.yaml index 3ae23af4ac4..927a8d478a9 100644 --- a/tests/components/sen6x/validate.esp32-idf.yaml +++ b/tests/components/sen6x/validate.esp32-idf.yaml @@ -1,5 +1,5 @@ -# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the -# keys that are left out. +# Config-only: partial algorithm_tuning, temperature_compensation and +# temperature_acceleration blocks, so the schema defaults fill in the keys that are left out. packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml @@ -8,6 +8,10 @@ sensor: id: sen6x_partial_tuning type: SEN65 i2c_id: i2c_bus + temperature_compensation: + offset: 1.5 + temperature_acceleration: + k: 30 voc_index: name: VOC Index algorithm_tuning: