[sen6x] Add VOC/NOx algorithm tuning (#18779)

This commit is contained in:
Brandon Harvey
2026-08-31 13:45:38 -04:00
committed by GitHub
parent 813c000684
commit bbe806f1e6
5 changed files with 208 additions and 20 deletions
+69 -10
View File
@@ -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"
+40 -2
View File
@@ -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<GasTuning> voc_tuning_params_;
optional<GasTuning> 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};
};
+68 -8
View File
@@ -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))
+13
View File
@@ -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:
@@ -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