From 237cfdebfcc586952fe4fb49125507bd0f9b0f0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:04:59 -1000 Subject: [PATCH 01/21] [fan] Store preset mode vector on Fan entity, not FanTraits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FanTraits contained a std::vector for preset modes. Every get_traits() call reconstructed this vector, causing heap allocations on every publish_state() and control()/perform() call. Move the vector storage to the Fan base class and have FanTraits hold a const pointer instead. Internal callers (save_state_, find_preset_mode_) search the Fan-owned vector directly, avoiding traits reconstruction. The old FanTraits setters are preserved as deprecated compatibility overloads (removed in 2026.11.0) that self-own the data, so external components continue to compile — they just get a deprecation warning and still heap-allocate until they migrate. --- esphome/components/copy/fan/copy_fan.cpp | 3 +- esphome/components/fan/fan.cpp | 17 +++++---- esphome/components/fan/fan.h | 11 ++++++ esphome/components/fan/fan_traits.h | 36 ++++++++++++------- .../components/hbridge/fan/hbridge_fan.cpp | 1 - esphome/components/hbridge/fan/hbridge_fan.h | 3 +- esphome/components/speed/fan/speed_fan.cpp | 1 - esphome/components/speed/fan/speed_fan.h | 3 +- .../components/template/fan/template_fan.cpp | 1 - .../components/template/fan/template_fan.h | 3 +- 10 files changed, 50 insertions(+), 29 deletions(-) diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index 14c600d71f..e7d01827aa 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -39,7 +39,8 @@ fan::FanTraits CopyFan::get_traits() { traits.set_speed(base.supports_speed()); traits.set_supported_speed_count(base.supported_speed_count()); traits.set_direction(base.supports_direction()); - traits.set_supported_preset_modes(base.supported_preset_modes()); + // Preset modes are wired from source fan's storage via get_traits() + this->set_supported_preset_modes(base.supported_preset_modes()); return traits; } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index dc7a75018c..659abf70a9 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -148,7 +148,13 @@ const char *Fan::find_preset_mode_(const char *preset_mode) { } const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { - return this->get_traits().find_preset_mode(preset_mode, len); + if (preset_mode == nullptr || len == 0) + return nullptr; + for (const char *mode : this->supported_preset_modes_) { + if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') + return mode; + } + return nullptr; } bool Fan::set_preset_mode_(const char *preset_mode, size_t len) { @@ -261,8 +267,6 @@ void Fan::save_state_() { return; } - auto traits = this->get_traits(); - FanRestoreState state{}; state.state = this->state; state.oscillating = this->oscillating; @@ -271,10 +275,9 @@ void Fan::save_state_() { state.preset_mode = FanRestoreState::NO_PRESET; if (this->has_preset_mode()) { - const auto &preset_modes = traits.supported_preset_modes(); - // Find index of current preset mode (pointer comparison is safe since preset is from traits) - for (size_t i = 0; i < preset_modes.size(); i++) { - if (preset_modes[i] == this->preset_mode_) { + // Find index of current preset mode (pointer comparison is safe since preset is from our vector) + for (size_t i = 0; i < this->supported_preset_modes_.size(); i++) { + if (this->supported_preset_modes_[i] == this->preset_mode_) { state.preset_mode = i; break; } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index e7b3681e32..42999d16f7 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -130,6 +130,14 @@ class Fan : public EntityBase { virtual FanTraits get_traits() = 0; + /// Set the supported preset modes (stored on Fan, referenced by FanTraits via pointer). + void set_supported_preset_modes(std::initializer_list preset_modes) { + this->supported_preset_modes_ = preset_modes; + } + void set_supported_preset_modes(const std::vector &preset_modes) { + this->supported_preset_modes_ = preset_modes; + } + /// Set the restore mode of this fan. void set_restore_mode(FanRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } @@ -171,6 +179,9 @@ class Fan : public EntityBase { ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; + /// Preset mode storage — owned by Fan, referenced by FanTraits via pointer. + std::vector supported_preset_modes_; + private: const char *preset_mode_{nullptr}; }; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index c0c5f34c50..f8202f36ce 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -3,6 +3,7 @@ #include #include #include +#include "esphome/core/helpers.h" namespace esphome { @@ -31,20 +32,29 @@ class FanTraits { /// Set whether this fan supports changing direction void set_direction(bool direction) { this->direction_ = direction; } /// Return the preset modes supported by the fan. - const std::vector &supported_preset_modes() const { return this->preset_modes_; } - /// Set the preset modes supported by the fan (from initializer list). - void set_supported_preset_modes(std::initializer_list preset_modes) { - this->preset_modes_ = preset_modes; + const std::vector &supported_preset_modes() const { + return this->preset_modes_ ? *this->preset_modes_ : this->owned_preset_modes_; } - /// Set the preset modes supported by the fan (from vector). - void set_supported_preset_modes(const std::vector &preset_modes) { this->preset_modes_ = preset_modes; } + /// Set the preset modes pointer (points to vector owned by Fan base class). + void set_supported_preset_modes(const std::vector *preset_modes) { this->preset_modes_ = preset_modes; } - // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages - void set_supported_preset_modes(const std::vector &preset_modes) = delete; - void set_supported_preset_modes(std::initializer_list preset_modes) = delete; + // Remove before 2027.1.0 + ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_preset_modes(std::initializer_list preset_modes) { + this->owned_preset_modes_ = preset_modes; + this->preset_modes_ = &this->owned_preset_modes_; + } + // Remove before 2027.1.0 + ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_preset_modes(const std::vector &preset_modes) { + this->owned_preset_modes_ = preset_modes; + this->preset_modes_ = &this->owned_preset_modes_; + } /// Return if preset modes are supported - bool supports_preset_modes() const { return !this->preset_modes_.empty(); } + bool supports_preset_modes() const { + return (this->preset_modes_ && !this->preset_modes_->empty()) || !this->owned_preset_modes_.empty(); + } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { return this->find_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); @@ -52,7 +62,8 @@ class FanTraits { const char *find_preset_mode(const char *preset_mode, size_t len) const { if (preset_mode == nullptr || len == 0) return nullptr; - for (const char *mode : this->preset_modes_) { + const auto &modes = this->preset_modes_ ? *this->preset_modes_ : this->owned_preset_modes_; + for (const char *mode : modes) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { return mode; // Return pointer from traits } @@ -65,7 +76,8 @@ class FanTraits { bool speed_{false}; bool direction_{false}; int speed_count_{}; - std::vector preset_modes_{}; + const std::vector *preset_modes_{nullptr}; + std::vector owned_preset_modes_{}; ///< Compat: used when old setters are called directly on traits }; } // namespace fan diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index 89c162eebf..d548128b99 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -30,7 +30,6 @@ fan::FanCall HBridgeFan::brake() { void HBridgeFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, true, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index ec1e8ada0e..7511e0eb0b 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -20,7 +20,7 @@ class HBridgeFan : public Component, public fan::Fan { void set_pin_a(output::FloatOutput *pin_a) { pin_a_ = pin_a; } void set_pin_b(output::FloatOutput *pin_b) { pin_b_ = pin_b; } void set_enable_pin(output::FloatOutput *enable) { enable_ = enable; } - void set_preset_modes(std::initializer_list presets) { preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } void setup() override; void dump_config() override; @@ -36,7 +36,6 @@ class HBridgeFan : public Component, public fan::Fan { int speed_count_{}; DecayMode decay_mode_{DECAY_MODE_SLOW}; fan::FanTraits traits_; - std::vector preset_modes_{}; void control(const fan::FanCall &call) override; void write_state_(); diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index d45237c467..eaa8a55858 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -9,7 +9,6 @@ static const char *const TAG = "speed.fan"; void SpeedFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->oscillating_ != nullptr, true, this->direction_ != nullptr, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index e9a389e0f3..44bd1a15cb 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -16,7 +16,7 @@ class SpeedFan : public Component, public fan::Fan { void set_output(output::FloatOutput *output) { this->output_ = output; } void set_oscillating(output::BinaryOutput *oscillating) { this->oscillating_ = oscillating; } void set_direction(output::BinaryOutput *direction) { this->direction_ = direction; } - void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } fan::FanTraits get_traits() override { return this->traits_; } protected: @@ -28,7 +28,6 @@ class SpeedFan : public Component, public fan::Fan { output::BinaryOutput *direction_{nullptr}; int speed_count_{}; fan::FanTraits traits_; - std::vector preset_modes_{}; }; } // namespace speed diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 46a5cba9bb..431be84654 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -9,7 +9,6 @@ void TemplateFan::setup() { // Construct traits before restore so preset modes can be looked up by index this->traits_ = fan::FanTraits(this->has_oscillating_, this->speed_count_ > 0, this->has_direction_, this->speed_count_); - this->traits_.set_supported_preset_modes(this->preset_modes_); auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/template/fan/template_fan.h b/esphome/components/template/fan/template_fan.h index b7e1d4ab5a..d8229ef047 100644 --- a/esphome/components/template/fan/template_fan.h +++ b/esphome/components/template/fan/template_fan.h @@ -13,7 +13,7 @@ class TemplateFan final : public Component, public fan::Fan { void set_has_direction(bool has_direction) { this->has_direction_ = has_direction; } void set_has_oscillating(bool has_oscillating) { this->has_oscillating_ = has_oscillating; } void set_speed_count(int count) { this->speed_count_ = count; } - void set_preset_modes(std::initializer_list presets) { this->preset_modes_ = presets; } + void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } fan::FanTraits get_traits() override { return this->traits_; } protected: @@ -23,7 +23,6 @@ class TemplateFan final : public Component, public fan::Fan { bool has_direction_{false}; int speed_count_{0}; fan::FanTraits traits_; - std::vector preset_modes_{}; }; } // namespace esphome::template_ From df58091d1d045b1ad76afca09abaf6e125830080 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:14:52 -1000 Subject: [PATCH 02/21] Make compat owned vector skip-on-copy to eliminate copy overhead Wrap the deprecated owned vector in a struct with a no-op copy constructor. FanTraits copies (which happen on every get_traits() call) don't pay the 24-byte cost of copying an empty vector. --- esphome/components/fan/fan_traits.h | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index f8202f36ce..b5d6556e3b 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -33,27 +33,27 @@ class FanTraits { void set_direction(bool direction) { this->direction_ = direction; } /// Return the preset modes supported by the fan. const std::vector &supported_preset_modes() const { - return this->preset_modes_ ? *this->preset_modes_ : this->owned_preset_modes_; + return this->preset_modes_ ? *this->preset_modes_ : this->owned_preset_modes_.modes; } /// Set the preset modes pointer (points to vector owned by Fan base class). void set_supported_preset_modes(const std::vector *preset_modes) { this->preset_modes_ = preset_modes; } - // Remove before 2027.1.0 + // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(std::initializer_list preset_modes) { - this->owned_preset_modes_ = preset_modes; - this->preset_modes_ = &this->owned_preset_modes_; + this->owned_preset_modes_.modes = preset_modes; + this->preset_modes_ = &this->owned_preset_modes_.modes; } - // Remove before 2027.1.0 + // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(const std::vector &preset_modes) { - this->owned_preset_modes_ = preset_modes; - this->preset_modes_ = &this->owned_preset_modes_; + this->owned_preset_modes_.modes = preset_modes; + this->preset_modes_ = &this->owned_preset_modes_.modes; } /// Return if preset modes are supported bool supports_preset_modes() const { - return (this->preset_modes_ && !this->preset_modes_->empty()) || !this->owned_preset_modes_.empty(); + return (this->preset_modes_ && !this->preset_modes_->empty()) || !this->owned_preset_modes_.modes.empty(); } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { @@ -62,7 +62,7 @@ class FanTraits { const char *find_preset_mode(const char *preset_mode, size_t len) const { if (preset_mode == nullptr || len == 0) return nullptr; - const auto &modes = this->preset_modes_ ? *this->preset_modes_ : this->owned_preset_modes_; + const auto &modes = this->preset_modes_ ? *this->preset_modes_ : this->owned_preset_modes_.modes; for (const char *mode : modes) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { return mode; // Return pointer from traits @@ -77,7 +77,17 @@ class FanTraits { bool direction_{false}; int speed_count_{}; const std::vector *preset_modes_{nullptr}; - std::vector owned_preset_modes_{}; ///< Compat: used when old setters are called directly on traits + /** Compat storage for deprecated setters — skipped on copy to avoid overhead. + * Remove in 2026.11.0 along with the deprecated overloads. + */ + struct OwnedPresetModes { + std::vector modes; + OwnedPresetModes() = default; + OwnedPresetModes(const OwnedPresetModes &) {} // NOLINT - no-op copy: compat data is not propagated + OwnedPresetModes &operator=(const OwnedPresetModes &) { return *this; } // NOLINT + OwnedPresetModes(OwnedPresetModes &&) = default; + OwnedPresetModes &operator=(OwnedPresetModes &&) = default; + } owned_preset_modes_; }; } // namespace fan From 4ff0d30e076d8b4c1a886e7e4ee0b04a26969abb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:24:58 -1000 Subject: [PATCH 03/21] Use leak-on-purpose raw pointer for Fan-owned preset modes vector Fan entities live for the entire program lifetime, so the preset modes vector never needs to be freed. Use a raw pointer (null by default, allocated on first set_supported_preset_modes() call) instead of an inline std::vector member. This saves 24 bytes of RAM per Fan instance for components that don't use preset modes (binary, bedjet, tuya, etc.). --- esphome/components/fan/fan.cpp | 10 +++++----- esphome/components/fan/fan.h | 13 +++++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 659abf70a9..f486c40ca4 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -148,9 +148,9 @@ const char *Fan::find_preset_mode_(const char *preset_mode) { } const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { - if (preset_mode == nullptr || len == 0) + if (preset_mode == nullptr || len == 0 || !this->supported_preset_modes_) return nullptr; - for (const char *mode : this->supported_preset_modes_) { + for (const char *mode : *this->supported_preset_modes_) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') return mode; } @@ -274,10 +274,10 @@ void Fan::save_state_() { state.direction = this->direction; state.preset_mode = FanRestoreState::NO_PRESET; - if (this->has_preset_mode()) { + if (this->has_preset_mode() && this->supported_preset_modes_) { // Find index of current preset mode (pointer comparison is safe since preset is from our vector) - for (size_t i = 0; i < this->supported_preset_modes_.size(); i++) { - if (this->supported_preset_modes_[i] == this->preset_mode_) { + for (size_t i = 0; i < this->supported_preset_modes_->size(); i++) { + if ((*this->supported_preset_modes_)[i] == this->preset_mode_) { state.preset_mode = i; break; } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 42999d16f7..2b76fa24da 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -132,10 +132,15 @@ class Fan : public EntityBase { /// Set the supported preset modes (stored on Fan, referenced by FanTraits via pointer). void set_supported_preset_modes(std::initializer_list preset_modes) { - this->supported_preset_modes_ = preset_modes; + if (!this->supported_preset_modes_) + this->supported_preset_modes_ = + new std::vector(); // NOLINT - intentional leak, entity lives forever + *this->supported_preset_modes_ = preset_modes; } void set_supported_preset_modes(const std::vector &preset_modes) { - this->supported_preset_modes_ = preset_modes; + if (!this->supported_preset_modes_) + this->supported_preset_modes_ = new std::vector(); // NOLINT + *this->supported_preset_modes_ = preset_modes; } /// Set the restore mode of this fan. @@ -179,8 +184,8 @@ class Fan : public EntityBase { ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; - /// Preset mode storage — owned by Fan, referenced by FanTraits via pointer. - std::vector supported_preset_modes_; + /// Preset mode storage — allocated on first use, never freed (entity lives forever). + std::vector *supported_preset_modes_{nullptr}; private: const char *preset_mode_{nullptr}; From 554d97ff04ba949d9f5cf64b8b417dcada8a6264 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:33:15 -1000 Subject: [PATCH 04/21] Fix clang-tidy: add braces around if statements, extract ensure helper --- esphome/components/fan/fan.h | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 2b76fa24da..eff57a343a 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -132,15 +132,10 @@ class Fan : public EntityBase { /// Set the supported preset modes (stored on Fan, referenced by FanTraits via pointer). void set_supported_preset_modes(std::initializer_list preset_modes) { - if (!this->supported_preset_modes_) - this->supported_preset_modes_ = - new std::vector(); // NOLINT - intentional leak, entity lives forever - *this->supported_preset_modes_ = preset_modes; + this->ensure_preset_modes_().assign(preset_modes.begin(), preset_modes.end()); } void set_supported_preset_modes(const std::vector &preset_modes) { - if (!this->supported_preset_modes_) - this->supported_preset_modes_ = new std::vector(); // NOLINT - *this->supported_preset_modes_ = preset_modes; + this->ensure_preset_modes_() = preset_modes; } /// Set the restore mode of this fan. @@ -184,7 +179,14 @@ class Fan : public EntityBase { ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; - /// Preset mode storage — allocated on first use, never freed (entity lives forever). + /// Lazy-allocate preset modes vector (never freed — entity lives forever). + std::vector &ensure_preset_modes_() { + if (!this->supported_preset_modes_) { + this->supported_preset_modes_ = new std::vector(); // NOLINT + } + return *this->supported_preset_modes_; + } + std::vector *supported_preset_modes_{nullptr}; private: From 5be5f1666222a622355e23de4a48b2af7692c02f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:52:41 -1000 Subject: [PATCH 05/21] Fix compat: heap-allocate deprecated vector, add find fallback Two bugs fixed from Copilot review: 1. Dangling pointer on copy: deprecated setters stored data in an OwnedPresetModes struct on FanTraits. If the traits object was copied, the copy's pointer dangled. Fix: deprecated setters now heap-allocate (intentional leak). Pointer survives any copy. Remove the OwnedPresetModes wrapper entirely. 2. find_preset_mode_ / save_state_ only searched the Fan-owned vector, breaking external components using the deprecated traits setters. Fix: fall back to get_traits() when the entity vector is null. --- esphome/components/fan/fan.cpp | 26 ++++++++++++++++--------- esphome/components/fan/fan_traits.h | 30 ++++++++++------------------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index f486c40ca4..3f4b7844c0 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -148,13 +148,19 @@ const char *Fan::find_preset_mode_(const char *preset_mode) { } const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { - if (preset_mode == nullptr || len == 0 || !this->supported_preset_modes_) + if (preset_mode == nullptr || len == 0) { return nullptr; - for (const char *mode : *this->supported_preset_modes_) { - if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') - return mode; } - return nullptr; + if (this->supported_preset_modes_) { + for (const char *mode : *this->supported_preset_modes_) { + if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { + return mode; + } + } + return nullptr; + } + // Fallback for deprecated path: external components may set modes on FanTraits directly + return this->get_traits().find_preset_mode(preset_mode, len); } bool Fan::set_preset_mode_(const char *preset_mode, size_t len) { @@ -274,10 +280,12 @@ void Fan::save_state_() { state.direction = this->direction; state.preset_mode = FanRestoreState::NO_PRESET; - if (this->has_preset_mode() && this->supported_preset_modes_) { - // Find index of current preset mode (pointer comparison is safe since preset is from our vector) - for (size_t i = 0; i < this->supported_preset_modes_->size(); i++) { - if ((*this->supported_preset_modes_)[i] == this->preset_mode_) { + if (this->has_preset_mode()) { + // Use Fan-owned vector, or fall back to traits for deprecated path + const auto &preset_modes = + this->supported_preset_modes_ ? *this->supported_preset_modes_ : this->get_traits().supported_preset_modes(); + for (size_t i = 0; i < preset_modes.size(); i++) { + if (preset_modes[i] == this->preset_mode_) { state.preset_mode = i; break; } diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index b5d6556e3b..b6549368c9 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -33,7 +33,8 @@ class FanTraits { void set_direction(bool direction) { this->direction_ = direction; } /// Return the preset modes supported by the fan. const std::vector &supported_preset_modes() const { - return this->preset_modes_ ? *this->preset_modes_ : this->owned_preset_modes_.modes; + static const std::vector EMPTY_VECTOR; + return this->preset_modes_ ? *this->preset_modes_ : EMPTY_VECTOR; } /// Set the preset modes pointer (points to vector owned by Fan base class). void set_supported_preset_modes(const std::vector *preset_modes) { this->preset_modes_ = preset_modes; } @@ -41,20 +42,17 @@ class FanTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(std::initializer_list preset_modes) { - this->owned_preset_modes_.modes = preset_modes; - this->preset_modes_ = &this->owned_preset_modes_.modes; + // NOLINT - intentional leak: pointer must survive copies of FanTraits + this->preset_modes_ = new std::vector(preset_modes); // NOLINT } // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(const std::vector &preset_modes) { - this->owned_preset_modes_.modes = preset_modes; - this->preset_modes_ = &this->owned_preset_modes_.modes; + this->preset_modes_ = new std::vector(preset_modes); // NOLINT } /// Return if preset modes are supported - bool supports_preset_modes() const { - return (this->preset_modes_ && !this->preset_modes_->empty()) || !this->owned_preset_modes_.modes.empty(); - } + bool supports_preset_modes() const { return this->preset_modes_ && !this->preset_modes_->empty(); } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { return this->find_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); @@ -62,7 +60,10 @@ class FanTraits { const char *find_preset_mode(const char *preset_mode, size_t len) const { if (preset_mode == nullptr || len == 0) return nullptr; - const auto &modes = this->preset_modes_ ? *this->preset_modes_ : this->owned_preset_modes_.modes; + if (!this->preset_modes_) { + return nullptr; + } + const auto &modes = *this->preset_modes_; for (const char *mode : modes) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { return mode; // Return pointer from traits @@ -77,17 +78,6 @@ class FanTraits { bool direction_{false}; int speed_count_{}; const std::vector *preset_modes_{nullptr}; - /** Compat storage for deprecated setters — skipped on copy to avoid overhead. - * Remove in 2026.11.0 along with the deprecated overloads. - */ - struct OwnedPresetModes { - std::vector modes; - OwnedPresetModes() = default; - OwnedPresetModes(const OwnedPresetModes &) {} // NOLINT - no-op copy: compat data is not propagated - OwnedPresetModes &operator=(const OwnedPresetModes &) { return *this; } // NOLINT - OwnedPresetModes(OwnedPresetModes &&) = default; - OwnedPresetModes &operator=(OwnedPresetModes &&) = default; - } owned_preset_modes_; }; } // namespace fan From 4838c61a8c36db6acd2ecdff7afaeadb4ad36712 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 14:02:27 -1000 Subject: [PATCH 06/21] Move preset modes pointer to private, consolidate empty vector to cpp - Move supported_preset_modes_ pointer and ensure helper to private - Move static EMPTY_VECTOR from inline header getter to a single file-scope constant in fan.cpp (avoids duplication per TU) - Add 2026.11.0 removal comments on all compat code paths --- esphome/components/fan/fan.cpp | 10 ++++++++++ esphome/components/fan/fan.h | 3 +-- esphome/components/fan/fan_traits.h | 7 ++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 3f4b7844c0..af34d28f7a 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -9,6 +9,16 @@ namespace fan { static const char *const TAG = "fan"; +// Compat: shared empty vector for getter when no preset modes are set. +// Remove in 2026.11.0 when deprecated FanTraits setters are removed +// and getter can return const vector * instead of const vector &. +static const std::vector EMPTY_PRESET_MODES; // NOLINT + +const std::vector &FanTraits::supported_preset_modes() const { + // Compat: return empty ref when pointer is null. Remove in 2026.11.0 (change return to const vector *). + return this->preset_modes_ ? *this->preset_modes_ : EMPTY_PRESET_MODES; +} + // Fan direction strings indexed by FanDirection enum (0-1): FORWARD, REVERSE, plus UNKNOWN PROGMEM_STRING_TABLE(FanDirectionStrings, "FORWARD", "REVERSE", "UNKNOWN"); diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index eff57a343a..b7d52aa15f 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -179,6 +179,7 @@ class Fan : public EntityBase { ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; + private: /// Lazy-allocate preset modes vector (never freed — entity lives forever). std::vector &ensure_preset_modes_() { if (!this->supported_preset_modes_) { @@ -188,8 +189,6 @@ class Fan : public EntityBase { } std::vector *supported_preset_modes_{nullptr}; - - private: const char *preset_mode_{nullptr}; }; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index b6549368c9..0098088ae6 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -31,11 +31,8 @@ class FanTraits { bool supports_direction() const { return this->direction_; } /// Set whether this fan supports changing direction void set_direction(bool direction) { this->direction_ = direction; } - /// Return the preset modes supported by the fan. - const std::vector &supported_preset_modes() const { - static const std::vector EMPTY_VECTOR; - return this->preset_modes_ ? *this->preset_modes_ : EMPTY_VECTOR; - } + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &supported_preset_modes() const; /// Set the preset modes pointer (points to vector owned by Fan base class). void set_supported_preset_modes(const std::vector *preset_modes) { this->preset_modes_ = preset_modes; } From 56f0d3f7f5ec70d50fdd7647c0eb1a3001caca38 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 14:08:07 -1000 Subject: [PATCH 07/21] Fix compat: use owned vector instead of leak, wire preset modes in subclasses - Deprecated FanTraits setters store in an owned compat_preset_modes_ vector (same copy cost as pre-PR). No heap leak, no dangling pointer. - Subclass get_traits() calls wire_preset_modes_() to attach the Fan-owned pointer to the returned traits. - Getter/find/supports all check compat vector as fallback. --- esphome/components/fan/fan.cpp | 10 +++++++-- esphome/components/fan/fan.h | 7 ++++++ esphome/components/fan/fan_traits.h | 22 +++++++++++-------- esphome/components/hbridge/fan/hbridge_fan.h | 5 ++++- esphome/components/speed/fan/speed_fan.h | 5 ++++- .../components/template/fan/template_fan.h | 5 ++++- 6 files changed, 40 insertions(+), 14 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index af34d28f7a..124c2105be 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -15,8 +15,14 @@ static const char *const TAG = "fan"; static const std::vector EMPTY_PRESET_MODES; // NOLINT const std::vector &FanTraits::supported_preset_modes() const { - // Compat: return empty ref when pointer is null. Remove in 2026.11.0 (change return to const vector *). - return this->preset_modes_ ? *this->preset_modes_ : EMPTY_PRESET_MODES; + if (this->preset_modes_) { + return *this->preset_modes_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0 (change return to const vector *). + if (!this->compat_preset_modes_.empty()) { + return this->compat_preset_modes_; + } + return EMPTY_PRESET_MODES; } // Fan direction strings indexed by FanDirection enum (0-1): FORWARD, REVERSE, plus UNKNOWN diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index b7d52aa15f..26b34d2bc6 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -175,6 +175,13 @@ class Fan : public EntityBase { const char *find_preset_mode_(const char *preset_mode); const char *find_preset_mode_(const char *preset_mode, size_t len); + /// Wire the Fan-owned preset modes pointer into the given traits object. + void wire_preset_modes_(FanTraits &traits) { + if (this->supported_preset_modes_) { + traits.set_supported_preset_modes(this->supported_preset_modes_); + } + } + LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 0098088ae6..6a003f535f 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -39,31 +39,32 @@ class FanTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(std::initializer_list preset_modes) { - // NOLINT - intentional leak: pointer must survive copies of FanTraits - this->preset_modes_ = new std::vector(preset_modes); // NOLINT + // Compat: store in owned vector. Copies copy the vector (same cost as before this PR). + this->compat_preset_modes_ = preset_modes; } // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(const std::vector &preset_modes) { - this->preset_modes_ = new std::vector(preset_modes); // NOLINT + this->compat_preset_modes_ = preset_modes; } /// Return if preset modes are supported - bool supports_preset_modes() const { return this->preset_modes_ && !this->preset_modes_->empty(); } + bool supports_preset_modes() const { + return (this->preset_modes_ && !this->preset_modes_->empty()) || !this->compat_preset_modes_.empty(); + } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { return this->find_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); } const char *find_preset_mode(const char *preset_mode, size_t len) const { - if (preset_mode == nullptr || len == 0) - return nullptr; - if (!this->preset_modes_) { + if (preset_mode == nullptr || len == 0) { return nullptr; } - const auto &modes = *this->preset_modes_; + // Check pointer-based storage (new path) then compat owned vector (deprecated path) + const auto &modes = this->preset_modes_ ? *this->preset_modes_ : this->compat_preset_modes_; for (const char *mode : modes) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { - return mode; // Return pointer from traits + return mode; } } return nullptr; @@ -75,6 +76,9 @@ class FanTraits { bool direction_{false}; int speed_count_{}; const std::vector *preset_modes_{nullptr}; + // Compat: owned storage for deprecated setters. Copies copy the vector (same cost as pre-PR). + // Remove in 2026.11.0. + std::vector compat_preset_modes_; }; } // namespace fan diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 7511e0eb0b..997f66ae48 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -24,7 +24,10 @@ class HBridgeFan : public Component, public fan::Fan { void setup() override; void dump_config() override; - fan::FanTraits get_traits() override { return this->traits_; } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } fan::FanCall brake(); diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index 44bd1a15cb..db96039a13 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -17,7 +17,10 @@ class SpeedFan : public Component, public fan::Fan { void set_oscillating(output::BinaryOutput *oscillating) { this->oscillating_ = oscillating; } void set_direction(output::BinaryOutput *direction) { this->direction_ = direction; } void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } - fan::FanTraits get_traits() override { return this->traits_; } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } protected: void control(const fan::FanCall &call) override; diff --git a/esphome/components/template/fan/template_fan.h b/esphome/components/template/fan/template_fan.h index d8229ef047..5ab6ae8c65 100644 --- a/esphome/components/template/fan/template_fan.h +++ b/esphome/components/template/fan/template_fan.h @@ -14,7 +14,10 @@ class TemplateFan final : public Component, public fan::Fan { void set_has_oscillating(bool has_oscillating) { this->has_oscillating_ = has_oscillating; } void set_speed_count(int count) { this->speed_count_ = count; } void set_preset_modes(std::initializer_list presets) { this->set_supported_preset_modes(presets); } - fan::FanTraits get_traits() override { return this->traits_; } + fan::FanTraits get_traits() override { + this->wire_preset_modes_(this->traits_); + return this->traits_; + } protected: void control(const fan::FanCall &call) override; From c42cfac50fc3f162ee0ea1df9fd795a64c9d4f86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 14:28:55 -1000 Subject: [PATCH 08/21] Fix dangling reference in save_state_, restore deleted string overloads 1. save_state_() bound a reference to supported_preset_modes() from a temporary FanTraits returned by get_traits(). When the compat path fired, the temporary was destroyed and the reference dangled. Fix: keep the FanTraits alive for the loop duration. 2. Restore deleted std::vector / initializer_list overloads to preserve clear compile-time diagnostics. --- esphome/components/fan/fan.cpp | 6 ++++-- esphome/components/fan/fan_traits.h | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 124c2105be..fb9654da5b 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -297,9 +297,11 @@ void Fan::save_state_() { state.preset_mode = FanRestoreState::NO_PRESET; if (this->has_preset_mode()) { - // Use Fan-owned vector, or fall back to traits for deprecated path + // Use Fan-owned vector, or fall back to traits for deprecated path. + // Keep traits alive so the reference to compat_preset_modes_ doesn't dangle. + auto traits = this->supported_preset_modes_ ? FanTraits() : this->get_traits(); const auto &preset_modes = - this->supported_preset_modes_ ? *this->supported_preset_modes_ : this->get_traits().supported_preset_modes(); + this->supported_preset_modes_ ? *this->supported_preset_modes_ : traits.supported_preset_modes(); for (size_t i = 0; i < preset_modes.size(); i++) { if (preset_modes[i] == this->preset_mode_) { state.preset_mode = i; diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 6a003f535f..046ae50929 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -48,6 +48,10 @@ class FanTraits { this->compat_preset_modes_ = preset_modes; } + // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages + void set_supported_preset_modes(const std::vector &preset_modes) = delete; + void set_supported_preset_modes(std::initializer_list preset_modes) = delete; + /// Return if preset modes are supported bool supports_preset_modes() const { return (this->preset_modes_ && !this->preset_modes_->empty()) || !this->compat_preset_modes_.empty(); From 2f93ba0d71bf5ed722b59c7930aef988985b91ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:21:11 -1000 Subject: [PATCH 09/21] Add integration test for deprecated FanTraits compat layer --- .../legacy_fan_component/__init__.py | 1 + .../legacy_fan_component/fan/__init__.py | 19 +++++ .../legacy_fan_component/fan/legacy_fan.h | 49 ++++++++++++ .../fixtures/legacy_fan_compat.yaml | 21 +++++ tests/integration/test_legacy_fan_compat.py | 79 +++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h create mode 100644 tests/integration/fixtures/legacy_fan_compat.yaml create mode 100644 tests/integration/test_legacy_fan_compat.py diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py new file mode 100644 index 0000000000..ceee477522 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py @@ -0,0 +1 @@ +"""Legacy fan component — tests deprecated FanTraits setters backward compat.""" diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py new file mode 100644 index 0000000000..72655dec95 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py @@ -0,0 +1,19 @@ +"""Legacy fan platform that uses deprecated FanTraits setters.""" + +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv + +legacy_fan_ns = cg.esphome_ns.namespace("legacy_fan_test") +LegacyFan = legacy_fan_ns.class_("LegacyFan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = fan.FAN_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(LegacyFan), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = await fan.new_fan(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h new file mode 100644 index 0000000000..3f8937905e --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/components/fan/fan.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace legacy_fan_test { + +/// Test fan that uses the DEPRECATED FanTraits setters for preset modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyFan : public fan::Fan, public Component { + public: + void setup() override { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(*this); + } + this->publish_state(); + } + + float get_setup_priority() const override { return setup_priority::LATE; } + + fan::FanTraits get_traits() override { + auto traits = fan::FanTraits(false, true, false, 3); + + // DEPRECATED API: setting preset modes directly on FanTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_preset_modes({"Turbo", "Silent", "Eco"}); +#pragma GCC diagnostic pop + + return traits; + } + + protected: + void control(const fan::FanCall &call) override { + if (call.get_state().has_value()) { + this->state = *call.get_state(); + } + if (call.get_speed().has_value()) { + this->speed = *call.get_speed(); + } + this->apply_preset_mode_(call); + this->publish_state(); + } +}; + +} // namespace legacy_fan_test +} // namespace esphome diff --git a/tests/integration/fixtures/legacy_fan_compat.yaml b/tests/integration/fixtures/legacy_fan_compat.yaml new file mode 100644 index 0000000000..d510f8adea --- /dev/null +++ b/tests/integration/fixtures/legacy_fan_compat.yaml @@ -0,0 +1,21 @@ +esphome: + name: legacy-fan-compat + platformio_options: + build_flags: + - "-DUSE_HOST" + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_fan_component] + +fan: + - platform: legacy_fan_component + name: "Legacy Fan" + id: legacy_fan diff --git a/tests/integration/test_legacy_fan_compat.py b/tests/integration/test_legacy_fan_compat.py new file mode 100644 index 0000000000..4ac8585e5a --- /dev/null +++ b/tests/integration/test_legacy_fan_compat.py @@ -0,0 +1,79 @@ +"""Integration test for backward compatibility of deprecated FanTraits setters. + +Verifies that external components using the old traits.set_supported_preset_modes() +API still work correctly during the deprecation period (removed in 2026.11.0). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import FanInfo, FanState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_fan_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated FanTraits preset mode setters still work end-to-end.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + + fan_infos = [e for e in entities if isinstance(e, FanInfo)] + assert len(fan_infos) == 1, f"Expected 1 fan entity, got {len(fan_infos)}" + + test_fan = fan_infos[0] + + # Verify preset modes set via deprecated FanTraits setter are exposed + assert set(test_fan.supported_preset_modes) == { + "Turbo", + "Silent", + "Eco", + }, ( + f"Expected preset modes {{Turbo, Silent, Eco}}, " + f"got {test_fan.supported_preset_modes}" + ) + + # Verify speed support + assert test_fan.supports_speed is True + assert test_fan.supported_speed_count == 3 + + # Subscribe and wait for initial states + states: dict[int, FanState] = {} + state_event = asyncio.Event() + + def on_state(state: FanState) -> None: + if isinstance(state, FanState): + states[state.key] = state + state_event.set() + + client.subscribe_states(on_state) + + # Wait for initial state + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + # Turn on fan with preset mode (tests find_preset_mode_ compat path) + state_event.clear() + client.fan_command( + key=test_fan.key, + state=True, + preset_mode="Turbo", + ) + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + fan_state = states[test_fan.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Turbo" From c8d842586ab16b1ce97a8d8ccf9322d526f829eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:23:44 -1000 Subject: [PATCH 10/21] Fix fan fixture: remove unnecessary build flags --- tests/integration/fixtures/legacy_fan_compat.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/integration/fixtures/legacy_fan_compat.yaml b/tests/integration/fixtures/legacy_fan_compat.yaml index d510f8adea..256fd4e4c1 100644 --- a/tests/integration/fixtures/legacy_fan_compat.yaml +++ b/tests/integration/fixtures/legacy_fan_compat.yaml @@ -1,8 +1,5 @@ esphome: name: legacy-fan-compat - platformio_options: - build_flags: - - "-DUSE_HOST" host: api: From 576b1c3ff0256279a77fdabfc04e44b2d8111ed4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:25:13 -1000 Subject: [PATCH 11/21] Fix fan fixture: use C++17 namespace style --- .../legacy_fan_component/fan/legacy_fan.h | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h index 3f8937905e..e6135d60be 100644 --- a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h @@ -3,8 +3,7 @@ #include "esphome/components/fan/fan.h" #include "esphome/core/component.h" -namespace esphome { -namespace legacy_fan_test { +namespace esphome::legacy_fan_test { /// Test fan that uses the DEPRECATED FanTraits setters for preset modes. /// This validates backward compatibility for external components that haven't migrated. @@ -45,5 +44,4 @@ class LegacyFan : public fan::Fan, public Component { } }; -} // namespace legacy_fan_test -} // namespace esphome +} // namespace esphome::legacy_fan_test From 95cdc908350d3b2114700e7de13974a9df856e7e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:27:20 -1000 Subject: [PATCH 12/21] Fix to_code signature: add ConfigType annotation --- .../external_components/legacy_fan_component/fan/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py index 72655dec95..ccd5106a63 100644 --- a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import fan import esphome.config_validation as cv +from esphome.types import ConfigType legacy_fan_ns = cg.esphome_ns.namespace("legacy_fan_test") LegacyFan = legacy_fan_ns.class_("LegacyFan", fan.Fan, cg.Component) @@ -14,6 +15,6 @@ CONFIG_SCHEMA = fan.FAN_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) From 3328423373c69f8026f905d98785647d90359211 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:31:27 -1000 Subject: [PATCH 13/21] Move pointer setter to protected with friend class Fan --- esphome/components/fan/fan_traits.h | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 046ae50929..05810ceea4 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -9,7 +9,11 @@ namespace esphome { namespace fan { +class Fan; // Forward declaration + class FanTraits { + friend class Fan; // Allow Fan to access protected pointer setter + public: FanTraits() = default; FanTraits(bool oscillation, bool speed, bool direction, int speed_count) @@ -33,9 +37,6 @@ class FanTraits { void set_direction(bool direction) { this->direction_ = direction; } // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. const std::vector &supported_preset_modes() const; - /// Set the preset modes pointer (points to vector owned by Fan base class). - void set_supported_preset_modes(const std::vector *preset_modes) { this->preset_modes_ = preset_modes; } - // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(std::initializer_list preset_modes) { @@ -75,6 +76,9 @@ class FanTraits { } protected: + /// Set the preset modes pointer (only Fan::wire_preset_modes_() should call this). + void set_supported_preset_modes(const std::vector *preset_modes) { this->preset_modes_ = preset_modes; } + bool oscillation_{false}; bool speed_{false}; bool direction_{false}; From 7c259d2f6426c835c98cc6fbe27b8ebe07f609af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:39:29 -1000 Subject: [PATCH 14/21] Simplify save_state_ compat path, move CopyFan preset copy to setup() - save_state_(): split into two clear branches instead of conditional FanTraits construction trick - CopyFan: copy source preset modes once in setup() instead of on every get_traits() call --- esphome/components/copy/fan/copy_fan.cpp | 10 +++++++-- esphome/components/fan/fan.cpp | 26 ++++++++++++++++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index e7d01827aa..bdaa35c467 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -7,6 +7,12 @@ namespace copy { static const char *const TAG = "copy.fan"; void CopyFan::setup() { + // Copy preset modes once from source fan — stored on Fan base class + auto source_traits = source_->get_traits(); + if (source_traits.supports_preset_modes()) { + this->set_supported_preset_modes(source_traits.supported_preset_modes()); + } + source_->add_on_state_callback([this]() { this->copy_state_from_source_(); this->publish_state(); @@ -39,8 +45,8 @@ fan::FanTraits CopyFan::get_traits() { traits.set_speed(base.supports_speed()); traits.set_supported_speed_count(base.supported_speed_count()); traits.set_direction(base.supports_direction()); - // Preset modes are wired from source fan's storage via get_traits() - this->set_supported_preset_modes(base.supported_preset_modes()); + // Preset modes are set once in setup() and wired via wire_preset_modes_() + this->wire_preset_modes_(traits); return traits; } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index fb9654da5b..563c0eff13 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -297,15 +297,23 @@ void Fan::save_state_() { state.preset_mode = FanRestoreState::NO_PRESET; if (this->has_preset_mode()) { - // Use Fan-owned vector, or fall back to traits for deprecated path. - // Keep traits alive so the reference to compat_preset_modes_ doesn't dangle. - auto traits = this->supported_preset_modes_ ? FanTraits() : this->get_traits(); - const auto &preset_modes = - this->supported_preset_modes_ ? *this->supported_preset_modes_ : traits.supported_preset_modes(); - for (size_t i = 0; i < preset_modes.size(); i++) { - if (preset_modes[i] == this->preset_mode_) { - state.preset_mode = i; - break; + if (this->supported_preset_modes_) { + // New path: search Fan-owned vector directly + for (size_t i = 0; i < this->supported_preset_modes_->size(); i++) { + if ((*this->supported_preset_modes_)[i] == this->preset_mode_) { + state.preset_mode = i; + break; + } + } + } else { + // Compat: fall back to traits for deprecated path. Remove in 2026.11.0. + auto traits = this->get_traits(); + const auto &preset_modes = traits.supported_preset_modes(); + for (size_t i = 0; i < preset_modes.size(); i++) { + if (preset_modes[i] == this->preset_mode_) { + state.preset_mode = i; + break; + } } } } From 3388d420ec3c3cadbff3a2db993c45ccc35b2af1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:42:36 -1000 Subject: [PATCH 15/21] Fix unused services variable in test --- tests/integration/test_legacy_fan_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_legacy_fan_compat.py b/tests/integration/test_legacy_fan_compat.py index 4ac8585e5a..a4b025b628 100644 --- a/tests/integration/test_legacy_fan_compat.py +++ b/tests/integration/test_legacy_fan_compat.py @@ -30,7 +30,7 @@ async def test_legacy_fan_compat( ) async with run_compiled(yaml_config), api_client_connected() as client: - entities, services = await client.list_entities_services() + entities, _ = await client.list_entities_services() fan_infos = [e for e in entities if isinstance(e, FanInfo)] assert len(fan_infos) == 1, f"Expected 1 fan entity, got {len(fan_infos)}" From 7513db9dcd70dde147181f56b711b0f42737a54c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:50:10 -1000 Subject: [PATCH 16/21] Fix clang-tidy: protected method needs trailing underscore --- esphome/components/fan/fan.h | 2 +- esphome/components/fan/fan_traits.h | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 26b34d2bc6..d5763edf2f 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -178,7 +178,7 @@ class Fan : public EntityBase { /// Wire the Fan-owned preset modes pointer into the given traits object. void wire_preset_modes_(FanTraits &traits) { if (this->supported_preset_modes_) { - traits.set_supported_preset_modes(this->supported_preset_modes_); + traits.set_supported_preset_modes_(this->supported_preset_modes_); } } diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 05810ceea4..78c215dc8c 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -77,7 +77,9 @@ class FanTraits { protected: /// Set the preset modes pointer (only Fan::wire_preset_modes_() should call this). - void set_supported_preset_modes(const std::vector *preset_modes) { this->preset_modes_ = preset_modes; } + void set_supported_preset_modes_(const std::vector *preset_modes) { + this->preset_modes_ = preset_modes; + } bool oscillation_{false}; bool speed_{false}; From 794b7ef56271e83d55ce869fedd09c369723a431 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:55:49 -1000 Subject: [PATCH 17/21] Fix supports_preset_modes: use same precedence as getter --- esphome/components/fan/fan_traits.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 78c215dc8c..229dd5a0c5 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -55,7 +55,11 @@ class FanTraits { /// Return if preset modes are supported bool supports_preset_modes() const { - return (this->preset_modes_ && !this->preset_modes_->empty()) || !this->compat_preset_modes_.empty(); + // Same precedence as supported_preset_modes() getter + if (this->preset_modes_) { + return !this->preset_modes_->empty(); + } + return !this->compat_preset_modes_.empty(); } /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. const char *find_preset_mode(const char *preset_mode) const { From 541ab9abf96a8049ee4270bc7d49b3475bb3ad20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 16:07:20 -1000 Subject: [PATCH 18/21] Fix fan test: use fan_schema(), remove unnecessary get_setup_priority --- .../legacy_fan_component/fan/__init__.py | 6 +----- .../legacy_fan_component/fan/legacy_fan.h | 2 -- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py index ccd5106a63..985d97d081 100644 --- a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py @@ -8,11 +8,7 @@ from esphome.types import ConfigType legacy_fan_ns = cg.esphome_ns.namespace("legacy_fan_test") LegacyFan = legacy_fan_ns.class_("LegacyFan", fan.Fan, cg.Component) -CONFIG_SCHEMA = fan.FAN_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(LegacyFan), - } -).extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = fan.fan_schema(LegacyFan).extend(cv.COMPONENT_SCHEMA) async def to_code(config: ConfigType) -> None: diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h index e6135d60be..ac378b59c5 100644 --- a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h @@ -17,8 +17,6 @@ class LegacyFan : public fan::Fan, public Component { this->publish_state(); } - float get_setup_priority() const override { return setup_priority::LATE; } - fan::FanTraits get_traits() override { auto traits = fan::FanTraits(false, true, false, 3); From cfbba0af9033e3fde8316b5f0c58dd5da4052e3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 16:18:01 -1000 Subject: [PATCH 19/21] Add comment about string literal pointer stability in compat save_state_ --- esphome/components/fan/fan.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 563c0eff13..9301e0cea4 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -307,6 +307,8 @@ void Fan::save_state_() { } } else { // Compat: fall back to traits for deprecated path. Remove in 2026.11.0. + // Pointer comparison works because preset_mode_ and the compat vector both + // hold pointers to string literals in .rodata (stable addresses). auto traits = this->get_traits(); const auto &preset_modes = traits.supported_preset_modes(); for (size_t i = 0; i < preset_modes.size(); i++) { From 05a5c27b90f34a01926d9d80c0209911f6cae478 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 16:21:30 -1000 Subject: [PATCH 20/21] Add 2026.11.0 removal comments to integration test and fixture --- .../external_components/legacy_fan_component/__init__.py | 5 ++++- tests/integration/test_legacy_fan_compat.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py index ceee477522..714be181fe 100644 --- a/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py +++ b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py @@ -1 +1,4 @@ -"""Legacy fan component — tests deprecated FanTraits setters backward compat.""" +"""Legacy fan component — tests deprecated FanTraits setters backward compat. + +Remove this entire directory in 2026.11.0 when the deprecated setters are removed. +""" diff --git a/tests/integration/test_legacy_fan_compat.py b/tests/integration/test_legacy_fan_compat.py index a4b025b628..5ee41772f3 100644 --- a/tests/integration/test_legacy_fan_compat.py +++ b/tests/integration/test_legacy_fan_compat.py @@ -1,7 +1,10 @@ """Integration test for backward compatibility of deprecated FanTraits setters. Verifies that external components using the old traits.set_supported_preset_modes() -API still work correctly during the deprecation period (removed in 2026.11.0). +API still work correctly during the deprecation period. + +Remove this entire test file and the legacy_fan_component external component +in 2026.11.0 when the deprecated FanTraits setters are removed. """ from __future__ import annotations From 29bc97cdb85458308d819957fb5dd3e7dfd42697 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 17:45:20 -1000 Subject: [PATCH 21/21] Fix comments: remove PR-relative wording --- esphome/components/fan/fan_traits.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index 229dd5a0c5..a2b2633af1 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -40,7 +40,7 @@ class FanTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_preset_modes() on the Fan entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_preset_modes(std::initializer_list preset_modes) { - // Compat: store in owned vector. Copies copy the vector (same cost as before this PR). + // Compat: store in owned vector. Copies copy the vector (deprecated path still copies this vector). this->compat_preset_modes_ = preset_modes; } // Remove before 2026.11.0 @@ -90,7 +90,7 @@ class FanTraits { bool direction_{false}; int speed_count_{}; const std::vector *preset_modes_{nullptr}; - // Compat: owned storage for deprecated setters. Copies copy the vector (same cost as pre-PR). + // Compat: owned storage for deprecated setters. Copies copy the vector (copies include this vector). // Remove in 2026.11.0. std::vector compat_preset_modes_; };