ESPHome 2026.10.0-dev
Loading...
Searching...
No Matches
sen6x.cpp
Go to the documentation of this file.
1#include "sen6x.h"
2#include "esphome/core/hal.h"
3#include "esphome/core/log.h"
4#include <cmath>
5
6namespace esphome::sen6x {
7
8static const char *const TAG = "sen6x";
9
10static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts
11static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete
12static constexpr uint32_t CMD_EXEC_DELAY = 20; // execution time of set commands (datasheet section 4.8)
13static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts
14// Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time.
15static constexpr uint32_t TIMEOUT_POLL = 1;
16static constexpr uint32_t TIMEOUT_SETUP_STEP = 2;
17static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202;
18static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100;
19static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014;
20static constexpr uint16_t SEN6X_CMD_GET_SERIAL_NUMBER = 0xD033;
21
22static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT = 0x0300; // SEN66 only!
23static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN62 = 0x04A3;
24static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN63C = 0x0471;
25static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN65 = 0x0446;
26static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467;
27static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5;
28
29static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021;
30static constexpr uint16_t SEN6X_CMD_RESET = 0xD304;
31static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0;
32static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1;
33
34static inline void set_read_command_and_words(SEN6XComponent::Sen6xType type, uint16_t &read_cmd, uint8_t &read_words) {
35 read_cmd = SEN6X_CMD_READ_MEASUREMENT;
36 read_words = 9;
37 switch (type) {
38 case SEN6XComponent::SEN62:
39 read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN62;
40 read_words = 6;
41 break;
42 case SEN6XComponent::SEN63C:
43 read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN63C;
44 read_words = 7;
45 break;
46 case SEN6XComponent::SEN65:
47 read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN65;
48 read_words = 8;
49 break;
50 case SEN6XComponent::SEN66:
51 read_cmd = SEN6X_CMD_READ_MEASUREMENT;
52 read_words = 9;
53 break;
54 case SEN6XComponent::SEN68:
55 read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN68;
56 read_words = 9;
57 break;
58 case SEN6XComponent::SEN69C:
59 read_cmd = SEN6X_CMD_READ_MEASUREMENT_SEN69C;
60 read_words = 10;
61 break;
62 default:
63 break;
64 }
65}
66
68 ESP_LOGCONFIG(TAG, "Setting up sen6x...");
69
70 // the sensor needs 100 ms to enter the idle state
71 this->set_timeout(100, [this]() {
72 // Reset the sensor to ensure a clean state regardless of prior commands or power issues
73 if (!this->write_command(SEN6X_CMD_RESET)) {
74 ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
75 this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
76 return;
77 }
78
79 // After reset the sensor needs 100 ms to become ready
80 this->set_timeout(100, [this]() {
81 // Step 1: Read serial number (~25ms with I2C delay)
82 uint16_t raw_serial_number[16];
83 if (!this->get_register(SEN6X_CMD_GET_SERIAL_NUMBER, raw_serial_number, 16, 20)) {
84 ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
85 this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
86 return;
87 }
89 ESP_LOGI(TAG, "Serial number: %s", this->serial_number_.c_str());
90
91 // Step 2: Read product name in next loop iteration
92 this->set_timeout(0, [this]() {
93 uint16_t raw_product_name[16];
94 if (!this->get_register(SEN6X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) {
95 ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
96 this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
97 return;
98 }
99
101
102 Sen6xType inferred_type = this->infer_type_from_product_name_(this->product_name_);
103 if (this->sen6x_type_ == UNKNOWN) {
104 this->sen6x_type_ = inferred_type;
105 if (inferred_type == UNKNOWN) {
106 ESP_LOGE(TAG, "Unknown product '%s'", this->product_name_.c_str());
107 this->mark_failed();
108 return;
109 }
110 ESP_LOGD(TAG, "Type inferred from product: %s", this->product_name_.c_str());
111 } else if (this->sen6x_type_ != inferred_type && inferred_type != UNKNOWN) {
112 ESP_LOGW(TAG, "Configured type (used) mismatches product '%s'", this->product_name_.c_str());
113 }
114 ESP_LOGI(TAG, "Product: %s", this->product_name_.c_str());
115
116 // Validate configured sensors against detected type and disable unsupported ones
117 const bool has_voc_nox = (this->sen6x_type_ == SEN65 || this->sen6x_type_ == SEN66 ||
118 this->sen6x_type_ == SEN68 || this->sen6x_type_ == SEN69C);
119 const bool has_co2 = (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN66 || this->sen6x_type_ == SEN69C);
120 const bool has_hcho = (this->sen6x_type_ == SEN68 || this->sen6x_type_ == SEN69C);
121 if (this->voc_sensor_ && !has_voc_nox) {
122 ESP_LOGE(TAG, "VOC requires SEN65, SEN66, SEN68, or SEN69C");
123 this->voc_sensor_ = nullptr;
124 }
125 if (this->nox_sensor_ && !has_voc_nox) {
126 ESP_LOGE(TAG, "NOx requires SEN65, SEN66, SEN68, or SEN69C");
127 this->nox_sensor_ = nullptr;
128 }
129 if (this->co2_sensor_ && !has_co2) {
130 ESP_LOGE(TAG, "CO2 requires SEN63C, SEN66, or SEN69C");
131 this->co2_sensor_ = nullptr;
132 }
133 if (this->hcho_sensor_ && !has_hcho) {
134 ESP_LOGE(TAG, "Formaldehyde requires SEN68 or SEN69C");
135 this->hcho_sensor_ = nullptr;
136 }
137
138 // Step 3: Read firmware version and start measurements in next loop iteration
139 this->set_timeout(0, [this]() {
140 uint16_t raw_firmware_version = 0;
141 if (!this->get_register(SEN6X_CMD_GET_FIRMWARE_VERSION, raw_firmware_version, 20)) {
142 ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
143 this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
144 return;
145 }
146 this->firmware_version_major_ = (raw_firmware_version >> 8) & 0xFF;
147 this->firmware_version_minor_ = raw_firmware_version & 0xFF;
148 ESP_LOGI(TAG, "Firmware: %u.%u", this->firmware_version_major_, this->firmware_version_minor_);
149
150 // Step 4: write configuration commands one at a time, then start measurements.
151 // Delay the first step so it doesn't run in the same loop tick as the read above.
152 this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); });
153 });
154 });
155 });
156 });
157}
158
159// One configuration write per invocation, spaced by CMD_EXEC_DELAY. Cases without a
160// configured value fall through; each taken case must advance setup_step_index_ so the
161// next invocation resumes at the following step. These writes are optional, so a failure
162// only warns and the chain continues to the mandatory start-measurements write.
164 switch (this->setup_step_index_) {
165 // Tuning writes are skipped when setup() disabled the sensor for this variant
166 case 0:
167 this->setup_step_index_++;
168 if (this->voc_sensor_ != nullptr && this->voc_tuning_params_.has_value()) {
169 this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value());
170 break;
171 }
172 [[fallthrough]];
173 case 1:
174 this->setup_step_index_++;
175 if (this->nox_sensor_ != nullptr && this->nox_tuning_params_.has_value()) {
176 this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value());
177 break;
178 }
179 [[fallthrough]];
180 default:
181 this->finish_setup_();
182 return;
183 }
184 this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); });
185}
186
188 if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) {
189 ESP_LOGE(TAG, "Write 0x%04X failed, error %d", SEN6X_CMD_START_MEASUREMENTS, this->last_error_);
190 this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
191 return;
192 }
193
194 this->set_timeout(60000, [this]() { this->startup_complete_ = true; });
195 this->initialized_ = true;
196 ESP_LOGD(TAG, "Initialized");
197}
198
199// Writes one optional configuration command. A failure warns and returns false, but does
200// not stop setup: the sensor still measures with that setting left at its default.
201bool SEN6XComponent::write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len) {
202 if (!this->write_command(i2c_command, data, len)) {
203 ESP_LOGE(TAG, "Write 0x%04X failed, error %d", i2c_command, this->last_error_);
204 this->status_set_warning();
205 return false;
206 }
207 return true;
208}
209
210bool SEN6XComponent::write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning) {
211 uint16_t params[6] = {tuning.index_offset,
215 tuning.std_initial,
216 tuning.gain_factor};
217 return this->write_config_words_(i2c_command, params, 6);
218}
219
220void SEN6XComponent::dump_config() {
221 ESP_LOGCONFIG(TAG,
222 "sen6x:\n"
223 " Product: %s\n"
224 " Serial: %s\n"
225 " Firmware: %u.%u\n"
226 " Address: 0x%02X",
227 this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_,
228 this->firmware_version_minor_, this->address_);
229 LOG_UPDATE_INTERVAL(this);
230 LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_);
231 LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_);
232 LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_);
233 LOG_SENSOR(" ", "PM 10.0", this->pm_10_0_sensor_);
234 LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
235 LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
236 LOG_SENSOR(" ", "VOC", this->voc_sensor_);
237 LOG_SENSOR(" ", "NOx", this->nox_sensor_);
238 LOG_SENSOR(" ", "HCHO", this->hcho_sensor_);
239 LOG_SENSOR(" ", "CO2", this->co2_sensor_);
240}
241
242void SEN6XComponent::update() {
243 if (!this->initialized_) {
244 return;
245 }
246
247 // Cancel any in-flight polling from a previous update() cycle.
248 this->cancel_timeout(TIMEOUT_POLL);
249
250 set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_);
251
252 // Polling uses chained timeouts to guarantee each I2C operation completes
253 // before the next begins. The flow is:
254 //
255 // poll_data_ready_()
256 // -> write_command (data ready status)
257 // -> timeout I2C_READ_DELAY
258 // -> read_data (check ready flag)
259 // -> if not ready: timeout POLL_INTERVAL -> poll_data_ready_() (retry)
260 // -> if ready: read_measurements_()
261 // -> write_command (read measurement)
262 // -> timeout I2C_READ_DELAY
263 // -> parse_and_publish_measurements_()
264 //
265 // All timeouts share a single ID (TIMEOUT_POLL) since only one is active
266 // at a time. cancel_timeout in update() stops any in-flight chain.
267 this->poll_retries_remaining_ = POLL_RETRIES;
268 this->poll_data_ready_();
269}
270
272 if (this->poll_retries_remaining_ == 0) {
273 this->status_set_warning();
274 ESP_LOGD(TAG, "Data not ready");
275 return;
276 }
277 ESP_LOGV(TAG, "Data ready polling attempt %u",
278 static_cast<unsigned>(POLL_RETRIES - this->poll_retries_remaining_ + 1));
280
281 if (!this->write_command(SEN6X_CMD_GET_DATA_READY_STATUS)) {
282 this->status_set_warning();
283 ESP_LOGD(TAG, "write data ready status error (%d)", this->last_error_);
284 return;
285 }
286
287 this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() {
288 uint16_t raw_read_status;
289 if (!this->read_data(&raw_read_status, 1)) {
290 this->status_set_warning();
291 ESP_LOGD(TAG, "read data ready status error (%d)", this->last_error_);
292 return;
293 }
294
295 if ((raw_read_status & 0x0001) == 0) {
296 // Not ready yet; schedule next attempt after POLL_INTERVAL.
297 this->set_timeout(TIMEOUT_POLL, POLL_INTERVAL, [this]() { this->poll_data_ready_(); });
298 return;
299 }
300
301 this->read_measurements_();
302 });
303}
304
306 if (!this->write_command(this->read_cmd_)) {
307 this->status_set_warning();
308 ESP_LOGD(TAG, "Read measurement failed (%d)", this->last_error_);
309 return;
310 }
311
312 this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_measurements_(); });
313}
314
316 uint16_t measurements[10];
317
318 if (!this->read_data(measurements, this->read_words_)) {
319 this->status_set_warning();
320 ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_);
321 return;
322 }
323 int8_t voc_index = -1;
324 int8_t nox_index = -1;
325 int8_t hcho_index = -1;
326 int8_t co2_index = -1;
327 bool co2_uint16 = false;
328 switch (this->sen6x_type_) {
329 case SEN62:
330 break;
331 case SEN63C:
332 co2_index = 6;
333 break;
334 case SEN65:
335 voc_index = 6;
336 nox_index = 7;
337 break;
338 case SEN66:
339 voc_index = 6;
340 nox_index = 7;
341 co2_index = 8;
342 co2_uint16 = true;
343 break;
344 case SEN68:
345 voc_index = 6;
346 nox_index = 7;
347 hcho_index = 8;
348 break;
349 case SEN69C:
350 voc_index = 6;
351 nox_index = 7;
352 hcho_index = 8;
353 co2_index = 9;
354 break;
355 default:
356 break;
357 }
358
359 float pm_1_0 = measurements[0] / 10.0f;
360 if (measurements[0] == 0xFFFF)
361 pm_1_0 = NAN;
362 float pm_2_5 = measurements[1] / 10.0f;
363 if (measurements[1] == 0xFFFF)
364 pm_2_5 = NAN;
365 float pm_4_0 = measurements[2] / 10.0f;
366 if (measurements[2] == 0xFFFF)
367 pm_4_0 = NAN;
368 float pm_10_0 = measurements[3] / 10.0f;
369 if (measurements[3] == 0xFFFF)
370 pm_10_0 = NAN;
371 float humidity = static_cast<int16_t>(measurements[4]) / 100.0f;
372 if (measurements[4] == 0x7FFF)
373 humidity = NAN;
374 float temperature = static_cast<int16_t>(measurements[5]) / 200.0f;
375 if (measurements[5] == 0x7FFF)
376 temperature = NAN;
377
378 float voc = NAN;
379 float nox = NAN;
380 float hcho = NAN;
381 float co2 = NAN;
382
383 if (voc_index >= 0) {
384 voc = static_cast<int16_t>(measurements[voc_index]) / 10.0f;
385 if (measurements[voc_index] == 0x7FFF)
386 voc = NAN;
387 }
388 if (nox_index >= 0) {
389 nox = static_cast<int16_t>(measurements[nox_index]) / 10.0f;
390 if (measurements[nox_index] == 0x7FFF)
391 nox = NAN;
392 }
393
394 if (hcho_index >= 0) {
395 const uint16_t hcho_raw = measurements[hcho_index];
396 hcho = hcho_raw / 10.0f;
397 if (hcho_raw == 0xFFFF)
398 hcho = NAN;
399 }
400
401 if (co2_index >= 0) {
402 if (co2_uint16) {
403 const uint16_t co2_raw = measurements[co2_index];
404 co2 = static_cast<float>(co2_raw);
405 if (co2_raw == 0xFFFF)
406 co2 = NAN;
407 } else {
408 const int16_t co2_raw = static_cast<int16_t>(measurements[co2_index]);
409 co2 = static_cast<float>(co2_raw);
410 if (co2_raw == 0x7FFF)
411 co2 = NAN;
412 }
413 }
414
415 if (!this->startup_complete_) {
416 ESP_LOGD(TAG, "Startup delay, ignoring values");
417 this->status_clear_warning();
418 return;
419 }
420
421 if (this->pm_1_0_sensor_ != nullptr)
422 this->pm_1_0_sensor_->publish_state(pm_1_0);
423 if (this->pm_2_5_sensor_ != nullptr)
424 this->pm_2_5_sensor_->publish_state(pm_2_5);
425 if (this->pm_4_0_sensor_ != nullptr)
426 this->pm_4_0_sensor_->publish_state(pm_4_0);
427 if (this->pm_10_0_sensor_ != nullptr)
428 this->pm_10_0_sensor_->publish_state(pm_10_0);
429 if (this->temperature_sensor_ != nullptr)
430 this->temperature_sensor_->publish_state(temperature);
431 if (this->humidity_sensor_ != nullptr)
432 this->humidity_sensor_->publish_state(humidity);
433 if (this->voc_sensor_ != nullptr)
434 this->voc_sensor_->publish_state(voc);
435 if (this->nox_sensor_ != nullptr)
436 this->nox_sensor_->publish_state(nox);
437 if (this->hcho_sensor_ != nullptr)
438 this->hcho_sensor_->publish_state(hcho);
439 if (this->co2_sensor_ != nullptr)
440 this->co2_sensor_->publish_state(co2);
441
442 this->status_clear_warning();
443}
444
445SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) {
446 if (product_name == "SEN62")
447 return SEN62;
448 if (product_name == "SEN63C")
449 return SEN63C;
450 if (product_name == "SEN65")
451 return SEN65;
452 if (product_name == "SEN66")
453 return SEN66;
454 if (product_name == "SEN68")
455 return SEN68;
456 if (product_name == "SEN69C")
457 return SEN69C;
458 return UNKNOWN;
459}
460
461} // namespace esphome::sen6x
void mark_failed()
Mark this component as failed.
virtual void setup()
Where the component's initialization should happen.
Definition component.cpp:84
bool cancel_timeout(const char *name)
Cancel a timeout function.
void set_timeout(const char *name, uint32_t timeout, std::function< void()> &&f)
Set a timeout function with a const char* name.
Definition component.cpp:96
void status_clear_warning()
Definition component.h:289
optional< GasTuning > voc_tuning_params_
Definition sen6x.h:74
Sen6xType infer_type_from_product_name_(const std::string &product_name)
Definition sen6x.cpp:445
bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len)
Definition sen6x.cpp:201
optional< GasTuning > nox_tuning_params_
Definition sen6x.h:75
bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning)
Definition sen6x.cpp:210
i2c::ErrorCode last_error_
last error code from I2C operation
bool get_register(uint16_t command, uint16_t *data, uint8_t len, uint8_t delay=0)
get data words from I2C register.
bool write_command(T i2c_register)
Write a command to the I2C device.
bool read_data(uint16_t *data, uint8_t len)
Read data words from I2C device.
static const char * sensirion_convert_to_string_in_place(uint16_t *array, size_t length)
This function performs an in-place conversion of the provided buffer from uint16_t values to big endi...
uint16_t type
const void size_t len
Definition hal.h:64
static void uint32_t
uint16_t learning_time_gain_hours
Definition sen6x.h:17
uint16_t learning_time_offset_hours
Definition sen6x.h:16
uint16_t gating_max_duration_minutes
Definition sen6x.h:18
uint16_t temperature
Definition sun_gtil2.cpp:12