From 1e72f0ee5aa347214d6e5476d26864758e912d83 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:17:20 +0200 Subject: [PATCH 01/11] [nextion] Gate waveform code behind `USE_NEXTION_WAVEFORM`, use `StaticRingBuffer` (#15273) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/nextion/nextion.cpp | 53 ++++++++----- esphome/components/nextion/nextion.h | 16 +++- esphome/components/nextion/nextion_base.h | 2 + .../components/nextion/nextion_commands.cpp | 2 + .../nextion/nextion_component_base.h | 6 ++ esphome/components/nextion/sensor/__init__.py | 18 ++--- .../nextion/sensor/nextion_sensor.cpp | 78 ++++++++++--------- .../nextion/sensor/nextion_sensor.h | 23 ++++-- esphome/core/defines.h | 1 + 9 files changed, 125 insertions(+), 74 deletions(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index ab268fed7f2..4a15cbe64fa 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -150,10 +150,12 @@ void Nextion::reset_(bool reset_nextion) { delete entry; // NOLINT(cppcoreguidelines-owning-memory) } this->nextion_queue_.clear(); +#ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { delete entry; // NOLINT(cppcoreguidelines-owning-memory) } this->waveform_queue_.clear(); +#endif // USE_NEXTION_WAVEFORM } void Nextion::dump_config() { @@ -496,20 +498,21 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid baud rate"); break; case 0x12: // invalid Waveform ID or Channel # was used +#ifdef USE_NEXTION_WAVEFORM if (this->waveform_queue_.empty()) { ESP_LOGW(TAG, "Waveform ID/ch used but no sensor queued"); } else { auto &nb = this->waveform_queue_.front(); NextionComponentBase *component = nb->component; - ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); } +#else // USE_NEXTION_WAVEFORM + ESP_LOGW(TAG, "Waveform ID/ch error but waveform not enabled"); +#endif // USE_NEXTION_WAVEFORM break; case 0x1A: // variable name invalid ESP_LOGW(TAG, "Invalid variable name"); @@ -812,29 +815,30 @@ void Nextion::process_nextion_commands_() { } case 0xFD: { // data transparent transmit finished ESP_LOGVV(TAG, "Data transmit done"); +#ifdef USE_NEXTION_WAVEFORM this->check_pending_waveform_(); +#endif // USE_NEXTION_WAVEFORM break; } case 0xFE: { // data transparent transmit ready ESP_LOGVV(TAG, "Ready for transmit"); +#ifdef USE_NEXTION_WAVEFORM if (this->waveform_queue_.empty()) { ESP_LOGE(TAG, "No waveforms queued"); break; } - auto &nb = this->waveform_queue_.front(); auto *component = nb->component; - size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() - : 255; // ADDT command can only send 255 - + size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() : 255; this->write_array(component->get_wave_buffer().data(), static_cast(buffer_to_send)); - ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); - component->clear_wave_buffer(buffer_to_send); delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); +#else // USE_NEXTION_WAVEFORM + ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); +#endif // USE_NEXTION_WAVEFORM break; } default: @@ -934,8 +938,13 @@ void Nextion::all_components_send_state_(bool force_update) { binarysensortype->send_state_to_nextion(); } for (auto *sensortype : this->sensortype_) { - if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_channel_id() == 0) +#ifdef USE_NEXTION_WAVEFORM + if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_channel_id() == UINT8_MAX) { +#else // USE_NEXTION_WAVEFORM + if (force_update || sensortype->get_needs_to_send_update()) { +#endif // USE_NEXTION_WAVEFORM sensortype->send_state_to_nextion(); + } } for (auto *switchtype : this->switchtype_) { if (force_update || switchtype->get_needs_to_send_update()) @@ -1239,13 +1248,11 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { } } +#ifdef USE_NEXTION_WAVEFORM /** - * @brief Add addt command to the queue + * @brief Add addt command to the waveform queue. * - * @param component_id The waveform component id - * @param wave_chan_id The waveform channel to send it to - * @param buffer_to_send The buffer size - * @param buffer_size The buffer data + * @param component Pointer to the Nextion component with waveform data to send. */ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping()) @@ -1262,7 +1269,11 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); - this->waveform_queue_.push_back(nextion_queue); + if (!this->waveform_queue_.push(nextion_queue)) { + ESP_LOGW(TAG, "Waveform queue full, drop"); + delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + return; + } if (this->waveform_queue_.size() == 1) this->check_pending_waveform_(); } @@ -1273,17 +1284,17 @@ void Nextion::check_pending_waveform_() { auto *nb = this->waveform_queue_.front(); auto *component = nb->component; - size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() - : 255; // ADDT command can only send 255 + size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() : 255; char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); if (!this->send_command_(command)) { delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); } } +#endif // USE_NEXTION_WAVEFORM void Nextion::set_writer(const nextion_writer_t &writer) { this->writer_ = writer; } diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index b3ecbf46b1c..d9103892891 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -9,6 +9,10 @@ #include "esphome/core/defines.h" #include "esphome/core/time.h" +#ifdef USE_NEXTION_WAVEFORM +#include "esphome/core/helpers.h" +#endif // USE_NEXTION_WAVEFORM + #include "nextion_base.h" #include "nextion_component.h" @@ -602,6 +606,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ void disable_component_touch(const char *component); +#ifdef USE_NEXTION_WAVEFORM /** * Add waveform data to a waveform component * @param component_id The integer component id. @@ -611,6 +616,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value); void open_waveform_channel(uint8_t component_id, uint8_t channel_number, uint8_t value); +#endif // USE_NEXTION_WAVEFORM /** * Display a picture at coordinates. @@ -1205,7 +1211,9 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void add_to_get_queue(NextionComponentBase *component) override; +#ifdef USE_NEXTION_WAVEFORM void add_addt_command_to_queue(NextionComponentBase *component) override; +#endif // USE_NEXTION_WAVEFORM void update_components_by_prefix(const std::string &prefix); @@ -1391,7 +1399,11 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe #endif // USE_NEXTION_COMMAND_SPACING std::list nextion_queue_; - std::list waveform_queue_; +#ifdef USE_NEXTION_WAVEFORM + /// Fixed-size ring buffer for waveform queue. Nextion supports at most 4 waveform + /// channels (IDs 0-3), so 4 entries is both the correct maximum and a safe default. + StaticRingBuffer waveform_queue_; +#endif // USE_NEXTION_WAVEFORM uint16_t recv_ret_string_(std::string &response, uint32_t timeout, bool recv_flag); void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; @@ -1460,7 +1472,9 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe const std::string &variable_name_to_send, const std::string &state_value, bool is_sleep_safe = false); +#ifdef USE_NEXTION_WAVEFORM void check_pending_waveform_(); +#endif // USE_NEXTION_WAVEFORM #ifdef USE_NEXTION_TFT_UPLOAD #ifdef USE_ESP8266 diff --git a/esphome/components/nextion/nextion_base.h b/esphome/components/nextion/nextion_base.h index 2c516fc80f2..4a2dc90d40e 100644 --- a/esphome/components/nextion/nextion_base.h +++ b/esphome/components/nextion/nextion_base.h @@ -33,7 +33,9 @@ class NextionBase { const std::string &variable_name_to_send, const std::string &state_value) = 0; +#ifdef USE_NEXTION_WAVEFORM virtual void add_addt_command_to_queue(NextionComponentBase *component) = 0; +#endif // USE_NEXTION_WAVEFORM virtual void add_to_get_queue(NextionComponentBase *component) = 0; diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index a7e65b5ddf9..a332d342ee5 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -217,6 +217,7 @@ void Nextion::set_component_value(const char *component, int32_t value) { this->add_no_result_to_queue_with_printf_(".val", "%s.val=%" PRId32, component, value); } +#ifdef USE_NEXTION_WAVEFORM void Nextion::add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value) { this->add_no_result_to_queue_with_printf_("add", "add %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); @@ -226,6 +227,7 @@ void Nextion::open_waveform_channel(uint8_t component_id, uint8_t channel_number this->add_no_result_to_queue_with_printf_("addt", "addt %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); } +#endif // USE_NEXTION_WAVEFORM void Nextion::set_component_coordinates(const char *component, uint16_t x, uint16_t y) { this->add_no_result_to_queue_with_printf_(".xcen", "%s.xcen=%" PRIu16, component, x); diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index c1d0ae8ed11..6676d019201 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -64,6 +64,7 @@ class NextionComponentBase { uint8_t get_component_id() const { return this->component_id_; } void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } +#ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } @@ -76,6 +77,7 @@ class NextionComponentBase { this->wave_buffer_.erase(this->wave_buffer_.begin(), this->wave_buffer_.begin() + buffer_sent); } } +#endif // USE_NEXTION_WAVEFORM const std::string &get_variable_name() const { return this->variable_name_; } const std::string &get_variable_name_to_send() const { return this->variable_name_to_send_; } @@ -85,19 +87,23 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } +#ifdef USE_NEXTION_WAVEFORM // Remove before 2026.10.0 ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } +#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; std::string variable_name_to_send_; uint8_t component_id_ = 0; +#ifdef USE_NEXTION_WAVEFORM uint8_t wave_chan_id_ = UINT8_MAX; std::vector wave_buffer_; int wave_max_length_ = 255; +#endif // USE_NEXTION_WAVEFORM bool needs_to_send_update_; }; diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index cab531f1db6..7351d8f1d54 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -85,16 +85,16 @@ async def to_code(config): cg.add(var.set_component_id(config[CONF_COMPONENT_ID])) if CONF_WAVE_CHANNEL_ID in config: + cg.add_define("USE_NEXTION_WAVEFORM") cg.add(var.set_wave_channel_id(config[CONF_WAVE_CHANNEL_ID])) - - if CONF_WAVEFORM_SEND_LAST_VALUE in config: - cg.add(var.set_waveform_send_last_value(config[CONF_WAVEFORM_SEND_LAST_VALUE])) - - if CONF_WAVE_MAX_VALUE in config: - cg.add(var.set_wave_max_value(config[CONF_WAVE_MAX_VALUE])) - - if CONF_WAVE_MAX_LENGTH in config: - cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) + if CONF_WAVEFORM_SEND_LAST_VALUE in config: + cg.add( + var.set_waveform_send_last_value(config[CONF_WAVEFORM_SEND_LAST_VALUE]) + ) + if CONF_WAVE_MAX_VALUE in config: + cg.add(var.set_wave_max_value(config[CONF_WAVE_MAX_VALUE])) + if CONF_WAVE_MAX_LENGTH in config: + cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) @automation.register_action( diff --git a/esphome/components/nextion/sensor/nextion_sensor.cpp b/esphome/components/nextion/sensor/nextion_sensor.cpp index d4fad86286a..ca657522f96 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.cpp +++ b/esphome/components/nextion/sensor/nextion_sensor.cpp @@ -10,37 +10,44 @@ void NextionSensor::process_sensor(const std::string &variable_name, int state) if (!this->nextion_->is_setup()) return; - if (this->wave_chan_id_ == UINT8_MAX && this->variable_name_ == variable_name) { +#ifdef USE_NEXTION_WAVEFORM + if (this->wave_chan_id_ == UINT8_MAX && this->variable_name_ == variable_name) +#else // USE_NEXTION_WAVEFORM + if (this->variable_name_ == variable_name) +#endif // USE_NEXTION_WAVEFORM + { this->publish_state(state); ESP_LOGD(TAG, "Sensor: %s=%d", variable_name.c_str(), state); } } +#ifdef USE_NEXTION_WAVEFORM void NextionSensor::add_to_wave_buffer(float state) { this->needs_to_send_update_ = true; - int wave_state = (int) ((state / (float) this->wave_maxvalue_) * 100); - - wave_buffer_.push_back(wave_state); - + this->wave_buffer_.push_back(wave_state); if (this->wave_buffer_.size() > (size_t) this->wave_max_length_) { this->wave_buffer_.erase(this->wave_buffer_.begin()); } } +#endif // USE_NEXTION_WAVEFORM void NextionSensor::update() { if (!this->nextion_->is_setup() || this->nextion_->is_updating()) return; +#ifdef USE_NEXTION_WAVEFORM if (this->wave_chan_id_ == UINT8_MAX) { this->nextion_->add_to_get_queue(this); } else { if (this->send_last_value_) { this->add_to_wave_buffer(this->last_value_); } - this->wave_update_(); } +#else // USE_NEXTION_WAVEFORM + this->nextion_->add_to_get_queue(this); +#endif // USE_NEXTION_WAVEFORM } void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) { @@ -50,61 +57,60 @@ void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) { if (std::isnan(state)) return; - if (this->wave_chan_id_ == UINT8_MAX) { - if (send_to_nextion) { - if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { - this->needs_to_send_update_ = true; - } else { - this->needs_to_send_update_ = false; - - if (this->precision_ > 0) { - double to_multiply = pow(10, this->precision_); - int state_value = (int) (state * to_multiply); - - this->nextion_->add_no_result_to_queue_with_set(this, (int) state_value); - } else { - this->nextion_->add_no_result_to_queue_with_set(this, (int) state); - } - } - } - } else { +#ifdef USE_NEXTION_WAVEFORM + if (this->wave_chan_id_ != UINT8_MAX) { + // Waveform sensor — buffer the value, don't send directly. if (this->send_last_value_) { this->last_value_ = state; // Update will handle setting the buffer } else { this->add_to_wave_buffer(state); } + this->update_component_settings(); + return; + } +#endif // USE_NEXTION_WAVEFORM + + if (send_to_nextion) { + if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { + this->needs_to_send_update_ = true; + } else { + this->needs_to_send_update_ = false; + if (this->precision_ > 0) { + double to_multiply = pow(10, this->precision_); + int state_value = (int) (state * to_multiply); + this->nextion_->add_no_result_to_queue_with_set(this, (int) state_value); + } else { + this->nextion_->add_no_result_to_queue_with_set(this, (int) state); + } + } } float published_state = state; - if (this->wave_chan_id_ == UINT8_MAX) { - if (publish) { - if (this->precision_ > 0) { - double to_multiply = pow(10, -this->precision_); - published_state = (float) (state * to_multiply); - } - - this->publish_state(published_state); + if (publish) { + if (this->precision_ > 0) { + double to_multiply = pow(10, -this->precision_); + published_state = (float) (state * to_multiply); } + this->publish_state(published_state); } this->update_component_settings(); ESP_LOGN(TAG, "Write: %s=%lf", this->variable_name_.c_str(), published_state); } +#ifdef USE_NEXTION_WAVEFORM void NextionSensor::wave_update_() { if (this->nextion_->is_sleeping() || this->wave_buffer_.empty()) { return; } - #ifdef NEXTION_PROTOCOL_LOG size_t buffer_to_send = this->wave_buffer_.size() < 255 ? this->wave_buffer_.size() : 255; // ADDT command can only send 255 - ESP_LOGN(TAG, "Wave update: %zu/%zu vals to comp %d ch %d", buffer_to_send, this->wave_buffer_.size(), this->component_id_, this->wave_chan_id_); -#endif - +#endif // NEXTION_PROTOCOL_LOG this->nextion_->add_addt_command_to_queue(this); } +#endif // USE_NEXTION_WAVEFORM } // namespace esphome::nextion diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index f1a3ff72ec0..72e3982b3a1 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -15,22 +15,30 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol void update_component() override { this->update(); } void update() override; - void add_to_wave_buffer(float state); void set_precision(uint8_t precision) { this->precision_ = precision; } void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } - void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } - void set_wave_max_value(uint32_t wave_maxvalue) { this->wave_maxvalue_ = wave_maxvalue; } void process_sensor(const std::string &variable_name, int state) override; void set_state(float state) override { this->set_state(state, true, true); } void set_state(float state, bool publish) override { this->set_state(state, publish, true); } void set_state(float state, bool publish, bool send_to_nextion) override; + NextionQueueType get_queue_type() const override { +#ifdef USE_NEXTION_WAVEFORM + return this->wave_chan_id_ == UINT8_MAX ? NextionQueueType::SENSOR : NextionQueueType::WAVEFORM_SENSOR; +#else // USE_NEXTION_WAVEFORM + return NextionQueueType::SENSOR; +#endif // USE_NEXTION_WAVEFORM + } + +#ifdef USE_NEXTION_WAVEFORM + void add_to_wave_buffer(float state); + void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_value(uint32_t wave_maxvalue) { this->wave_maxvalue_ = wave_maxvalue; } void set_waveform_send_last_value(bool send_last_value) { this->send_last_value_ = send_last_value; } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } - NextionQueueType get_queue_type() const override { - return this->wave_chan_id_ == UINT8_MAX ? NextionQueueType::SENSOR : NextionQueueType::WAVEFORM_SENSOR; - } +#endif // USE_NEXTION_WAVEFORM + void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value, publish, send_to_nextion); @@ -38,10 +46,11 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol protected: uint8_t precision_ = 0; +#ifdef USE_NEXTION_WAVEFORM uint32_t wave_maxvalue_ = 255; - float last_value_ = 0; bool send_last_value_ = true; void wave_update_(); +#endif // USE_NEXTION_WAVEFORM }; } // namespace esphome::nextion diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 23e65f55bc0..faa8c6d4b0e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -123,6 +123,7 @@ #define USE_NEXTION_MAX_COMMANDS_PER_LOOP #define USE_NEXTION_MAX_QUEUE_SIZE #define USE_NEXTION_TFT_UPLOAD +#define USE_NEXTION_WAVEFORM #define USE_NUMBER #define USE_OUTPUT #define USE_POWER_SUPPLY From 4134763f3455929a837465c037fca0ab55faa817 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:32:10 -0400 Subject: [PATCH 02/11] [at581x][canbus] Fix walrus operator skipping falsy config values (#15390) --- esphome/components/at581x/__init__.py | 4 ++-- esphome/components/canbus/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 0780814ea6e..34e65706288 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -177,7 +177,7 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): template_ = int(template_ / 1000000) cg.add(var.set_frequency(template_)) - if sens_dist := config.get(CONF_SENSING_DISTANCE): + if (sens_dist := config.get(CONF_SENSING_DISTANCE)) is not None: template_ = await cg.templatable(sens_dist, args, int) cg.add(var.set_sensing_distance(template_)) @@ -209,7 +209,7 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): template_ = int(template_) cg.add(var.set_trigger_keep(template_)) - if stage_gain := config.get(CONF_STAGE_GAIN): + if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: template_ = await cg.templatable(stage_gain, args, int) cg.add(var.set_stage_gain(template_)) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index c94c8647a95..7d3bf78f492 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -161,7 +161,7 @@ async def canbus_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_CANBUS_ID]) - if can_id := config.get(CONF_CAN_ID): + if (can_id := config.get(CONF_CAN_ID)) is not None: can_id = await cg.templatable(can_id, args, cg.uint32) cg.add(var.set_can_id(can_id)) cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID])) From 4d0d3cc271754930b6c33cf0e02b439173af6e3a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:53:53 -0400 Subject: [PATCH 03/11] [sen5x] Remove dead voc_baseline config option (#15391) --- esphome/components/sen5x/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 9fe51121f16..ce35cf5bf15 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -25,7 +25,6 @@ from esphome.const import ( CONF_TEMPERATURE_COMPENSATION, CONF_TIME_CONSTANT, CONF_VOC, - CONF_VOC_BASELINE, DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -165,7 +164,6 @@ CONFIG_SCHEMA = ( gain_factor=230, ), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, - cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, From 347f981768e8ff9baca9db4814ef3672d5c65be1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2026 11:18:17 -1000 Subject: [PATCH 04/11] [scheduler] Fix unrealistic scheduler benchmarks missing periodic drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduler registration benchmarks (SetTimeout, SetInterval, Defer) were not calling scheduler.call() periodically to drain and clean up cancelled items. In production, call() runs every loop iteration, keeping the scheduler containers small. Without draining, cancelled items accumulated causing O(n²) scan cost in cancel_item_locked_ that doesn't reflect real-world behavior. - SetTimeout: was only calling process_to_add() (no cleanup), now calls call() every kKeyCount iterations - SetInterval: was calling process_to_add() (no cleanup of items_), now calls call() for proper cleanup - Defer: was never draining the defer queue, now calls call() to process deferred items as production does - All three now advance time (++now) to match production loop behavior --- tests/benchmarks/core/bench_scheduler.cpp | 44 +++++++++++++++++------ 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 9357734cc8e..b2616af2ea5 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -82,12 +82,24 @@ BENCHMARK(Scheduler_Call_5IntervalsFiring); static void Scheduler_SetTimeout(benchmark::State &state) { Scheduler scheduler; Component dummy_component; + // Number of distinct timeout keys; controls how many unique timers exist + // simultaneously and the drain cadence for process_to_add(). + static constexpr int kKeyCount = 5; for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % 5), 1000, []() {}); + scheduler.set_timeout(&dummy_component, static_cast(i % kKeyCount), 1000, []() {}); + // Drain periodically to reflect production behavior where call() runs + // each main loop iteration. call() moves to_add_ into items_ and cleans + // up cancelled items. Without this, cancelled items accumulate causing + // O(n²) scan cost in cancel_item_locked_. + if ((i + 1) % kKeyCount == 0) { + scheduler.call(++now); + } } - scheduler.process_to_add(); + // Final drain in case kInnerIterations is not a multiple of kKeyCount + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -104,17 +116,19 @@ static void Scheduler_SetInterval(benchmark::State &state) { static constexpr int kKeyCount = 5; for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { scheduler.set_interval(&dummy_component, static_cast(i % kKeyCount), 1000, []() {}); - // Drain to_add_ periodically to reflect production behavior where - // process_to_add() runs each main loop iteration. Without this, - // cancelled items accumulate in to_add_ causing O(n²) scan cost. + // Drain periodically to reflect production behavior where call() runs + // each main loop iteration. call() moves to_add_ into items_ and cleans + // up cancelled items. Without this, cancelled items accumulate causing + // O(n²) scan cost in cancel_item_locked_. if ((i + 1) % kKeyCount == 0) { - scheduler.process_to_add(); + scheduler.call(++now); } } - // Final drain in case kInnerIterations is not a multiple of 5 - scheduler.process_to_add(); + // Final drain in case kInnerIterations is not a multiple of kKeyCount + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -126,14 +140,24 @@ BENCHMARK(Scheduler_SetInterval); static void Scheduler_Defer(benchmark::State &state) { Scheduler scheduler; Component dummy_component; + // Number of distinct defer keys; controls how many unique defers exist + // simultaneously and the drain cadence for call(). + static constexpr int kKeyCount = 5; // defer() is Component::defer which calls set_timeout(delay=0). // Call set_timeout directly since defer() is protected. + // Drain with call() periodically to reflect production behavior where + // call() runs each main loop iteration, keeping the defer queue small. for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % 5), 0, []() {}); + scheduler.set_timeout(&dummy_component, static_cast(i % kKeyCount), 0, []() {}); + if ((i + 1) % kKeyCount == 0) { + scheduler.call(++now); + } } - scheduler.process_to_add(); + // Final drain in case kInnerIterations is not a multiple of kKeyCount + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); From 615b2b339aa1cea43398671e48c4377d835fe850 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2026 11:24:53 -1000 Subject: [PATCH 05/11] [scheduler] Batch 3 registrations per call() for realistic worst case Instead of draining after every single registration or batching 5, use a batch size of 3 which represents a realistic worst case where multiple components schedule in the same loop iteration while staying within the recycling pool (MAX_POOL_SIZE=5). --- tests/benchmarks/core/bench_scheduler.cpp | 46 +++++++++-------------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index b2616af2ea5..981edbe15dc 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -82,23 +82,19 @@ BENCHMARK(Scheduler_Call_5IntervalsFiring); static void Scheduler_SetTimeout(benchmark::State &state) { Scheduler scheduler; Component dummy_component; - // Number of distinct timeout keys; controls how many unique timers exist - // simultaneously and the drain cadence for process_to_add(). - static constexpr int kKeyCount = 5; + // Register 3 timeouts then call() — realistic worst case where multiple + // components schedule in the same loop iteration. Keeps item count within + // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + static constexpr int kBatchSize = 3; for (auto _ : state) { uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % kKeyCount), 1000, []() {}); - // Drain periodically to reflect production behavior where call() runs - // each main loop iteration. call() moves to_add_ into items_ and cleans - // up cancelled items. Without this, cancelled items accumulate causing - // O(n²) scan cost in cancel_item_locked_. - if ((i + 1) % kKeyCount == 0) { + scheduler.set_timeout(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { scheduler.call(++now); } } - // Final drain in case kInnerIterations is not a multiple of kKeyCount scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } @@ -111,23 +107,19 @@ BENCHMARK(Scheduler_SetTimeout); static void Scheduler_SetInterval(benchmark::State &state) { Scheduler scheduler; Component dummy_component; - // Number of distinct interval keys; controls how many unique timers exist - // simultaneously and the drain cadence for process_to_add(). - static constexpr int kKeyCount = 5; + // Register 3 intervals then call() — realistic worst case where multiple + // components schedule in the same loop iteration. Keeps item count within + // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + static constexpr int kBatchSize = 3; for (auto _ : state) { uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_interval(&dummy_component, static_cast(i % kKeyCount), 1000, []() {}); - // Drain periodically to reflect production behavior where call() runs - // each main loop iteration. call() moves to_add_ into items_ and cleans - // up cancelled items. Without this, cancelled items accumulate causing - // O(n²) scan cost in cancel_item_locked_. - if ((i + 1) % kKeyCount == 0) { + scheduler.set_interval(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { scheduler.call(++now); } } - // Final drain in case kInnerIterations is not a multiple of kKeyCount scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } @@ -140,23 +132,21 @@ BENCHMARK(Scheduler_SetInterval); static void Scheduler_Defer(benchmark::State &state) { Scheduler scheduler; Component dummy_component; - // Number of distinct defer keys; controls how many unique defers exist - // simultaneously and the drain cadence for call(). - static constexpr int kKeyCount = 5; // defer() is Component::defer which calls set_timeout(delay=0). // Call set_timeout directly since defer() is protected. - // Drain with call() periodically to reflect production behavior where - // call() runs each main loop iteration, keeping the defer queue small. + // Register 3 defers then call() — realistic worst case where multiple + // components defer in the same loop iteration. Keeps item count within + // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + static constexpr int kBatchSize = 3; for (auto _ : state) { uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % kKeyCount), 0, []() {}); - if ((i + 1) % kKeyCount == 0) { + scheduler.set_timeout(&dummy_component, static_cast(i % kBatchSize), 0, []() {}); + if ((i + 1) % kBatchSize == 0) { scheduler.call(++now); } } - // Final drain in case kInnerIterations is not a multiple of kKeyCount scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } From 279686b1c28b5f12bb99842ef04fb4ea6b0e5530 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2026 11:26:28 -1000 Subject: [PATCH 06/11] [scheduler] Add ExceedPool benchmark to measure pool overflow cliff Add Scheduler_SetTimeout_ExceedPool with batch size 10 (exceeding MAX_POOL_SIZE=5) to measure the performance impact when the recycling pool is exhausted and items must be malloc'd/freed each cycle. --- tests/benchmarks/core/bench_scheduler.cpp | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 981edbe15dc..c6cf7e874da 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -154,4 +154,29 @@ static void Scheduler_Defer(benchmark::State &state) { } BENCHMARK(Scheduler_Defer); +// --- Scheduler: set_timeout with batch size exceeding pool (cliff test) --- + +static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // Register 10 timeouts then call() — exceeds MAX_POOL_SIZE=5 to measure + // the performance cliff when the recycling pool is exhausted and items + // must be malloc'd/freed. + static constexpr int kBatchSize = 10; + for (auto _ : state) { + uint32_t now = millis(); + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } + } + scheduler.call(++now); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_SetTimeout_ExceedPool); + } // namespace esphome::benchmarks From 904577bf91636aedec0025c3cdf661e54f76fa31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2026 11:31:27 -1000 Subject: [PATCH 07/11] [scheduler] Extract warm_pool helper for scheduler benchmarks Replace duplicated pool warmup blocks with a shared warm_pool() helper that registers and replaces items twice to populate the recycling pool before the benchmark loop begins. --- tests/benchmarks/core/bench_scheduler.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index c6cf7e874da..b9ef8d054af 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -10,6 +10,21 @@ namespace esphome::benchmarks { // sub-microsecond benchmarks. static constexpr int kInnerIterations = 2000; +// Warm the scheduler pool by registering and replacing items twice. +// The first batch allocates fresh items; the second batch cancels them and +// populates the recycling pool with the cancelled items from the first batch. +static void warm_pool(Scheduler &scheduler, Component *component, int batch_size, uint32_t delay) { + uint32_t now = millis(); + for (int i = 0; i < batch_size; i++) { + scheduler.set_timeout(component, static_cast(i), delay, []() {}); + } + scheduler.call(++now); + for (int i = 0; i < batch_size; i++) { + scheduler.set_timeout(component, static_cast(i), delay, []() {}); + } + scheduler.call(++now); +} + // --- Scheduler fast path: no work to do --- static void Scheduler_Call_NoWork(benchmark::State &state) { @@ -87,6 +102,7 @@ static void Scheduler_SetTimeout(benchmark::State &state) { // components schedule in the same loop iteration. Keeps item count within // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. static constexpr int kBatchSize = 3; + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { @@ -112,6 +128,7 @@ static void Scheduler_SetInterval(benchmark::State &state) { // components schedule in the same loop iteration. Keeps item count within // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. static constexpr int kBatchSize = 3; + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { @@ -139,6 +156,7 @@ static void Scheduler_Defer(benchmark::State &state) { // components defer in the same loop iteration. Keeps item count within // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. static constexpr int kBatchSize = 3; + warm_pool(scheduler, &dummy_component, kBatchSize, 0); for (auto _ : state) { uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { @@ -164,6 +182,7 @@ static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { // the performance cliff when the recycling pool is exhausted and items // must be malloc'd/freed. static constexpr int kBatchSize = 10; + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { From ce5c36a02af8d848641fed1c78a07fd6ac3d147d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2026 11:38:18 -1000 Subject: [PATCH 08/11] [scheduler] Fix pool imbalance in benchmarks, add static_assert Change kInnerIterations from 2000 to 2100 (divisible by batch sizes 3 and 10) to prevent pool imbalance at iteration boundaries that caused spurious malloc. Add static_assert to each benchmark to catch this at compile time. --- tests/benchmarks/core/bench_scheduler.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index b9ef8d054af..155c7949bbd 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -8,7 +8,9 @@ namespace esphome::benchmarks { // Inner iteration count to amortize CodSpeed instrumentation overhead. // Without this, the ~60ns per-iteration valgrind start/stop cost dominates // sub-microsecond benchmarks. -static constexpr int kInnerIterations = 2000; +// Must be divisible by all batch sizes used below (3, 10) to avoid +// pool imbalance at iteration boundaries that causes spurious malloc. +static constexpr int kInnerIterations = 2100; // Warm the scheduler pool by registering and replacing items twice. // The first batch allocates fresh items; the second batch cancels them and @@ -102,6 +104,7 @@ static void Scheduler_SetTimeout(benchmark::State &state) { // components schedule in the same loop iteration. Keeps item count within // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { uint32_t now = millis(); @@ -128,6 +131,7 @@ static void Scheduler_SetInterval(benchmark::State &state) { // components schedule in the same loop iteration. Keeps item count within // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { uint32_t now = millis(); @@ -156,6 +160,7 @@ static void Scheduler_Defer(benchmark::State &state) { // components defer in the same loop iteration. Keeps item count within // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 0); for (auto _ : state) { uint32_t now = millis(); @@ -182,6 +187,7 @@ static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { // the performance cliff when the recycling pool is exhausted and items // must be malloc'd/freed. static constexpr int kBatchSize = 10; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { uint32_t now = millis(); From ed14694e46835fa92af7866645a7b6ecd80339c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2026 11:45:03 -1000 Subject: [PATCH 09/11] [scheduler] Add defer benchmark variants and fix anonymous defer path - Scheduler_Defer: use nullptr name matching Component::defer(func) production pattern (skips cancel_item_locked_ entirely) - Scheduler_Defer_SameID: fixed ID 0 measuring cancel-and-replace pattern for coalescing rapid updates - Scheduler_Defer_UniqueID: unique IDs measuring cancel scan overhead when no match is found --- tests/benchmarks/core/bench_scheduler.cpp | 63 +++++++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 155c7949bbd..65daa02c15c 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -155,17 +155,16 @@ static void Scheduler_Defer(benchmark::State &state) { Component dummy_component; // defer() is Component::defer which calls set_timeout(delay=0). - // Call set_timeout directly since defer() is protected. - // Register 3 defers then call() — realistic worst case where multiple - // components defer in the same loop iteration. Keeps item count within - // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + // Component::defer(func) passes nullptr as the name, which skips + // cancel_item_locked_ entirely — matching production behavior where + // defers are anonymous fire-and-forget callbacks. static constexpr int kBatchSize = 3; static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); warm_pool(scheduler, &dummy_component, kBatchSize, 0); for (auto _ : state) { uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % kBatchSize), 0, []() {}); + scheduler.set_timeout(&dummy_component, static_cast(nullptr), 0, []() {}); if ((i + 1) % kBatchSize == 0) { scheduler.call(++now); } @@ -177,6 +176,60 @@ static void Scheduler_Defer(benchmark::State &state) { } BENCHMARK(Scheduler_Defer); +// --- Scheduler: defer with same ID (cancel-and-replace pattern) --- + +static void Scheduler_Defer_SameID(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // Measures defer with a fixed numeric ID — each call cancels the previous + // pending defer before adding the new one. This is the pattern used by + // components that defer work but want to coalesce rapid updates. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 0); + for (auto _ : state) { + uint32_t now = millis(); + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, static_cast(0), 0, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } + } + scheduler.call(++now); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_Defer_SameID); + +// --- Scheduler: defer with unique IDs (no cancel path) --- + +static void Scheduler_Defer_UniqueID(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // Measures defer with unique numeric IDs — cancel_item_locked_ runs but + // never finds a match, measuring the scan overhead on an empty search. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 0); + for (auto _ : state) { + uint32_t now = millis(); + uint32_t id = 0; + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, id++, 0, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } + } + scheduler.call(++now); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_Defer_UniqueID); + // --- Scheduler: set_timeout with batch size exceeding pool (cliff test) --- static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { From 4535b82483370b09d7e168f234ed995634f85684 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2026 11:51:52 -1000 Subject: [PATCH 10/11] [scheduler] Remove redundant Defer_UniqueID, keep anonymous + same-ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UniqueID benchmark was unrealistic — unique IDs still ran cancel_item_locked_ scanning all containers for matches that never exist. Replace with just two meaningful defer variants: - Defer: anonymous (nullptr name, skips cancel entirely) - Defer_SameID: fixed ID (cancel-and-replace coalescing pattern) --- tests/benchmarks/core/bench_scheduler.cpp | 27 ----------------------- 1 file changed, 27 deletions(-) diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 65daa02c15c..214fe0e4b87 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -203,33 +203,6 @@ static void Scheduler_Defer_SameID(benchmark::State &state) { } BENCHMARK(Scheduler_Defer_SameID); -// --- Scheduler: defer with unique IDs (no cancel path) --- - -static void Scheduler_Defer_UniqueID(benchmark::State &state) { - Scheduler scheduler; - Component dummy_component; - - // Measures defer with unique numeric IDs — cancel_item_locked_ runs but - // never finds a match, measuring the scan overhead on an empty search. - static constexpr int kBatchSize = 3; - static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); - warm_pool(scheduler, &dummy_component, kBatchSize, 0); - for (auto _ : state) { - uint32_t now = millis(); - uint32_t id = 0; - for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, id++, 0, []() {}); - if ((i + 1) % kBatchSize == 0) { - scheduler.call(++now); - } - } - scheduler.call(++now); - benchmark::DoNotOptimize(scheduler); - } - state.SetItemsProcessed(state.iterations() * kInnerIterations); -} -BENCHMARK(Scheduler_Defer_UniqueID); - // --- Scheduler: set_timeout with batch size exceeding pool (cliff test) --- static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { From 0fb534bb2847ab21f466c7e86a1974554220e3ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2026 11:57:57 -1000 Subject: [PATCH 11/11] [scheduler] Skip cancel for anonymous items, add empty-container fast path Two optimizations to reduce overhead in the scheduler hot path: 1. Skip cancel_item_locked_ entirely for anonymous items (STATIC_STRING with nullptr name) in set_timer_common_. These can never match any existing item, so the scan is pure waste. This is the common path for Component::defer(func). 2. Split mark_matching_items_removed_locked_ into an inline wrapper that checks for empty containers and a noinline slow path. This avoids the function call overhead when containers are empty, which is the common case for defer_queue_ and to_add_ after draining. --- esphome/core/scheduler.cpp | 24 +++++++++++++++++++++--- esphome/core/scheduler.h | 30 +++++++++++++++--------------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 9ee3b2fdd2a..71b29390d6b 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -214,8 +214,9 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type #endif /* ESPHOME_DEBUG_SCHEDULER */ } - // Common epilogue: atomic cancel-and-add (unless skip_cancel is true) - if (!skip_cancel) { + // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) + // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. + if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false, /* find_first= */ true); } @@ -742,6 +743,23 @@ bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const // When find_first=false, cancels ALL matches across all containers (needed for // public cancel path where DelayAction parallel mode can create duplicates). // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id +size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vector &container, + Component *component, NameType name_type, + const char *static_name, uint32_t hash_or_id, + SchedulerItem::Type type, bool match_retry, + bool find_first) { + size_t count = 0; + for (auto *item : container) { + if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { + this->set_item_removed_(item, true); + if (find_first) + return 1; + count++; + } + } + return count; +} + bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool find_first) { @@ -767,7 +785,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type // The main loop may be executing an item's callback right now, and recycling // would destroy the callback while it's running (use-after-free). // Only the main loop in call() should recycle items after execution completes. - if (!this->items_.empty()) { + { size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name, hash_or_id, type, match_retry, find_first); total_cancelled += heap_cancelled; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 1e44f41da84..43a3ec7049b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -495,23 +495,23 @@ class Scheduler { // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // Returns the number of items marked for removal. // IMPORTANT: Must be called with scheduler lock held - __attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector &container, - Component *component, NameType name_type, - const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry, - bool find_first = false) { - size_t count = 0; - for (auto *item : container) { - if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { - this->set_item_removed_(item, true); - if (find_first) - return 1; - count++; - } - } - return count; + // Inlined: the fast path (empty container) avoids calling the out-of-line scan. + inline size_t HOT mark_matching_items_removed_locked_(std::vector &container, Component *component, + NameType name_type, const char *static_name, + uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, + bool find_first = false) { + if (container.empty()) + return 0; + return this->mark_matching_items_removed_slow_locked_(container, component, name_type, static_name, hash_or_id, + type, match_retry, find_first); } + // Out-of-line slow path for mark_matching_items_removed_locked_ when container is non-empty. + // IMPORTANT: Must be called with scheduler lock held + __attribute__((noinline)) size_t mark_matching_items_removed_slow_locked_( + std::vector &container, Component *component, NameType name_type, const char *static_name, + uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool find_first); + Mutex lock_; std::vector items_; std::vector to_add_;