From 7ae1f2f6d7686c35e0ddaffba1d89bda6e3d06af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 12:38:25 -1000 Subject: [PATCH 01/27] [climate] Store custom mode vectors on Climate entity, not ClimateTraits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClimateTraits contained two std::vector members for custom fan modes and custom presets. Every get_traits() / traits() call reconstructed these vectors, causing heap allocations on every publish_state() and control()/perform() call — the hottest paths in the climate component. Move the vector storage to the Climate base class and have ClimateTraits hold const pointers instead. get_traits() wires the pointers automatically. This eliminates all heap allocation from ClimateTraits copies, making the struct trivially copyable (floats + bitmasks + 2 pointers). Additionally, set_custom_fan_mode_() and set_custom_preset_() no longer need to call get_traits() just to search for a mode — they search the Climate-owned vectors directly, removing another traits rebuild from the control/perform path. --- .../bedjet/climate/bedjet_climate.h | 7 ++- esphome/components/climate/climate.cpp | 17 +++--- esphome/components/climate/climate.h | 35 ++++++++++- esphome/components/climate/climate_traits.h | 60 +++++++------------ esphome/components/demo/demo_climate.h | 6 +- esphome/components/midea/ac_adapter.cpp | 4 +- esphome/components/midea/air_conditioner.cpp | 10 ++-- esphome/components/midea/air_conditioner.h | 6 +- .../thermostat/thermostat_climate.cpp | 17 +++--- 9 files changed, 89 insertions(+), 73 deletions(-) diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index 05f4a849e07..1d2f28b7cd9 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -42,16 +42,17 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli climate::CLIMATE_MODE_DRY, }); - // It would be better if we had a slider for the fan modes. - traits.set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); traits.set_supported_presets({ // If we support NONE, then have to decide what happens if the user switches to it (turn off?) // climate::CLIMATE_PRESET_NONE, // Climate doesn't have a "TURBO" mode, but we can use the BOOST preset instead. climate::CLIMATE_PRESET_BOOST, }); + // Custom fan modes and presets are stored on Climate base class and wired via get_traits() + // It would be better if we had a slider for the fan modes. + this->set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); // String literals are stored in rodata and valid for program lifetime - traits.set_supported_custom_presets({ + this->set_supported_custom_presets({ this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", "M1", "M2", diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 32cac0961c6..51d91469c17 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -484,6 +484,11 @@ void Climate::publish_state() { ClimateTraits Climate::get_traits() { auto traits = this->traits(); + // Wire custom mode pointers from Climate-owned storage + if (!this->supported_custom_fan_modes_.empty()) + traits.set_supported_custom_fan_modes(&this->supported_custom_fan_modes_); + if (!this->supported_custom_presets_.empty()) + traits.set_supported_custom_presets(&this->supported_custom_presets_); #ifdef USE_CLIMATE_VISUAL_OVERRIDES if (!std::isnan(this->visual_min_temperature_override_)) { traits.set_visual_min_temperature(this->visual_min_temperature_override_); @@ -681,9 +686,8 @@ bool Climate::set_fan_mode_(ClimateFanMode mode) { } bool Climate::set_custom_fan_mode_(const char *mode, size_t len) { - auto traits = this->get_traits(); - return set_custom_mode(this->custom_fan_mode_, this->fan_mode, - traits.find_custom_fan_mode_(mode, len), this->has_custom_fan_mode()); + return set_custom_mode(this->custom_fan_mode_, this->fan_mode, this->find_custom_fan_mode_(mode, len), + this->has_custom_fan_mode()); } void Climate::clear_custom_fan_mode_() { this->custom_fan_mode_ = nullptr; } @@ -691,8 +695,7 @@ void Climate::clear_custom_fan_mode_() { this->custom_fan_mode_ = nullptr; } bool Climate::set_preset_(ClimatePreset preset) { return set_primary_mode(this->preset, this->custom_preset_, preset); } bool Climate::set_custom_preset_(const char *preset, size_t len) { - auto traits = this->get_traits(); - return set_custom_mode(this->custom_preset_, this->preset, traits.find_custom_preset_(preset, len), + return set_custom_mode(this->custom_preset_, this->preset, this->find_custom_preset_(preset, len), this->has_custom_preset()); } @@ -703,7 +706,7 @@ const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { } const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) { - return this->get_traits().find_custom_fan_mode_(custom_fan_mode, len); + return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); } const char *Climate::find_custom_preset_(const char *custom_preset) { @@ -711,7 +714,7 @@ const char *Climate::find_custom_preset_(const char *custom_preset) { } const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { - return this->get_traits().find_custom_preset_(custom_preset, len); + return vector_find(this->supported_custom_presets_, custom_preset, len); } void Climate::dump_traits_(const char *tag) { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 0251365dd8a..6214afcbe32 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -234,6 +234,28 @@ class Climate : public EntityBase { void set_visual_max_humidity_override(float visual_max_humidity_override); #endif + /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). + void set_supported_custom_fan_modes(std::initializer_list modes) { + this->supported_custom_fan_modes_ = modes; + } + void set_supported_custom_fan_modes(const std::vector &modes) { + this->supported_custom_fan_modes_ = modes; + } + template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { + this->supported_custom_fan_modes_.assign(modes, modes + N); + } + + /// Set the supported custom presets (stored on Climate, referenced by ClimateTraits). + void set_supported_custom_presets(std::initializer_list presets) { + this->supported_custom_presets_ = presets; + } + void set_supported_custom_presets(const std::vector &presets) { + this->supported_custom_presets_ = presets; + } + template void set_supported_custom_presets(const char *const (&presets)[N]) { + this->supported_custom_presets_.assign(presets, presets + N); + } + /// Check if a custom fan mode is currently active. bool has_custom_fan_mode() const { return this->custom_fan_mode_ != nullptr; } @@ -336,13 +358,20 @@ class Climate : public EntityBase { * called from publish_state() */ void save_state_(const ClimateTraits &traits); - void save_state_() { this->save_state_(this->traits()); } + void save_state_() { this->save_state_(this->get_traits()); } void dump_traits_(const char *tag); LazyCallbackManager state_callback_{}; LazyCallbackManager control_callback_{}; ESPPreferenceObject rtc_; + + /** Custom mode storage - owned by Climate, referenced by ClimateTraits via pointer. + * Pointers in these vectors must point to string literals or static data. + */ + std::vector supported_custom_fan_modes_; + std::vector supported_custom_presets_; + #ifdef USE_CLIMATE_VISUAL_OVERRIDES float visual_min_temperature_override_{NAN}; float visual_max_temperature_override_{NAN}; @@ -355,14 +384,14 @@ class Climate : public EntityBase { private: /** The active custom fan mode (private - enforces use of safe setters). * - * Points to an entry in traits.supported_custom_fan_modes_ or nullptr. + * Points to an entry in supported_custom_fan_modes_ or nullptr. * Use get_custom_fan_mode() to read, set_custom_fan_mode_() to modify. */ const char *custom_fan_mode_{nullptr}; /** The active custom preset (private - enforces use of safe setters). * - * Points to an entry in traits.supported_custom_presets_ or nullptr. + * Points to an entry in supported_custom_presets_ or nullptr. * Use get_custom_preset() to read, set_custom_preset_() to modify. */ const char *custom_preset_{nullptr}; diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 80ef0854d59..c0e844126f6 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -147,27 +147,21 @@ class ClimateTraits { void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); } bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); } bool get_supports_fan_modes() const { - return !this->supported_fan_modes_.empty() || !this->supported_custom_fan_modes_.empty(); + return !this->supported_fan_modes_.empty() || + (this->supported_custom_fan_modes_ && !this->supported_custom_fan_modes_->empty()); } const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; } - void set_supported_custom_fan_modes(std::initializer_list modes) { + void set_supported_custom_fan_modes(const std::vector *modes) { this->supported_custom_fan_modes_ = modes; } - void set_supported_custom_fan_modes(const std::vector &modes) { - this->supported_custom_fan_modes_ = modes; - } - template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { - this->supported_custom_fan_modes_.assign(modes, modes + N); - } - // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages - void set_supported_custom_fan_modes(const std::vector &modes) = delete; - void set_supported_custom_fan_modes(std::initializer_list modes) = delete; - - const std::vector &get_supported_custom_fan_modes() const { return this->supported_custom_fan_modes_; } + const std::vector &get_supported_custom_fan_modes() const { + static const std::vector empty; + return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : empty; + } bool supports_custom_fan_mode(const char *custom_fan_mode) const { - return vector_contains(this->supported_custom_fan_modes_, custom_fan_mode); + return this->supported_custom_fan_modes_ && vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode); } bool supports_custom_fan_mode(const std::string &custom_fan_mode) const { return this->supports_custom_fan_mode(custom_fan_mode.c_str()); @@ -179,23 +173,16 @@ class ClimateTraits { bool get_supports_presets() const { return !this->supported_presets_.empty(); } const ClimatePresetMask &get_supported_presets() const { return this->supported_presets_; } - void set_supported_custom_presets(std::initializer_list presets) { + void set_supported_custom_presets(const std::vector *presets) { this->supported_custom_presets_ = presets; } - void set_supported_custom_presets(const std::vector &presets) { - this->supported_custom_presets_ = presets; - } - template void set_supported_custom_presets(const char *const (&presets)[N]) { - this->supported_custom_presets_.assign(presets, presets + N); - } - // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages - void set_supported_custom_presets(const std::vector &presets) = delete; - void set_supported_custom_presets(std::initializer_list presets) = delete; - - const std::vector &get_supported_custom_presets() const { return this->supported_custom_presets_; } + const std::vector &get_supported_custom_presets() const { + static const std::vector empty; + return this->supported_custom_presets_ ? *this->supported_custom_presets_ : empty; + } bool supports_custom_preset(const char *custom_preset) const { - return vector_contains(this->supported_custom_presets_, custom_preset); + return this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset); } bool supports_custom_preset(const std::string &custom_preset) const { return this->supports_custom_preset(custom_preset.c_str()); @@ -264,7 +251,8 @@ class ClimateTraits { return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); } const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len) const { - return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); + return this->supported_custom_fan_modes_ ? vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len) + : nullptr; } /// Find and return the matching custom preset pointer from supported presets, or nullptr if not found @@ -273,7 +261,8 @@ class ClimateTraits { return this->find_custom_preset_(custom_preset, strlen(custom_preset)); } const char *find_custom_preset_(const char *custom_preset, size_t len) const { - return vector_find(this->supported_custom_presets_, custom_preset, len); + return this->supported_custom_presets_ ? vector_find(*this->supported_custom_presets_, custom_preset, len) + : nullptr; } uint32_t feature_flags_{0}; @@ -289,16 +278,13 @@ class ClimateTraits { climate::ClimateSwingModeMask supported_swing_modes_; climate::ClimatePresetMask supported_presets_; - /** Custom mode storage using const char* pointers to eliminate std::string overhead. + /** Custom mode storage - pointers to vectors owned by the Climate base class. * - * Pointers must remain valid for the ClimateTraits lifetime. Safe patterns: - * - String literals: set_supported_custom_fan_modes({"Turbo", "Silent"}) - * - Static const data: static const char* MODE = "Eco"; - * - * Climate class setters validate pointers are from these vectors before storing. + * ClimateTraits does not own this data; Climate stores the vectors and + * get_traits() wires these pointers automatically. */ - std::vector supported_custom_fan_modes_; - std::vector supported_custom_presets_; + const std::vector *supported_custom_fan_modes_{nullptr}; + const std::vector *supported_custom_presets_{nullptr}; }; } // namespace esphome::climate diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index c5f07ac1145..1706ba804e5 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -105,14 +105,14 @@ class DemoClimate : public climate::Climate, public Component { climate::CLIMATE_FAN_DIFFUSE, climate::CLIMATE_FAN_QUIET, }); - traits.set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); traits.set_supported_swing_modes({ climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, }); - traits.set_supported_custom_presets({"My Preset"}); + this->set_supported_custom_presets({"My Preset"}); break; case DemoClimateType::TYPE_3: traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | @@ -123,7 +123,7 @@ class DemoClimate : public climate::Climate, public Component { climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL, }); - traits.set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); traits.set_supported_swing_modes({ climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index d903db4a1b8..8b20a562c8b 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -168,8 +168,8 @@ void Converters::to_climate_traits(ClimateTraits &traits, const dudanov::midea:: traits.add_supported_preset(ClimatePreset::CLIMATE_PRESET_BOOST); if (capabilities.supportEcoPreset()) traits.add_supported_preset(ClimatePreset::CLIMATE_PRESET_ECO); - if (capabilities.supportFrostProtectionPreset()) - traits.set_supported_custom_presets({Constants::FREEZE_PROTECTION}); + // Frost protection custom preset is handled by AirConditioner directly + // since custom presets are stored on the Climate base class } } // namespace ac diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 4d59a4fbbca..235698128ab 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -91,17 +91,17 @@ ClimateTraits AirConditioner::traits() { traits.set_supported_modes(this->supported_modes_); traits.set_supported_swing_modes(this->supported_swing_modes_); traits.set_supported_presets(this->supported_presets_); - if (!this->supported_custom_presets_.empty()) - traits.set_supported_custom_presets(this->supported_custom_presets_); - if (!this->supported_custom_fan_modes_.empty()) - traits.set_supported_custom_fan_modes(this->supported_custom_fan_modes_); + // Custom fan modes and presets are stored on Climate base class and wired via get_traits() /* + MINIMAL SET OF CAPABILITIES */ traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_AUTO); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_LOW); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_MEDIUM); traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_HIGH); - if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) + if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { Converters::to_climate_traits(traits, this->base_.getCapabilities()); + if (this->base_.getCapabilities().supportFrostProtectionPreset()) + this->set_supported_custom_presets({Constants::FREEZE_PROTECTION}); + } if (!traits.get_supported_modes().empty()) traits.add_supported_mode(ClimateMode::CLIMATE_MODE_OFF); if (!traits.get_supported_swing_modes().empty()) diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 70833b8bcca..da248346868 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -46,8 +46,8 @@ class AirConditioner : public ApplianceBase, void set_supported_modes(ClimateModeMask modes) { this->supported_modes_ = modes; } void set_supported_swing_modes(ClimateSwingModeMask modes) { this->supported_swing_modes_ = modes; } void set_supported_presets(ClimatePresetMask presets) { this->supported_presets_ = presets; } - void set_custom_presets(std::initializer_list presets) { this->supported_custom_presets_ = presets; } - void set_custom_fan_modes(std::initializer_list modes) { this->supported_custom_fan_modes_ = modes; } + void set_custom_presets(std::initializer_list presets) { this->set_supported_custom_presets(presets); } + void set_custom_fan_modes(std::initializer_list modes) { this->set_supported_custom_fan_modes(modes); } protected: void control(const ClimateCall &call) override; @@ -55,8 +55,6 @@ class AirConditioner : public ApplianceBase, ClimateModeMask supported_modes_{}; ClimateSwingModeMask supported_swing_modes_{}; ClimatePresetMask supported_presets_{}; - std::vector supported_custom_presets_{}; - std::vector supported_custom_fan_modes_{}; Sensor *outdoor_sensor_{nullptr}; Sensor *humidity_sensor_{nullptr}; Sensor *power_sensor_{nullptr}; diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d52a22f880d..453df6859a6 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -332,15 +332,7 @@ climate::ClimateTraits ThermostatClimate::traits() { traits.add_supported_preset(entry.preset); } - // Extract custom preset names from the custom_preset_config_ vector - if (!this->custom_preset_config_.empty()) { - std::vector custom_preset_names; - custom_preset_names.reserve(this->custom_preset_config_.size()); - for (const auto &entry : this->custom_preset_config_) { - custom_preset_names.push_back(entry.name); - } - traits.set_supported_custom_presets(custom_preset_names); - } + // Custom presets are stored on Climate base class and wired via get_traits() return traits; } @@ -1293,6 +1285,13 @@ void ThermostatClimate::set_preset_config(std::initializer_list pre void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { this->custom_preset_config_ = presets; + // Populate Climate base class custom presets vector + std::vector names; + names.reserve(presets.size()); + for (const auto &entry : this->custom_preset_config_) { + names.push_back(entry.name); + } + this->set_supported_custom_presets(names); } ThermostatClimate::ThermostatClimate() = default; From 013d8324752865903ade68c4455e41c4e8b0cef2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 12:42:15 -1000 Subject: [PATCH 02/27] Fix clang-tidy: rename static constant empty to EMPTY --- esphome/components/climate/climate_traits.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index c0e844126f6..1b80846729c 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -157,8 +157,8 @@ class ClimateTraits { } const std::vector &get_supported_custom_fan_modes() const { - static const std::vector empty; - return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : empty; + static const std::vector EMPTY; + return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : EMPTY; } bool supports_custom_fan_mode(const char *custom_fan_mode) const { return this->supported_custom_fan_modes_ && vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode); @@ -178,8 +178,8 @@ class ClimateTraits { } const std::vector &get_supported_custom_presets() const { - static const std::vector empty; - return this->supported_custom_presets_ ? *this->supported_custom_presets_ : empty; + static const std::vector EMPTY; + return this->supported_custom_presets_ ? *this->supported_custom_presets_ : EMPTY; } bool supports_custom_preset(const char *custom_preset) const { return this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset); From 236d1ea88f00b22be505aa1f5351b4a92de2ab33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 12:56:30 -1000 Subject: [PATCH 03/27] Fix: use inline function for empty vector to avoid EMPTY macro clash and static local overhead --- esphome/components/climate/climate_traits.h | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 1b80846729c..5ec7b87cd66 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -41,6 +41,12 @@ inline const char *vector_find(const std::vector &vec, const char return nullptr; } +/// Shared empty vector for returning const references when no custom modes are set. +inline const std::vector &get_empty_custom_modes() { + static const std::vector INSTANCE; + return INSTANCE; +} + /** This class contains all static data for climate devices. * * All climate devices must support these features: @@ -157,8 +163,7 @@ class ClimateTraits { } const std::vector &get_supported_custom_fan_modes() const { - static const std::vector EMPTY; - return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : EMPTY; + return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : get_empty_custom_modes(); } bool supports_custom_fan_mode(const char *custom_fan_mode) const { return this->supported_custom_fan_modes_ && vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode); @@ -178,8 +183,7 @@ class ClimateTraits { } const std::vector &get_supported_custom_presets() const { - static const std::vector EMPTY; - return this->supported_custom_presets_ ? *this->supported_custom_presets_ : EMPTY; + return this->supported_custom_presets_ ? *this->supported_custom_presets_ : get_empty_custom_modes(); } bool supports_custom_preset(const char *custom_preset) const { return this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset); From a723ca31ed79b3674b22cfc0ba47223e61a576f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:02:22 -1000 Subject: [PATCH 04/27] Add deprecated compat layer, restore const ref return type Keep the old set_supported_custom_fan_modes() and set_supported_custom_presets() overloads on ClimateTraits as deprecated compatibility shims. They self-own the data so external components continue to compile without changes (heap alloc but functional). Restores get_supported_custom_fan_modes() / get_supported_custom_presets() to return const vector & (not pointer) for full backward compat. --- esphome/components/climate/climate_traits.h | 38 ++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 5ec7b87cd66..cfa15158559 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -41,12 +41,6 @@ inline const char *vector_find(const std::vector &vec, const char return nullptr; } -/// Shared empty vector for returning const references when no custom modes are set. -inline const std::vector &get_empty_custom_modes() { - static const std::vector INSTANCE; - return INSTANCE; -} - /** This class contains all static data for climate devices. * * All climate devices must support these features: @@ -162,8 +156,21 @@ class ClimateTraits { this->supported_custom_fan_modes_ = modes; } + // Remove before 2027.1.0 + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2027.1.0", "2026.7.0") + void set_supported_custom_fan_modes(std::initializer_list modes) { + this->owned_custom_fan_modes_ = modes; + this->supported_custom_fan_modes_ = &this->owned_custom_fan_modes_; + } + // Remove before 2027.1.0 + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2027.1.0", "2026.7.0") + void set_supported_custom_fan_modes(const std::vector &modes) { + this->owned_custom_fan_modes_ = modes; + this->supported_custom_fan_modes_ = &this->owned_custom_fan_modes_; + } + const std::vector &get_supported_custom_fan_modes() const { - return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : get_empty_custom_modes(); + return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : this->owned_custom_fan_modes_; } bool supports_custom_fan_mode(const char *custom_fan_mode) const { return this->supported_custom_fan_modes_ && vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode); @@ -182,8 +189,21 @@ class ClimateTraits { this->supported_custom_presets_ = presets; } + // Remove before 2027.1.0 + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2027.1.0", "2026.7.0") + void set_supported_custom_presets(std::initializer_list presets) { + this->owned_custom_presets_ = presets; + this->supported_custom_presets_ = &this->owned_custom_presets_; + } + // Remove before 2027.1.0 + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2027.1.0", "2026.7.0") + void set_supported_custom_presets(const std::vector &presets) { + this->owned_custom_presets_ = presets; + this->supported_custom_presets_ = &this->owned_custom_presets_; + } + const std::vector &get_supported_custom_presets() const { - return this->supported_custom_presets_ ? *this->supported_custom_presets_ : get_empty_custom_modes(); + return this->supported_custom_presets_ ? *this->supported_custom_presets_ : this->owned_custom_presets_; } bool supports_custom_preset(const char *custom_preset) const { return this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset); @@ -289,6 +309,8 @@ class ClimateTraits { */ const std::vector *supported_custom_fan_modes_{nullptr}; const std::vector *supported_custom_presets_{nullptr}; + std::vector owned_custom_fan_modes_{}; ///< Compat: used when deprecated setters are called on traits + std::vector owned_custom_presets_{}; ///< Compat: used when deprecated setters are called on traits }; } // namespace esphome::climate From 3f1dfd0f3f34d60f35a998f6d746f213f2f5a9e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:05:33 -1000 Subject: [PATCH 05/27] Fix deprecation dates: 2026.5.0 deprecated, 2026.11.0 removed --- esphome/components/climate/climate_traits.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index cfa15158559..5cc4aa470c8 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -156,14 +156,14 @@ class ClimateTraits { this->supported_custom_fan_modes_ = modes; } - // Remove before 2027.1.0 - ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2027.1.0", "2026.7.0") + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(std::initializer_list modes) { this->owned_custom_fan_modes_ = modes; this->supported_custom_fan_modes_ = &this->owned_custom_fan_modes_; } - // Remove before 2027.1.0 - ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2027.1.0", "2026.7.0") + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(const std::vector &modes) { this->owned_custom_fan_modes_ = modes; this->supported_custom_fan_modes_ = &this->owned_custom_fan_modes_; @@ -189,14 +189,14 @@ class ClimateTraits { this->supported_custom_presets_ = presets; } - // Remove before 2027.1.0 - ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2027.1.0", "2026.7.0") + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(std::initializer_list presets) { this->owned_custom_presets_ = presets; this->supported_custom_presets_ = &this->owned_custom_presets_; } - // Remove before 2027.1.0 - ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2027.1.0", "2026.7.0") + // Remove before 2026.11.0 + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(const std::vector &presets) { this->owned_custom_presets_ = presets; this->supported_custom_presets_ = &this->owned_custom_presets_; From fe395ac41d18949d8acf26ea012dabdffad5d1d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:10:04 -1000 Subject: [PATCH 06/27] Use plain vector compat members, add custom modes benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owned vectors add 48 bytes to ClimateTraits copies — this is the cost of backward compat and will be removed in 2026.11.0. Add ClimatePublish_WithCustomModes benchmark to show the improvement for climate devices that use custom fan modes and presets (the case that previously heap-allocated on every publish). --- tests/benchmarks/components/climate/bench_climate.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/benchmarks/components/climate/bench_climate.cpp b/tests/benchmarks/components/climate/bench_climate.cpp index 316a72b2b64..0416b6daa36 100644 --- a/tests/benchmarks/components/climate/bench_climate.cpp +++ b/tests/benchmarks/components/climate/bench_climate.cpp @@ -54,6 +54,9 @@ static void setup_hvac_climate(BenchClimate &climate) { climate.traits_.set_visual_target_temperature_step(0.5f); climate.traits_.set_visual_current_temperature_step(0.1f); climate.traits_.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION); + // Custom modes use the new API — stored on Climate entity, zero-copy in get_traits() + climate.set_supported_custom_fan_modes({"Turbo", "Silent", "Eco"}); + climate.set_supported_custom_presets({"My Preset", "Night Mode"}); } // --- Climate::publish_state() with temperature update --- From b2290891a3b3351efccb9b7147856713df3d901d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:11:24 -1000 Subject: [PATCH 07/27] =?UTF-8?q?Revert=20benchmark=20changes=20=E2=80=94?= =?UTF-8?q?=20keep=20identical=20to=20base=20for=20apples-to-apples=20comp?= =?UTF-8?q?arison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/benchmarks/components/climate/bench_climate.cpp | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/benchmarks/components/climate/bench_climate.cpp b/tests/benchmarks/components/climate/bench_climate.cpp index 0416b6daa36..316a72b2b64 100644 --- a/tests/benchmarks/components/climate/bench_climate.cpp +++ b/tests/benchmarks/components/climate/bench_climate.cpp @@ -54,9 +54,6 @@ static void setup_hvac_climate(BenchClimate &climate) { climate.traits_.set_visual_target_temperature_step(0.5f); climate.traits_.set_visual_current_temperature_step(0.1f); climate.traits_.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION); - // Custom modes use the new API — stored on Climate entity, zero-copy in get_traits() - climate.set_supported_custom_fan_modes({"Turbo", "Silent", "Eco"}); - climate.set_supported_custom_presets({"My Preset", "Night Mode"}); } // --- Climate::publish_state() with temperature update --- From ba57555b6f691ee5c6741acbaba8b47a52b74579 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:13:16 -1000 Subject: [PATCH 08/27] Make compat owned vectors skip-on-copy to eliminate copy overhead Wrap the deprecated owned vectors in a struct with a no-op copy constructor. This way ClimateTraits copies (which happen on every get_traits() call) don't pay the 48-byte cost of copying two empty vectors. The compat data only matters for the original traits object where the deprecated setter was called. --- esphome/components/climate/climate_traits.h | 34 +++++++++++++-------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 5cc4aa470c8..4b8a67cf6f0 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -159,18 +159,18 @@ class ClimateTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(std::initializer_list modes) { - this->owned_custom_fan_modes_ = modes; - this->supported_custom_fan_modes_ = &this->owned_custom_fan_modes_; + this->owned_custom_modes_.fan_modes = modes; + this->supported_custom_fan_modes_ = &this->owned_custom_modes_.fan_modes; } // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(const std::vector &modes) { - this->owned_custom_fan_modes_ = modes; - this->supported_custom_fan_modes_ = &this->owned_custom_fan_modes_; + this->owned_custom_modes_.fan_modes = modes; + this->supported_custom_fan_modes_ = &this->owned_custom_modes_.fan_modes; } const std::vector &get_supported_custom_fan_modes() const { - return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : this->owned_custom_fan_modes_; + return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : this->owned_custom_modes_.fan_modes; } bool supports_custom_fan_mode(const char *custom_fan_mode) const { return this->supported_custom_fan_modes_ && vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode); @@ -192,18 +192,18 @@ class ClimateTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(std::initializer_list presets) { - this->owned_custom_presets_ = presets; - this->supported_custom_presets_ = &this->owned_custom_presets_; + this->owned_custom_modes_.presets = presets; + this->supported_custom_presets_ = &this->owned_custom_modes_.presets; } // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(const std::vector &presets) { - this->owned_custom_presets_ = presets; - this->supported_custom_presets_ = &this->owned_custom_presets_; + this->owned_custom_modes_.presets = presets; + this->supported_custom_presets_ = &this->owned_custom_modes_.presets; } const std::vector &get_supported_custom_presets() const { - return this->supported_custom_presets_ ? *this->supported_custom_presets_ : this->owned_custom_presets_; + return this->supported_custom_presets_ ? *this->supported_custom_presets_ : this->owned_custom_modes_.presets; } bool supports_custom_preset(const char *custom_preset) const { return this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset); @@ -309,8 +309,18 @@ class ClimateTraits { */ const std::vector *supported_custom_fan_modes_{nullptr}; const std::vector *supported_custom_presets_{nullptr}; - std::vector owned_custom_fan_modes_{}; ///< Compat: used when deprecated setters are called on traits - std::vector owned_custom_presets_{}; ///< Compat: used when deprecated setters are called on traits + /** Compat storage for deprecated setters — skipped on copy to avoid overhead. + * Remove in 2026.11.0 along with the deprecated overloads. + */ + struct OwnedCustomModes { + std::vector fan_modes; + std::vector presets; + OwnedCustomModes() = default; + OwnedCustomModes(const OwnedCustomModes &) {} // NOLINT - no-op copy: compat data is not propagated + OwnedCustomModes &operator=(const OwnedCustomModes &) { return *this; } // NOLINT + OwnedCustomModes(OwnedCustomModes &&) = default; + OwnedCustomModes &operator=(OwnedCustomModes &&) = default; + } owned_custom_modes_; }; } // namespace esphome::climate From 805bc4c9a8f554bd485614389007ba3ccee928d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:22:46 -1000 Subject: [PATCH 09/27] Use leak-on-purpose raw pointers for Climate-owned vectors Climate entities live for the entire program lifetime, so the custom mode vectors never need to be freed. Use raw pointers (null by default, allocated on first set_supported_custom_*() call) instead of inline std::vector members. This saves 48 bytes of RAM per Climate instance (two empty vectors) for components that don't use custom modes (bang_bang, pid, etc.), and avoids any copy overhead in get_traits(). --- esphome/components/climate/climate.cpp | 13 ++++++----- esphome/components/climate/climate.h | 31 ++++++++++++++++++-------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 51d91469c17..19a71959aac 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -485,10 +485,10 @@ void Climate::publish_state() { ClimateTraits Climate::get_traits() { auto traits = this->traits(); // Wire custom mode pointers from Climate-owned storage - if (!this->supported_custom_fan_modes_.empty()) - traits.set_supported_custom_fan_modes(&this->supported_custom_fan_modes_); - if (!this->supported_custom_presets_.empty()) - traits.set_supported_custom_presets(&this->supported_custom_presets_); + if (this->supported_custom_fan_modes_) + traits.set_supported_custom_fan_modes(this->supported_custom_fan_modes_); + if (this->supported_custom_presets_) + traits.set_supported_custom_presets(this->supported_custom_presets_); #ifdef USE_CLIMATE_VISUAL_OVERRIDES if (!std::isnan(this->visual_min_temperature_override_)) { traits.set_visual_min_temperature(this->visual_min_temperature_override_); @@ -706,7 +706,8 @@ const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { } const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) { - return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); + return this->supported_custom_fan_modes_ ? vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len) + : nullptr; } const char *Climate::find_custom_preset_(const char *custom_preset) { @@ -714,7 +715,7 @@ const char *Climate::find_custom_preset_(const char *custom_preset) { } const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { - return vector_find(this->supported_custom_presets_, custom_preset, len); + return this->supported_custom_presets_ ? vector_find(*this->supported_custom_presets_, custom_preset, len) : nullptr; } void Climate::dump_traits_(const char *tag) { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 6214afcbe32..69ea192456e 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -236,24 +236,37 @@ class Climate : public EntityBase { /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). void set_supported_custom_fan_modes(std::initializer_list modes) { - this->supported_custom_fan_modes_ = modes; + if (!this->supported_custom_fan_modes_) + this->supported_custom_fan_modes_ = + new std::vector(); // NOLINT - intentional leak, entity lives forever + *this->supported_custom_fan_modes_ = modes; } void set_supported_custom_fan_modes(const std::vector &modes) { - this->supported_custom_fan_modes_ = modes; + if (!this->supported_custom_fan_modes_) + this->supported_custom_fan_modes_ = new std::vector(); // NOLINT + *this->supported_custom_fan_modes_ = modes; } template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { - this->supported_custom_fan_modes_.assign(modes, modes + N); + if (!this->supported_custom_fan_modes_) + this->supported_custom_fan_modes_ = new std::vector(); // NOLINT + this->supported_custom_fan_modes_->assign(modes, modes + N); } /// Set the supported custom presets (stored on Climate, referenced by ClimateTraits). void set_supported_custom_presets(std::initializer_list presets) { - this->supported_custom_presets_ = presets; + if (!this->supported_custom_presets_) + this->supported_custom_presets_ = new std::vector(); // NOLINT + *this->supported_custom_presets_ = presets; } void set_supported_custom_presets(const std::vector &presets) { - this->supported_custom_presets_ = presets; + if (!this->supported_custom_presets_) + this->supported_custom_presets_ = new std::vector(); // NOLINT + *this->supported_custom_presets_ = presets; } template void set_supported_custom_presets(const char *const (&presets)[N]) { - this->supported_custom_presets_.assign(presets, presets + N); + if (!this->supported_custom_presets_) + this->supported_custom_presets_ = new std::vector(); // NOLINT + this->supported_custom_presets_->assign(presets, presets + N); } /// Check if a custom fan mode is currently active. @@ -366,11 +379,11 @@ class Climate : public EntityBase { LazyCallbackManager control_callback_{}; ESPPreferenceObject rtc_; - /** Custom mode storage - owned by Climate, referenced by ClimateTraits via pointer. + /** Custom mode storage — allocated on first use, never freed (entity lives forever). * Pointers in these vectors must point to string literals or static data. */ - std::vector supported_custom_fan_modes_; - std::vector supported_custom_presets_; + std::vector *supported_custom_fan_modes_{nullptr}; + std::vector *supported_custom_presets_{nullptr}; #ifdef USE_CLIMATE_VISUAL_OVERRIDES float visual_min_temperature_override_{NAN}; From c283e11ffa879d7ae214531b6f5025afb177f4f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:32:32 -1000 Subject: [PATCH 10/27] Fix clang-tidy: add braces around if statements, extract ensure helpers --- esphome/components/climate/climate.h | 42 +++++++++++++--------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 69ea192456e..8e369369570 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -236,37 +236,24 @@ class Climate : public EntityBase { /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). void set_supported_custom_fan_modes(std::initializer_list modes) { - if (!this->supported_custom_fan_modes_) - this->supported_custom_fan_modes_ = - new std::vector(); // NOLINT - intentional leak, entity lives forever - *this->supported_custom_fan_modes_ = modes; + this->ensure_custom_fan_modes_().assign(modes.begin(), modes.end()); } void set_supported_custom_fan_modes(const std::vector &modes) { - if (!this->supported_custom_fan_modes_) - this->supported_custom_fan_modes_ = new std::vector(); // NOLINT - *this->supported_custom_fan_modes_ = modes; + this->ensure_custom_fan_modes_() = modes; } template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { - if (!this->supported_custom_fan_modes_) - this->supported_custom_fan_modes_ = new std::vector(); // NOLINT - this->supported_custom_fan_modes_->assign(modes, modes + N); + this->ensure_custom_fan_modes_().assign(modes, modes + N); } /// Set the supported custom presets (stored on Climate, referenced by ClimateTraits). void set_supported_custom_presets(std::initializer_list presets) { - if (!this->supported_custom_presets_) - this->supported_custom_presets_ = new std::vector(); // NOLINT - *this->supported_custom_presets_ = presets; + this->ensure_custom_presets_().assign(presets.begin(), presets.end()); } void set_supported_custom_presets(const std::vector &presets) { - if (!this->supported_custom_presets_) - this->supported_custom_presets_ = new std::vector(); // NOLINT - *this->supported_custom_presets_ = presets; + this->ensure_custom_presets_() = presets; } template void set_supported_custom_presets(const char *const (&presets)[N]) { - if (!this->supported_custom_presets_) - this->supported_custom_presets_ = new std::vector(); // NOLINT - this->supported_custom_presets_->assign(presets, presets + N); + this->ensure_custom_presets_().assign(presets, presets + N); } /// Check if a custom fan mode is currently active. @@ -379,9 +366,20 @@ class Climate : public EntityBase { LazyCallbackManager control_callback_{}; ESPPreferenceObject rtc_; - /** Custom mode storage — allocated on first use, never freed (entity lives forever). - * Pointers in these vectors must point to string literals or static data. - */ + /// Lazy-allocate custom mode vectors (never freed — entity lives forever). + std::vector &ensure_custom_fan_modes_() { + if (!this->supported_custom_fan_modes_) { + this->supported_custom_fan_modes_ = new std::vector(); // NOLINT + } + return *this->supported_custom_fan_modes_; + } + std::vector &ensure_custom_presets_() { + if (!this->supported_custom_presets_) { + this->supported_custom_presets_ = new std::vector(); // NOLINT + } + return *this->supported_custom_presets_; + } + std::vector *supported_custom_fan_modes_{nullptr}; std::vector *supported_custom_presets_{nullptr}; From 5a9e55707b7f675b3d883b0baab1a220a4d75430 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 13:50:18 -1000 Subject: [PATCH 11/27] Fix compat: heap-allocate deprecated vectors, add find fallback Two bugs fixed from Copilot review: 1. Dangling pointer on copy: deprecated setters stored data in an OwnedCustomModes struct on ClimateTraits. If the traits object was copied (when NRVO doesn't apply), the copy's pointer dangled. Fix: deprecated setters now heap-allocate (intentional leak, same pattern as Climate entity). Pointer survives any copy. Remove the OwnedCustomModes wrapper entirely. 2. find_custom_fan_mode_ / find_custom_preset_ only searched the Climate-owned vectors, breaking external components using the deprecated traits setters. Fix: fall back to get_traits() when the entity vector is null. --- esphome/components/climate/climate.cpp | 13 +++++++-- esphome/components/climate/climate_traits.h | 31 ++++++--------------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 19a71959aac..7e3df90889b 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -706,8 +706,11 @@ const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode) { } const char *Climate::find_custom_fan_mode_(const char *custom_fan_mode, size_t len) { - return this->supported_custom_fan_modes_ ? vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len) - : nullptr; + if (this->supported_custom_fan_modes_) { + return vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len); + } + // Fallback for deprecated path: external components may set modes on ClimateTraits directly + return this->get_traits().find_custom_fan_mode_(custom_fan_mode, len); } const char *Climate::find_custom_preset_(const char *custom_preset) { @@ -715,7 +718,11 @@ const char *Climate::find_custom_preset_(const char *custom_preset) { } const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { - return this->supported_custom_presets_ ? vector_find(*this->supported_custom_presets_, custom_preset, len) : nullptr; + if (this->supported_custom_presets_) { + return vector_find(*this->supported_custom_presets_, custom_preset, len); + } + // Fallback for deprecated path: external components may set modes on ClimateTraits directly + return this->get_traits().find_custom_preset_(custom_preset, len); } void Climate::dump_traits_(const char *tag) { diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 4b8a67cf6f0..fdca2638c28 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -159,18 +159,18 @@ class ClimateTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(std::initializer_list modes) { - this->owned_custom_modes_.fan_modes = modes; - this->supported_custom_fan_modes_ = &this->owned_custom_modes_.fan_modes; + // NOLINT - intentional leak: pointer must survive copies of ClimateTraits + this->supported_custom_fan_modes_ = new std::vector(modes); // NOLINT } // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(const std::vector &modes) { - this->owned_custom_modes_.fan_modes = modes; - this->supported_custom_fan_modes_ = &this->owned_custom_modes_.fan_modes; + this->supported_custom_fan_modes_ = new std::vector(modes); // NOLINT } const std::vector &get_supported_custom_fan_modes() const { - return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : this->owned_custom_modes_.fan_modes; + static const std::vector EMPTY_VECTOR; + return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : EMPTY_VECTOR; } bool supports_custom_fan_mode(const char *custom_fan_mode) const { return this->supported_custom_fan_modes_ && vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode); @@ -192,18 +192,17 @@ class ClimateTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(std::initializer_list presets) { - this->owned_custom_modes_.presets = presets; - this->supported_custom_presets_ = &this->owned_custom_modes_.presets; + this->supported_custom_presets_ = new std::vector(presets); // NOLINT } // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(const std::vector &presets) { - this->owned_custom_modes_.presets = presets; - this->supported_custom_presets_ = &this->owned_custom_modes_.presets; + this->supported_custom_presets_ = new std::vector(presets); // NOLINT } const std::vector &get_supported_custom_presets() const { - return this->supported_custom_presets_ ? *this->supported_custom_presets_ : this->owned_custom_modes_.presets; + static const std::vector EMPTY_VECTOR; + return this->supported_custom_presets_ ? *this->supported_custom_presets_ : EMPTY_VECTOR; } bool supports_custom_preset(const char *custom_preset) const { return this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset); @@ -309,18 +308,6 @@ class ClimateTraits { */ const std::vector *supported_custom_fan_modes_{nullptr}; const std::vector *supported_custom_presets_{nullptr}; - /** Compat storage for deprecated setters — skipped on copy to avoid overhead. - * Remove in 2026.11.0 along with the deprecated overloads. - */ - struct OwnedCustomModes { - std::vector fan_modes; - std::vector presets; - OwnedCustomModes() = default; - OwnedCustomModes(const OwnedCustomModes &) {} // NOLINT - no-op copy: compat data is not propagated - OwnedCustomModes &operator=(const OwnedCustomModes &) { return *this; } // NOLINT - OwnedCustomModes(OwnedCustomModes &&) = default; - OwnedCustomModes &operator=(OwnedCustomModes &&) = default; - } owned_custom_modes_; }; } // namespace esphome::climate From ebc4421ba66b1cfe06138cf0539c43992cee766b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 14:01:08 -1000 Subject: [PATCH 12/27] Move custom mode pointers to private, consolidate empty vector to cpp - Move supported_custom_fan_modes_ and supported_custom_presets_ pointers and ensure helpers to private section - Move static EMPTY_VECTOR from inline header getters to a single file-scope constant in climate_traits.cpp (avoids duplication per TU) - Add 2026.11.0 removal comments on all compat code paths --- esphome/components/climate/climate.h | 20 +++++++++---------- esphome/components/climate/climate_traits.cpp | 15 ++++++++++++++ esphome/components/climate/climate_traits.h | 12 ++++------- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 8e369369570..861a57a6736 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -366,6 +366,16 @@ class Climate : public EntityBase { LazyCallbackManager control_callback_{}; ESPPreferenceObject rtc_; +#ifdef USE_CLIMATE_VISUAL_OVERRIDES + float visual_min_temperature_override_{NAN}; + float visual_max_temperature_override_{NAN}; + float visual_target_temperature_step_override_{NAN}; + float visual_current_temperature_step_override_{NAN}; + float visual_min_humidity_override_{NAN}; + float visual_max_humidity_override_{NAN}; +#endif + + private: /// Lazy-allocate custom mode vectors (never freed — entity lives forever). std::vector &ensure_custom_fan_modes_() { if (!this->supported_custom_fan_modes_) { @@ -383,16 +393,6 @@ class Climate : public EntityBase { std::vector *supported_custom_fan_modes_{nullptr}; std::vector *supported_custom_presets_{nullptr}; -#ifdef USE_CLIMATE_VISUAL_OVERRIDES - float visual_min_temperature_override_{NAN}; - float visual_max_temperature_override_{NAN}; - float visual_target_temperature_step_override_{NAN}; - float visual_current_temperature_step_override_{NAN}; - float visual_min_humidity_override_{NAN}; - float visual_max_humidity_override_{NAN}; -#endif - - private: /** The active custom fan mode (private - enforces use of safe setters). * * Points to an entry in supported_custom_fan_modes_ or nullptr. diff --git a/esphome/components/climate/climate_traits.cpp b/esphome/components/climate/climate_traits.cpp index 9bf2d9acd3a..3af0e609a83 100644 --- a/esphome/components/climate/climate_traits.cpp +++ b/esphome/components/climate/climate_traits.cpp @@ -2,6 +2,21 @@ namespace esphome::climate { +// Compat: shared empty vector for getters when no custom modes are set. +// Remove in 2026.11.0 when deprecated ClimateTraits setters are removed +// and getters can return const vector * instead of const vector &. +static const std::vector EMPTY_CUSTOM_MODES; // NOLINT + +const std::vector &ClimateTraits::get_supported_custom_fan_modes() const { + // Compat: return empty ref when pointer is null. Remove in 2026.11.0 (change return to const vector *). + return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : EMPTY_CUSTOM_MODES; +} + +const std::vector &ClimateTraits::get_supported_custom_presets() const { + // Compat: return empty ref when pointer is null. Remove in 2026.11.0 (change return to const vector *). + return this->supported_custom_presets_ ? *this->supported_custom_presets_ : EMPTY_CUSTOM_MODES; +} + int8_t ClimateTraits::get_target_temperature_accuracy_decimals() const { return step_to_accuracy_decimals(this->visual_target_temperature_step_); } diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index fdca2638c28..86ebf33c48b 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -168,10 +168,8 @@ class ClimateTraits { this->supported_custom_fan_modes_ = new std::vector(modes); // NOLINT } - const std::vector &get_supported_custom_fan_modes() const { - static const std::vector EMPTY_VECTOR; - return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : EMPTY_VECTOR; - } + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &get_supported_custom_fan_modes() const; bool supports_custom_fan_mode(const char *custom_fan_mode) const { return this->supported_custom_fan_modes_ && vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode); } @@ -200,10 +198,8 @@ class ClimateTraits { this->supported_custom_presets_ = new std::vector(presets); // NOLINT } - const std::vector &get_supported_custom_presets() const { - static const std::vector EMPTY_VECTOR; - return this->supported_custom_presets_ ? *this->supported_custom_presets_ : EMPTY_VECTOR; - } + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. + const std::vector &get_supported_custom_presets() const; bool supports_custom_preset(const char *custom_preset) const { return this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset); } From 027cb0c35c4269091e301f10a4c461a22bbf6126 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 14:11:15 -1000 Subject: [PATCH 13/27] Fix compat: use owned vectors instead of heap leak Replace new-and-leak deprecated setters with plain owned vector members on ClimateTraits. Copies copy the vector (same cost as before this PR). No heap leak, no dangling pointer, no smart pointer overhead. All compat paths (getters, find, supports) check the owned vector as fallback when the pointer path is not set. --- esphome/components/climate/climate_traits.cpp | 20 ++++++++-- esphome/components/climate/climate_traits.h | 38 +++++++++++++------ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/esphome/components/climate/climate_traits.cpp b/esphome/components/climate/climate_traits.cpp index 3af0e609a83..398e25f69e3 100644 --- a/esphome/components/climate/climate_traits.cpp +++ b/esphome/components/climate/climate_traits.cpp @@ -8,13 +8,25 @@ namespace esphome::climate { static const std::vector EMPTY_CUSTOM_MODES; // NOLINT const std::vector &ClimateTraits::get_supported_custom_fan_modes() const { - // Compat: return empty ref when pointer is null. Remove in 2026.11.0 (change return to const vector *). - return this->supported_custom_fan_modes_ ? *this->supported_custom_fan_modes_ : EMPTY_CUSTOM_MODES; + if (this->supported_custom_fan_modes_) { + return *this->supported_custom_fan_modes_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0. + if (!this->compat_custom_fan_modes_.empty()) { + return this->compat_custom_fan_modes_; + } + return EMPTY_CUSTOM_MODES; } const std::vector &ClimateTraits::get_supported_custom_presets() const { - // Compat: return empty ref when pointer is null. Remove in 2026.11.0 (change return to const vector *). - return this->supported_custom_presets_ ? *this->supported_custom_presets_ : EMPTY_CUSTOM_MODES; + if (this->supported_custom_presets_) { + return *this->supported_custom_presets_; + } + // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0. + if (!this->compat_custom_presets_.empty()) { + return this->compat_custom_presets_; + } + return EMPTY_CUSTOM_MODES; } int8_t ClimateTraits::get_target_temperature_accuracy_decimals() const { diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 86ebf33c48b..939bf96ed55 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -148,7 +148,8 @@ class ClimateTraits { bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); } bool get_supports_fan_modes() const { return !this->supported_fan_modes_.empty() || - (this->supported_custom_fan_modes_ && !this->supported_custom_fan_modes_->empty()); + (this->supported_custom_fan_modes_ && !this->supported_custom_fan_modes_->empty()) || + !this->compat_custom_fan_modes_.empty(); // Compat: remove in 2026.11.0 } const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; } @@ -159,19 +160,21 @@ class ClimateTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(std::initializer_list modes) { - // NOLINT - intentional leak: pointer must survive copies of ClimateTraits - this->supported_custom_fan_modes_ = new std::vector(modes); // NOLINT + // Compat: store in owned vector. Copies copy the vector (same cost as before this PR). + this->compat_custom_fan_modes_ = modes; } // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(const std::vector &modes) { - this->supported_custom_fan_modes_ = new std::vector(modes); // NOLINT + this->compat_custom_fan_modes_ = modes; } // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. const std::vector &get_supported_custom_fan_modes() const; bool supports_custom_fan_mode(const char *custom_fan_mode) const { - return this->supported_custom_fan_modes_ && vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode); + return (this->supported_custom_fan_modes_ && + vector_contains(*this->supported_custom_fan_modes_, custom_fan_mode)) || + vector_contains(this->compat_custom_fan_modes_, custom_fan_mode); // Compat: remove in 2026.11.0 } bool supports_custom_fan_mode(const std::string &custom_fan_mode) const { return this->supports_custom_fan_mode(custom_fan_mode.c_str()); @@ -190,18 +193,19 @@ class ClimateTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(std::initializer_list presets) { - this->supported_custom_presets_ = new std::vector(presets); // NOLINT + this->compat_custom_presets_ = presets; } // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(const std::vector &presets) { - this->supported_custom_presets_ = new std::vector(presets); // NOLINT + this->compat_custom_presets_ = presets; } // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. const std::vector &get_supported_custom_presets() const; bool supports_custom_preset(const char *custom_preset) const { - return this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset); + return (this->supported_custom_presets_ && vector_contains(*this->supported_custom_presets_, custom_preset)) || + vector_contains(this->compat_custom_presets_, custom_preset); // Compat: remove in 2026.11.0 } bool supports_custom_preset(const std::string &custom_preset) const { return this->supports_custom_preset(custom_preset.c_str()); @@ -270,8 +274,11 @@ class ClimateTraits { return this->find_custom_fan_mode_(custom_fan_mode, strlen(custom_fan_mode)); } const char *find_custom_fan_mode_(const char *custom_fan_mode, size_t len) const { - return this->supported_custom_fan_modes_ ? vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len) - : nullptr; + if (this->supported_custom_fan_modes_) { + return vector_find(*this->supported_custom_fan_modes_, custom_fan_mode, len); + } + // Compat: check owned vector from deprecated setters. Remove in 2026.11.0. + return vector_find(this->compat_custom_fan_modes_, custom_fan_mode, len); } /// Find and return the matching custom preset pointer from supported presets, or nullptr if not found @@ -280,8 +287,11 @@ class ClimateTraits { return this->find_custom_preset_(custom_preset, strlen(custom_preset)); } const char *find_custom_preset_(const char *custom_preset, size_t len) const { - return this->supported_custom_presets_ ? vector_find(*this->supported_custom_presets_, custom_preset, len) - : nullptr; + if (this->supported_custom_presets_) { + return vector_find(*this->supported_custom_presets_, custom_preset, len); + } + // Compat: check owned vector from deprecated setters. Remove in 2026.11.0. + return vector_find(this->compat_custom_presets_, custom_preset, len); } uint32_t feature_flags_{0}; @@ -304,6 +314,10 @@ class ClimateTraits { */ const std::vector *supported_custom_fan_modes_{nullptr}; const std::vector *supported_custom_presets_{nullptr}; + // Compat: owned storage for deprecated setters. Copies copy the vector (same cost as pre-PR). + // Remove in 2026.11.0. + std::vector compat_custom_fan_modes_; + std::vector compat_custom_presets_; }; } // namespace esphome::climate From 413202e02e150b5026bb18fff455b5e5052906cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 14:31:06 -1000 Subject: [PATCH 14/27] Restore deleted std::string overloads for clear compile-time diagnostics --- esphome/components/climate/climate_traits.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 939bf96ed55..d364be0ac81 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -169,6 +169,10 @@ class ClimateTraits { this->compat_custom_fan_modes_ = modes; } + // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages + void set_supported_custom_fan_modes(const std::vector &modes) = delete; + void set_supported_custom_fan_modes(std::initializer_list modes) = delete; + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. const std::vector &get_supported_custom_fan_modes() const; bool supports_custom_fan_mode(const char *custom_fan_mode) const { @@ -201,6 +205,10 @@ class ClimateTraits { this->compat_custom_presets_ = presets; } + // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages + void set_supported_custom_presets(const std::vector &presets) = delete; + void set_supported_custom_presets(std::initializer_list presets) = delete; + // Compat: returns const ref with empty fallback. In 2026.11.0 change to return const vector *. const std::vector &get_supported_custom_presets() const; bool supports_custom_preset(const char *custom_preset) const { From 602bcd2c9724c66cbf763e64afdf8d43963d8cd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:19:45 -1000 Subject: [PATCH 15/27] Add integration test for deprecated ClimateTraits compat layer --- .../legacy_climate_component/__init__.py | 1 + .../climate/__init__.py | 15 +++ .../climate/legacy_climate.h | 59 ++++++++++++ .../fixtures/legacy_climate_compat.yaml | 21 +++++ .../integration/test_legacy_climate_compat.py | 93 +++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 tests/integration/fixtures/external_components/legacy_climate_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h create mode 100644 tests/integration/fixtures/legacy_climate_compat.yaml create mode 100644 tests/integration/test_legacy_climate_compat.py diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py new file mode 100644 index 00000000000..a495fb81ad3 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py @@ -0,0 +1 @@ +"""Legacy climate component — tests deprecated ClimateTraits setters backward compat.""" diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py new file mode 100644 index 00000000000..08ef4f542a7 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py @@ -0,0 +1,15 @@ +"""Legacy climate platform that uses deprecated ClimateTraits setters.""" + +import esphome.codegen as cg +from esphome.components import climate +import esphome.config_validation as cv + +legacy_climate_ns = cg.esphome_ns.namespace("legacy_climate_test") +LegacyClimate = legacy_climate_ns.class_("LegacyClimate", climate.Climate, cg.Component) + +CONFIG_SCHEMA = climate.climate_schema(LegacyClimate).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = await climate.new_climate(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h new file mode 100644 index 00000000000..b2fea53dd65 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/components/climate/climate.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace legacy_climate_test { + +/// Test climate that uses the DEPRECATED ClimateTraits setters for custom modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyClimate : public climate::Climate, public Component { + public: + void setup() override { + this->mode = climate::CLIMATE_MODE_OFF; + this->target_temperature = 22.0f; + this->current_temperature = 20.0f; + this->publish_state(); + } + + float get_setup_priority() const override { return setup_priority::LATE; } + + protected: + climate::ClimateTraits traits() override { + auto traits = climate::ClimateTraits(); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_COOL}); + traits.set_visual_min_temperature(16.0f); + traits.set_visual_max_temperature(30.0f); + traits.set_visual_temperature_step(0.5f); + + // DEPRECATED API: setting custom modes directly on ClimateTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_custom_fan_modes({"Turbo", "Silent", "Auto"}); + traits.set_supported_custom_presets({"Eco Mode", "Night Mode"}); +#pragma GCC diagnostic pop + + return traits; + } + + void control(const climate::ClimateCall &call) override { + if (call.get_mode().has_value()) { + this->mode = *call.get_mode(); + } + if (call.get_target_temperature().has_value()) { + this->target_temperature = *call.get_target_temperature(); + } + if (call.get_custom_fan_mode() != nullptr) { + this->set_custom_fan_mode_(call.get_custom_fan_mode()); + } + if (call.get_custom_preset() != nullptr) { + this->set_custom_preset_(call.get_custom_preset()); + } + this->publish_state(); + } +}; + +} // namespace legacy_climate_test +} // namespace esphome diff --git a/tests/integration/fixtures/legacy_climate_compat.yaml b/tests/integration/fixtures/legacy_climate_compat.yaml new file mode 100644 index 00000000000..13f88aab02a --- /dev/null +++ b/tests/integration/fixtures/legacy_climate_compat.yaml @@ -0,0 +1,21 @@ +esphome: + name: legacy-climate-compat + platformio_options: + build_flags: + - "-DUSE_HOST" + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_climate_component] + +climate: + - platform: legacy_climate_component + name: "Legacy Climate" + id: legacy_climate diff --git a/tests/integration/test_legacy_climate_compat.py b/tests/integration/test_legacy_climate_compat.py new file mode 100644 index 00000000000..430778134d8 --- /dev/null +++ b/tests/integration/test_legacy_climate_compat.py @@ -0,0 +1,93 @@ +"""Integration test for backward compatibility of deprecated ClimateTraits setters. + +Verifies that external components using the old traits.set_supported_custom_fan_modes() +and traits.set_supported_custom_presets() API still work correctly during the +deprecation period (removed in 2026.11.0). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import aioesphomeapi +from aioesphomeapi import ClimateInfo +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_climate_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated ClimateTraits custom 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 + ) + + loop = asyncio.get_running_loop() + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) + + climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] + assert len(climate_infos) == 1, ( + f"Expected 1 climate entity, got {len(climate_infos)}" + ) + + test_climate = climate_infos[0] + + # Verify custom fan modes set via deprecated ClimateTraits setter are exposed + assert set(test_climate.supported_custom_fan_modes) == { + "Turbo", + "Silent", + "Auto", + }, ( + f"Expected custom fan modes {{Turbo, Silent, Auto}}, " + f"got {test_climate.supported_custom_fan_modes}" + ) + + # Verify custom presets set via deprecated ClimateTraits setter are exposed + assert set(test_climate.supported_custom_presets) == { + "Eco Mode", + "Night Mode", + }, ( + f"Expected custom presets {{Eco Mode, Night Mode}}, " + f"got {test_climate.supported_custom_presets}" + ) + + # Set up state tracking with InitialStateHelper + turbo_future: asyncio.Future[aioesphomeapi.ClimateState] = loop.create_future() + + def on_state(state: aioesphomeapi.EntityState) -> None: + if ( + isinstance(state, aioesphomeapi.ClimateState) + and state.custom_fan_mode == "Turbo" + and not turbo_future.done() + ): + turbo_future.set_result(state) + + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Verify we can set a custom fan mode via API (tests find_custom_fan_mode_ compat path) + client.climate_command(test_climate.key, custom_fan_mode="Turbo") + + try: + turbo_state = await asyncio.wait_for(turbo_future, timeout=5.0) + except TimeoutError: + pytest.fail("Custom fan mode 'Turbo' not received within 5 seconds") + + assert turbo_state.custom_fan_mode == "Turbo" From 991c34a4f3168d7306ac17bd9c733470f926d35e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:24:38 -1000 Subject: [PATCH 16/27] Fix fixtures: remove unnecessary build flags, use C++17 namespace style --- .../legacy_climate_component/climate/legacy_climate.h | 6 ++---- tests/integration/fixtures/legacy_climate_compat.yaml | 3 --- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h index b2fea53dd65..67b83e51374 100644 --- a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h @@ -3,8 +3,7 @@ #include "esphome/components/climate/climate.h" #include "esphome/core/component.h" -namespace esphome { -namespace legacy_climate_test { +namespace esphome::legacy_climate_test { /// Test climate that uses the DEPRECATED ClimateTraits setters for custom modes. /// This validates backward compatibility for external components that haven't migrated. @@ -55,5 +54,4 @@ class LegacyClimate : public climate::Climate, public Component { } }; -} // namespace legacy_climate_test -} // namespace esphome +} // namespace esphome::legacy_climate_test diff --git a/tests/integration/fixtures/legacy_climate_compat.yaml b/tests/integration/fixtures/legacy_climate_compat.yaml index 13f88aab02a..112e50a4685 100644 --- a/tests/integration/fixtures/legacy_climate_compat.yaml +++ b/tests/integration/fixtures/legacy_climate_compat.yaml @@ -1,8 +1,5 @@ esphome: name: legacy-climate-compat - platformio_options: - build_flags: - - "-DUSE_HOST" host: api: From 1c054db1f1e7e7e5efe69d7a4a8fd47de43fa536 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:26:51 -1000 Subject: [PATCH 17/27] Fix to_code signature: add ConfigType annotation --- .../legacy_climate_component/climate/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py index 08ef4f542a7..0810ae02a16 100644 --- a/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import climate import esphome.config_validation as cv +from esphome.types import ConfigType legacy_climate_ns = cg.esphome_ns.namespace("legacy_climate_test") LegacyClimate = legacy_climate_ns.class_("LegacyClimate", climate.Climate, cg.Component) @@ -10,6 +11,6 @@ LegacyClimate = legacy_climate_ns.class_("LegacyClimate", climate.Climate, cg.Co CONFIG_SCHEMA = climate.climate_schema(LegacyClimate).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) From d54fd0ef696392b2163b3244726b879b38f661e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:29:57 -1000 Subject: [PATCH 18/27] Move pointer setters to protected (friend-only), add missing vector include --- esphome/components/climate/climate.h | 1 + esphome/components/climate/climate_traits.h | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 861a57a6736..04f653a2b0f 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -1,5 +1,6 @@ #pragma once +#include #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index d364be0ac81..58d82ecfb32 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -153,10 +153,6 @@ class ClimateTraits { } const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; } - void set_supported_custom_fan_modes(const std::vector *modes) { - this->supported_custom_fan_modes_ = modes; - } - // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(std::initializer_list modes) { @@ -190,10 +186,6 @@ class ClimateTraits { bool get_supports_presets() const { return !this->supported_presets_.empty(); } const ClimatePresetMask &get_supported_presets() const { return this->supported_presets_; } - void set_supported_custom_presets(const std::vector *presets) { - this->supported_custom_presets_ = presets; - } - // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_presets(std::initializer_list presets) { @@ -276,6 +268,14 @@ class ClimateTraits { } } + /// Set custom mode pointers (only Climate::get_traits() should call these). + void set_supported_custom_fan_modes(const std::vector *modes) { + this->supported_custom_fan_modes_ = modes; + } + void set_supported_custom_presets(const std::vector *presets) { + this->supported_custom_presets_ = presets; + } + /// Find and return the matching custom fan mode pointer from supported modes, or nullptr if not found /// This is protected as it's an implementation detail - use Climate::find_custom_fan_mode_() instead const char *find_custom_fan_mode_(const char *custom_fan_mode) const { From 317db8438c304ffd682c9b06e6613434050a8fb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:36:56 -1000 Subject: [PATCH 19/27] Move set_supported_custom_* out of traits() hot path - Midea: frost protection preset now set once in on_status_change() when autoconf completes, guarded by a flag - BedJet: custom fan modes and presets moved from traits() to setup() - Test fixture: use has_custom_fan_mode() instead of nullptr check --- esphome/components/bedjet/climate/bedjet_climate.cpp | 9 +++++++++ esphome/components/bedjet/climate/bedjet_climate.h | 12 ++---------- esphome/components/midea/air_conditioner.cpp | 8 ++++++-- esphome/components/midea/air_conditioner.h | 1 + .../climate/legacy_climate.h | 4 ++-- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp index a17407f08ff..88ed902a112 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.cpp +++ b/esphome/components/bedjet/climate/bedjet_climate.cpp @@ -61,6 +61,15 @@ void BedJetClimate::dump_config() { } void BedJetClimate::setup() { + // Set custom modes once during setup — stored on Climate base class, wired via get_traits() + this->set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); + this->set_supported_custom_presets({ + this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", + "M1", + "M2", + "M3", + }); + // restore set points auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h index 1d2f28b7cd9..d12c2a8255c 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.h +++ b/esphome/components/bedjet/climate/bedjet_climate.h @@ -48,16 +48,8 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli // Climate doesn't have a "TURBO" mode, but we can use the BOOST preset instead. climate::CLIMATE_PRESET_BOOST, }); - // Custom fan modes and presets are stored on Climate base class and wired via get_traits() - // It would be better if we had a slider for the fan modes. - this->set_supported_custom_fan_modes(BEDJET_FAN_STEP_NAMES); - // String literals are stored in rodata and valid for program lifetime - this->set_supported_custom_presets({ - this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", - "M1", - "M2", - "M3", - }); + // Custom fan modes and presets are set once in setup(), stored on Climate base class, + // and wired automatically via get_traits() traits.set_visual_min_temperature(19.0); traits.set_visual_max_temperature(43.0); traits.set_visual_temperature_step(1.0); diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 235698128ab..77bec5c7a2c 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -24,6 +24,12 @@ template void update_property(T &property, const T &value, bool &fla } void AirConditioner::on_status_change() { + // Set frost protection custom preset once when autoconf completes + if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && + this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { + this->set_supported_custom_presets({Constants::FREEZE_PROTECTION}); + this->frost_protection_set_ = true; + } bool need_publish = false; update_property(this->target_temperature, this->base_.getTargetTemp(), need_publish); update_property(this->current_temperature, this->base_.getIndoorTemp(), need_publish); @@ -99,8 +105,6 @@ ClimateTraits AirConditioner::traits() { traits.add_supported_fan_mode(ClimateFanMode::CLIMATE_FAN_HIGH); if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { Converters::to_climate_traits(traits, this->base_.getCapabilities()); - if (this->base_.getCapabilities().supportFrostProtectionPreset()) - this->set_supported_custom_presets({Constants::FREEZE_PROTECTION}); } if (!traits.get_supported_modes().empty()) traits.add_supported_mode(ClimateMode::CLIMATE_MODE_OFF); diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index da248346868..8dbc71b4226 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -55,6 +55,7 @@ class AirConditioner : public ApplianceBase, ClimateModeMask supported_modes_{}; ClimateSwingModeMask supported_swing_modes_{}; ClimatePresetMask supported_presets_{}; + bool frost_protection_set_{false}; Sensor *outdoor_sensor_{nullptr}; Sensor *humidity_sensor_{nullptr}; Sensor *power_sensor_{nullptr}; diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h index 67b83e51374..670134899a7 100644 --- a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h @@ -44,10 +44,10 @@ class LegacyClimate : public climate::Climate, public Component { if (call.get_target_temperature().has_value()) { this->target_temperature = *call.get_target_temperature(); } - if (call.get_custom_fan_mode() != nullptr) { + if (call.has_custom_fan_mode()) { this->set_custom_fan_mode_(call.get_custom_fan_mode()); } - if (call.get_custom_preset() != nullptr) { + if (call.has_custom_preset()) { this->set_custom_preset_(call.get_custom_preset()); } this->publish_state(); From 7591f1d4732bfc6881e4ee4d83a7bee270156dfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:41:47 -1000 Subject: [PATCH 20/27] Add deprecated array overloads for compat, fix Copilot review --- esphome/components/climate/climate_traits.h | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 58d82ecfb32..c0bc78f8dbf 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -164,6 +164,12 @@ class ClimateTraits { void set_supported_custom_fan_modes(const std::vector &modes) { this->compat_custom_fan_modes_ = modes; } + // Remove before 2026.11.0 + template + ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_custom_fan_modes(const char *const (&modes)[N]) { + this->compat_custom_fan_modes_.assign(modes, modes + N); + } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_custom_fan_modes(const std::vector &modes) = delete; @@ -196,6 +202,12 @@ class ClimateTraits { void set_supported_custom_presets(const std::vector &presets) { this->compat_custom_presets_ = presets; } + // Remove before 2026.11.0 + template + ESPDEPRECATED("Call set_supported_custom_presets() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") + void set_supported_custom_presets(const char *const (&presets)[N]) { + this->compat_custom_presets_.assign(presets, presets + N); + } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_supported_custom_presets(const std::vector &presets) = delete; From 863dc62daee2a127d5f29418593c512f39dec0b0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:48:50 -1000 Subject: [PATCH 21/27] Fix clang-tidy: protected methods need trailing underscore --- esphome/components/climate/climate.cpp | 4 ++-- esphome/components/climate/climate_traits.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 7e3df90889b..e1324971407 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -486,9 +486,9 @@ ClimateTraits Climate::get_traits() { auto traits = this->traits(); // Wire custom mode pointers from Climate-owned storage if (this->supported_custom_fan_modes_) - traits.set_supported_custom_fan_modes(this->supported_custom_fan_modes_); + traits.set_supported_custom_fan_modes_(this->supported_custom_fan_modes_); if (this->supported_custom_presets_) - traits.set_supported_custom_presets(this->supported_custom_presets_); + traits.set_supported_custom_presets_(this->supported_custom_presets_); #ifdef USE_CLIMATE_VISUAL_OVERRIDES if (!std::isnan(this->visual_min_temperature_override_)) { traits.set_visual_min_temperature(this->visual_min_temperature_override_); diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index c0bc78f8dbf..d31de34e248 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -281,10 +281,10 @@ class ClimateTraits { } /// Set custom mode pointers (only Climate::get_traits() should call these). - void set_supported_custom_fan_modes(const std::vector *modes) { + void set_supported_custom_fan_modes_(const std::vector *modes) { this->supported_custom_fan_modes_ = modes; } - void set_supported_custom_presets(const std::vector *presets) { + void set_supported_custom_presets_(const std::vector *presets) { this->supported_custom_presets_ = presets; } From a3f5458ae3556e7702f67bdb4b09f18cfb58f9e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:54:54 -1000 Subject: [PATCH 22/27] Fix unused services variable in test --- tests/integration/test_legacy_climate_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_legacy_climate_compat.py b/tests/integration/test_legacy_climate_compat.py index 430778134d8..1ea3c2bc4a5 100644 --- a/tests/integration/test_legacy_climate_compat.py +++ b/tests/integration/test_legacy_climate_compat.py @@ -35,7 +35,7 @@ async def test_legacy_climate_compat( loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - entities, services = await client.list_entities_services() + entities, _ = await client.list_entities_services() initial_state_helper = InitialStateHelper(entities) climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] From eaa76592859ca4f385e48beb24c6618deab693b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:58:23 -1000 Subject: [PATCH 23/27] Fix Midea preset merge, fix get_supports_fan_modes precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Midea: merge frost protection into existing custom presets instead of overwriting (fixes potential loss of user-configured presets) - get_supports_fan_modes(): use same precedence as getter — if pointer is set, only check pointer; fall back to compat only when unset --- esphome/components/climate/climate_traits.h | 11 ++++++++--- esphome/components/midea/air_conditioner.cpp | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index d31de34e248..65cae3a0776 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -147,9 +147,14 @@ class ClimateTraits { void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); } bool supports_fan_mode(ClimateFanMode fan_mode) const { return this->supported_fan_modes_.count(fan_mode); } bool get_supports_fan_modes() const { - return !this->supported_fan_modes_.empty() || - (this->supported_custom_fan_modes_ && !this->supported_custom_fan_modes_->empty()) || - !this->compat_custom_fan_modes_.empty(); // Compat: remove in 2026.11.0 + if (!this->supported_fan_modes_.empty()) { + return true; + } + // Same precedence as get_supported_custom_fan_modes() getter + if (this->supported_custom_fan_modes_) { + return !this->supported_custom_fan_modes_->empty(); + } + return !this->compat_custom_fan_modes_.empty(); // Compat: remove in 2026.11.0 } const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_fan_modes_; } diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 77bec5c7a2c..2a9a5e9902e 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -24,10 +24,22 @@ template void update_property(T &property, const T &value, bool &fla } void AirConditioner::on_status_change() { - // Set frost protection custom preset once when autoconf completes + // Add frost protection custom preset once when autoconf completes (merge, don't overwrite) if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { - this->set_supported_custom_presets({Constants::FREEZE_PROTECTION}); + auto presets = this->get_traits().get_supported_custom_presets(); + bool found = false; + for (const char *p : presets) { + if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) { + found = true; + break; + } + } + if (!found) { + std::vector merged(presets.begin(), presets.end()); + merged.push_back(Constants::FREEZE_PROTECTION); + this->set_supported_custom_presets(merged); + } this->frost_protection_set_ = true; } bool need_publish = false; From b5df9de79b4d85b8d99e8bb09c871d4d958adc7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 16:08:08 -1000 Subject: [PATCH 24/27] Remove unnecessary get_setup_priority from test fixture --- .../legacy_climate_component/climate/legacy_climate.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h index 670134899a7..bdf5179fa5b 100644 --- a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h +++ b/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h @@ -16,8 +16,6 @@ class LegacyClimate : public climate::Climate, public Component { this->publish_state(); } - float get_setup_priority() const override { return setup_priority::LATE; } - protected: climate::ClimateTraits traits() override { auto traits = climate::ClimateTraits(); From 0b68b497bbc56ea01e089c9a61dbbed5fa5fe55c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 16:17:13 -1000 Subject: [PATCH 25/27] Move demo custom modes to setup(), simplify Midea frost protection - Demo: custom fan modes and presets now set once in setup() instead of on every traits() call, matching bedjet/thermostat pattern - Midea: frost protection merge only calls get_traits() once (guarded by frost_protection_set_ flag), not on every status change --- esphome/components/demo/demo_climate.h | 18 +++++++++++++++--- esphome/components/midea/air_conditioner.cpp | 9 +++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index 1706ba804e5..c6d328b1bc5 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -16,6 +16,19 @@ class DemoClimate : public climate::Climate, public Component { public: void set_type(DemoClimateType type) { type_ = type; } void setup() override { + // Set custom modes once during setup — stored on Climate base class, wired via get_traits() + switch (type_) { + case DemoClimateType::TYPE_1: + break; + case DemoClimateType::TYPE_2: + this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + this->set_supported_custom_presets({"My Preset"}); + break; + case DemoClimateType::TYPE_3: + this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + break; + } + // Set initial state switch (type_) { case DemoClimateType::TYPE_1: this->current_temperature = 20.0; @@ -105,14 +118,13 @@ class DemoClimate : public climate::Climate, public Component { climate::CLIMATE_FAN_DIFFUSE, climate::CLIMATE_FAN_QUIET, }); - this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + // Custom fan modes and presets are set once in setup() traits.set_supported_swing_modes({ climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_BOTH, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, }); - this->set_supported_custom_presets({"My Preset"}); break; case DemoClimateType::TYPE_3: traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | @@ -123,7 +135,7 @@ class DemoClimate : public climate::Climate, public Component { climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL, }); - this->set_supported_custom_fan_modes({"Auto Low", "Auto High"}); + // Custom fan modes are set once in setup() traits.set_supported_swing_modes({ climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 2a9a5e9902e..50521cf238b 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -24,19 +24,20 @@ template void update_property(T &property, const T &value, bool &fla } void AirConditioner::on_status_change() { - // Add frost protection custom preset once when autoconf completes (merge, don't overwrite) + // Add frost protection custom preset once when autoconf completes if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK && this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) { - auto presets = this->get_traits().get_supported_custom_presets(); + // Read existing presets (set by codegen), append frost protection, write back + const auto &existing = this->get_traits().get_supported_custom_presets(); bool found = false; - for (const char *p : presets) { + for (const char *p : existing) { if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) { found = true; break; } } if (!found) { - std::vector merged(presets.begin(), presets.end()); + std::vector merged(existing.begin(), existing.end()); merged.push_back(Constants::FREEZE_PROTECTION); this->set_supported_custom_presets(merged); } From 592371f9f6853d617f518570d55e6596141289a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 16:20:32 -1000 Subject: [PATCH 26/27] Add 2026.11.0 removal comments to integration test and fixture --- .../external_components/legacy_climate_component/__init__.py | 5 ++++- tests/integration/test_legacy_climate_compat.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py index a495fb81ad3..ba9eff2d892 100644 --- a/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py +++ b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py @@ -1 +1,4 @@ -"""Legacy climate component — tests deprecated ClimateTraits setters backward compat.""" +"""Legacy climate component — tests deprecated ClimateTraits setters backward compat. + +Remove this entire directory in 2026.11.0 when the deprecated setters are removed. +""" diff --git a/tests/integration/test_legacy_climate_compat.py b/tests/integration/test_legacy_climate_compat.py index 1ea3c2bc4a5..aad71dd04ae 100644 --- a/tests/integration/test_legacy_climate_compat.py +++ b/tests/integration/test_legacy_climate_compat.py @@ -2,7 +2,10 @@ Verifies that external components using the old traits.set_supported_custom_fan_modes() and traits.set_supported_custom_presets() API still work correctly during the -deprecation period (removed in 2026.11.0). +deprecation period. + +Remove this entire test file and the legacy_climate_component external component +in 2026.11.0 when the deprecated ClimateTraits setters are removed. """ from __future__ import annotations From b25e63344f97ceab4c0ea100fb359e230d838101 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 17:45:10 -1000 Subject: [PATCH 27/27] Fix comments: remove PR-relative wording --- esphome/components/climate/climate_traits.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 65cae3a0776..082b2127a9f 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -161,7 +161,7 @@ class ClimateTraits { // Remove before 2026.11.0 ESPDEPRECATED("Call set_supported_custom_fan_modes() on the Climate entity instead. Removed in 2026.11.0", "2026.5.0") void set_supported_custom_fan_modes(std::initializer_list 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_custom_fan_modes_ = modes; } // Remove before 2026.11.0 @@ -339,7 +339,7 @@ class ClimateTraits { */ const std::vector *supported_custom_fan_modes_{nullptr}; const std::vector *supported_custom_presets_{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_custom_fan_modes_; std::vector compat_custom_presets_;