From 14c54597cef47ff446753a74a1e43394d7728b73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:20:29 -1000 Subject: [PATCH 01/15] [binary_sensor] Make set_new_state non-virtual, add on_state_changed_ hook set_new_state is only called internally. Making it non-virtual eliminates a vtable entry and allows the compiler to inline it. BinarySensor now overrides on_state_changed_() for logging and ControllerRegistry notification instead of overriding set_new_state. --- esphome/components/binary_sensor/binary_sensor.cpp | 12 ++++-------- esphome/components/binary_sensor/binary_sensor.h | 2 +- esphome/core/entity_base.h | 10 +++++++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 3ccaaf8f03..564cb48f64 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -33,16 +33,12 @@ void BinarySensor::publish_initial_state(bool new_state) { this->publish_state(new_state); } -bool BinarySensor::set_new_state(const optional &new_state) { - if (StatefulEntityBase::set_new_state(new_state)) { - // weirdly, this file could be compiled even without USE_BINARY_SENSOR defined +void BinarySensor::on_state_changed_(const optional &old_state, const optional &new_state, bool had_state) { + StatefulEntityBase::on_state_changed_(old_state, new_state, had_state); #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) - ControllerRegistry::notify_binary_sensor_update(this); + ControllerRegistry::notify_binary_sensor_update(this); #endif - ESP_LOGD(TAG, "'%s' >> %s", this->get_name().c_str(), ONOFFMAYBE(new_state)); - return true; - } - return false; + ESP_LOGD(TAG, "'%s' >> %s", this->get_name().c_str(), ONOFFMAYBE(new_state)); } #ifdef USE_BINARY_SENSOR_FILTER diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 6e6f77e010..31e8f92b96 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -74,7 +74,7 @@ class BinarySensor : public StatefulEntityBase { Filter *filter_list_{nullptr}; #endif - bool set_new_state(const optional &new_state) override; + void on_state_changed_(const optional &old_state, const optional &new_state, bool had_state) override; }; class BinarySensorInitiallyOff : public BinarySensor { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index d19ef558ec..60132a4c18 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -342,8 +342,8 @@ template class StatefulEntityBase : public EntityBase { * Pass nullopt to invalidate (clear) the state. Pass a value to set it. * Returns true if the state actually changed, false if it was the same. */ - virtual bool set_new_state(const optional &new_state) { - // Access flags_ directly to avoid virtual/function call overhead in this hot path + bool set_new_state(const optional &new_state) { + // Access flags_ directly to avoid function call overhead in this hot path bool had_state = this->flags_.has_state; if (new_state.has_value()) { if (had_state && this->get_state() == new_state.value()) @@ -358,10 +358,14 @@ template class StatefulEntityBase : public EntityBase { if (new_state.has_value()) { this->set_state_value_(new_state.value()); } + this->on_state_changed_(old_state, new_state, had_state); + return true; + } + /// Called after state storage is updated. Subclasses override for logging/notifications. + virtual void on_state_changed_(const optional &old_state, const optional &new_state, bool had_state) { this->full_state_callbacks_.call(old_state, new_state); if (new_state.has_value() && (this->get_trigger_on_initial_state() || had_state)) this->state_callbacks_.call(new_state.value()); - return true; } /// Subclasses implement this to store the actual value into their own storage. virtual void set_state_value_(const T &value) = 0; From 58498bacff0960648c3a7556623a07e38d84540d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:21:25 -1000 Subject: [PATCH 02/15] [binary_sensor] Skip old_state construction when no full_state_callbacks registered --- esphome/core/entity_base.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 60132a4c18..b6785a9aab 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -351,9 +351,12 @@ template class StatefulEntityBase : public EntityBase { } else if (!had_state) { return false; // already invalidated, no change } - // State changed — capture old state, then update storage before firing callbacks - // so callback code can inspect the entity's current state via get_state()/has_state() - optional old_state = had_state ? optional(this->get_state()) : nullopt; + // State changed — update storage before firing callbacks so callback code + // can inspect the entity's current state via get_state()/has_state() + // Only construct old_state optional when full_state_callbacks need it + optional old_state; + if (!this->full_state_callbacks_.empty()) + old_state = had_state ? optional(this->get_state()) : nullopt; this->flags_.has_state = new_state.has_value(); if (new_state.has_value()) { this->set_state_value_(new_state.value()); From 349f45f582e1f7bb7da963db19f89dd589383373 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:26:16 -1000 Subject: [PATCH 03/15] [binary_sensor] Move send_state_internal and invalidate_state to .cpp set_new_state is a template method (~189 bytes when instantiated). Inlining callers like send_state_internal and invalidate_state causes the template code to be duplicated at every call site (publish_state, Filter::output, BinarySensorInvalidateAction::play, etc.), adding ~384 bytes of flash. Keep these as out-of-line definitions in the .cpp so the template is instantiated once. --- esphome/components/binary_sensor/binary_sensor.cpp | 4 ++++ esphome/components/binary_sensor/binary_sensor.h | 3 ++- esphome/core/entity_base.h | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 564cb48f64..fe7e169fea 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -32,6 +32,10 @@ void BinarySensor::publish_initial_state(bool new_state) { this->invalidate_state(); this->publish_state(new_state); } +// Defined out-of-line to prevent set_new_state template from being inlined at each call site, +// which would duplicate ~189 bytes of template code per caller (publish_state, Filter::output, etc.) +void BinarySensor::send_state_internal(bool new_state) { this->set_new_state(new_state); } +void BinarySensor::invalidate_state() { this->set_new_state({}); } void BinarySensor::on_state_changed_(const optional &old_state, const optional &new_state, bool had_state) { StatefulEntityBase::on_state_changed_(old_state, new_state, had_state); diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 31e8f92b96..7cd8a44a0f 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -57,7 +57,8 @@ class BinarySensor : public StatefulEntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - void send_state_internal(bool new_state) { this->set_new_state(new_state); } + /// Defined in .cpp to avoid inlining set_new_state template code at every call site. + void send_state_internal(bool new_state); /// Return whether this binary sensor has outputted a state. virtual bool is_status_binary_sensor() const; diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index b6785a9aab..202f386234 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -320,7 +320,8 @@ template class StatefulEntityBase : public EntityBase { /// Return the current state if available, otherwise return the provided default. T get_state_default(T default_value) const { return this->has_state() ? this->get_state() : default_value; } /// Clear the state — sets has_state() to false and fires callbacks with nullopt. - void invalidate_state() { this->set_new_state({}); } + /// Defined out-of-line in subclass .cpp to avoid inlining set_new_state template code at every call site. + void invalidate_state(); template void add_full_state_callback(F &&callback) { this->full_state_callbacks_.add(std::forward(callback)); From a827410ecfc5f47ca932c4ddf6a9488415ef9505 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:27:34 -1000 Subject: [PATCH 04/15] [binary_sensor] Fix clang-tidy naming: no trailing underscore on virtual methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - set_new_state → set_new_state_ (protected non-virtual, trailing underscore) - on_state_changed_ → on_state_changed (virtual, no trailing underscore) - set_state_value_ → set_state_value (virtual, no trailing underscore) Update StatefulEntityBase docs to list on_state_changed and invalidate_state. --- .../components/binary_sensor/binary_sensor.cpp | 8 ++++---- esphome/components/binary_sensor/binary_sensor.h | 4 ++-- esphome/core/entity_base.h | 16 +++++++++------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index fe7e169fea..a78fc9229c 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -34,11 +34,11 @@ void BinarySensor::publish_initial_state(bool new_state) { } // Defined out-of-line to prevent set_new_state template from being inlined at each call site, // which would duplicate ~189 bytes of template code per caller (publish_state, Filter::output, etc.) -void BinarySensor::send_state_internal(bool new_state) { this->set_new_state(new_state); } -void BinarySensor::invalidate_state() { this->set_new_state({}); } +void BinarySensor::send_state_internal(bool new_state) { this->set_new_state_(new_state); } +void BinarySensor::invalidate_state() { this->set_new_state_({}); } -void BinarySensor::on_state_changed_(const optional &old_state, const optional &new_state, bool had_state) { - StatefulEntityBase::on_state_changed_(old_state, new_state, had_state); +void BinarySensor::on_state_changed(const optional &old_state, const optional &new_state, bool had_state) { + StatefulEntityBase::on_state_changed(old_state, new_state, had_state); #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_binary_sensor_update(this); #endif diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 7cd8a44a0f..11dccf7c0d 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -68,14 +68,14 @@ class BinarySensor : public StatefulEntityBase { protected: bool get_trigger_on_initial_state() const override { return this->trigger_on_initial_state_; } - void set_state_value_(const bool &value) override { this->state = value; } + void set_state_value(const bool &value) override { this->state = value; } bool trigger_on_initial_state_{true}; #ifdef USE_BINARY_SENSOR_FILTER Filter *filter_list_{nullptr}; #endif - void on_state_changed_(const optional &old_state, const optional &new_state, bool had_state) override; + void on_state_changed(const optional &old_state, const optional &new_state, bool had_state) override; }; class BinarySensorInitiallyOff : public BinarySensor { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 202f386234..de3499a6c3 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -300,8 +300,10 @@ void log_entity_unit_of_measurement(const char *tag, const char *prefix, const E * * Subclasses must implement: * - get_state(): return a const reference to the current value - * - set_state_value_(): store a new value (called only when the state actually changes) + * - set_state_value(): store a new value (called only when the state actually changes) * - get_trigger_on_initial_state() / set_trigger_on_initial_state(): control initial callback behavior + * - on_state_changed() (optional override): called after state updates, for logging/notifications + * - invalidate_state(): must be defined out-of-line in subclass .cpp to avoid template bloat * * This class does not store the state value — subclasses own their storage. Whether a state * has been set is tracked by EntityBase::has_state(). @@ -320,7 +322,7 @@ template class StatefulEntityBase : public EntityBase { /// Return the current state if available, otherwise return the provided default. T get_state_default(T default_value) const { return this->has_state() ? this->get_state() : default_value; } /// Clear the state — sets has_state() to false and fires callbacks with nullopt. - /// Defined out-of-line in subclass .cpp to avoid inlining set_new_state template code at every call site. + /// Defined out-of-line in subclass .cpp to avoid inlining set_new_state_ template code at every call site. void invalidate_state(); template void add_full_state_callback(F &&callback) { @@ -343,7 +345,7 @@ template class StatefulEntityBase : public EntityBase { * Pass nullopt to invalidate (clear) the state. Pass a value to set it. * Returns true if the state actually changed, false if it was the same. */ - bool set_new_state(const optional &new_state) { + bool set_new_state_(const optional &new_state) { // Access flags_ directly to avoid function call overhead in this hot path bool had_state = this->flags_.has_state; if (new_state.has_value()) { @@ -360,19 +362,19 @@ template class StatefulEntityBase : public EntityBase { old_state = had_state ? optional(this->get_state()) : nullopt; this->flags_.has_state = new_state.has_value(); if (new_state.has_value()) { - this->set_state_value_(new_state.value()); + this->set_state_value(new_state.value()); } - this->on_state_changed_(old_state, new_state, had_state); + this->on_state_changed(old_state, new_state, had_state); return true; } /// Called after state storage is updated. Subclasses override for logging/notifications. - virtual void on_state_changed_(const optional &old_state, const optional &new_state, bool had_state) { + virtual void on_state_changed(const optional &old_state, const optional &new_state, bool had_state) { this->full_state_callbacks_.call(old_state, new_state); if (new_state.has_value() && (this->get_trigger_on_initial_state() || had_state)) this->state_callbacks_.call(new_state.value()); } /// Subclasses implement this to store the actual value into their own storage. - virtual void set_state_value_(const T &value) = 0; + virtual void set_state_value(const T &value) = 0; LazyCallbackManager previous, optional current)> full_state_callbacks_; LazyCallbackManager state_callbacks_; }; From 27d38baf0defc4cdde8c3c195c04d0c954c680ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:36:17 -1000 Subject: [PATCH 05/15] [binary_sensor] Fix invalidate_state: keep inline on base, hide on BinarySensor invalidate_state() can't be declaration-only on a template class. Keep it inline on StatefulEntityBase, and add a hiding declaration on BinarySensor with an out-of-line definition in the .cpp to prevent template bloat from automation.h and filter.cpp callers. --- esphome/components/binary_sensor/binary_sensor.h | 4 +++- esphome/core/entity_base.h | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 11dccf7c0d..109fdb0f1a 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -57,8 +57,10 @@ class BinarySensor : public StatefulEntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - /// Defined in .cpp to avoid inlining set_new_state template code at every call site. + /// Defined in .cpp to avoid inlining set_new_state_ template code at every call site. void send_state_internal(bool new_state); + /// Hides base class inline version to prevent template bloat from automation.h and filter.cpp callers. + void invalidate_state(); /// Return whether this binary sensor has outputted a state. virtual bool is_status_binary_sensor() const; diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index de3499a6c3..7cf6804c4c 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -303,7 +303,6 @@ void log_entity_unit_of_measurement(const char *tag, const char *prefix, const E * - set_state_value(): store a new value (called only when the state actually changes) * - get_trigger_on_initial_state() / set_trigger_on_initial_state(): control initial callback behavior * - on_state_changed() (optional override): called after state updates, for logging/notifications - * - invalidate_state(): must be defined out-of-line in subclass .cpp to avoid template bloat * * This class does not store the state value — subclasses own their storage. Whether a state * has been set is tracked by EntityBase::has_state(). @@ -322,8 +321,7 @@ template class StatefulEntityBase : public EntityBase { /// Return the current state if available, otherwise return the provided default. T get_state_default(T default_value) const { return this->has_state() ? this->get_state() : default_value; } /// Clear the state — sets has_state() to false and fires callbacks with nullopt. - /// Defined out-of-line in subclass .cpp to avoid inlining set_new_state_ template code at every call site. - void invalidate_state(); + void invalidate_state() { this->set_new_state_({}); } template void add_full_state_callback(F &&callback) { this->full_state_callbacks_.add(std::forward(callback)); From 0417de007a71a6c262b8309401aee350e100f5b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:37:54 -1000 Subject: [PATCH 06/15] [binary_sensor] Restore virtual set_new_state, remove on_state_changed hook Making set_new_state virtual means callers like send_state_internal and invalidate_state resolve via vtable dispatch to the .cpp, avoiding template bloat without needing out-of-line tricks or hiding declarations. BinarySensor overrides set_new_state directly for logging and ControllerRegistry notification, matching the original pattern. --- .../components/binary_sensor/binary_sensor.cpp | 18 ++++++++++-------- .../components/binary_sensor/binary_sensor.h | 5 +---- esphome/core/entity_base.h | 17 +++++++++-------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index a78fc9229c..4d093b3630 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -32,17 +32,19 @@ void BinarySensor::publish_initial_state(bool new_state) { this->invalidate_state(); this->publish_state(new_state); } -// Defined out-of-line to prevent set_new_state template from being inlined at each call site, -// which would duplicate ~189 bytes of template code per caller (publish_state, Filter::output, etc.) -void BinarySensor::send_state_internal(bool new_state) { this->set_new_state_(new_state); } -void BinarySensor::invalidate_state() { this->set_new_state_({}); } +// Defined out-of-line: set_new_state is virtual so callers in other TUs (filter.cpp, automation.h) +// dispatch here without inlining the ~189 byte template body at each call site. +void BinarySensor::send_state_internal(bool new_state) { this->set_new_state(new_state); } -void BinarySensor::on_state_changed(const optional &old_state, const optional &new_state, bool had_state) { - StatefulEntityBase::on_state_changed(old_state, new_state, had_state); +bool BinarySensor::set_new_state(const optional &new_state) { + if (StatefulEntityBase::set_new_state(new_state)) { #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) - ControllerRegistry::notify_binary_sensor_update(this); + ControllerRegistry::notify_binary_sensor_update(this); #endif - ESP_LOGD(TAG, "'%s' >> %s", this->get_name().c_str(), ONOFFMAYBE(new_state)); + ESP_LOGD(TAG, "'%s' >> %s", this->get_name().c_str(), ONOFFMAYBE(new_state)); + return true; + } + return false; } #ifdef USE_BINARY_SENSOR_FILTER diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 109fdb0f1a..5ad2bd2edf 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -57,10 +57,7 @@ class BinarySensor : public StatefulEntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - /// Defined in .cpp to avoid inlining set_new_state_ template code at every call site. void send_state_internal(bool new_state); - /// Hides base class inline version to prevent template bloat from automation.h and filter.cpp callers. - void invalidate_state(); /// Return whether this binary sensor has outputted a state. virtual bool is_status_binary_sensor() const; @@ -77,7 +74,7 @@ class BinarySensor : public StatefulEntityBase { Filter *filter_list_{nullptr}; #endif - void on_state_changed(const optional &old_state, const optional &new_state, bool had_state) override; + bool set_new_state(const optional &new_state) override; }; class BinarySensorInitiallyOff : public BinarySensor { diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 7cf6804c4c..30a06a7f8a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -302,7 +302,8 @@ void log_entity_unit_of_measurement(const char *tag, const char *prefix, const E * - get_state(): return a const reference to the current value * - set_state_value(): store a new value (called only when the state actually changes) * - get_trigger_on_initial_state() / set_trigger_on_initial_state(): control initial callback behavior - * - on_state_changed() (optional override): called after state updates, for logging/notifications + * + * Subclasses may override set_new_state() for additional behavior (logging, notifications). * * This class does not store the state value — subclasses own their storage. Whether a state * has been set is tracked by EntityBase::has_state(). @@ -312,6 +313,9 @@ void log_entity_unit_of_measurement(const char *tag, const char *prefix, const E * - state_callbacks_: fired only when the new state has a value, and either this is not the * first state (had_state) or trigger_on_initial_state is set * + * invalidate_state() and callers of set_new_state() should be defined out-of-line in the + * subclass .cpp to avoid inlining the template body (~189 bytes) at every call site. + * * @tparam T The type of the state value */ template class StatefulEntityBase : public EntityBase { @@ -321,7 +325,7 @@ template class StatefulEntityBase : public EntityBase { /// Return the current state if available, otherwise return the provided default. T get_state_default(T default_value) const { return this->has_state() ? this->get_state() : default_value; } /// Clear the state — sets has_state() to false and fires callbacks with nullopt. - void invalidate_state() { this->set_new_state_({}); } + void invalidate_state() { this->set_new_state({}); } template void add_full_state_callback(F &&callback) { this->full_state_callbacks_.add(std::forward(callback)); @@ -342,8 +346,9 @@ template class StatefulEntityBase : public EntityBase { * * Pass nullopt to invalidate (clear) the state. Pass a value to set it. * Returns true if the state actually changed, false if it was the same. + * Subclasses may override to add logging/notifications after calling the base. */ - bool set_new_state_(const optional &new_state) { + virtual bool set_new_state(const optional &new_state) { // Access flags_ directly to avoid function call overhead in this hot path bool had_state = this->flags_.has_state; if (new_state.has_value()) { @@ -362,14 +367,10 @@ template class StatefulEntityBase : public EntityBase { if (new_state.has_value()) { this->set_state_value(new_state.value()); } - this->on_state_changed(old_state, new_state, had_state); - return true; - } - /// Called after state storage is updated. Subclasses override for logging/notifications. - virtual void on_state_changed(const optional &old_state, const optional &new_state, bool had_state) { this->full_state_callbacks_.call(old_state, new_state); if (new_state.has_value() && (this->get_trigger_on_initial_state() || had_state)) this->state_callbacks_.call(new_state.value()); + return true; } /// Subclasses implement this to store the actual value into their own storage. virtual void set_state_value(const T &value) = 0; From 3fd28eeea208e08d59267c49056d91222e13a528 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:38:38 -1000 Subject: [PATCH 07/15] [binary_sensor] Inline send_state_internal - virtual dispatch prevents template bloat --- esphome/components/binary_sensor/binary_sensor.cpp | 4 ---- esphome/components/binary_sensor/binary_sensor.h | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 4d093b3630..a7b2f5ba91 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -32,10 +32,6 @@ void BinarySensor::publish_initial_state(bool new_state) { this->invalidate_state(); this->publish_state(new_state); } -// Defined out-of-line: set_new_state is virtual so callers in other TUs (filter.cpp, automation.h) -// dispatch here without inlining the ~189 byte template body at each call site. -void BinarySensor::send_state_internal(bool new_state) { this->set_new_state(new_state); } - bool BinarySensor::set_new_state(const optional &new_state) { if (StatefulEntityBase::set_new_state(new_state)) { #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 5ad2bd2edf..37362bc432 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -57,7 +57,7 @@ class BinarySensor : public StatefulEntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - void send_state_internal(bool new_state); + void send_state_internal(bool new_state) { this->set_new_state(new_state); } /// Return whether this binary sensor has outputted a state. virtual bool is_status_binary_sensor() const; From 1a8cbe92ff4c6f94ea185038f6b06f97ddffbf56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:39:28 -1000 Subject: [PATCH 08/15] [binary_sensor] Update StatefulEntityBase docs to reflect virtual dispatch design --- esphome/core/entity_base.h | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 30a06a7f8a..8b3e426b58 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -297,25 +297,25 @@ void log_entity_device_class(const char *tag, const char *prefix, const EntityBa void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj); /** Base class for entities that track a typed state value with change-detection and callbacks. + * + * This class does not store the state value — subclasses own their storage. Whether a state + * has been set is tracked by EntityBase::has_state(). * * Subclasses must implement: * - get_state(): return a const reference to the current value * - set_state_value(): store a new value (called only when the state actually changes) * - get_trigger_on_initial_state() / set_trigger_on_initial_state(): control initial callback behavior * - * Subclasses may override set_new_state() for additional behavior (logging, notifications). - * - * This class does not store the state value — subclasses own their storage. Whether a state - * has been set is tracked by EntityBase::has_state(). + * Subclasses may override set_new_state() to add behavior (logging, notifications) after calling + * the base implementation. Since set_new_state() is virtual, callers like invalidate_state() and + * send_state_internal() dispatch through the vtable to the subclass override in the .cpp, + * avoiding template code bloat at inline call sites. * * Callback behavior: * - full_state_callbacks_: fired on every change, receives optional previous and current * - state_callbacks_: fired only when the new state has a value, and either this is not the * first state (had_state) or trigger_on_initial_state is set * - * invalidate_state() and callers of set_new_state() should be defined out-of-line in the - * subclass .cpp to avoid inlining the template body (~189 bytes) at every call site. - * * @tparam T The type of the state value */ template class StatefulEntityBase : public EntityBase { From a6e230d665ee39e06cf8d3e6b6e4dd11df7c1d98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:40:47 -1000 Subject: [PATCH 09/15] [binary_sensor] Restore comment about USE_BINARY_SENSOR guard --- esphome/components/binary_sensor/binary_sensor.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index a7b2f5ba91..0fb58082cb 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -34,6 +34,7 @@ void BinarySensor::publish_initial_state(bool new_state) { } bool BinarySensor::set_new_state(const optional &new_state) { if (StatefulEntityBase::set_new_state(new_state)) { + // weirdly, this file could be compiled even without USE_BINARY_SENSOR defined #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_binary_sensor_update(this); #endif From 8aa9a82c13835cad02d520f4bcc0061c32dbdfe5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:43:37 -1000 Subject: [PATCH 10/15] [binary_sensor] Clarify trigger_on_initial_state docs - subclass decides storage --- esphome/core/entity_base.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8b3e426b58..4ea7d06987 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -304,7 +304,8 @@ void log_entity_unit_of_measurement(const char *tag, const char *prefix, const E * Subclasses must implement: * - get_state(): return a const reference to the current value * - set_state_value(): store a new value (called only when the state actually changes) - * - get_trigger_on_initial_state() / set_trigger_on_initial_state(): control initial callback behavior + * - get_trigger_on_initial_state(): return whether callbacks should fire on the first state + * - set_trigger_on_initial_state(): store the value (subclass decides how) * * Subclasses may override set_new_state() to add behavior (logging, notifications) after calling * the base implementation. Since set_new_state() is virtual, callers like invalidate_state() and @@ -335,7 +336,7 @@ template class StatefulEntityBase : public EntityBase { } /// Control whether state_callbacks_ fire on the very first state (before any previous state exists). - /// Subclasses must implement set_trigger_on_initial_state() to store this value. + /// Subclasses decide how to store the value. virtual void set_trigger_on_initial_state(bool value) = 0; protected: From 9291b71176c0e8ae13a6df023b5bccc9a2f24a24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:45:00 -1000 Subject: [PATCH 11/15] [binary_sensor] Move set_trigger_on_initial_state off StatefulEntityBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_trigger_on_initial_state is only called from codegen on concrete BinarySensor instances. It's an implementation detail of BinarySensor, not part of the StatefulEntityBase contract. Only get_trigger_on_initial_state remains as a pure virtual — subclasses decide how to control it. --- esphome/components/binary_sensor/binary_sensor.h | 2 +- esphome/core/entity_base.h | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 37362bc432..9525832705 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -35,7 +35,7 @@ class BinarySensor : public StatefulEntityBase { explicit BinarySensor() = default; const bool &get_state() const override { return this->state; } - void set_trigger_on_initial_state(bool value) override { this->trigger_on_initial_state_ = value; } + void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } /** Publish a new state to the front-end. * diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 4ea7d06987..187894fea0 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -305,7 +305,6 @@ void log_entity_unit_of_measurement(const char *tag, const char *prefix, const E * - get_state(): return a const reference to the current value * - set_state_value(): store a new value (called only when the state actually changes) * - get_trigger_on_initial_state(): return whether callbacks should fire on the first state - * - set_trigger_on_initial_state(): store the value (subclass decides how) * * Subclasses may override set_new_state() to add behavior (logging, notifications) after calling * the base implementation. Since set_new_state() is virtual, callers like invalidate_state() and @@ -335,10 +334,6 @@ template class StatefulEntityBase : public EntityBase { this->state_callbacks_.add(std::forward(callback)); } - /// Control whether state_callbacks_ fire on the very first state (before any previous state exists). - /// Subclasses decide how to store the value. - virtual void set_trigger_on_initial_state(bool value) = 0; - protected: /// Subclasses return whether callbacks should fire on the very first state. virtual bool get_trigger_on_initial_state() const = 0; From 6c95f2e706e87fd64667de1791267229dcea2d44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 16:56:35 -1000 Subject: [PATCH 12/15] [core] Fix placement new storage name for templated types When Pvariable types contain template arguments with :: (e.g. Automation>), the namespace extraction logic incorrectly split on :: inside the template params, producing invalid C++ identifiers like Automation str: + """Extract the component namespace from a fully-qualified C++ type string. + + Strips leading ``esphome::`` and template arguments, then returns + the first namespace segment. Falls back to ``"esphome"`` when the + type has no namespace qualifier (after stripping templates). + + Examples:: + + esphome::dsmr::Dsmr -> dsmr + esphome::logger::Logger -> logger + esphome::Automation, std::optional> -> esphome + Logger -> esphome + """ + bare = type_str.removeprefix("esphome::") + # Strip template arguments before namespace extraction to avoid + # matching :: inside template params (e.g. Automation>) + bare_no_template = bare.split("<", maxsplit=1)[0] + if "::" in bare_no_template: + return bare_no_template.split("::", maxsplit=1)[0].rstrip("_") + return "esphome" + + def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": """Declare a new pointer variable in the code generation. @@ -585,14 +608,7 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": # to avoid heap fragmentation on embedded devices. the_type = id_.type # Extract component namespace from type for memory analysis attribution - type_str = str(the_type) - # Strip leading esphome:: to get the component namespace - # e.g. esphome::dsmr::Dsmr -> dsmr, logger::Logger -> logger - bare = type_str.removeprefix("esphome::") - if "::" in bare: - component_ns = bare.split("::", maxsplit=1)[0].rstrip("_") - else: - component_ns = "esphome" + component_ns = _extract_component_ns(str(the_type)) storage_name = f"{component_ns}__{id_.id}__pstorage" # Declare aligned byte array for the object storage diff --git a/tests/unit_tests/test_codegen.py b/tests/unit_tests/test_codegen.py index 3f32a117ff..8d01fef7c2 100644 --- a/tests/unit_tests/test_codegen.py +++ b/tests/unit_tests/test_codegen.py @@ -1,6 +1,7 @@ import pytest from esphome import codegen as cg +from esphome.cpp_generator import _extract_component_ns # Test interface remains the same. @@ -75,3 +76,29 @@ from esphome import codegen as cg ) def test_exists(attr): assert hasattr(cg, attr) + + +@pytest.mark.parametrize( + ("type_str", "expected"), + ( + ("esphome::dsmr::Dsmr", "dsmr"), + ("esphome::logger::Logger", "logger"), + ("esphome::web_server::WebServer", "web_server"), + ("esphome::deep_sleep::DeepSleep", "deep_sleep"), + ("esphome::Component", "esphome"), + ("Logger", "esphome"), + # Template types with :: in template args must not confuse extraction + ( + "esphome::Automation, std::optional>", + "esphome", + ), + ( + "esphome::StatelessLambdaAction, std::optional>", + "esphome", + ), + # Namespaced template type + ("esphome::sensor::Sensor", "sensor"), + ), +) +def test_extract_component_ns(type_str, expected): + assert _extract_component_ns(type_str) == expected From 4a200f8a2ba72e1950ce4262e0df22adec8b38f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 18:16:49 -1000 Subject: [PATCH 13/15] [binary_sensor] Reduce flash: cache get_state(), guard full_state_callbacks call - Cache get_state() result to avoid calling the virtual method twice (once for comparison, once for old_state construction) - Move full_state_callbacks_.call() inside the !empty() guard so both the optional construction and the call are skipped when no callbacks - Swap to had_state || get_trigger_on_initial_state() so the virtual call is skipped on the common path (every change except the first) Saves ~21 bytes of flash in set_new_state. --- esphome/core/entity_base.h | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 187894fea0..869981eb7a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -347,24 +347,29 @@ template class StatefulEntityBase : public EntityBase { virtual bool set_new_state(const optional &new_state) { // Access flags_ directly to avoid function call overhead in this hot path bool had_state = this->flags_.has_state; + // Cache get_state() result to avoid calling the virtual method twice + T current{}; + if (had_state) + current = this->get_state(); if (new_state.has_value()) { - if (had_state && this->get_state() == new_state.value()) + if (had_state && current == new_state.value()) return false; // same value, no change } else if (!had_state) { return false; // already invalidated, no change } // State changed — update storage before firing callbacks so callback code // can inspect the entity's current state via get_state()/has_state() - // Only construct old_state optional when full_state_callbacks need it - optional old_state; - if (!this->full_state_callbacks_.empty()) - old_state = had_state ? optional(this->get_state()) : nullopt; this->flags_.has_state = new_state.has_value(); if (new_state.has_value()) { this->set_state_value(new_state.value()); } - this->full_state_callbacks_.call(old_state, new_state); - if (new_state.has_value() && (this->get_trigger_on_initial_state() || had_state)) + // Only construct old_state and call full_state_callbacks when callbacks are registered + if (!this->full_state_callbacks_.empty()) { + optional old_state = had_state ? optional(current) : nullopt; + this->full_state_callbacks_.call(old_state, new_state); + } + // had_state first: on every change except the first, skips the virtual call + if (new_state.has_value() && (had_state || this->get_trigger_on_initial_state())) this->state_callbacks_.call(new_state.value()); return true; } From cd5b19ab0d8aac48a0e0dce8361305eba33102ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 18:38:34 -1000 Subject: [PATCH 14/15] [binary_sensor] Fast-path dedup in send_state_internal before virtual dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check state == new_state directly before calling virtual set_new_state. Avoids the virtual dispatch chain (BinarySensor::set_new_state → StatefulEntityBase::set_new_state → virtual get_state()) on the common no-change path. Fixes -20% CodSpeed regression on BinarySensorPublish_NoChange benchmark. --- esphome/components/binary_sensor/binary_sensor.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 9525832705..28c156763a 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -57,7 +57,12 @@ class BinarySensor : public StatefulEntityBase { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) - void send_state_internal(bool new_state) { this->set_new_state(new_state); } + void send_state_internal(bool new_state) { + // Fast path: skip virtual dispatch when state hasn't changed + if (this->flags_.has_state && this->state == new_state) + return; + this->set_new_state(new_state); + } /// Return whether this binary sensor has outputted a state. virtual bool is_status_binary_sensor() const; From eaf38c10ccc86e755ea1bf909b60566909165243 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Mar 2026 18:40:00 -1000 Subject: [PATCH 15/15] [binary_sensor] Use const T* instead of T current{} in set_new_state Avoids requiring T to be default-constructible, which would break future instantiations with non-default-constructible state types. Capture old_state before set_state_value() since the pointer aliases subclass storage that gets overwritten. --- esphome/core/entity_base.h | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 869981eb7a..d03326ccf8 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -347,27 +347,26 @@ template class StatefulEntityBase : public EntityBase { virtual bool set_new_state(const optional &new_state) { // Access flags_ directly to avoid function call overhead in this hot path bool had_state = this->flags_.has_state; - // Cache get_state() result to avoid calling the virtual method twice - T current{}; - if (had_state) - current = this->get_state(); + // Use pointer to avoid requiring T to be default-constructible + const T *current = had_state ? &this->get_state() : nullptr; if (new_state.has_value()) { - if (had_state && current == new_state.value()) + if (current != nullptr && *current == new_state.value()) return false; // same value, no change } else if (!had_state) { return false; // already invalidated, no change } - // State changed — update storage before firing callbacks so callback code - // can inspect the entity's current state via get_state()/has_state() + // Capture old_state before set_state_value — current pointer aliases subclass storage + bool has_full_cbs = !this->full_state_callbacks_.empty(); + optional old_state; + if (has_full_cbs) + old_state = current != nullptr ? optional(*current) : nullopt; + // Update storage before firing callbacks so callback code can inspect current state this->flags_.has_state = new_state.has_value(); if (new_state.has_value()) { this->set_state_value(new_state.value()); } - // Only construct old_state and call full_state_callbacks when callbacks are registered - if (!this->full_state_callbacks_.empty()) { - optional old_state = had_state ? optional(current) : nullopt; + if (has_full_cbs) this->full_state_callbacks_.call(old_state, new_state); - } // had_state first: on every change except the first, skips the virtual call if (new_state.has_value() && (had_state || this->get_trigger_on_initial_state())) this->state_callbacks_.call(new_state.value());