From 43ff2157b9377ad4ddb87945dceac67d09d2dbc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 12:49:24 -1000 Subject: [PATCH 1/5] [sht4x] Fix heater causing measurement jitter Integrate heater activation into the measurement cycle instead of running it on an independent timer. The separate heater interval could overlap with measurement polling, causing readings to be taken while the sensor was still hot. Now the heater fires during update() when due, skipping that measurement cycle. The next regular update() takes an ambient reading after the sensor has cooled. Also replace magic numbers with named constexpr constants for the datasheet conversion formulas. Closes https://github.com/esphome/esphome/issues/15011 --- esphome/components/sht4x/sht4x.cpp | 90 +++++++++++++++++------------- esphome/components/sht4x/sht4x.h | 4 +- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index bf23e42e66..a195f2d658 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -9,14 +9,12 @@ static const char *const TAG = "sht4x"; static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0}; static const uint8_t SERIAL_NUMBER_COMMAND = 0x89; -void SHT4XComponent::start_heater_() { - uint8_t cmd[] = {this->heater_command_}; - - ESP_LOGD(TAG, "Heater turning on"); - if (this->write(cmd, 1) != i2c::ERROR_OK) { - this->status_set_error(LOG_STR("Failed to turn on heater")); - } -} +// Conversion constants from SHT4x datasheet +static constexpr float TEMPERATURE_OFFSET = -45.0f; +static constexpr float TEMPERATURE_SPAN = 175.0f; +static constexpr float HUMIDITY_OFFSET = -6.0f; +static constexpr float HUMIDITY_SPAN = 125.0f; +static constexpr float RAW_MAX = 65535.0f; void SHT4XComponent::read_serial_number_() { uint16_t buffer[2]; @@ -39,8 +37,8 @@ void SHT4XComponent::setup() { this->read_serial_number_(); if (std::isfinite(this->duty_cycle_) && this->duty_cycle_ > 0.0f) { - uint32_t heater_interval = static_cast(static_cast(this->heater_time_) / this->duty_cycle_); - ESP_LOGD(TAG, "Heater interval: %" PRIu32, heater_interval); + this->heater_interval_ = static_cast(static_cast(this->heater_time_) / this->duty_cycle_); + ESP_LOGD(TAG, "Heater interval: %" PRIu32, this->heater_interval_); if (this->heater_power_ == SHT4X_HEATERPOWER_HIGH) { if (this->heater_time_ == SHT4X_HEATERTIME_LONG) { @@ -62,8 +60,6 @@ void SHT4XComponent::setup() { } } ESP_LOGD(TAG, "Heater command: %x", this->heater_command_); - - this->set_interval(heater_interval, [this]() { this->start_heater_(); }); } } @@ -83,43 +79,59 @@ void SHT4XComponent::dump_config() { } void SHT4XComponent::update() { - // Send command + bool use_heater = false; + + // Check if heater is due during this measurement cycle + if (this->heater_interval_ > 0) { + uint32_t now = millis(); + if (now - this->last_heater_millis_ >= this->heater_interval_) { + use_heater = true; + this->last_heater_millis_ = now; + } + } + + if (use_heater) { + // Heater command heats the sensor to remove condensation, then takes a + // measurement (per datasheet section 4.9). The measurement is taken while + // the sensor is still hot, so we must discard it — it does not reflect + // ambient conditions. The next regular update() cycle will provide a + // valid reading after the sensor has cooled. + ESP_LOGD(TAG, "Heater turning on"); + if (!this->write_command(this->heater_command_)) { + this->status_set_warning(LOG_STR("Failed to send heater command")); + return; + } + return; + } + if (!this->write_command(MEASURECOMMANDS[this->precision_])) { - // Warning will be printed only if warning status is not set yet this->status_set_warning(LOG_STR("Failed to send measurement command")); return; } - this->set_timeout(10, [this]() { - uint16_t buffer[2]; + this->set_timeout(10, [this]() { this->read_and_publish_(); }); +} - // Read measurement - if (!this->read_data(buffer, 2)) { - // Using ESP_LOGW to force the warning to be printed - ESP_LOGW(TAG, "Sensor read failed"); - this->status_set_warning(); - return; - } +void SHT4XComponent::read_and_publish_() { + uint16_t buffer[2]; - this->status_clear_warning(); + if (!this->read_data(buffer, 2)) { + ESP_LOGW(TAG, "Sensor read failed"); + this->status_set_warning(); + return; + } - // Evaluate and publish measurements - if (this->temp_sensor_ != nullptr) { - // Temp is contained in the first result word - float sensor_value_temp = buffer[0]; - float temp = -45 + 175 * sensor_value_temp / 65535; + this->status_clear_warning(); - this->temp_sensor_->publish_state(temp); - } + if (this->temp_sensor_ != nullptr) { + float temp = TEMPERATURE_OFFSET + TEMPERATURE_SPAN * static_cast(buffer[0]) / RAW_MAX; + this->temp_sensor_->publish_state(temp); + } - if (this->humidity_sensor_ != nullptr) { - // Relative humidity is in the second result word - float sensor_value_rh = buffer[1]; - float rh = -6 + 125 * sensor_value_rh / 65535; - - this->humidity_sensor_->publish_state(rh); - } - }); + if (this->humidity_sensor_ != nullptr) { + float rh = HUMIDITY_OFFSET + HUMIDITY_SPAN * static_cast(buffer[1]) / RAW_MAX; + this->humidity_sensor_->publish_state(rh); + } } } // namespace sht4x diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index aec0f3d7f8..d8ff0280bf 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -35,9 +35,11 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri SHT4XHEATERTIME heater_time_; float duty_cycle_; - void start_heater_(); void read_serial_number_(); + void read_and_publish_(); uint8_t heater_command_; + uint32_t heater_interval_{0}; + uint32_t last_heater_millis_{0}; uint32_t serial_number_; sensor::Sensor *temp_sensor_{nullptr}; From a73b5cc94a0b01c1e631d5dfd3e51f829453a393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 12:55:01 -1000 Subject: [PATCH 2/5] [sht4x] Ensure normal measurement between heater cycles When heater_interval < update_interval, the heater would fire on nearly every update cycle, skipping almost all real measurements. Push the next eligible heater time forward by at least one update_interval to guarantee at least one ambient reading between heater activations. --- esphome/components/sht4x/sht4x.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index a195f2d658..ff2f3da46a 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -86,16 +86,19 @@ void SHT4XComponent::update() { uint32_t now = millis(); if (now - this->last_heater_millis_ >= this->heater_interval_) { use_heater = true; - this->last_heater_millis_ = now; + // Ensure at least one normal measurement between heater cycles by + // pushing the next eligible heater time forward by one update interval. + // The configured duty cycle is a maximum, not an exact target. + uint32_t interval = std::max(this->heater_interval_, this->get_update_interval()); + this->last_heater_millis_ = now - this->heater_interval_ + interval; } } if (use_heater) { - // Heater command heats the sensor to remove condensation, then takes a - // measurement (per datasheet section 4.9). The measurement is taken while - // the sensor is still hot, so we must discard it — it does not reflect - // ambient conditions. The next regular update() cycle will provide a - // valid reading after the sensor has cooled. + // Heater command heats the sensor to remove condensation (datasheet 4.9). + // The measurement it produces is taken while hot and does not reflect + // ambient conditions, so we skip this cycle. The next update() will + // take a regular reading after the sensor has cooled. ESP_LOGD(TAG, "Heater turning on"); if (!this->write_command(this->heater_command_)) { this->status_set_warning(LOG_STR("Failed to send heater command")); From c2b2aaddd1e0f024de5c156db9c5c88793c00472 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 12:57:53 -1000 Subject: [PATCH 3/5] [sht4x] Move timestamp update after successful command, add heater test Only advance last_heater_millis_ after the heater command succeeds so a failed write retries on the next cycle instead of silently skipping. Add heater settings to the test YAML to ensure CI exercises the heater code path. --- esphome/components/sht4x/sht4x.cpp | 11 ++++++----- tests/components/sht4x/common.yaml | 4 ++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index ff2f3da46a..e8e84f535d 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -86,11 +86,6 @@ void SHT4XComponent::update() { uint32_t now = millis(); if (now - this->last_heater_millis_ >= this->heater_interval_) { use_heater = true; - // Ensure at least one normal measurement between heater cycles by - // pushing the next eligible heater time forward by one update interval. - // The configured duty cycle is a maximum, not an exact target. - uint32_t interval = std::max(this->heater_interval_, this->get_update_interval()); - this->last_heater_millis_ = now - this->heater_interval_ + interval; } } @@ -104,6 +99,12 @@ void SHT4XComponent::update() { this->status_set_warning(LOG_STR("Failed to send heater command")); return; } + // Only update timestamp after successful command. Ensure at least one + // normal measurement between heater cycles by pushing the next eligible + // heater time forward by at least one update interval. + uint32_t now = millis(); + uint32_t interval = std::max(this->heater_interval_, this->get_update_interval()); + this->last_heater_millis_ = now - this->heater_interval_ + interval; return; } diff --git a/tests/components/sht4x/common.yaml b/tests/components/sht4x/common.yaml index 50d5ad8ca4..bec192d6db 100644 --- a/tests/components/sht4x/common.yaml +++ b/tests/components/sht4x/common.yaml @@ -6,4 +6,8 @@ sensor: humidity: name: SHT4X Humidity address: 0x44 + precision: High + heater_max_duty: 0.02 + heater_power: High + heater_time: Long update_interval: 15s From 464d3331428a94499ae5075a3708cad495fe446c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 14:08:36 -1000 Subject: [PATCH 4/5] [sht4x] Fire heater after measurement instead of skipping cycles Per swoboda1337's suggestion, run the heater after the measurement read instead of before. This maximizes cooldown time before the next reading and avoids skipping any measurement cycles. --- esphome/components/sht4x/sht4x.cpp | 75 +++++++++++------------------- esphome/components/sht4x/sht4x.h | 1 - 2 files changed, 28 insertions(+), 48 deletions(-) diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index e8e84f535d..d9d6a71630 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -79,63 +79,44 @@ void SHT4XComponent::dump_config() { } void SHT4XComponent::update() { - bool use_heater = false; - - // Check if heater is due during this measurement cycle - if (this->heater_interval_ > 0) { - uint32_t now = millis(); - if (now - this->last_heater_millis_ >= this->heater_interval_) { - use_heater = true; - } - } - - if (use_heater) { - // Heater command heats the sensor to remove condensation (datasheet 4.9). - // The measurement it produces is taken while hot and does not reflect - // ambient conditions, so we skip this cycle. The next update() will - // take a regular reading after the sensor has cooled. - ESP_LOGD(TAG, "Heater turning on"); - if (!this->write_command(this->heater_command_)) { - this->status_set_warning(LOG_STR("Failed to send heater command")); - return; - } - // Only update timestamp after successful command. Ensure at least one - // normal measurement between heater cycles by pushing the next eligible - // heater time forward by at least one update interval. - uint32_t now = millis(); - uint32_t interval = std::max(this->heater_interval_, this->get_update_interval()); - this->last_heater_millis_ = now - this->heater_interval_ + interval; - return; - } - if (!this->write_command(MEASURECOMMANDS[this->precision_])) { this->status_set_warning(LOG_STR("Failed to send measurement command")); return; } - this->set_timeout(10, [this]() { this->read_and_publish_(); }); -} + this->set_timeout(10, [this]() { + uint16_t buffer[2]; -void SHT4XComponent::read_and_publish_() { - uint16_t buffer[2]; + if (!this->read_data(buffer, 2)) { + ESP_LOGW(TAG, "Sensor read failed"); + this->status_set_warning(); + return; + } - if (!this->read_data(buffer, 2)) { - ESP_LOGW(TAG, "Sensor read failed"); - this->status_set_warning(); - return; - } + this->status_clear_warning(); - this->status_clear_warning(); + if (this->temp_sensor_ != nullptr) { + float temp = TEMPERATURE_OFFSET + TEMPERATURE_SPAN * static_cast(buffer[0]) / RAW_MAX; + this->temp_sensor_->publish_state(temp); + } - if (this->temp_sensor_ != nullptr) { - float temp = TEMPERATURE_OFFSET + TEMPERATURE_SPAN * static_cast(buffer[0]) / RAW_MAX; - this->temp_sensor_->publish_state(temp); - } + if (this->humidity_sensor_ != nullptr) { + float rh = HUMIDITY_OFFSET + HUMIDITY_SPAN * static_cast(buffer[1]) / RAW_MAX; + this->humidity_sensor_->publish_state(rh); + } - if (this->humidity_sensor_ != nullptr) { - float rh = HUMIDITY_OFFSET + HUMIDITY_SPAN * static_cast(buffer[1]) / RAW_MAX; - this->humidity_sensor_->publish_state(rh); - } + // Fire heater after measurement to maximize cooldown time before the next reading. + // The heater command produces a measurement that we don't need (datasheet 4.9). + if (this->heater_interval_ > 0) { + uint32_t now = millis(); + if (now - this->last_heater_millis_ >= this->heater_interval_) { + ESP_LOGD(TAG, "Heater turning on"); + if (this->write_command(this->heater_command_)) { + this->last_heater_millis_ = now; + } + } + } + }); } } // namespace sht4x diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index d8ff0280bf..51f473fe3f 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -36,7 +36,6 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri float duty_cycle_; void read_serial_number_(); - void read_and_publish_(); uint8_t heater_command_; uint32_t heater_interval_{0}; uint32_t last_heater_millis_{0}; From 0302a14fa2525e3311506d6f2bbb79bf502e3a93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 14:10:40 -1000 Subject: [PATCH 5/5] [sht4x] Restore original comments removed in refactor --- esphome/components/sht4x/sht4x.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index d9d6a71630..43c2436a56 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -79,7 +79,9 @@ void SHT4XComponent::dump_config() { } void SHT4XComponent::update() { + // Send command if (!this->write_command(MEASURECOMMANDS[this->precision_])) { + // Warning will be printed only if warning status is not set yet this->status_set_warning(LOG_STR("Failed to send measurement command")); return; } @@ -87,7 +89,9 @@ void SHT4XComponent::update() { this->set_timeout(10, [this]() { uint16_t buffer[2]; + // Read measurement if (!this->read_data(buffer, 2)) { + // Using ESP_LOGW to force the warning to be printed ESP_LOGW(TAG, "Sensor read failed"); this->status_set_warning(); return; @@ -95,12 +99,15 @@ void SHT4XComponent::update() { this->status_clear_warning(); + // Evaluate and publish measurements if (this->temp_sensor_ != nullptr) { + // Temp is contained in the first result word float temp = TEMPERATURE_OFFSET + TEMPERATURE_SPAN * static_cast(buffer[0]) / RAW_MAX; this->temp_sensor_->publish_state(temp); } if (this->humidity_sensor_ != nullptr) { + // Relative humidity is in the second result word float rh = HUMIDITY_OFFSET + HUMIDITY_SPAN * static_cast(buffer[1]) / RAW_MAX; this->humidity_sensor_->publish_state(rh); }