[sen6x] Add temperature compensation, acceleration and startup delay (#18781)

Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Brandon Harvey
2026-10-17 11:01:14 -10:00
committed by GitHub
co-authored by Jonathan Swoboda J. Nick Koston J. Nick Koston
parent ae62960193
commit 0f6fc503bd
5 changed files with 148 additions and 7 deletions
+39 -4
View File
@@ -1,6 +1,7 @@
#include "sen6x.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <cmath>
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<uint16_t>(compensation.offset),
static_cast<uint16_t>(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() {
+30 -1
View File
@@ -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<GasTuning> voc_tuning_params_;
optional<GasTuning> nox_tuning_params_;
optional<TemperatureAcceleration> temperature_acceleration_;
optional<TemperatureCompensation> 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
+63
View File
@@ -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)
+10
View File
@@ -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
@@ -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: