From bbe806f1e6108b0d0597e2d1a055e6321ba99fd8 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:45:38 -0500 Subject: [PATCH] [sen6x] Add VOC/NOx algorithm tuning (#18779) --- esphome/components/sen6x/sen6x.cpp | 79 ++++++++++++++++--- esphome/components/sen6x/sen6x.h | 42 +++++++++- esphome/components/sen6x/sensor.py | 76 ++++++++++++++++-- tests/components/sen6x/common.yaml | 13 +++ .../components/sen6x/validate.esp32-idf.yaml | 18 +++++ 5 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 tests/components/sen6x/validate.esp32-idf.yaml diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 2a6ea64735..ed6cb24c52 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -9,9 +9,11 @@ static const char *const TAG = "sen6x"; static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete +static constexpr uint32_t CMD_EXEC_DELAY = 20; // execution time of set commands (datasheet section 4.8) static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts -// Single numeric timeout ID — the chain is sequential so only one is active at a time. +// Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time. static constexpr uint32_t TIMEOUT_POLL = 1; +static constexpr uint32_t TIMEOUT_SETUP_STEP = 2; 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; @@ -26,6 +28,8 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; 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 inline void set_read_command_and_words(SEN6XComponent::Sen6xType type, uint16_t &read_cmd, uint8_t &read_words) { read_cmd = SEN6X_CMD_READ_MEASUREMENT; @@ -143,21 +147,76 @@ void SEN6XComponent::setup() { this->firmware_version_minor_ = raw_firmware_version & 0xFF; ESP_LOGI(TAG, "Firmware: %u.%u", this->firmware_version_major_, this->firmware_version_minor_); - if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } - - this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); - this->initialized_ = true; - ESP_LOGD(TAG, "Initialized"); + // Step 4: write configuration commands one at a time, then start measurements. + // Delay the first step so it doesn't run in the same loop tick as the read above. + this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); }); }); }); }); } +// 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. +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_(); + return; + } + this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); +} + +void SEN6XComponent::finish_setup_() { + if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { + ESP_LOGE(TAG, "Write 0x%04X failed, error %d", SEN6X_CMD_START_MEASUREMENTS, this->last_error_); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); + this->initialized_ = true; + 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. +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; + } + return true; +} + +bool SEN6XComponent::write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning) { + uint16_t params[6] = {tuning.index_offset, + tuning.learning_time_offset_hours, + tuning.learning_time_gain_hours, + tuning.gating_max_duration_minutes, + tuning.std_initial, + tuning.gain_factor}; + return this->write_config_words_(i2c_command, params, 6); +} + void SEN6XComponent::dump_config() { ESP_LOGCONFIG(TAG, "sen6x:\n" diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 041bf3b1aa..64ce3371fc 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -1,11 +1,25 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/optional.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" namespace esphome::sen6x { +// The NOx algorithm requires std_initial to stay at 50 (Sensirion datasheet) +static constexpr uint16_t NOX_STD_INITIAL = 50; + +// Raw parameter block for the VOC/NOx algorithm tuning commands +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; +}; + class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) @@ -27,22 +41,46 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S enum Sen6xType { SEN62, SEN63C, SEN65, SEN66, SEN68, SEN69C, UNKNOWN }; void set_type(const std::string &type) { sen6x_type_ = infer_type_from_product_name_(type); } + 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) { + this->voc_tuning_params_ = GasTuning{ + index_offset, learning_time_offset_hours, learning_time_gain_hours, gating_max_duration_minutes, std_initial, + 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) { + this->nox_tuning_params_ = GasTuning{index_offset, + learning_time_offset_hours, + learning_time_gain_hours, + gating_max_duration_minutes, + NOX_STD_INITIAL, + gain_factor}; + } protected: Sen6xType infer_type_from_product_name_(const std::string &product_name); + void run_next_setup_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); void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); - bool initialized_{false}; std::string product_name_; - Sen6xType sen6x_type_{UNKNOWN}; std::string serial_number_; + optional voc_tuning_params_; + optional nox_tuning_params_; + Sen6xType sen6x_type_{UNKNOWN}; uint16_t read_cmd_{0}; + 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 startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b0ffdc53a4..4c0242f2e3 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -3,15 +3,22 @@ from esphome.components import i2c, sensirion_common, sensor 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_CO2, CONF_FORMALDEHYDE, + CONF_GAIN_FACTOR, + CONF_GATING_MAX_DURATION_MINUTES, CONF_HUMIDITY, CONF_ID, + CONF_INDEX_OFFSET, + CONF_LEARNING_TIME_GAIN_HOURS, + CONF_LEARNING_TIME_OFFSET_HOURS, CONF_NOX, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_STD_INITIAL, CONF_TEMPERATURE, CONF_TYPE, CONF_VOC, @@ -44,6 +51,42 @@ SEN6XComponent = sen6x_ns.class_( ) +def _gas_index_schema( + *, + index_offset: int, + gating_max_duration: int, + std_initial: int | None, +) -> cv.Schema: + """Sensor schema for a gas index sensor with optional algorithm tuning. + + std_initial is only configurable for VOC; the NOx algorithm requires 50. + """ + tuning_schema = { + cv.Optional(CONF_INDEX_OFFSET, default=index_offset): cv.int_range( + min=1, max=250 + ), + cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_range( + min=1, max=1000 + ), + cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_range( + min=1, max=1000 + ), + cv.Optional( + CONF_GATING_MAX_DURATION_MINUTES, default=gating_max_duration + ): cv.int_range(min=0, max=3000), + cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_range(min=1, max=1000), + } + if std_initial is not None: + tuning_schema[cv.Optional(CONF_STD_INITIAL, default=std_initial)] = ( + cv.int_range(min=10, max=5000) + ) + return sensor.sensor_schema( + icon=ICON_RADIATOR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ).extend({cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(tuning_schema)}) + + 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"), @@ -94,15 +137,15 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_VOC_INDEX): sensor.sensor_schema( - icon=ICON_RADIATOR, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, + cv.Optional(CONF_VOC_INDEX): _gas_index_schema( + index_offset=100, + gating_max_duration=180, + std_initial=50, ), - cv.Optional(CONF_NOX_INDEX): sensor.sensor_schema( - icon=ICON_RADIATOR, - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, + cv.Optional(CONF_NOX_INDEX): _gas_index_schema( + index_offset=1, + gating_max_duration=720, + std_initial=None, ), cv.Optional(CONF_CO2): sensor.sensor_schema( unit_of_measurement=UNIT_PARTS_PER_MILLION, @@ -149,3 +192,20 @@ async def to_code(config: ConfigType) -> None: if cfg := config.get(key): sens = await sensor.new_sensor(cfg) cg.add(getattr(var, func_name)(sens)) + + for key, setter in ( + (CONF_VOC_INDEX, "set_voc_algorithm_tuning"), + (CONF_NOX_INDEX, "set_nox_algorithm_tuning"), + ): + if (tuning := config.get(key, {}).get(CONF_ALGORITHM_TUNING)) is not None: + args = [ + tuning[CONF_INDEX_OFFSET], + tuning[CONF_LEARNING_TIME_OFFSET_HOURS], + tuning[CONF_LEARNING_TIME_GAIN_HOURS], + tuning[CONF_GATING_MAX_DURATION_MINUTES], + ] + # std_initial is in the schema for VOC only + if (std_initial := tuning.get(CONF_STD_INITIAL)) is not None: + args.append(std_initial) + args.append(tuning[CONF_GAIN_FACTOR]) + cg.add(getattr(var, setter)(*args)) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 859e012c4a..c9b6f22c0f 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -28,8 +28,21 @@ sensor: accuracy_decimals: 1 nox_index: name: NOx Index + algorithm_tuning: + index_offset: 8 + learning_time_offset_hours: 6 + learning_time_gain_hours: 24 + gating_max_duration_minutes: 900 + gain_factor: 180 voc_index: name: VOC Index + algorithm_tuning: + index_offset: 120 + learning_time_offset_hours: 6 + learning_time_gain_hours: 24 + gating_max_duration_minutes: 240 + std_initial: 75 + gain_factor: 180 co2: name: Carbon Dioxide formaldehyde: diff --git a/tests/components/sen6x/validate.esp32-idf.yaml b/tests/components/sen6x/validate.esp32-idf.yaml new file mode 100644 index 0000000000..3ae23af4ac --- /dev/null +++ b/tests/components/sen6x/validate.esp32-idf.yaml @@ -0,0 +1,18 @@ +# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the +# keys that are left out. +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +sensor: + - platform: sen6x + id: sen6x_partial_tuning + type: SEN65 + i2c_id: i2c_bus + voc_index: + name: VOC Index + algorithm_tuning: + index_offset: 60 + nox_index: + name: NOx Index + algorithm_tuning: + gain_factor: 45