From 8b24112be5aadb2952d7dff58cbfd05341349e29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:14:48 -0600 Subject: [PATCH 01/12] [pipsolar] Batch UART reads to reduce per-loop overhead (#13829) --- esphome/components/pipsolar/pipsolar.cpp | 64 +++++++++++++++--------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index bafd5273da..d7b37f6130 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -13,9 +13,12 @@ void Pipsolar::setup() { } void Pipsolar::empty_uart_buffer_() { - uint8_t byte; - while (this->available()) { - this->read_byte(&byte); + uint8_t buf[64]; + int avail; + while ((avail = this->available()) > 0) { + if (!this->read_array(buf, std::min(static_cast(avail), sizeof(buf)))) { + break; + } } } @@ -94,32 +97,47 @@ void Pipsolar::loop() { } if (this->state_ == STATE_COMMAND || this->state_ == STATE_POLL) { - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - - // make sure data and null terminator fit in buffer - if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { - this->read_pos_ = 0; - this->empty_uart_buffer_(); - ESP_LOGW(TAG, "response data too long, discarding."); + int avail = this->available(); + while (avail > 0) { + uint8_t buf[64]; + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { break; } - this->read_buffer_[this->read_pos_] = byte; - this->read_pos_++; + avail -= to_read; + bool done = false; + for (size_t i = 0; i < to_read; i++) { + uint8_t byte = buf[i]; - // end of answer - if (byte == 0x0D) { - this->read_buffer_[this->read_pos_] = 0; - this->empty_uart_buffer_(); - if (this->state_ == STATE_POLL) { - this->state_ = STATE_POLL_COMPLETE; + // make sure data and null terminator fit in buffer + if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { + this->read_pos_ = 0; + this->empty_uart_buffer_(); + ESP_LOGW(TAG, "response data too long, discarding."); + done = true; + break; } - if (this->state_ == STATE_COMMAND) { - this->state_ = STATE_COMMAND_COMPLETE; + this->read_buffer_[this->read_pos_] = byte; + this->read_pos_++; + + // end of answer + if (byte == 0x0D) { + this->read_buffer_[this->read_pos_] = 0; + this->empty_uart_buffer_(); + if (this->state_ == STATE_POLL) { + this->state_ = STATE_POLL_COMPLETE; + } + if (this->state_ == STATE_COMMAND) { + this->state_ = STATE_COMMAND_COMPLETE; + } + done = true; + break; } } - } // available + if (done) { + break; + } + } } if (this->state_ == STATE_COMMAND) { if (millis() - this->command_start_millis_ > esphome::pipsolar::Pipsolar::COMMAND_TIMEOUT) { From 623f33c9f9ea480d35d258b34c2fca427ce54ae4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:04 -0600 Subject: [PATCH 02/12] [rd03d] Batch UART reads to reduce per-loop overhead (#13830) --- esphome/components/rd03d/rd03d.cpp | 67 +++++++++++++++++------------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index 090e4dcf32..e4dbdf41cb 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -1,4 +1,5 @@ #include "rd03d.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -80,37 +81,47 @@ void RD03DComponent::dump_config() { } void RD03DComponent::loop() { - while (this->available()) { - uint8_t byte = this->read(); - ESP_LOGVV(TAG, "Received byte: 0x%02X, buffer_pos: %d", byte, this->buffer_pos_); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t i = 0; i < to_read; i++) { + uint8_t byte = buf[i]; + ESP_LOGVV(TAG, "Received byte: 0x%02X, buffer_pos: %d", byte, this->buffer_pos_); - // Check if we're looking for frame header - if (this->buffer_pos_ < FRAME_HEADER_SIZE) { - if (byte == FRAME_HEADER[this->buffer_pos_]) { - this->buffer_[this->buffer_pos_++] = byte; - } else if (byte == FRAME_HEADER[0]) { - // Start over if we see a potential new header - this->buffer_[0] = byte; - this->buffer_pos_ = 1; - } else { + // Check if we're looking for frame header + if (this->buffer_pos_ < FRAME_HEADER_SIZE) { + if (byte == FRAME_HEADER[this->buffer_pos_]) { + this->buffer_[this->buffer_pos_++] = byte; + } else if (byte == FRAME_HEADER[0]) { + // Start over if we see a potential new header + this->buffer_[0] = byte; + this->buffer_pos_ = 1; + } else { + this->buffer_pos_ = 0; + } + continue; + } + + // Accumulate data bytes + this->buffer_[this->buffer_pos_++] = byte; + + // Check if we have a complete frame + if (this->buffer_pos_ == FRAME_SIZE) { + // Validate footer + if (this->buffer_[FRAME_SIZE - 2] == FRAME_FOOTER[0] && this->buffer_[FRAME_SIZE - 1] == FRAME_FOOTER[1]) { + this->process_frame_(); + } else { + ESP_LOGW(TAG, "Invalid frame footer: 0x%02X 0x%02X (expected 0x55 0xCC)", this->buffer_[FRAME_SIZE - 2], + this->buffer_[FRAME_SIZE - 1]); + } this->buffer_pos_ = 0; } - continue; - } - - // Accumulate data bytes - this->buffer_[this->buffer_pos_++] = byte; - - // Check if we have a complete frame - if (this->buffer_pos_ == FRAME_SIZE) { - // Validate footer - if (this->buffer_[FRAME_SIZE - 2] == FRAME_FOOTER[0] && this->buffer_[FRAME_SIZE - 1] == FRAME_FOOTER[1]) { - this->process_frame_(); - } else { - ESP_LOGW(TAG, "Invalid frame footer: 0x%02X 0x%02X (expected 0x55 0xCC)", this->buffer_[FRAME_SIZE - 2], - this->buffer_[FRAME_SIZE - 1]); - } - this->buffer_pos_ = 0; } } } From e7a900fbaa082c906dcb54f438a876da11763456 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:15 -0600 Subject: [PATCH 03/12] [rf_bridge] Batch UART reads to reduce per-loop overhead (#13831) --- esphome/components/rf_bridge/rf_bridge.cpp | 23 ++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 8105767485..e33c13aafe 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -136,14 +136,21 @@ void RFBridgeComponent::loop() { this->last_bridge_byte_ = now; } - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - if (this->parse_bridge_byte_(byte)) { - ESP_LOGVV(TAG, "Parsed: 0x%02X", byte); - this->last_bridge_byte_ = now; - } else { - this->rx_buffer_.clear(); + int avail = this->available(); + while (avail > 0) { + uint8_t buf[64]; + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t i = 0; i < to_read; i++) { + if (this->parse_bridge_byte_(buf[i])) { + ESP_LOGVV(TAG, "Parsed: 0x%02X", buf[i]); + this->last_bridge_byte_ = now; + } else { + this->rx_buffer_.clear(); + } } } } From e176cf50abb2acfb911d72c8f1c9445530542a5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:15:28 -0600 Subject: [PATCH 04/12] [dfplayer] Batch UART reads to reduce per-loop overhead (#13832) --- esphome/components/dfplayer/dfplayer.cpp | 276 ++++++++++++----------- 1 file changed, 143 insertions(+), 133 deletions(-) diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 70bd42e1a5..48c06be558 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -1,4 +1,5 @@ #include "dfplayer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -131,140 +132,149 @@ void DFPlayer::send_cmd_(uint8_t cmd, uint16_t argument) { } void DFPlayer::loop() { - // Read message - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - - if (this->read_pos_ == DFPLAYER_READ_BUFFER_LENGTH) - this->read_pos_ = 0; - - switch (this->read_pos_) { - case 0: // Start mark - if (byte != 0x7E) - continue; - break; - case 1: // Version - if (byte != 0xFF) { - ESP_LOGW(TAG, "Expected Version 0xFF, got %#02x", byte); - this->read_pos_ = 0; - continue; - } - break; - case 2: // Buffer length - if (byte != 0x06) { - ESP_LOGW(TAG, "Expected Buffer length 0x06, got %#02x", byte); - this->read_pos_ = 0; - continue; - } - break; - case 9: // End byte -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - char byte_sequence[100]; - byte_sequence[0] = '\0'; - for (size_t i = 0; i < this->read_pos_ + 1; ++i) { - snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ", - this->read_buffer_[i]); - } - ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence); -#endif - if (byte != 0xEF) { - ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte); - this->read_pos_ = 0; - continue; - } - // Parse valid received command - uint8_t cmd = this->read_buffer_[3]; - uint16_t argument = (this->read_buffer_[5] << 8) | this->read_buffer_[6]; - - ESP_LOGV(TAG, "Received message cmd: %#02x arg %#04x", cmd, argument); - - switch (cmd) { - case 0x3A: - if (argument == 1) { - ESP_LOGI(TAG, "USB loaded"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card loaded"); - } - break; - case 0x3B: - if (argument == 1) { - ESP_LOGI(TAG, "USB unloaded"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card unloaded"); - } - break; - case 0x3F: - if (argument == 1) { - ESP_LOGI(TAG, "USB available"); - } else if (argument == 2) { - ESP_LOGI(TAG, "TF Card available"); - } else if (argument == 3) { - ESP_LOGI(TAG, "USB, TF Card available"); - } - break; - case 0x40: - ESP_LOGV(TAG, "Nack"); - this->ack_set_is_playing_ = false; - this->ack_reset_is_playing_ = false; - switch (argument) { - case 0x01: - ESP_LOGE(TAG, "Module is busy or uninitialized"); - break; - case 0x02: - ESP_LOGE(TAG, "Module is in sleep mode"); - break; - case 0x03: - ESP_LOGE(TAG, "Serial receive error"); - break; - case 0x04: - ESP_LOGE(TAG, "Checksum incorrect"); - break; - case 0x05: - ESP_LOGE(TAG, "Specified track is out of current track scope"); - this->is_playing_ = false; - break; - case 0x06: - ESP_LOGE(TAG, "Specified track is not found"); - this->is_playing_ = false; - break; - case 0x07: - ESP_LOGE(TAG, "Insertion error (an inserting operation only can be done when a track is being played)"); - break; - case 0x08: - ESP_LOGE(TAG, "SD card reading failed (SD card pulled out or damaged)"); - break; - case 0x09: - ESP_LOGE(TAG, "Entered into sleep mode"); - this->is_playing_ = false; - break; - } - break; - case 0x41: - ESP_LOGV(TAG, "Ack ok"); - this->is_playing_ |= this->ack_set_is_playing_; - this->is_playing_ &= !this->ack_reset_is_playing_; - this->ack_set_is_playing_ = false; - this->ack_reset_is_playing_ = false; - break; - case 0x3C: - ESP_LOGV(TAG, "Playback finished (USB drive)"); - this->is_playing_ = false; - this->on_finished_playback_callback_.call(); - case 0x3D: - ESP_LOGV(TAG, "Playback finished (SD card)"); - this->is_playing_ = false; - this->on_finished_playback_callback_.call(); - break; - default: - ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument); - } - this->sent_cmd_ = 0; - this->read_pos_ = 0; - continue; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + for (size_t bi = 0; bi < to_read; bi++) { + uint8_t byte = buf[bi]; + + if (this->read_pos_ == DFPLAYER_READ_BUFFER_LENGTH) + this->read_pos_ = 0; + + switch (this->read_pos_) { + case 0: // Start mark + if (byte != 0x7E) + continue; + break; + case 1: // Version + if (byte != 0xFF) { + ESP_LOGW(TAG, "Expected Version 0xFF, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + break; + case 2: // Buffer length + if (byte != 0x06) { + ESP_LOGW(TAG, "Expected Buffer length 0x06, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + break; + case 9: // End byte +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE + char byte_sequence[100]; + byte_sequence[0] = '\0'; + for (size_t i = 0; i < this->read_pos_ + 1; ++i) { + snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ", + this->read_buffer_[i]); + } + ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence); +#endif + if (byte != 0xEF) { + ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte); + this->read_pos_ = 0; + continue; + } + // Parse valid received command + uint8_t cmd = this->read_buffer_[3]; + uint16_t argument = (this->read_buffer_[5] << 8) | this->read_buffer_[6]; + + ESP_LOGV(TAG, "Received message cmd: %#02x arg %#04x", cmd, argument); + + switch (cmd) { + case 0x3A: + if (argument == 1) { + ESP_LOGI(TAG, "USB loaded"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card loaded"); + } + break; + case 0x3B: + if (argument == 1) { + ESP_LOGI(TAG, "USB unloaded"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card unloaded"); + } + break; + case 0x3F: + if (argument == 1) { + ESP_LOGI(TAG, "USB available"); + } else if (argument == 2) { + ESP_LOGI(TAG, "TF Card available"); + } else if (argument == 3) { + ESP_LOGI(TAG, "USB, TF Card available"); + } + break; + case 0x40: + ESP_LOGV(TAG, "Nack"); + this->ack_set_is_playing_ = false; + this->ack_reset_is_playing_ = false; + switch (argument) { + case 0x01: + ESP_LOGE(TAG, "Module is busy or uninitialized"); + break; + case 0x02: + ESP_LOGE(TAG, "Module is in sleep mode"); + break; + case 0x03: + ESP_LOGE(TAG, "Serial receive error"); + break; + case 0x04: + ESP_LOGE(TAG, "Checksum incorrect"); + break; + case 0x05: + ESP_LOGE(TAG, "Specified track is out of current track scope"); + this->is_playing_ = false; + break; + case 0x06: + ESP_LOGE(TAG, "Specified track is not found"); + this->is_playing_ = false; + break; + case 0x07: + ESP_LOGE(TAG, + "Insertion error (an inserting operation only can be done when a track is being played)"); + break; + case 0x08: + ESP_LOGE(TAG, "SD card reading failed (SD card pulled out or damaged)"); + break; + case 0x09: + ESP_LOGE(TAG, "Entered into sleep mode"); + this->is_playing_ = false; + break; + } + break; + case 0x41: + ESP_LOGV(TAG, "Ack ok"); + this->is_playing_ |= this->ack_set_is_playing_; + this->is_playing_ &= !this->ack_reset_is_playing_; + this->ack_set_is_playing_ = false; + this->ack_reset_is_playing_ = false; + break; + case 0x3C: + ESP_LOGV(TAG, "Playback finished (USB drive)"); + this->is_playing_ = false; + this->on_finished_playback_callback_.call(); + case 0x3D: + ESP_LOGV(TAG, "Playback finished (SD card)"); + this->is_playing_ = false; + this->on_finished_playback_callback_.call(); + break; + default: + ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument); + } + this->sent_cmd_ = 0; + this->read_pos_ = 0; + continue; + } + this->read_buffer_[this->read_pos_] = byte; + this->read_pos_++; } - this->read_buffer_[this->read_pos_] = byte; - this->read_pos_++; } } void DFPlayer::dump_config() { From a5ee4510433c247e8eebe982ff82d587a2e303f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:17:58 -0600 Subject: [PATCH 05/12] [tuya] Batch UART reads to reduce per-loop overhead (#13827) --- esphome/components/tuya/tuya.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index 2812fb6ad6..9ee4c09b86 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -31,10 +31,19 @@ void Tuya::setup() { } void Tuya::loop() { - while (this->available()) { - uint8_t c; - this->read_byte(&c); - this->handle_char_(c); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->handle_char_(buf[i]); + } } process_command_queue_(); } From 8fffe7453dd4cca340808964e8cd7ea24c8002df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:18:12 -0600 Subject: [PATCH 06/12] [seeed_mr24hpc1/mr60fda2/mr60bha2] Batch UART reads to reduce per-loop overhead (#13825) --- .../seeed_mr24hpc1/seeed_mr24hpc1.cpp | 17 ++++++++++----- .../seeed_mr60bha2/seeed_mr60bha2.cpp | 21 ++++++++++++------- .../seeed_mr60fda2/seeed_mr60fda2.cpp | 17 ++++++++++----- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 08d83f9390..3f2103b401 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -106,12 +106,19 @@ void MR24HPC1Component::update_() { // main loop void MR24HPC1Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->r24_split_data_frame_(byte); // split data frame + for (size_t i = 0; i < to_read; i++) { + this->r24_split_data_frame_(buf[i]); // split data frame + } } if ((this->s_output_info_switch_flag_ == OUTPUT_SWTICH_OFF) && diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index b9ce1f9151..d95e13241d 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -30,14 +30,21 @@ void MR60BHA2Component::dump_config() { // main loop void MR60BHA2Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->rx_message_.push_back(byte); - if (!this->validate_message_()) { - this->rx_message_.clear(); + for (size_t i = 0; i < to_read; i++) { + this->rx_message_.push_back(buf[i]); + if (!this->validate_message_()) { + this->rx_message_.clear(); + } } } } diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index b5b5b4d05a..441ee2b5c2 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -49,12 +49,19 @@ void MR60FDA2Component::setup() { // main loop void MR60FDA2Component::loop() { - uint8_t byte; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - // Is there data on the serial port - while (this->available()) { - this->read_byte(&byte); - this->split_frame_(byte); // split data frame + for (size_t i = 0; i < to_read; i++) { + this->split_frame_(buf[i]); // split data frame + } } } From 4a9ff48f0246a4bd6131f19a76fce42db9af8781 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:20:50 -0600 Subject: [PATCH 07/12] [nextion] Batch UART reads to reduce loop overhead (#13823) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/nextion/nextion.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index fd6ce0a24b..56bbc840fb 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -397,11 +397,17 @@ bool Nextion::remove_from_q_(bool report_empty) { } void Nextion::process_serial_() { - uint8_t d; + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; - while (this->available()) { - read_byte(&d); - this->command_data_ += d; + this->command_data_.append(reinterpret_cast(buf), to_read); } } // nextion.tech/instruction-set/ From cd55eb927ddba835775dd71f2da7e2cdb7b59ea7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:21:15 -0600 Subject: [PATCH 08/12] [modbus] Batch UART reads to reduce loop overhead (#13822) --- esphome/components/modbus/modbus.cpp | 29 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 357cd48e11..c1f5635028 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -19,16 +19,25 @@ void Modbus::setup() { void Modbus::loop() { const uint32_t now = App.get_loop_component_start_time(); - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - if (this->parse_modbus_byte_(byte)) { - this->last_modbus_byte_ = now; - } else { - size_t at = this->rx_buffer_.size(); - if (at > 0) { - ESP_LOGV(TAG, "Clearing buffer of %d bytes - parse failed", at); - this->rx_buffer_.clear(); + // Read all available bytes in batches to reduce UART call overhead. + int avail = this->available(); + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(static_cast(avail), sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + if (this->parse_modbus_byte_(buf[i])) { + this->last_modbus_byte_ = now; + } else { + size_t at = this->rx_buffer_.size(); + if (at > 0) { + ESP_LOGV(TAG, "Clearing buffer of %d bytes - parse failed", at); + this->rx_buffer_.clear(); + } } } } From 41a9588d811088ad72e9e6655c29256aa597b0c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:26:06 -0600 Subject: [PATCH 09/12] [i2c] Replace switch with if-else to avoid CSWTCH table in RAM (#13815) --- esphome/components/i2c/i2c_bus_arduino.cpp | 34 ++++++++++------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index e728830147..edd6b81588 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -134,25 +134,23 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe for (size_t j = 0; j != read_count; j++) read_buffer[j] = wire_->read(); } - switch (status) { - case 0: - return ERROR_OK; - case 1: - // transmit buffer not large enough - ESP_LOGVV(TAG, "TX failed: buffer not large enough"); - return ERROR_UNKNOWN; - case 2: - case 3: - ESP_LOGVV(TAG, "TX failed: not acknowledged: %d", status); - return ERROR_NOT_ACKNOWLEDGED; - case 5: - ESP_LOGVV(TAG, "TX failed: timeout"); - return ERROR_UNKNOWN; - case 4: - default: - ESP_LOGVV(TAG, "TX failed: unknown error %u", status); - return ERROR_UNKNOWN; + // Avoid switch to prevent compiler-generated lookup table in RAM on ESP8266 + if (status == 0) + return ERROR_OK; + if (status == 1) { + ESP_LOGVV(TAG, "TX failed: buffer not large enough"); + return ERROR_UNKNOWN; } + if (status == 2 || status == 3) { + ESP_LOGVV(TAG, "TX failed: not acknowledged: %u", status); + return ERROR_NOT_ACKNOWLEDGED; + } + if (status == 5) { + ESP_LOGVV(TAG, "TX failed: timeout"); + return ERROR_UNKNOWN; + } + ESP_LOGVV(TAG, "TX failed: unknown error %u", status); + return ERROR_UNKNOWN; } /// Perform I2C bus recovery, see: From e4ea016d1e6a7cb7a93f12c6cd2c1e419858c899 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:26:19 -0600 Subject: [PATCH 10/12] [ci] Block new std::to_string() usage, suggest snprintf alternatives (#13369) --- .../components/api/homeassistant_service.h | 4 +- esphome/components/esp32_ble/ble_uuid.h | 2 +- .../voice_assistant/voice_assistant.h | 2 +- script/ci-custom.py | 47 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 8ee23c75fe..2322d96eef 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -25,7 +25,9 @@ template class TemplatableStringValue : public TemplatableValue static std::string value_to_string(T &&val) { return to_string(std::forward(val)); } + template static std::string value_to_string(T &&val) { + return to_string(std::forward(val)); // NOLINT + } // Overloads for string types - needed because std::to_string doesn't support them static std::string value_to_string(char *val) { diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 6c8ef7bfd9..503fde6945 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -48,7 +48,7 @@ class ESPBTUUID { // Remove before 2026.8.0 ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const; + std::string to_string() const; // NOLINT const char *to_str(std::span output) const; protected: diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 2a5f3a55a7..0ef7ecc81a 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -83,7 +83,7 @@ struct Timer { } // Remove before 2026.8.0 ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const { + std::string to_string() const { // NOLINT char buffer[TO_STR_BUFFER_SIZE]; return this->to_str(buffer); } diff --git a/script/ci-custom.py b/script/ci-custom.py index b5bec74fa7..8c405b04ae 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -756,6 +756,53 @@ def lint_no_sprintf(fname, match): ) +@lint_re_check( + # Match std::to_string() or unqualified to_string() calls + # The esphome namespace has "using std::to_string;" so unqualified calls resolve to std::to_string + # Use negative lookbehind for unqualified calls to avoid matching: + # - Function definitions: "const char *to_string(" or "std::string to_string(" + # - Method definitions: "Class::to_string(" + # - Method calls: ".to_string(" or "->to_string(" + # - Other identifiers: "_to_string(" + # Also explicitly match std::to_string since : is in the lookbehind + r"(?:(?:])to_string|std\s*::\s*to_string)\s*\(" + CPP_RE_EOL, + include=cpp_include, + exclude=[ + # Vendored library + "esphome/components/http_request/httplib.h", + # Deprecated helpers that return std::string + "esphome/core/helpers.cpp", + # The using declaration itself + "esphome/core/helpers.h", + # Test fixtures - not production embedded code + "tests/integration/fixtures/*", + ], +) +def lint_no_std_to_string(fname, match): + return ( + f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) " + f"allocates heap memory. On long-running embedded devices, repeated heap allocations " + f"fragment memory over time.\n" + f"Please use {highlight('snprintf()')} with a stack buffer instead.\n" + f"\n" + f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n" + f" uint8_t: 4 chars - %u (or PRIu8)\n" + f" int8_t: 5 chars - %d (or PRId8)\n" + f" uint16_t: 6 chars - %u (or PRIu16)\n" + f" int16_t: 7 chars - %d (or PRId16)\n" + f" uint32_t: 11 chars - %" + "PRIu32\n" + " int32_t: 12 chars - %" + "PRId32\n" + " uint64_t: 21 chars - %" + "PRIu64\n" + " int64_t: 21 chars - %" + "PRId64\n" + f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n" + f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n" + f"\n" + f"For sensor values, use value_accuracy_to_buf() from helpers.h.\n" + f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n' + f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" + ) + + @lint_re_check( # Match scanf family functions: scanf, sscanf, fscanf, vscanf, vsscanf, vfscanf # Also match std:: prefixed versions From 6c6da8a3cd2ab8dd41c6cd20cbed8884a30a7906 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 12:45:24 -0600 Subject: [PATCH 11/12] [api] Skip class generation for empty SOURCE_CLIENT protobuf messages (#13880) --- esphome/components/api/api_pb2.h | 91 ---------------------- esphome/components/api/api_pb2_dump.cpp | 28 ------- esphome/components/api/api_pb2_service.cpp | 16 ++-- script/api_protobuf/api_protobuf.py | 53 ++++++++++--- 4 files changed, 52 insertions(+), 136 deletions(-) diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index cf6c65f285..15819da172 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -440,19 +440,6 @@ class PingResponse final : public ProtoMessage { protected: }; -class DeviceInfoRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 9; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "device_info_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; #ifdef USE_AREAS class AreaInfo final : public ProtoMessage { public: @@ -546,19 +533,6 @@ class DeviceInfoResponse final : public ProtoMessage { protected: }; -class ListEntitiesRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 11; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "list_entities_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 19; @@ -572,19 +546,6 @@ class ListEntitiesDoneResponse final : public ProtoMessage { protected: }; -class SubscribeStatesRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 20; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "subscribe_states_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; #ifdef USE_BINARY_SENSOR class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { public: @@ -1037,19 +998,6 @@ class NoiseEncryptionSetKeyResponse final : public ProtoMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -class SubscribeHomeassistantServicesRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 34; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "subscribe_homeassistant_services_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class HomeassistantServiceMap final : public ProtoMessage { public: StringRef key{}; @@ -1117,19 +1065,6 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { }; #endif #ifdef USE_API_HOMEASSISTANT_STATES -class SubscribeHomeAssistantStatesRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 38; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "subscribe_home_assistant_states_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class SubscribeHomeAssistantStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 39; @@ -2160,19 +2095,6 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage { protected: }; -class SubscribeBluetoothConnectionsFreeRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 80; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "subscribe_bluetooth_connections_free_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class BluetoothConnectionsFreeResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 81; @@ -2279,19 +2201,6 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage { protected: }; -class UnsubscribeBluetoothLEAdvertisementsRequest final : public ProtoMessage { - public: - static constexpr uint8_t MESSAGE_TYPE = 87; - static constexpr uint8_t ESTIMATED_SIZE = 0; -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *message_name() const override { return "unsubscribe_bluetooth_le_advertisements_request"; } -#endif -#ifdef HAS_PROTO_MESSAGE_DUMP - const char *dump_to(DumpBuffer &out) const override; -#endif - - protected: -}; class BluetoothDeviceClearCacheResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 88; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index e9db36ae21..f1e3bdcafe 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -764,10 +764,6 @@ const char *PingResponse::dump_to(DumpBuffer &out) const { out.append("PingResponse {}"); return out.c_str(); } -const char *DeviceInfoRequest::dump_to(DumpBuffer &out) const { - out.append("DeviceInfoRequest {}"); - return out.c_str(); -} #ifdef USE_AREAS const char *AreaInfo::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "AreaInfo"); @@ -848,18 +844,10 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif return out.c_str(); } -const char *ListEntitiesRequest::dump_to(DumpBuffer &out) const { - out.append("ListEntitiesRequest {}"); - return out.c_str(); -} const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append("ListEntitiesDoneResponse {}"); return out.c_str(); } -const char *SubscribeStatesRequest::dump_to(DumpBuffer &out) const { - out.append("SubscribeStatesRequest {}"); - return out.c_str(); -} #ifdef USE_BINARY_SENSOR const char *ListEntitiesBinarySensorResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "ListEntitiesBinarySensorResponse"); @@ -1191,10 +1179,6 @@ const char *NoiseEncryptionSetKeyResponse::dump_to(DumpBuffer &out) const { } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES -const char *SubscribeHomeassistantServicesRequest::dump_to(DumpBuffer &out) const { - out.append("SubscribeHomeassistantServicesRequest {}"); - return out.c_str(); -} const char *HomeassistantServiceMap::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "HomeassistantServiceMap"); dump_field(out, "key", this->key); @@ -1245,10 +1229,6 @@ const char *HomeassistantActionResponse::dump_to(DumpBuffer &out) const { } #endif #ifdef USE_API_HOMEASSISTANT_STATES -const char *SubscribeHomeAssistantStatesRequest::dump_to(DumpBuffer &out) const { - out.append("SubscribeHomeAssistantStatesRequest {}"); - return out.c_str(); -} const char *SubscribeHomeAssistantStateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "SubscribeHomeAssistantStateResponse"); dump_field(out, "entity_id", this->entity_id); @@ -1924,10 +1904,6 @@ const char *BluetoothGATTNotifyDataResponse::dump_to(DumpBuffer &out) const { dump_bytes_field(out, "data", this->data_ptr_, this->data_len_); return out.c_str(); } -const char *SubscribeBluetoothConnectionsFreeRequest::dump_to(DumpBuffer &out) const { - out.append("SubscribeBluetoothConnectionsFreeRequest {}"); - return out.c_str(); -} const char *BluetoothConnectionsFreeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "BluetoothConnectionsFreeResponse"); dump_field(out, "free", this->free); @@ -1970,10 +1946,6 @@ const char *BluetoothDeviceUnpairingResponse::dump_to(DumpBuffer &out) const { dump_field(out, "error", this->error); return out.c_str(); } -const char *UnsubscribeBluetoothLEAdvertisementsRequest::dump_to(DumpBuffer &out) const { - out.append("UnsubscribeBluetoothLEAdvertisementsRequest {}"); - return out.c_str(); -} const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, "BluetoothDeviceClearCacheResponse"); dump_field(out, "address", this->address); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 2d15deb90d..f9151ae3b4 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -27,7 +27,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, case DisconnectRequest::MESSAGE_TYPE: // No setup required case PingRequest::MESSAGE_TYPE: // No setup required break; - case DeviceInfoRequest::MESSAGE_TYPE: // Connection setup only + case 9 /* DeviceInfoRequest is empty */: // Connection setup only if (!this->check_connection_setup_()) { return; } @@ -76,21 +76,21 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, this->on_ping_response(); break; } - case DeviceInfoRequest::MESSAGE_TYPE: { + case 9 /* DeviceInfoRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_device_info_request")); #endif this->on_device_info_request(); break; } - case ListEntitiesRequest::MESSAGE_TYPE: { + case 11 /* ListEntitiesRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_list_entities_request")); #endif this->on_list_entities_request(); break; } - case SubscribeStatesRequest::MESSAGE_TYPE: { + case 20 /* SubscribeStatesRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_states_request")); #endif @@ -151,7 +151,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } #endif #ifdef USE_API_HOMEASSISTANT_SERVICES - case SubscribeHomeassistantServicesRequest::MESSAGE_TYPE: { + case 34 /* SubscribeHomeassistantServicesRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_homeassistant_services_request")); #endif @@ -169,7 +169,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #ifdef USE_API_HOMEASSISTANT_STATES - case SubscribeHomeAssistantStatesRequest::MESSAGE_TYPE: { + case 38 /* SubscribeHomeAssistantStatesRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_home_assistant_states_request")); #endif @@ -376,7 +376,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } #endif #ifdef USE_BLUETOOTH_PROXY - case SubscribeBluetoothConnectionsFreeRequest::MESSAGE_TYPE: { + case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); #endif @@ -385,7 +385,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, } #endif #ifdef USE_BLUETOOTH_PROXY - case UnsubscribeBluetoothLEAdvertisementsRequest::MESSAGE_TYPE: { + case 87 /* UnsubscribeBluetoothLEAdvertisementsRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_unsubscribe_bluetooth_le_advertisements_request")); #endif diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index ece0b5692f..4fbee49dae 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2277,6 +2277,12 @@ ifdefs: dict[str, str] = {} # Track messages with no fields (empty messages) for parameter elision EMPTY_MESSAGES: set[str] = set() +# Track empty SOURCE_CLIENT messages that don't need class generation +# These messages have no fields and are only received (never sent), so the +# class definition (vtable, dump_to, message_name, ESTIMATED_SIZE) is dead code +# that the compiler compiles but the linker strips away. +SKIP_CLASS_GENERATION: set[str] = set() + def get_opt( desc: descriptor.DescriptorProto, @@ -2527,7 +2533,11 @@ def build_service_message_type( case += "#endif\n" case += f"this->{func}({'msg' if not is_empty else ''});\n" case += "break;" - RECEIVE_CASES[id_] = (case, ifdef, mt.name) + if mt.name in SKIP_CLASS_GENERATION: + case_label = f"{id_} /* {mt.name} is empty */" + else: + case_label = f"{mt.name}::MESSAGE_TYPE" + RECEIVE_CASES[id_] = (case, ifdef, case_label) # Only close ifdef if we opened it if ifdef is not None: @@ -2723,6 +2733,19 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint mt = file.message_type + # Identify empty SOURCE_CLIENT messages that don't need class generation + for m in mt: + if m.options.deprecated: + continue + if not m.options.HasExtension(pb.id): + continue + source = message_source_map.get(m.name) + if source != SOURCE_CLIENT: + continue + has_fields = any(not field.options.deprecated for field in m.field) + if not has_fields: + SKIP_CLASS_GENERATION.add(m.name) + # Collect messages by base class base_class_groups = collect_messages_by_base_class(mt) @@ -2755,6 +2778,10 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint if m.name not in used_messages and not m.options.HasExtension(pb.id): continue + # Skip class generation for empty SOURCE_CLIENT messages + if m.name in SKIP_CLASS_GENERATION: + continue + s, c, dc = build_message_type(m, base_class_fields, message_source_map) msg_ifdef = message_ifdef_map.get(m.name) @@ -2901,10 +2928,18 @@ static const char *const TAG = "api.service"; no_conn_ids: set[int] = set() conn_only_ids: set[int] = set() - for id_, (_, _, case_msg_name) in cases: - if case_msg_name in message_auth_map: - needs_auth = message_auth_map[case_msg_name] - needs_conn = message_conn_map[case_msg_name] + # Build a reverse lookup from message id to message name for auth lookups + id_to_msg_name: dict[int, str] = {} + for mt in file.message_type: + id_ = get_opt(mt, pb.id) + if id_ is not None and not mt.options.deprecated: + id_to_msg_name[id_] = mt.name + + for id_, (_, _, case_label) in cases: + msg_name = id_to_msg_name.get(id_, "") + if msg_name in message_auth_map: + needs_auth = message_auth_map[msg_name] + needs_conn = message_conn_map[msg_name] if not needs_conn: no_conn_ids.add(id_) @@ -2915,10 +2950,10 @@ static const char *const TAG = "api.service"; def generate_cases(ids: set[int], comment: str) -> str: result = "" for id_ in sorted(ids): - _, ifdef, msg_name = RECEIVE_CASES[id_] + _, ifdef, case_label = RECEIVE_CASES[id_] if ifdef: result += f"#ifdef {ifdef}\n" - result += f" case {msg_name}::MESSAGE_TYPE: {comment}\n" + result += f" case {case_label}: {comment}\n" if ifdef: result += "#endif\n" return result @@ -2958,11 +2993,11 @@ static const char *const TAG = "api.service"; # Dispatch switch out += " switch (msg_type) {\n" - for i, (case, ifdef, message_name) in cases: + for i, (case, ifdef, case_label) in cases: if ifdef is not None: out += f"#ifdef {ifdef}\n" - c = f" case {message_name}::MESSAGE_TYPE: {{\n" + c = f" case {case_label}: {{\n" c += indent(case, " ") + "\n" c += " }" out += c + "\n" From e0712cc53b464ebf3af3c4cb811eabc11173524e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Feb 2026 13:16:22 -0600 Subject: [PATCH 12/12] [scheduler] Make core timer ID collisions impossible with type-safe internal IDs (#13882) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/binary_sensor/automation.cpp | 34 +++-- esphome/components/binary_sensor/filter.cpp | 39 ++++-- esphome/components/sensor/filter.cpp | 11 +- esphome/core/base_automation.h | 10 +- esphome/core/component.cpp | 16 ++- esphome/core/component.h | 14 ++ esphome/core/scheduler.cpp | 8 +- esphome/core/scheduler.h | 37 +++++- .../scheduler_internal_id_no_collision.yaml | 109 +++++++++++++++ ...test_scheduler_internal_id_no_collision.py | 124 ++++++++++++++++++ 10 files changed, 359 insertions(+), 43 deletions(-) create mode 100644 tests/integration/fixtures/scheduler_internal_id_no_collision.yaml create mode 100644 tests/integration/test_scheduler_internal_id_no_collision.py diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index dfe911a2f8..faebe7e88f 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -5,6 +5,14 @@ namespace esphome::binary_sensor { static const char *const TAG = "binary_sensor.automation"; +// MultiClickTrigger timeout IDs. +// MultiClickTrigger is its own Component instance, so the scheduler scopes +// IDs by component pointer — no risk of collisions between instances. +constexpr uint32_t MULTICLICK_TRIGGER_ID = 0; +constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1; +constexpr uint32_t MULTICLICK_IS_VALID_ID = 2; +constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3; + void MultiClickTrigger::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { @@ -27,7 +35,7 @@ void MultiClickTrigger::on_state_(bool state) { evt.min_length, evt.max_length); this->at_index_ = 1; if (this->timing_.size() == 1 && evt.max_length == 4294967294UL) { - this->set_timeout("trigger", evt.min_length, [this]() { this->trigger_(); }); + this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); } else { this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); @@ -57,13 +65,13 @@ void MultiClickTrigger::on_state_(bool state) { this->schedule_is_not_valid_(evt.max_length); } else if (*this->at_index_ + 1 != this->timing_.size()) { ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT - this->cancel_timeout("is_not_valid"); + this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); } else { ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT this->is_valid_ = false; - this->cancel_timeout("is_not_valid"); - this->set_timeout("trigger", evt.min_length, [this]() { this->trigger_(); }); + this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); + this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); } *this->at_index_ = *this->at_index_ + 1; @@ -71,14 +79,14 @@ void MultiClickTrigger::on_state_(bool state) { void MultiClickTrigger::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; - this->set_timeout("cooldown", this->invalid_cooldown_, [this]() { + this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() { ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again."); this->is_in_cooldown_ = false; }); this->at_index_.reset(); - this->cancel_timeout("trigger"); - this->cancel_timeout("is_valid"); - this->cancel_timeout("is_not_valid"); + this->cancel_timeout(MULTICLICK_TRIGGER_ID); + this->cancel_timeout(MULTICLICK_IS_VALID_ID); + this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); } void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { @@ -86,13 +94,13 @@ void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { return; } this->is_valid_ = false; - this->set_timeout("is_valid", min_length, [this]() { + this->set_timeout(MULTICLICK_IS_VALID_ID, min_length, [this]() { ESP_LOGV(TAG, "Multi Click: You can now %s the button.", this->parent_->state ? "RELEASE" : "PRESS"); this->is_valid_ = true; }); } void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { - this->set_timeout("is_not_valid", max_length, [this]() { + this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS"); this->is_valid_ = false; this->schedule_cooldown_(); @@ -106,9 +114,9 @@ void MultiClickTrigger::cancel() { void MultiClickTrigger::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); this->at_index_.reset(); - this->cancel_timeout("trigger"); - this->cancel_timeout("is_valid"); - this->cancel_timeout("is_not_valid"); + this->cancel_timeout(MULTICLICK_TRIGGER_ID); + this->cancel_timeout(MULTICLICK_IS_VALID_ID); + this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->trigger(); } diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 9c7238f6d7..d69671c5bf 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -6,6 +6,14 @@ namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; +// Timeout IDs for filter classes. +// Each filter is its own Component instance, so the scheduler scopes +// IDs by component pointer — no risk of collisions between instances. +constexpr uint32_t FILTER_TIMEOUT_ID = 0; +// AutorepeatFilter needs two distinct IDs (both timeouts on the same component) +constexpr uint32_t AUTOREPEAT_TIMING_ID = 0; +constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1; + void Filter::output(bool value) { if (this->next_ == nullptr) { this->parent_->send_state_internal(value); @@ -23,16 +31,16 @@ void Filter::input(bool value) { } void TimeoutFilter::input(bool value) { - this->set_timeout("timeout", this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); }); // we do not de-dup here otherwise changes from invalid to valid state will not be output this->output(value); } optional DelayedOnOffFilter::new_value(bool value) { if (value) { - this->set_timeout("ON_OFF", this->on_delay_.value(), [this]() { this->output(true); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); }); } else { - this->set_timeout("ON_OFF", this->off_delay_.value(), [this]() { this->output(false); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); }); } return {}; } @@ -41,10 +49,10 @@ float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HA optional DelayedOnFilter::new_value(bool value) { if (value) { - this->set_timeout("ON", this->delay_.value(), [this]() { this->output(true); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); }); return {}; } else { - this->cancel_timeout("ON"); + this->cancel_timeout(FILTER_TIMEOUT_ID); return false; } } @@ -53,10 +61,10 @@ float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDW optional DelayedOffFilter::new_value(bool value) { if (!value) { - this->set_timeout("OFF", this->delay_.value(), [this]() { this->output(false); }); + this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); }); return {}; } else { - this->cancel_timeout("OFF"); + this->cancel_timeout(FILTER_TIMEOUT_ID); return true; } } @@ -76,8 +84,8 @@ optional AutorepeatFilter::new_value(bool value) { this->next_timing_(); return true; } else { - this->cancel_timeout("TIMING"); - this->cancel_timeout("ON_OFF"); + this->cancel_timeout(AUTOREPEAT_TIMING_ID); + this->cancel_timeout(AUTOREPEAT_ON_OFF_ID); this->active_timing_ = 0; return false; } @@ -88,8 +96,10 @@ void AutorepeatFilter::next_timing_() { // 1st time: starts waiting the first delay // 2nd time: starts waiting the second delay and starts toggling with the first time_off / _on // last time: no delay to start but have to bump the index to reflect the last - if (this->active_timing_ < this->timings_.size()) - this->set_timeout("TIMING", this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); }); + if (this->active_timing_ < this->timings_.size()) { + this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay, + [this]() { this->next_timing_(); }); + } if (this->active_timing_ <= this->timings_.size()) { this->active_timing_++; @@ -104,7 +114,8 @@ void AutorepeatFilter::next_timing_() { void AutorepeatFilter::next_value_(bool val) { const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2]; this->output(val); // This is at least the second one so not initial - this->set_timeout("ON_OFF", val ? timing.time_on : timing.time_off, [this, val]() { this->next_value_(!val); }); + this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off, + [this, val]() { this->next_value_(!val); }); } float AutorepeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } @@ -115,7 +126,7 @@ optional LambdaFilter::new_value(bool value) { return this->f_(value); } optional SettleFilter::new_value(bool value) { if (!this->steady_) { - this->set_timeout("SETTLE", this->delay_.value(), [this, value]() { + this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() { this->steady_ = true; this->output(value); }); @@ -123,7 +134,7 @@ optional SettleFilter::new_value(bool value) { } else { this->steady_ = false; this->output(value); - this->set_timeout("SETTLE", this->delay_.value(), [this]() { this->steady_ = true; }); + this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; }); return value; } } diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 375505a557..ea0e2f0d7c 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -9,6 +9,11 @@ namespace esphome::sensor { static const char *const TAG = "sensor.filter"; +// Filter scheduler IDs. +// Each filter is its own Component instance, so the scheduler scopes +// IDs by component pointer — no risk of collisions between instances. +constexpr uint32_t FILTER_ID = 0; + // Filter void Filter::input(float value) { ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value); @@ -191,7 +196,7 @@ optional ThrottleAverageFilter::new_value(float value) { return {}; } void ThrottleAverageFilter::setup() { - this->set_interval("throttle_average", this->time_period_, [this]() { + this->set_interval(FILTER_ID, this->time_period_, [this]() { ESP_LOGVV(TAG, "ThrottleAverageFilter(%p)::interval(sum=%f, n=%i)", this, this->sum_, this->n_); if (this->n_ == 0) { if (this->have_nan_) @@ -383,7 +388,7 @@ optional TimeoutFilterConfigured::new_value(float value) { // DebounceFilter optional DebounceFilter::new_value(float value) { - this->set_timeout("debounce", this->time_period_, [this, value]() { this->output(value); }); + this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); }); return {}; } @@ -406,7 +411,7 @@ optional HeartbeatFilter::new_value(float value) { } void HeartbeatFilter::setup() { - this->set_interval("heartbeat", this->time_period_, [this]() { + this->set_interval(FILTER_ID, this->time_period_, [this]() { ESP_LOGVV(TAG, "HeartbeatFilter(%p)::interval(has_value=%s, last_input=%f)", this, YESNO(this->has_value_), this->last_input_); if (!this->has_value_) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 19d0ccf972..67e1755cc9 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -191,15 +191,17 @@ template class DelayAction : public Action, public Compon // instead of std::bind to avoid bind overhead (~16 bytes heap + faster execution) if constexpr (sizeof...(Ts) == 0) { App.scheduler.set_timer_common_( - this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::STATIC_STRING, "delay", 0, this->delay_.value(), + this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, nullptr, + static_cast(InternalSchedulerID::DELAY_ACTION), this->delay_.value(), [this]() { this->play_next_(); }, /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } else { // For delays with arguments, use std::bind to preserve argument values // Arguments must be copied because original references may be invalid after delay auto f = std::bind(&DelayAction::play_next_, this, x...); - App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::STATIC_STRING, - "delay", 0, this->delay_.value(x...), std::move(f), + App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::NUMERIC_ID_INTERNAL, + nullptr, static_cast(InternalSchedulerID::DELAY_ACTION), + this->delay_.value(x...), std::move(f), /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); } } @@ -208,7 +210,7 @@ template class DelayAction : public Action, public Compon void play(const Ts &...x) override { /* ignore - see play_complex */ } - void stop() override { this->cancel_timeout("delay"); } + void stop() override { this->cancel_timeout(InternalSchedulerID::DELAY_ACTION); } }; template class LambdaAction : public Action { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 6d8d1c57af..90aa36f4db 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -201,12 +201,24 @@ void Component::set_timeout(uint32_t id, uint32_t timeout, std::function bool Component::cancel_timeout(uint32_t id) { return App.scheduler.cancel_timeout(this, id); } +void Component::set_timeout(InternalSchedulerID id, uint32_t timeout, std::function &&f) { // NOLINT + App.scheduler.set_timeout(this, id, timeout, std::move(f)); +} + +bool Component::cancel_timeout(InternalSchedulerID id) { return App.scheduler.cancel_timeout(this, id); } + void Component::set_interval(uint32_t id, uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, id, interval, std::move(f)); } bool Component::cancel_interval(uint32_t id) { return App.scheduler.cancel_interval(this, id); } +void Component::set_interval(InternalSchedulerID id, uint32_t interval, std::function &&f) { // NOLINT + App.scheduler.set_interval(this, id, interval, std::move(f)); +} + +bool Component::cancel_interval(InternalSchedulerID id) { return App.scheduler.cancel_interval(this, id); } + void Component::set_retry(uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, float backoff_increase_factor) { // NOLINT #pragma GCC diagnostic push @@ -533,12 +545,12 @@ void PollingComponent::call_setup() { void PollingComponent::start_poller() { // Register interval. - this->set_interval("update", this->get_update_interval(), [this]() { this->update(); }); + this->set_interval(InternalSchedulerID::POLLING_UPDATE, this->get_update_interval(), [this]() { this->update(); }); } void PollingComponent::stop_poller() { // Clear the interval to suspend component - this->cancel_interval("update"); + this->cancel_interval(InternalSchedulerID::POLLING_UPDATE); } uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } diff --git a/esphome/core/component.h b/esphome/core/component.h index c3582e23b1..9ab77cc2f9 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -49,6 +49,14 @@ extern const float LATE; static const uint32_t SCHEDULER_DONT_RUN = 4294967295UL; +/// Type-safe scheduler IDs for core base classes. +/// Uses a separate NameType (NUMERIC_ID_INTERNAL) so IDs can never collide +/// with component-level NUMERIC_ID values, even if the uint32_t values overlap. +enum class InternalSchedulerID : uint32_t { + POLLING_UPDATE = 0, // PollingComponent interval + DELAY_ACTION = 1, // DelayAction timeout +}; + // Forward declaration class PollingComponent; @@ -335,6 +343,8 @@ class Component { */ void set_interval(uint32_t id, uint32_t interval, std::function &&f); // NOLINT + void set_interval(InternalSchedulerID id, uint32_t interval, std::function &&f); // NOLINT + void set_interval(uint32_t interval, std::function &&f); // NOLINT /** Cancel an interval function. @@ -347,6 +357,7 @@ class Component { bool cancel_interval(const std::string &name); // NOLINT bool cancel_interval(const char *name); // NOLINT bool cancel_interval(uint32_t id); // NOLINT + bool cancel_interval(InternalSchedulerID id); // NOLINT /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. // Remove before 2026.8.0 @@ -425,6 +436,8 @@ class Component { */ void set_timeout(uint32_t id, uint32_t timeout, std::function &&f); // NOLINT + void set_timeout(InternalSchedulerID id, uint32_t timeout, std::function &&f); // NOLINT + void set_timeout(uint32_t timeout, std::function &&f); // NOLINT /** Cancel a timeout function. @@ -437,6 +450,7 @@ class Component { bool cancel_timeout(const std::string &name); // NOLINT bool cancel_timeout(const char *name); // NOLINT bool cancel_timeout(uint32_t id); // NOLINT + bool cancel_timeout(InternalSchedulerID id); // NOLINT /** Defer a callback to the next loop() call. * diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a5e308829a..97ac28b623 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -53,9 +53,12 @@ struct SchedulerNameLog { } else if (name_type == NameType::HASHED_STRING) { ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("hash:0x%08" PRIX32), hash_or_id); return buffer; - } else { // NUMERIC_ID + } else if (name_type == NameType::NUMERIC_ID) { ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("id:%" PRIu32), hash_or_id); return buffer; + } else { // NUMERIC_ID_INTERNAL + ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("iid:%" PRIu32), hash_or_id); + return buffer; } } }; @@ -137,6 +140,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type case NameType::NUMERIC_ID: item->set_numeric_id(hash_or_id); break; + case NameType::NUMERIC_ID_INTERNAL: + item->set_internal_id(hash_or_id); + break; } item->type = type; item->callback = std::move(func); diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 20b069f3f0..ede729d164 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -46,11 +46,20 @@ class Scheduler { void set_timeout(Component *component, const char *name, uint32_t timeout, std::function func); /// Set a timeout with a numeric ID (zero heap allocation) void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func); + /// Set a timeout with an internal scheduler ID (separate namespace from component NUMERIC_ID) + void set_timeout(Component *component, InternalSchedulerID id, uint32_t timeout, std::function func) { + this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, + static_cast(id), timeout, std::move(func)); + } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") bool cancel_timeout(Component *component, const std::string &name); bool cancel_timeout(Component *component, const char *name); bool cancel_timeout(Component *component, uint32_t id); + bool cancel_timeout(Component *component, InternalSchedulerID id) { + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(id), + SchedulerItem::TIMEOUT); + } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") void set_interval(Component *component, const std::string &name, uint32_t interval, std::function func); @@ -66,11 +75,20 @@ class Scheduler { void set_interval(Component *component, const char *name, uint32_t interval, std::function func); /// Set an interval with a numeric ID (zero heap allocation) void set_interval(Component *component, uint32_t id, uint32_t interval, std::function func); + /// Set an interval with an internal scheduler ID (separate namespace from component NUMERIC_ID) + void set_interval(Component *component, InternalSchedulerID id, uint32_t interval, std::function func) { + this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, + static_cast(id), interval, std::move(func)); + } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") bool cancel_interval(Component *component, const std::string &name); bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); + bool cancel_interval(Component *component, InternalSchedulerID id) { + return this->cancel_item_(component, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(id), + SchedulerItem::INTERVAL); + } // Remove before 2026.8.0 ESPDEPRECATED("set_retry is deprecated and will be removed in 2026.8.0. Use set_timeout or set_interval instead.", @@ -112,11 +130,12 @@ class Scheduler { void process_to_add(); // Name storage type discriminator for SchedulerItem - // Used to distinguish between static strings, hashed strings, and numeric IDs + // Used to distinguish between static strings, hashed strings, numeric IDs, and internal numeric IDs enum class NameType : uint8_t { - STATIC_STRING = 0, // const char* pointer to static/flash storage - HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string - NUMERIC_ID = 2 // uint32_t numeric identifier + STATIC_STRING = 0, // const char* pointer to static/flash storage + HASHED_STRING = 1, // uint32_t FNV-1a hash of a runtime string + NUMERIC_ID = 2, // uint32_t numeric identifier (component-level) + NUMERIC_ID_INTERNAL = 3 // uint32_t numeric identifier (core/internal, separate namespace) }; protected: @@ -147,7 +166,7 @@ class Scheduler { // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; - NameType name_type_ : 2; // Discriminator for name_ union (STATIC_STRING, HASHED_STRING, NUMERIC_ID) + NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum) bool is_retry : 1; // True if this is a retry timeout // 4 bits padding #else @@ -155,7 +174,7 @@ class Scheduler { // Bit-packed fields (5 bits used, 3 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; bool remove : 1; - NameType name_type_ : 2; // Discriminator for name_ union (STATIC_STRING, HASHED_STRING, NUMERIC_ID) + NameType name_type_ : 2; // Discriminator for name_ union (0–3, see NameType enum) bool is_retry : 1; // True if this is a retry timeout // 3 bits padding #endif @@ -218,6 +237,12 @@ class Scheduler { name_type_ = NameType::NUMERIC_ID; } + // Helper to set an internal numeric ID (separate namespace from NUMERIC_ID) + void set_internal_id(uint32_t id) { + name_.hash_or_id = id; + name_type_ = NameType::NUMERIC_ID_INTERNAL; + } + static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); // Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility. diff --git a/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml new file mode 100644 index 0000000000..46dbb8e728 --- /dev/null +++ b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml @@ -0,0 +1,109 @@ +esphome: + name: scheduler-internal-id-test + on_boot: + priority: -100 + then: + - logger.log: "Starting scheduler internal ID collision tests" + +host: +api: +logger: + level: VERBOSE + +globals: + - id: tests_done + type: bool + initial_value: 'false' + +script: + - id: test_internal_id_no_collision + then: + - logger.log: "Testing NUMERIC_ID_INTERNAL vs NUMERIC_ID isolation" + - lambda: |- + // All tests use the same component and the same uint32_t value (0). + // NUMERIC_ID_INTERNAL and NUMERIC_ID are separate NameType values, + // so the scheduler must treat them as independent timers. + auto *comp = id(test_sensor); + + // ---- Test 1: Both timeout types fire independently ---- + // Set an internal timeout with ID 0 + App.scheduler.set_timeout(comp, InternalSchedulerID{0}, 50, []() { + ESP_LOGI("test", "Internal timeout 0 fired"); + }); + // Set a component numeric timeout with the same ID 0 + App.scheduler.set_timeout(comp, 0U, 50, []() { + ESP_LOGI("test", "Numeric timeout 0 fired"); + }); + + // ---- Test 2: Cancelling numeric ID does NOT cancel internal ID ---- + // Set an internal timeout with ID 1 + App.scheduler.set_timeout(comp, InternalSchedulerID{1}, 100, []() { + ESP_LOGI("test", "Internal timeout 1 survived cancel"); + }); + // Set a numeric timeout with the same ID 1 + App.scheduler.set_timeout(comp, 1U, 100, []() { + ESP_LOGE("test", "ERROR: Numeric timeout 1 should have been cancelled"); + }); + // Cancel only the numeric one + App.scheduler.cancel_timeout(comp, 1U); + + // ---- Test 3: Cancelling internal ID does NOT cancel numeric ID ---- + // Set a numeric timeout with ID 2 + App.scheduler.set_timeout(comp, 2U, 150, []() { + ESP_LOGI("test", "Numeric timeout 2 survived cancel"); + }); + // Set an internal timeout with the same ID 2 + App.scheduler.set_timeout(comp, InternalSchedulerID{2}, 150, []() { + ESP_LOGE("test", "ERROR: Internal timeout 2 should have been cancelled"); + }); + // Cancel only the internal one + App.scheduler.cancel_timeout(comp, InternalSchedulerID{2}); + + // ---- Test 4: Both interval types fire independently ---- + static int internal_interval_count = 0; + static int numeric_interval_count = 0; + App.scheduler.set_interval(comp, InternalSchedulerID{3}, 100, []() { + internal_interval_count++; + if (internal_interval_count == 2) { + ESP_LOGI("test", "Internal interval 3 fired twice"); + App.scheduler.cancel_interval(id(test_sensor), InternalSchedulerID{3}); + } + }); + App.scheduler.set_interval(comp, 3U, 100, []() { + numeric_interval_count++; + if (numeric_interval_count == 2) { + ESP_LOGI("test", "Numeric interval 3 fired twice"); + App.scheduler.cancel_interval(id(test_sensor), 3U); + } + }); + + // ---- Test 5: String name does NOT collide with internal ID ---- + // Use string name and internal ID 10 on same component + App.scheduler.set_timeout(comp, "collision_test", 200, []() { + ESP_LOGI("test", "String timeout collision_test fired"); + }); + App.scheduler.set_timeout(comp, InternalSchedulerID{10}, 200, []() { + ESP_LOGI("test", "Internal timeout 10 fired"); + }); + + // Log completion after all timers should have fired + App.scheduler.set_timeout(comp, 9999U, 1500, []() { + ESP_LOGI("test", "All collision tests complete"); + }); + +sensor: + - platform: template + name: Test Sensor + id: test_sensor + lambda: return 1.0; + update_interval: never + +interval: + - interval: 0.1s + then: + - if: + condition: + lambda: 'return id(tests_done) == false;' + then: + - lambda: 'id(tests_done) = true;' + - script.execute: test_internal_id_no_collision diff --git a/tests/integration/test_scheduler_internal_id_no_collision.py b/tests/integration/test_scheduler_internal_id_no_collision.py new file mode 100644 index 0000000000..d30e725e00 --- /dev/null +++ b/tests/integration/test_scheduler_internal_id_no_collision.py @@ -0,0 +1,124 @@ +"""Test that NUMERIC_ID_INTERNAL and NUMERIC_ID cannot collide. + +Verifies that InternalSchedulerID (used by core base classes like +PollingComponent and DelayAction) and uint32_t numeric IDs (used by +components) are in completely separate matching namespaces, even when +the underlying uint32_t values are identical and on the same component. +""" + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_scheduler_internal_id_no_collision( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that internal and numeric IDs with same value don't collide.""" + # Test 1: Both types fire independently with same ID + internal_timeout_0_fired = asyncio.Event() + numeric_timeout_0_fired = asyncio.Event() + + # Test 2: Cancelling numeric doesn't cancel internal + internal_timeout_1_survived = asyncio.Event() + numeric_timeout_1_error = asyncio.Event() + + # Test 3: Cancelling internal doesn't cancel numeric + numeric_timeout_2_survived = asyncio.Event() + internal_timeout_2_error = asyncio.Event() + + # Test 4: Both interval types fire independently + internal_interval_3_done = asyncio.Event() + numeric_interval_3_done = asyncio.Event() + + # Test 5: String name doesn't collide with internal ID + string_timeout_fired = asyncio.Event() + internal_timeout_10_fired = asyncio.Event() + + # Completion + all_tests_complete = asyncio.Event() + + def on_log_line(line: str) -> None: + clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line) + + if "Internal timeout 0 fired" in clean_line: + internal_timeout_0_fired.set() + elif "Numeric timeout 0 fired" in clean_line: + numeric_timeout_0_fired.set() + elif "Internal timeout 1 survived cancel" in clean_line: + internal_timeout_1_survived.set() + elif "ERROR: Numeric timeout 1 should have been cancelled" in clean_line: + numeric_timeout_1_error.set() + elif "Numeric timeout 2 survived cancel" in clean_line: + numeric_timeout_2_survived.set() + elif "ERROR: Internal timeout 2 should have been cancelled" in clean_line: + internal_timeout_2_error.set() + elif "Internal interval 3 fired twice" in clean_line: + internal_interval_3_done.set() + elif "Numeric interval 3 fired twice" in clean_line: + numeric_interval_3_done.set() + elif "String timeout collision_test fired" in clean_line: + string_timeout_fired.set() + elif "Internal timeout 10 fired" in clean_line: + internal_timeout_10_fired.set() + elif "All collision tests complete" in clean_line: + all_tests_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=on_log_line), + api_client_connected() as client, + ): + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "scheduler-internal-id-test" + + try: + await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail("Not all collision tests completed within 5 seconds") + + # Test 1: Both timeout types with same ID 0 must fire + assert internal_timeout_0_fired.is_set(), ( + "Internal timeout with ID 0 should have fired" + ) + assert numeric_timeout_0_fired.is_set(), ( + "Numeric timeout with ID 0 should have fired" + ) + + # Test 2: Cancelling numeric ID must NOT cancel internal ID + assert internal_timeout_1_survived.is_set(), ( + "Internal timeout 1 should survive cancellation of numeric timeout 1" + ) + assert not numeric_timeout_1_error.is_set(), ( + "Numeric timeout 1 should have been cancelled" + ) + + # Test 3: Cancelling internal ID must NOT cancel numeric ID + assert numeric_timeout_2_survived.is_set(), ( + "Numeric timeout 2 should survive cancellation of internal timeout 2" + ) + assert not internal_timeout_2_error.is_set(), ( + "Internal timeout 2 should have been cancelled" + ) + + # Test 4: Both interval types with same ID must fire independently + assert internal_interval_3_done.is_set(), ( + "Internal interval 3 should have fired at least twice" + ) + assert numeric_interval_3_done.is_set(), ( + "Numeric interval 3 should have fired at least twice" + ) + + # Test 5: String name and internal ID don't collide + assert string_timeout_fired.is_set(), ( + "String timeout 'collision_test' should have fired" + ) + assert internal_timeout_10_fired.is_set(), ( + "Internal timeout 10 should have fired alongside string timeout" + )