mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 23:37:34 +00:00
[sen6x] Add VOC/NOx algorithm tuning (#18779)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user