diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index a03bf5fc1a9..933d22f089a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1207,9 +1207,9 @@ message ListEntitiesClimateResponse { bool supports_action = 12; // Deprecated: use feature_flags repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer_no_template) = "climate::ClimateFanModeMask"]; repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer_no_template) = "climate::ClimateSwingModeMask"]; - repeated string supported_custom_fan_modes = 15 [(container_pointer_no_template) = "std::vector"]; + repeated string supported_custom_fan_modes = 15 [(container_pointer_no_template) = "std::span"]; repeated ClimatePreset supported_presets = 16 [(container_pointer_no_template) = "climate::ClimatePresetMask"]; - repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "std::vector"]; + repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "std::span"]; bool disabled_by_default = 18; string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 20; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index e50bf2f722a..5885303a86a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -806,9 +806,13 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity(); msg.supported_fan_modes = &traits.get_supported_fan_modes(); - msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes(); + const auto &fan_modes = traits.get_supported_custom_fan_modes(); + const std::span custom_fan_modes(fan_modes.data(), fan_modes.size()); + msg.supported_custom_fan_modes = &custom_fan_modes; msg.supported_presets = &traits.get_supported_presets(); - msg.supported_custom_presets = &traits.get_supported_custom_presets(); + const auto &presets = traits.get_supported_custom_presets(); + const std::span custom_presets(presets.data(), presets.size()); + msg.supported_custom_presets = &custom_presets; msg.supported_swing_modes = &traits.get_supported_swing_modes(); return fill_and_encode_entity_info(climate, msg, conn, remaining_size); } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b4e3d4c55b4..350e962e84a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1666,9 +1666,9 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { bool supports_action{false}; const climate::ClimateFanModeMask *supported_fan_modes{}; const climate::ClimateSwingModeMask *supported_swing_modes{}; - const std::vector *supported_custom_fan_modes{}; + const std::span *supported_custom_fan_modes{}; const climate::ClimatePresetMask *supported_presets{}; - const std::vector *supported_custom_presets{}; + const std::span *supported_custom_presets{}; float visual_current_temperature_step{0.0f}; bool supports_current_humidity{false}; bool supports_target_humidity{false}; diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp index 196d4785e98..62e82f3146b 100644 --- a/esphome/components/bedjet/climate/bedjet_climate.cpp +++ b/esphome/components/bedjet/climate/bedjet_climate.cpp @@ -61,7 +61,7 @@ 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_fan_modes_static(BEDJET_FAN_STEP_NAMES, BEDJET_FAN_SPEED_COUNT); this->set_supported_custom_presets({ this->heating_mode_ == HEAT_MODE_EXTENDED ? "LTD HT" : "EXT HT", "M1", diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index e64092d30e5..b5ebb5eb3c6 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -458,6 +458,24 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: await web_server.add_entity_config(var, web_server_config) +def set_custom_fan_modes(var: MockObj, modes: list[str]) -> None: + """Point the climate at a shared flash table of custom fan modes.""" + if modes: + table = cg.shared_progmem_array( + "climate_custom_fan_modes", cg.const_char_ptr, modes + ) + cg.add(var.set_supported_custom_fan_modes_static(table, len(modes))) + + +def set_custom_presets(var: MockObj, presets: list[str]) -> None: + """Point the climate at a shared flash table of custom presets.""" + if presets: + table = cg.shared_progmem_array( + "climate_custom_presets", cg.const_char_ptr, presets + ) + cg.add(var.set_supported_custom_presets_static(table, len(presets))) + + async def register_climate(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index d08952115bd..ed749b4a6d4 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -479,10 +479,8 @@ 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_) - 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_fan_modes_(&this->supported_custom_fan_modes_); + 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_); @@ -628,7 +626,7 @@ template bool set_primary_mode(optional &primary, const char *&cu * After: fan_mode=nullopt, custom_fan_mode_="Turbo" (pointer from traits) * * Lifetime Safety: - * - found_ptr must come from traits.find_custom_*_mode_() + * - found_ptr must come from Climate::find_custom_*_() * - Only pointers found in traits are stored, ensuring they remain valid * - Prevents dangling pointers from temporary strings * @@ -684,11 +682,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) { - 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); + return vector_find(this->supported_custom_fan_modes_, custom_fan_mode, len); } const char *Climate::find_custom_preset_(const char *custom_preset) { @@ -696,11 +690,7 @@ const char *Climate::find_custom_preset_(const char *custom_preset) { } const char *Climate::find_custom_preset_(const char *custom_preset, size_t len) { - 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); + 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 a9068972356..53cd6acad9b 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -246,26 +246,34 @@ class Climate : public EntityBase { } #endif - /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). + /// Set the supported custom fan modes; the list is copied (stored on Climate, referenced by ClimateTraits). void set_supported_custom_fan_modes(std::initializer_list modes) { - this->ensure_custom_fan_modes_().assign(modes.begin(), modes.end()); + this->supported_custom_fan_modes_.assign_copy(modes.begin(), modes.size()); } void set_supported_custom_fan_modes(const std::vector &modes) { - this->ensure_custom_fan_modes_() = modes; + this->supported_custom_fan_modes_.assign_copy(modes.data(), modes.size()); } template void set_supported_custom_fan_modes(const char *const (&modes)[N]) { - this->ensure_custom_fan_modes_().assign(modes, modes + N); + this->supported_custom_fan_modes_.assign_copy(modes, N); + } + /// Points at a static table that outlives the climate (codegen); call before any runtime copy. + void set_supported_custom_fan_modes_static(const char *const *modes, size_t count) { + this->supported_custom_fan_modes_.assign_static(modes, count); } - /// Set the supported custom presets (stored on Climate, referenced by ClimateTraits). + /// Set the supported custom presets; the list is copied (stored on Climate, referenced by ClimateTraits). void set_supported_custom_presets(std::initializer_list presets) { - this->ensure_custom_presets_().assign(presets.begin(), presets.end()); + this->supported_custom_presets_.assign_copy(presets.begin(), presets.size()); } void set_supported_custom_presets(const std::vector &presets) { - this->ensure_custom_presets_() = presets; + this->supported_custom_presets_.assign_copy(presets.data(), presets.size()); } template void set_supported_custom_presets(const char *const (&presets)[N]) { - this->ensure_custom_presets_().assign(presets, presets + N); + this->supported_custom_presets_.assign_copy(presets, N); + } + /// Points at a static table that outlives the climate (codegen); call before any runtime copy. + void set_supported_custom_presets_static(const char *const *presets, size_t count) { + this->supported_custom_presets_.assign_static(presets, count); } /// Check if a custom fan mode is currently active. @@ -388,22 +396,8 @@ class Climate : public EntityBase { #endif private: - /// 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}; + ClimateCustomModes supported_custom_fan_modes_; + ClimateCustomModes supported_custom_presets_; /** The active custom fan mode (private - enforces use of safe setters). * diff --git a/esphome/components/climate/climate_traits.cpp b/esphome/components/climate/climate_traits.cpp index 398e25f69e3..2bf0afbb7b6 100644 --- a/esphome/components/climate/climate_traits.cpp +++ b/esphome/components/climate/climate_traits.cpp @@ -2,31 +2,14 @@ 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 +static const ClimateCustomModes EMPTY_CUSTOM_MODES; // NOLINT -const std::vector &ClimateTraits::get_supported_custom_fan_modes() const { - 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 ClimateCustomModes &ClimateTraits::get_supported_custom_fan_modes() const { + return this->supported_custom_fan_modes_ != nullptr ? *this->supported_custom_fan_modes_ : EMPTY_CUSTOM_MODES; } -const std::vector &ClimateTraits::get_supported_custom_presets() const { - 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; +const ClimateCustomModes &ClimateTraits::get_supported_custom_presets() const { + return this->supported_custom_presets_ != nullptr ? *this->supported_custom_presets_ : 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 6c776e0228f..37b1e7bc224 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -1,7 +1,7 @@ #pragma once #include -#include +#include #include "climate_mode.h" #include "esphome/core/finite_set_mask.h" #include "esphome/core/helpers.h" @@ -18,9 +18,12 @@ using ClimateSwingModeMask = FiniteSetMask>; using ClimatePresetMask = FiniteSetMask>; -// Lightweight linear search for small vectors (1-20 items) of const char* pointers +/// Custom fan modes and custom presets: a flash table from codegen, or an owned copy set at runtime. +using ClimateCustomModes = ConstVector; + +// Lightweight linear search for small lists (1-20 items) of const char* pointers // Avoids std::find template overhead -inline bool vector_contains(const std::vector &vec, const char *value, size_t len) { +template inline bool vector_contains(const List &vec, const char *value, size_t len) { for (const char *item : vec) { if (strncmp(item, value, len) == 0 && item[len] == '\0') return true; @@ -28,12 +31,12 @@ inline bool vector_contains(const std::vector &vec, const char *va return false; } -inline bool vector_contains(const std::vector &vec, const char *value) { +template inline bool vector_contains(const List &vec, const char *value) { return vector_contains(vec, value, strlen(value)); } -// Find and return matching pointer from vector, or nullptr if not found -inline const char *vector_find(const std::vector &vec, const char *value, size_t len) { +// Find and return matching pointer from a list, or nullptr if not found +template inline const char *vector_find(const List &vec, const char *value, size_t len) { for (const char *item : vec) { if (strncmp(item, value, len) == 0 && item[len] == '\0') return item; @@ -71,7 +74,7 @@ inline const char *vector_find(const std::vector &vec, const char class Climate; // Forward declaration class ClimateTraits { - friend class Climate; // Allow Climate to access protected find methods + friend class Climate; // Climate wires its custom mode lists into traits public: /// Get/set feature flags (see ClimateFeatures enum in climate_mode.h) @@ -93,42 +96,14 @@ class ClimateTraits { 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 + return this->supported_custom_fan_modes_ != nullptr && !this->supported_custom_fan_modes_->empty(); } const ClimateFanModeMask &get_supported_fan_modes() const { return this->supported_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(std::initializer_list modes) { - // 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 - 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->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; - 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; + /// Empty when the climate has no custom fan modes. Set them on the Climate entity. + const ClimateCustomModes &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)) || - vector_contains(this->compat_custom_fan_modes_, custom_fan_mode); // Compat: remove in 2026.11.0 + return vector_contains(this->get_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()); @@ -140,32 +115,10 @@ class ClimateTraits { bool get_supports_presets() const { return !this->supported_presets_.empty(); } const ClimatePresetMask &get_supported_presets() const { return this->supported_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) { - 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->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; - 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; + /// Empty when the climate has no custom presets. Set them on the Climate entity. + const ClimateCustomModes &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)) || - vector_contains(this->compat_custom_presets_, custom_preset); // Compat: remove in 2026.11.0 + return vector_contains(this->get_supported_custom_presets(), custom_preset); } bool supports_custom_preset(const std::string &custom_preset) const { return this->supports_custom_preset(custom_preset.c_str()); @@ -232,38 +185,8 @@ 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 { - 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 { - 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 - /// This is protected as it's an implementation detail - use Climate::find_custom_preset_() instead - const char *find_custom_preset_(const char *custom_preset) const { - return this->find_custom_preset_(custom_preset, strlen(custom_preset)); - } - const char *find_custom_preset_(const char *custom_preset, size_t len) const { - 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); - } + void set_supported_custom_fan_modes_(const ClimateCustomModes *modes) { this->supported_custom_fan_modes_ = modes; } + void set_supported_custom_presets_(const ClimateCustomModes *presets) { this->supported_custom_presets_ = presets; } uint32_t feature_flags_{0}; float visual_min_temperature_{10}; @@ -279,17 +202,10 @@ class ClimateTraits { climate::ClimatePresetMask supported_presets_; TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS}; - /** Custom mode storage - pointers to vectors owned by the Climate base class. - * - * ClimateTraits does not own this data; Climate stores the vectors and - * get_traits() wires these pointers automatically. - */ - 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 (copies include this vector). - // Remove in 2026.11.0. - std::vector compat_custom_fan_modes_; - std::vector compat_custom_presets_; + /// Owned by the Climate entity; get_traits() wires these pointers. + const ClimateCustomModes *supported_custom_fan_modes_{nullptr}; + const ClimateCustomModes *supported_custom_presets_{nullptr}; }; +static_assert(std::is_trivially_copyable_v, "ClimateTraits is copied on every traits() call"); } // namespace esphome::climate diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 30771d25ca2..a034abed6e0 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -5,11 +5,6 @@ namespace esphome::midea::ac { -const char *const Constants::TAG = "midea"; -const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; -const char *const Constants::SILENT = "Silent"; -const char *const Constants::TURBO = "Turbo"; - ClimateMode Converters::to_climate_mode(MideaMode mode) { switch (mode) { case MideaMode::MODE_AUTO: diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 4a888ee8ff2..747d8cbf695 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -17,10 +17,10 @@ using MideaPreset = dudanov::midea::ac::Preset; class Constants { public: - static const char *const TAG; - static const char *const FREEZE_PROTECTION; - static const char *const SILENT; - static const char *const TURBO; + static constexpr const char *TAG = "midea"; + static constexpr const char *FREEZE_PROTECTION = "Freeze Protection"; + static constexpr const char *SILENT = "Silent"; + static constexpr const char *TURBO = "Turbo"; }; class Converters { diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 9977d2088ff..d227f22a3dd 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -44,8 +44,6 @@ class AirConditioner final : public ApplianceBasesupported_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->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; diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index da952ab87b0..079b3e77065 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -210,10 +210,8 @@ async def to_code(config): cg.add(var.set_supported_swing_modes(config[CONF_SUPPORTED_SWING_MODES])) if CONF_SUPPORTED_PRESETS in config: cg.add(var.set_supported_presets(config[CONF_SUPPORTED_PRESETS])) - if CONF_CUSTOM_PRESETS in config: - cg.add(var.set_custom_presets(config[CONF_CUSTOM_PRESETS])) - if CONF_CUSTOM_FAN_MODES in config: - cg.add(var.set_custom_fan_modes(config[CONF_CUSTOM_FAN_MODES])) + climate.set_custom_presets(var, config.get(CONF_CUSTOM_PRESETS, [])) + climate.set_custom_fan_modes(var, config.get(CONF_CUSTOM_FAN_MODES, [])) sensors = sensor.sub_sensors(config) await sensors(CONF_OUTDOOR_TEMPERATURE, var.set_outdoor_temperature_sensor) await sensors(CONF_POWER_USAGE, var.set_power_sensor) diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 5f7627c5c38..cca772d8e52 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -324,12 +324,7 @@ async def to_code(config: ConfigType) -> None: for mode in config.get(CONF_SUPPORTED_FAN_MODES, []): cg.add(var.add_supported_fan_mode(mode)) - if CONF_CUSTOM_FAN_MODES in config: - cg.add( - var.set_supported_custom_fan_modes( - cg.ArrayInitializer(*config[CONF_CUSTOM_FAN_MODES]) - ) - ) + climate.set_custom_fan_modes(var, config.get(CONF_CUSTOM_FAN_MODES, [])) for mode in config.get(CONF_SUPPORTED_SWING_MODES, []): cg.add(var.add_supported_swing_mode(mode)) @@ -337,12 +332,7 @@ async def to_code(config: ConfigType) -> None: for preset in config.get(CONF_SUPPORTED_PRESETS, []): cg.add(var.add_supported_preset(preset)) - if CONF_CUSTOM_PRESETS in config: - cg.add( - var.set_supported_custom_presets( - cg.ArrayInitializer(*config[CONF_CUSTOM_PRESETS]) - ) - ) + climate.set_custom_presets(var, config.get(CONF_CUSTOM_PRESETS, [])) for key, trigger_getter, arg_type in SET_ACTIONS: if (conf := config.get(key)) is not None: diff --git a/tests/component_tests/climate/__init__.py b/tests/component_tests/climate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/climate/config/custom_modes.yaml b/tests/component_tests/climate/config/custom_modes.yaml new file mode 100644 index 00000000000..ba92a8aea26 --- /dev/null +++ b/tests/component_tests/climate/config/custom_modes.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +sensor: + - platform: template + id: room_temp + lambda: return 21.0; + +climate: + - platform: template + id: climate_a + name: Climate A + optimistic: true + sensor: room_temp + supported_modes: ["OFF", HEAT] + custom_fan_modes: [turbo, silent] + custom_presets: [eco] + - platform: template + id: climate_b + name: Climate B + optimistic: true + sensor: room_temp + supported_modes: ["OFF", HEAT] + custom_fan_modes: [turbo, silent] + - platform: template + id: climate_c + name: Climate C + optimistic: true + sensor: room_temp + supported_modes: ["OFF", HEAT] diff --git a/tests/component_tests/climate/test_custom_modes.py b/tests/component_tests/climate/test_custom_modes.py new file mode 100644 index 00000000000..23a116dca6f --- /dev/null +++ b/tests/component_tests/climate/test_custom_modes.py @@ -0,0 +1,27 @@ +"""Custom fan modes and presets from YAML live in shared flash tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_custom_modes_use_shared_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("custom_modes.yaml")) + + fan_tables = re.findall( + r"static constexpr const char \* (climate_custom_fan_modes\w*)\[\] PROGMEM", + main_cpp, + ) + assert len(fan_tables) == 1 # Climate A and B share one table + assert ( + main_cpp.count(f"set_supported_custom_fan_modes_static({fan_tables[0]}, 2);") + == 2 + ) + assert ( + "climate_a->set_supported_custom_presets_static(climate_custom_presets, 1);" + in main_cpp + ) + assert "climate_c->set_supported_custom" not in main_cpp diff --git a/tests/components/climate/climate_test.cpp b/tests/components/climate/climate_test.cpp index bda014b87a4..4a568a75ab7 100644 --- a/tests/components/climate/climate_test.cpp +++ b/tests/components/climate/climate_test.cpp @@ -1,4 +1,7 @@ #include + +#include + #include "esphome/components/climate/climate.h" namespace esphome::climate::testing { @@ -70,4 +73,35 @@ TEST(ClimateRestoreStateTest, KeepsRestoringTheOtherFieldsWhenTheModeIsDropped) EXPECT_EQ(climate.fan_mode, CLIMATE_FAN_HIGH); } +static constexpr const char *const FAN_TABLE[] = {"Turbo", "Silent"}; + +TEST(ClimateCustomModesTest, StaticTableIsViewedNotCopied) { + TestClimate climate; + climate.set_supported_custom_fan_modes_static(FAN_TABLE, 2); + auto traits = climate.get_traits(); + ASSERT_EQ(traits.get_supported_custom_fan_modes().size(), 2u); + EXPECT_EQ(traits.get_supported_custom_fan_modes().data(), FAN_TABLE); + EXPECT_TRUE(traits.get_supports_fan_modes()); + EXPECT_TRUE(traits.supports_custom_fan_mode("Silent")); + EXPECT_FALSE(traits.supports_custom_fan_mode("Quiet")); +} + +TEST(ClimateCustomModesTest, RuntimeListsAreCopiedAndReplaced) { + TestClimate climate; + std::vector presets{"Eco", "Sleep"}; + climate.set_supported_custom_presets(presets); + presets[0] = "Changed"; // the climate keeps its own copy + EXPECT_STREQ(climate.get_traits().get_supported_custom_presets()[0], "Eco"); + climate.set_supported_custom_presets({"Night"}); // frees the previous copy + ASSERT_EQ(climate.get_traits().get_supported_custom_presets().size(), 1u); + EXPECT_STREQ(climate.get_traits().get_supported_custom_presets()[0], "Night"); +} + +TEST(ClimateCustomModesTest, TraitsWithoutAClimateHaveEmptyLists) { + ClimateTraits traits; + EXPECT_TRUE(traits.get_supported_custom_fan_modes().empty()); + EXPECT_TRUE(traits.get_supported_custom_presets().empty()); + EXPECT_FALSE(traits.supports_custom_preset("Eco")); +} + } // namespace esphome::climate::testing diff --git a/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py deleted file mode 100644 index ba9eff2d892..00000000000 --- a/tests/integration/fixtures/external_components/legacy_climate_component/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""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/fixtures/external_components/legacy_climate_component/climate/__init__.py b/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py deleted file mode 100644 index 0810ae02a16..00000000000 --- a/tests/integration/fixtures/external_components/legacy_climate_component/climate/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Legacy climate platform that uses deprecated ClimateTraits setters.""" - -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) - -CONFIG_SCHEMA = climate.climate_schema(LegacyClimate).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config: ConfigType) -> None: - 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 deleted file mode 100644 index bdf5179fa5b..00000000000 --- a/tests/integration/fixtures/external_components/legacy_climate_component/climate/legacy_climate.h +++ /dev/null @@ -1,55 +0,0 @@ -#pragma once - -#include "esphome/components/climate/climate.h" -#include "esphome/core/component.h" - -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. -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(); - } - - 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.has_custom_fan_mode()) { - this->set_custom_fan_mode_(call.get_custom_fan_mode()); - } - if (call.has_custom_preset()) { - this->set_custom_preset_(call.get_custom_preset()); - } - this->publish_state(); - } -}; - -} // namespace esphome::legacy_climate_test diff --git a/tests/integration/fixtures/legacy_climate_compat.yaml b/tests/integration/fixtures/legacy_climate_compat.yaml deleted file mode 100644 index 112e50a4685..00000000000 --- a/tests/integration/fixtures/legacy_climate_compat.yaml +++ /dev/null @@ -1,18 +0,0 @@ -esphome: - name: legacy-climate-compat - -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/integration_test_durations.json b/tests/integration/integration_test_durations.json index 060799862f7..cea269df243 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -61,7 +61,6 @@ "tests/integration/test_improv_serial_uart.py": 15.6, "tests/integration/test_large_message_batching.py": 13.6, "tests/integration/test_legacy_area.py": 11.44, - "tests/integration/test_legacy_climate_compat.py": 12.99, "tests/integration/test_legacy_fan_compat.py": 8.75, "tests/integration/test_light_automations.py": 15.38, "tests/integration/test_light_binary_effect_off_phase.py": 23.85, diff --git a/tests/integration/test_legacy_climate_compat.py b/tests/integration/test_legacy_climate_compat.py deleted file mode 100644 index aad71dd04ae..00000000000 --- a/tests/integration/test_legacy_climate_compat.py +++ /dev/null @@ -1,96 +0,0 @@ -"""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. - -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 - -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, _ = 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"