[sen6x] Add CO2 configuration options (#18780)

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 10:41:13 -10:00
committed by GitHub
co-authored by Jonathan Swoboda J. Nick Koston J. Nick Koston
parent 5759c6b8e6
commit 325c747dcd
4 changed files with 183 additions and 29 deletions
+114 -29
View File
@@ -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<SetupStep>(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<unsigned>(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<uint16_t>(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();
+29
View File
@@ -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<GasTuning> voc_tuning_params_;
optional<GasTuning> 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<bool> 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};
};
+32
View File
@@ -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(
+8
View File
@@ -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