From d6475eaeed764bb30589323f832cf305d94bd69f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 12:15:18 -1000 Subject: [PATCH] [binary_sensor] Remove redundant `optional` state_, save 8 bytes per instance (#15095) --- .../binary_sensor/binary_sensor.cpp | 7 -- .../components/binary_sensor/binary_sensor.h | 21 +++-- esphome/core/entity_base.h | 88 +++++++++++++------ 3 files changed, 77 insertions(+), 39 deletions(-) diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 8ace7eafd1..7596975a68 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -32,13 +32,6 @@ void BinarySensor::publish_initial_state(bool new_state) { this->invalidate_state(); this->publish_state(new_state); } -void BinarySensor::send_state_internal(bool new_state) { - // copy the new state to the visible property for backwards compatibility, before any callbacks - this->state = new_state; - // Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed - this->set_new_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 diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 6ae5d04bcb..28c156763a 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -32,7 +32,10 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi */ class BinarySensor : public StatefulEntityBase { public: - explicit BinarySensor(){}; + explicit BinarySensor() = default; + + const bool &get_state() const override { return this->state; } + void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } /** Publish a new state to the front-end. * @@ -54,16 +57,24 @@ 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) { + // 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; - // For backward compatibility, provide an accessible property - + /// The current state of this binary sensor. Also used as the backing storage for StatefulEntityBase. bool state{}; 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; } + + bool trigger_on_initial_state_{true}; #ifdef USE_BINARY_SENSOR_FILTER Filter *filter_list_{nullptr}; #endif @@ -73,7 +84,7 @@ class BinarySensor : public StatefulEntityBase { class BinarySensorInitiallyOff : public BinarySensor { public: - bool has_state() const override { return true; } + BinarySensorInitiallyOff() { this->set_has_state(true); } }; } // namespace esphome::binary_sensor diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8c1f1a213e..5a69c9dd09 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -296,15 +296,36 @@ void log_entity_device_class(const char *tag, const char *prefix, const EntityBa #define LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj) log_entity_unit_of_measurement(tag, prefix, obj) void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj); -/** - * An entity that has a state. - * @tparam T The type of the state +/** 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(): return whether callbacks should fire on the first 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() + * dispatch through the vtable to the subclass override in the .cpp, avoiding template code + * bloat at inline call sites. Subclasses may also add a fast-path dedup check before calling + * set_new_state() to skip virtual dispatch entirely when the state hasn't changed. + * + * 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 + * + * @tparam T The type of the state value */ template class StatefulEntityBase : public EntityBase { public: - virtual bool has_state() const { return this->state_.has_value(); } - virtual const T &get_state() const { return this->state_.value(); } // NOLINT(bugprone-unchecked-optional-access) - virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); } + /// Return the current state value. Only valid when has_state() is true. + virtual const T &get_state() const = 0; + /// 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({}); } template void add_full_state_callback(F &&callback) { @@ -314,33 +335,46 @@ template class StatefulEntityBase : public EntityBase { this->state_callbacks_.add(std::forward(callback)); } - void set_trigger_on_initial_state(bool trigger_on_initial_state) { - this->trigger_on_initial_state_ = trigger_on_initial_state; - } - protected: - optional state_{}; - /** - * Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous. + /// Subclasses return whether callbacks should fire on the very first state. + virtual bool get_trigger_on_initial_state() const = 0; + + /** Apply a new state, de-duplicating and firing callbacks as needed. * - * @param new_state The new state. - * @return True if the state was changed, false if it was the same as before. + * 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. */ virtual bool set_new_state(const optional &new_state) { - if (this->state_ != new_state) { - // call the full state callbacks with the previous and new state - this->full_state_callbacks_.call(this->state_, new_state); - // trigger legacy callbacks only if the new state is valid and either the trigger on initial state is enabled or - // the previous state was valid - auto had_state = this->has_state(); - this->state_ = new_state; - if (new_state.has_value() && (this->trigger_on_initial_state_ || had_state)) - this->state_callbacks_.call(new_state.value()); - return true; + // Access flags_ directly to avoid function call overhead in this hot path + bool had_state = this->flags_.has_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 (current != nullptr && *current == new_state.value()) + return false; // same value, no change + } else if (!had_state) { + return false; // already invalidated, no change } - return false; + // 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()); + } + 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()); + return true; } - bool trigger_on_initial_state_{true}; + /// Subclasses implement this to store the actual value into their own storage. + virtual void set_state_value(const T &value) = 0; LazyCallbackManager previous, optional current)> full_state_callbacks_; LazyCallbackManager state_callbacks_; };