From cd3f10630b5280e5ab9cf2dd743be50cf39af5a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:01:36 -0500 Subject: [PATCH 01/14] wip --- esphome/components/copy/fan/copy_fan.cpp | 14 ++- esphome/components/fan/fan.cpp | 88 +++++++++++++------ esphome/components/fan/fan.h | 28 ++++-- esphome/components/fan/fan_traits.h | 12 +++ .../components/hbridge/fan/hbridge_fan.cpp | 8 +- esphome/components/speed/fan/speed_fan.cpp | 8 +- .../components/template/fan/template_fan.cpp | 8 +- 7 files changed, 125 insertions(+), 41 deletions(-) diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index 15a7f5e025..c1e873e083 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -12,7 +12,11 @@ void CopyFan::setup() { this->oscillating = source_->oscillating; this->speed = source_->speed; this->direction = source_->direction; - this->preset_mode = source_->preset_mode; + const char *preset = source_->get_preset_mode(); + if (preset != nullptr) + this->set_preset_mode_(preset); + else + this->clear_preset_mode_(); this->publish_state(); }); @@ -20,7 +24,11 @@ void CopyFan::setup() { this->oscillating = source_->oscillating; this->speed = source_->speed; this->direction = source_->direction; - this->preset_mode = source_->preset_mode; + const char *preset = source_->get_preset_mode(); + if (preset != nullptr) + this->set_preset_mode_(preset); + else + this->clear_preset_mode_(); this->publish_state(); } @@ -49,7 +57,7 @@ void CopyFan::control(const fan::FanCall &call) { call2.set_speed(*call.get_speed()); if (call.get_direction().has_value()) call2.set_direction(*call.get_direction()); - if (!call.get_preset_mode().empty()) + if (call.has_preset_mode()) call2.set_preset_mode(call.get_preset_mode()); call2.perform(); } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 5b4f437f99..e38b7b43a4 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -17,6 +17,27 @@ const LogString *fan_direction_to_string(FanDirection direction) { } } +FanCall &FanCall::set_preset_mode(const std::string &preset_mode) { return this->set_preset_mode(preset_mode.c_str()); } + +FanCall &FanCall::set_preset_mode(const char *preset_mode) { + if (preset_mode == nullptr || strlen(preset_mode) == 0) { + this->preset_mode_ = nullptr; + return *this; + } + + // Find and validate pointer from traits immediately + auto traits = this->parent_.get_traits(); + const char *validated_mode = traits.find_preset_mode(preset_mode); + if (validated_mode != nullptr) { + this->preset_mode_ = validated_mode; // Store pointer from traits + } else { + // Preset mode not found in traits - log warning and don't set + ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), preset_mode); + this->preset_mode_ = nullptr; + } + return *this; +} + void FanCall::perform() { ESP_LOGD(TAG, "'%s' - Setting:", this->parent_.get_name().c_str()); this->validate_(); @@ -32,8 +53,8 @@ void FanCall::perform() { if (this->direction_.has_value()) { ESP_LOGD(TAG, " Direction: %s", LOG_STR_ARG(fan_direction_to_string(*this->direction_))); } - if (!this->preset_mode_.empty()) { - ESP_LOGD(TAG, " Preset Mode: %s", this->preset_mode_.c_str()); + if (this->has_preset_mode()) { + ESP_LOGD(TAG, " Preset Mode: %s", this->preset_mode_); } this->parent_.control(*this); } @@ -46,30 +67,15 @@ void FanCall::validate_() { // https://developers.home-assistant.io/docs/core/entity/fan/#preset-modes // "Manually setting a speed must disable any set preset mode" - this->preset_mode_.clear(); - } - - if (!this->preset_mode_.empty()) { - const auto &preset_modes = traits.supported_preset_modes(); - bool found = false; - for (const auto &mode : preset_modes) { - if (strcmp(mode, this->preset_mode_.c_str()) == 0) { - found = true; - break; - } - } - if (!found) { - ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str()); - this->preset_mode_.clear(); - } + this->preset_mode_ = nullptr; } // when turning on... if (!this->parent_.state && this->binary_state_.has_value() && *this->binary_state_ // ..,and no preset mode will be active... - && this->preset_mode_.empty() && - this->parent_.preset_mode.empty() + && !this->has_preset_mode() && + this->parent_.get_preset_mode() == nullptr // ...and neither current nor new speed is available... && traits.supports_speed() && this->parent_.speed == 0 && !this->speed_.has_value()) { // ...set speed to 100% @@ -120,7 +126,7 @@ void FanRestoreState::apply(Fan &fan) { // Use stored preset index to get preset name const auto &preset_modes = traits.supported_preset_modes(); if (this->preset_mode < preset_modes.size()) { - fan.preset_mode = preset_modes[this->preset_mode]; + fan.set_preset_mode_(preset_modes[this->preset_mode]); } } fan.publish_state(); @@ -131,6 +137,36 @@ FanCall Fan::turn_off() { return this->make_call().set_state(false); } FanCall Fan::toggle() { return this->make_call().set_state(!this->state); } FanCall Fan::make_call() { return FanCall(*this); } +const char *Fan::find_preset_mode_(const char *preset_mode) { return this->get_traits().find_preset_mode(preset_mode); } + +bool Fan::set_preset_mode_(const char *preset_mode) { + const char *validated = this->find_preset_mode_(preset_mode); + if (validated == nullptr) { + return false; // Preset mode not supported + } + if (this->preset_mode_ == validated) { + return false; // No change + } + this->preset_mode_ = validated; + // Keep deprecated member in sync during deprecation period +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->preset_mode = validated; +#pragma GCC diagnostic pop + return true; +} + +bool Fan::set_preset_mode_(const std::string &preset_mode) { return this->set_preset_mode_(preset_mode.c_str()); } + +void Fan::clear_preset_mode_() { + this->preset_mode_ = nullptr; + // Keep deprecated member in sync during deprecation period +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->preset_mode.clear(); +#pragma GCC diagnostic pop +} + void Fan::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } void Fan::publish_state() { auto traits = this->get_traits(); @@ -146,8 +182,9 @@ void Fan::publish_state() { if (traits.supports_direction()) { ESP_LOGD(TAG, " Direction: %s", LOG_STR_ARG(fan_direction_to_string(this->direction))); } - if (traits.supports_preset_modes() && !this->preset_mode.empty()) { - ESP_LOGD(TAG, " Preset Mode: %s", this->preset_mode.c_str()); + const char *preset = this->get_preset_mode(); + if (traits.supports_preset_modes() && preset != nullptr) { + ESP_LOGD(TAG, " Preset Mode: %s", preset); } this->state_callback_.call(); this->save_state_(); @@ -199,12 +236,13 @@ void Fan::save_state_() { state.speed = this->speed; state.direction = this->direction; - if (traits.supports_preset_modes() && !this->preset_mode.empty()) { + const char *preset = this->get_preset_mode(); + if (traits.supports_preset_modes() && preset != nullptr) { const auto &preset_modes = traits.supported_preset_modes(); // Store index of current preset mode size_t i = 0; for (const auto &mode : preset_modes) { - if (strcmp(mode, this->preset_mode.c_str()) == 0) { + if (strcmp(mode, preset) == 0) { state.preset_mode = i; break; } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 3739de29a2..5bbddb0005 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -70,11 +70,10 @@ class FanCall { return *this; } optional get_direction() const { return this->direction_; } - FanCall &set_preset_mode(const std::string &preset_mode) { - this->preset_mode_ = preset_mode; - return *this; - } - std::string get_preset_mode() const { return this->preset_mode_; } + FanCall &set_preset_mode(const std::string &preset_mode); + FanCall &set_preset_mode(const char *preset_mode); + const char *get_preset_mode() const { return this->preset_mode_; } + bool has_preset_mode() const { return this->preset_mode_ != nullptr; } void perform(); @@ -86,7 +85,7 @@ class FanCall { optional oscillating_; optional speed_; optional direction_{}; - std::string preset_mode_{}; + const char *preset_mode_{nullptr}; // Pointer to string in traits (after validation) }; struct FanRestoreState { @@ -113,7 +112,9 @@ class Fan : public EntityBase { /// The current direction of the fan FanDirection direction{FanDirection::FORWARD}; // The current preset mode of the fan - std::string preset_mode{}; + // Deprecated: Use get_preset_mode() instead. Will be removed in 2026.5.0 + std::string preset_mode {} + __attribute__((deprecated("Use get_preset_mode() instead of .preset_mode. Will be removed in 2026.5.0"))); FanCall turn_on(); FanCall turn_off(); @@ -130,6 +131,9 @@ class Fan : public EntityBase { /// Set the restore mode of this fan. void set_restore_mode(FanRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } + /// Get the current preset mode (returns pointer to string stored in traits, or nullptr if not set) + const char *get_preset_mode() const { return this->preset_mode_; } + protected: friend FanCall; @@ -140,9 +144,19 @@ class Fan : public EntityBase { void dump_traits_(const char *tag, const char *prefix); + /// Set the preset mode (finds and stores pointer from traits). Returns true if changed. + bool set_preset_mode_(const char *preset_mode); + /// Set the preset mode (finds and stores pointer from traits). Returns true if changed. + bool set_preset_mode_(const std::string &preset_mode); + /// Clear the preset mode + void clear_preset_mode_(); + /// Find and return the matching preset mode pointer from traits, or nullptr if not found. + const char *find_preset_mode_(const char *preset_mode); + CallbackManager state_callback_{}; ESPPreferenceObject rtc_; FanRestoreMode restore_mode_; + const char *preset_mode_{nullptr}; }; } // namespace fan diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index bfb17a05ab..eb6f726a3c 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -39,6 +40,17 @@ class FanTraits { void set_supported_preset_modes(const std::vector &preset_modes) { this->preset_modes_ = preset_modes; } /// Return if preset modes are supported bool supports_preset_modes() const { return !this->preset_modes_.empty(); } + /// Find and return the matching preset mode pointer from supported modes, or nullptr if not found. + const char *find_preset_mode(const char *preset_mode) const { + if (preset_mode == nullptr) + return nullptr; + for (const char *mode : this->preset_modes_) { + if (strcmp(mode, preset_mode) == 0) { + return mode; // Return pointer from traits + } + } + return nullptr; + } protected: bool oscillation_{false}; diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index 605a9d4ef3..01680ae651 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -51,13 +51,17 @@ void HBridgeFan::dump_config() { void HBridgeFan::control(const fan::FanCall &call) { if (call.get_state().has_value()) this->state = *call.get_state(); - if (call.get_speed().has_value()) + if (call.get_speed().has_value()) { this->speed = *call.get_speed(); + // Speed manually set, clear preset mode + this->clear_preset_mode_(); + } if (call.get_oscillating().has_value()) this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value()) this->direction = *call.get_direction(); - this->preset_mode = call.get_preset_mode(); + if (call.has_preset_mode()) + this->set_preset_mode_(call.get_preset_mode()); this->write_state_(); this->publish_state(); diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index 57bd795416..43b149e382 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -23,13 +23,17 @@ void SpeedFan::dump_config() { LOG_FAN("", "Speed Fan", this); } void SpeedFan::control(const fan::FanCall &call) { if (call.get_state().has_value()) this->state = *call.get_state(); - if (call.get_speed().has_value()) + if (call.get_speed().has_value()) { this->speed = *call.get_speed(); + // Speed manually set, clear preset mode + this->clear_preset_mode_(); + } if (call.get_oscillating().has_value()) this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value()) this->direction = *call.get_direction(); - this->preset_mode = call.get_preset_mode(); + if (call.has_preset_mode()) + this->set_preset_mode_(call.get_preset_mode()); this->write_state_(); this->publish_state(); diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 5f4a2ae8f7..4ec7e121cf 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -23,13 +23,17 @@ void TemplateFan::dump_config() { LOG_FAN("", "Template Fan", this); } void TemplateFan::control(const fan::FanCall &call) { if (call.get_state().has_value()) this->state = *call.get_state(); - if (call.get_speed().has_value() && (this->speed_count_ > 0)) + if (call.get_speed().has_value() && (this->speed_count_ > 0)) { this->speed = *call.get_speed(); + // Speed manually set, clear preset mode + this->clear_preset_mode_(); + } if (call.get_oscillating().has_value() && this->has_oscillating_) this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value() && this->has_direction_) this->direction = *call.get_direction(); - this->preset_mode = call.get_preset_mode(); + if (call.has_preset_mode()) + this->set_preset_mode_(call.get_preset_mode()); this->publish_state(); } From 58ae4a38be7b4905ce490ffe722b12de6bb9ffd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:04:27 -0500 Subject: [PATCH 02/14] wip --- esphome/components/fan/fan.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 5bbddb0005..1ca7bfbeb3 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -113,8 +113,8 @@ class Fan : public EntityBase { FanDirection direction{FanDirection::FORWARD}; // The current preset mode of the fan // Deprecated: Use get_preset_mode() instead. Will be removed in 2026.5.0 - std::string preset_mode {} __attribute__((deprecated("Use get_preset_mode() instead of .preset_mode. Will be removed in 2026.5.0"))); + std::string preset_mode{}; FanCall turn_on(); FanCall turn_off(); From cf85621d64e331d0792b47441bc5bd9a71a86873 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:05:31 -0500 Subject: [PATCH 03/14] wip --- esphome/components/fan/fan.h | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 1ca7bfbeb3..6cae01999d 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -136,6 +136,7 @@ class Fan : public EntityBase { protected: friend FanCall; + friend struct FanRestoreState; virtual void control(const FanCall &call) = 0; From 79e2340588fdf6c538b32d71360ee162017f0229 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:06:18 -0500 Subject: [PATCH 04/14] wip --- esphome/components/fan/fan.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index e38b7b43a4..24ce9188f4 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -121,14 +121,12 @@ void FanRestoreState::apply(Fan &fan) { fan.speed = this->speed; fan.direction = this->direction; - auto traits = fan.get_traits(); - if (traits.supports_preset_modes()) { - // Use stored preset index to get preset name - const auto &preset_modes = traits.supported_preset_modes(); - if (this->preset_mode < preset_modes.size()) { - fan.set_preset_mode_(preset_modes[this->preset_mode]); - } + // Use stored preset index to get preset name from traits + const auto &preset_modes = fan.get_traits().supported_preset_modes(); + if (this->preset_mode < preset_modes.size()) { + fan.set_preset_mode_(preset_modes[this->preset_mode]); } + fan.publish_state(); } From 4fabe464c8b193b29d652d1012f6f01a111985b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:08:24 -0500 Subject: [PATCH 05/14] wip --- esphome/components/fan/fan.cpp | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 24ce9188f4..eb2e8743f3 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -139,11 +139,8 @@ const char *Fan::find_preset_mode_(const char *preset_mode) { return this->get_t bool Fan::set_preset_mode_(const char *preset_mode) { const char *validated = this->find_preset_mode_(preset_mode); - if (validated == nullptr) { - return false; // Preset mode not supported - } - if (this->preset_mode_ == validated) { - return false; // No change + if (validated == nullptr || this->preset_mode_ == validated) { + return false; // Preset mode not supported or no change } this->preset_mode_ = validated; // Keep deprecated member in sync during deprecation period @@ -235,16 +232,14 @@ void Fan::save_state_() { state.direction = this->direction; const char *preset = this->get_preset_mode(); - if (traits.supports_preset_modes() && preset != nullptr) { + if (preset != nullptr) { const auto &preset_modes = traits.supported_preset_modes(); - // Store index of current preset mode - size_t i = 0; - for (const auto &mode : preset_modes) { - if (strcmp(mode, preset) == 0) { + // Find index of current preset mode (pointer comparison is safe since preset is from traits) + for (size_t i = 0; i < preset_modes.size(); i++) { + if (preset_modes[i] == preset) { state.preset_mode = i; break; } - i++; } } From 410afd196f05c85050c21f751e19e5598f51bd15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:13:57 -0500 Subject: [PATCH 06/14] preen --- esphome/components/fan/fan_traits.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/fan/fan_traits.h b/esphome/components/fan/fan_traits.h index bfb17a05ab..df345f9b04 100644 --- a/esphome/components/fan/fan_traits.h +++ b/esphome/components/fan/fan_traits.h @@ -37,6 +37,11 @@ class FanTraits { } /// Set the preset modes supported by the fan (from vector). void set_supported_preset_modes(const std::vector &preset_modes) { this->preset_modes_ = preset_modes; } + + // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages + void set_supported_preset_modes(const std::vector &preset_modes) = delete; + void set_supported_preset_modes(std::initializer_list preset_modes) = delete; + /// Return if preset modes are supported bool supports_preset_modes() const { return !this->preset_modes_.empty(); } From 91ae8c82b0ddd655aa88197dd455cea1b26995bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:15:59 -0500 Subject: [PATCH 07/14] preen --- esphome/components/fan/fan.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 6cae01999d..16fe42f9f4 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -112,8 +112,9 @@ class Fan : public EntityBase { /// The current direction of the fan FanDirection direction{FanDirection::FORWARD}; // The current preset mode of the fan - // Deprecated: Use get_preset_mode() instead. Will be removed in 2026.5.0 - __attribute__((deprecated("Use get_preset_mode() instead of .preset_mode. Will be removed in 2026.5.0"))); + // Deprecated: Use get_preset_mode() for reading and set_preset_mode_() for writing. Will be removed in 2026.5.0 + __attribute__((deprecated("Use get_preset_mode() for reading and set_preset_mode_() for writing instead of " + ".preset_mode. Will be removed in 2026.5.0"))); std::string preset_mode{}; FanCall turn_on(); From 76952026b7d0d9ee9ac9be5ff66edd57f2cd1f45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:18:14 -0500 Subject: [PATCH 08/14] preen --- esphome/components/copy/fan/copy_fan.cpp | 12 ++---------- esphome/components/fan/fan.cpp | 8 ++++++++ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index c1e873e083..d35ece950b 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -12,11 +12,7 @@ void CopyFan::setup() { this->oscillating = source_->oscillating; this->speed = source_->speed; this->direction = source_->direction; - const char *preset = source_->get_preset_mode(); - if (preset != nullptr) - this->set_preset_mode_(preset); - else - this->clear_preset_mode_(); + this->set_preset_mode_(source_->get_preset_mode()); this->publish_state(); }); @@ -24,11 +20,7 @@ void CopyFan::setup() { this->oscillating = source_->oscillating; this->speed = source_->speed; this->direction = source_->direction; - const char *preset = source_->get_preset_mode(); - if (preset != nullptr) - this->set_preset_mode_(preset); - else - this->clear_preset_mode_(); + this->set_preset_mode_(source_->get_preset_mode()); this->publish_state(); } diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index eb2e8743f3..c4abab0b4a 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -138,6 +138,14 @@ FanCall Fan::make_call() { return FanCall(*this); } const char *Fan::find_preset_mode_(const char *preset_mode) { return this->get_traits().find_preset_mode(preset_mode); } bool Fan::set_preset_mode_(const char *preset_mode) { + if (preset_mode == nullptr) { + // Treat nullptr as clearing the preset mode + if (this->preset_mode_ == nullptr) { + return false; // No change + } + this->clear_preset_mode_(); + return true; + } const char *validated = this->find_preset_mode_(preset_mode); if (validated == nullptr || this->preset_mode_ == validated) { return false; // Preset mode not supported or no change From 9dcfbed8af77761f682355f96a325bb1649ca5fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:37:22 -0500 Subject: [PATCH 09/14] wip --- esphome/components/api/api_connection.cpp | 4 ++-- esphome/components/fan/fan.cpp | 14 +------------- esphome/components/fan/fan.h | 8 +++----- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 33d5072d9c..90e37c8c59 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -410,8 +410,8 @@ uint16_t APIConnection::try_send_fan_state(EntityBase *entity, APIConnection *co } if (traits.supports_direction()) msg.direction = static_cast(fan->direction); - if (traits.supports_preset_modes()) - msg.set_preset_mode(StringRef(fan->preset_mode)); + if (traits.supports_preset_modes() && fan->has_preset_mode()) + msg.set_preset_mode(StringRef(fan->get_preset_mode())); return fill_and_encode_entity_state(fan, msg, FanStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } uint16_t APIConnection::try_send_fan_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index c4abab0b4a..cfc09f4d53 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -151,24 +151,12 @@ bool Fan::set_preset_mode_(const char *preset_mode) { return false; // Preset mode not supported or no change } this->preset_mode_ = validated; - // Keep deprecated member in sync during deprecation period -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->preset_mode = validated; -#pragma GCC diagnostic pop return true; } bool Fan::set_preset_mode_(const std::string &preset_mode) { return this->set_preset_mode_(preset_mode.c_str()); } -void Fan::clear_preset_mode_() { - this->preset_mode_ = nullptr; - // Keep deprecated member in sync during deprecation period -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->preset_mode.clear(); -#pragma GCC diagnostic pop -} +void Fan::clear_preset_mode_() { this->preset_mode_ = nullptr; } void Fan::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } void Fan::publish_state() { diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 16fe42f9f4..33e546b2bb 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -111,11 +111,6 @@ class Fan : public EntityBase { int speed{0}; /// The current direction of the fan FanDirection direction{FanDirection::FORWARD}; - // The current preset mode of the fan - // Deprecated: Use get_preset_mode() for reading and set_preset_mode_() for writing. Will be removed in 2026.5.0 - __attribute__((deprecated("Use get_preset_mode() for reading and set_preset_mode_() for writing instead of " - ".preset_mode. Will be removed in 2026.5.0"))); - std::string preset_mode{}; FanCall turn_on(); FanCall turn_off(); @@ -135,6 +130,9 @@ class Fan : public EntityBase { /// Get the current preset mode (returns pointer to string stored in traits, or nullptr if not set) const char *get_preset_mode() const { return this->preset_mode_; } + /// Check if a preset mode is currently active + bool has_preset_mode() const { return this->preset_mode_ != nullptr; } + protected: friend FanCall; friend struct FanRestoreState; From e6421ac50c2960a3cdb8b633da1986f49ccece9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:42:32 -0500 Subject: [PATCH 10/14] remove bugfix --- esphome/components/hbridge/fan/hbridge_fan.cpp | 5 +---- esphome/components/speed/fan/speed_fan.cpp | 5 +---- esphome/components/template/fan/template_fan.cpp | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index 01680ae651..18591fb1ff 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -51,11 +51,8 @@ void HBridgeFan::dump_config() { void HBridgeFan::control(const fan::FanCall &call) { if (call.get_state().has_value()) this->state = *call.get_state(); - if (call.get_speed().has_value()) { + if (call.get_speed().has_value()) this->speed = *call.get_speed(); - // Speed manually set, clear preset mode - this->clear_preset_mode_(); - } if (call.get_oscillating().has_value()) this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value()) diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index 43b149e382..c1ccb0a0bb 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -23,11 +23,8 @@ void SpeedFan::dump_config() { LOG_FAN("", "Speed Fan", this); } void SpeedFan::control(const fan::FanCall &call) { if (call.get_state().has_value()) this->state = *call.get_state(); - if (call.get_speed().has_value()) { + if (call.get_speed().has_value()) this->speed = *call.get_speed(); - // Speed manually set, clear preset mode - this->clear_preset_mode_(); - } if (call.get_oscillating().has_value()) this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value()) diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 4ec7e121cf..7793fc0b7c 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -23,11 +23,8 @@ void TemplateFan::dump_config() { LOG_FAN("", "Template Fan", this); } void TemplateFan::control(const fan::FanCall &call) { if (call.get_state().has_value()) this->state = *call.get_state(); - if (call.get_speed().has_value() && (this->speed_count_ > 0)) { + if (call.get_speed().has_value() && (this->speed_count_ > 0)) this->speed = *call.get_speed(); - // Speed manually set, clear preset mode - this->clear_preset_mode_(); - } if (call.get_oscillating().has_value() && this->has_oscillating_) this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value() && this->has_direction_) From d5938df53194267735ab0574a27eb5266204f346 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:45:12 -0500 Subject: [PATCH 11/14] remove bugfix --- esphome/components/hbridge/fan/hbridge_fan.cpp | 3 +-- esphome/components/speed/fan/speed_fan.cpp | 3 +-- esphome/components/template/fan/template_fan.cpp | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/esphome/components/hbridge/fan/hbridge_fan.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index 18591fb1ff..488208b725 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -57,8 +57,7 @@ void HBridgeFan::control(const fan::FanCall &call) { this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value()) this->direction = *call.get_direction(); - if (call.has_preset_mode()) - this->set_preset_mode_(call.get_preset_mode()); + this->set_preset_mode_(call.get_preset_mode()); this->write_state_(); this->publish_state(); diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index c1ccb0a0bb..801593c2ac 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -29,8 +29,7 @@ void SpeedFan::control(const fan::FanCall &call) { this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value()) this->direction = *call.get_direction(); - if (call.has_preset_mode()) - this->set_preset_mode_(call.get_preset_mode()); + this->set_preset_mode_(call.get_preset_mode()); this->write_state_(); this->publish_state(); diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 7793fc0b7c..eba4c673b5 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -29,8 +29,7 @@ void TemplateFan::control(const fan::FanCall &call) { this->oscillating = *call.get_oscillating(); if (call.get_direction().has_value() && this->has_direction_) this->direction = *call.get_direction(); - if (call.has_preset_mode()) - this->set_preset_mode_(call.get_preset_mode()); + this->set_preset_mode_(call.get_preset_mode()); this->publish_state(); } From cbaa15635f80f961f40c187a9e300b6696cc815e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 11:49:35 -0500 Subject: [PATCH 12/14] remove bugfix --- esphome/components/fan/automation.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 90661c307c..048ba04646 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -212,18 +212,18 @@ class FanPresetSetTrigger : public Trigger { public: FanPresetSetTrigger(Fan *state) { state->add_on_state_callback([this, state]() { - auto preset_mode = state->preset_mode; + auto preset_mode = state->get_preset_mode(); auto should_trigger = preset_mode != this->last_preset_mode_; this->last_preset_mode_ = preset_mode; - if (should_trigger) { + if (should_trigger && preset_mode != nullptr) { this->trigger(preset_mode); } }); - this->last_preset_mode_ = state->preset_mode; + this->last_preset_mode_ = state->get_preset_mode(); } protected: - std::string last_preset_mode_; + const char *last_preset_mode_{nullptr}; }; } // namespace fan From 5c184777c673db996a351f84492b4ba5bd00fa5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Oct 2025 12:05:48 -0500 Subject: [PATCH 13/14] remove bugfix --- esphome/components/fan/automation.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 048ba04646..48de8d66fb 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -212,7 +212,7 @@ class FanPresetSetTrigger : public Trigger { public: FanPresetSetTrigger(Fan *state) { state->add_on_state_callback([this, state]() { - auto preset_mode = state->get_preset_mode(); + const auto *preset_mode = state->get_preset_mode(); auto should_trigger = preset_mode != this->last_preset_mode_; this->last_preset_mode_ = preset_mode; if (should_trigger && preset_mode != nullptr) { From 6d0527ff2ad406603067a939d0b3f703db33fc0c Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Fri, 31 Oct 2025 20:04:55 +0100 Subject: [PATCH 14/14] [substitutions] fix jinja parsing strings that look like sets as sets (#11611) --- esphome/components/substitutions/jinja.py | 10 ++++++++-- .../fixtures/substitutions/00-simple_var.approved.yaml | 1 + .../fixtures/substitutions/00-simple_var.input.yaml | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/esphome/components/substitutions/jinja.py b/esphome/components/substitutions/jinja.py index cb3c6dfac5..fb9f843da2 100644 --- a/esphome/components/substitutions/jinja.py +++ b/esphome/components/substitutions/jinja.py @@ -138,6 +138,7 @@ def _concat_nodes_override(values: Iterator[Any]) -> Any: values = chain(head, values) raw = "".join([str(v) for v in values]) + result = None try: # Attempt to parse the concatenated string into a Python literal. # This allows expressions like "1 + 2" to be evaluated to the integer 3. @@ -145,11 +146,16 @@ def _concat_nodes_override(values: Iterator[Any]) -> Any: # fall back to returning the raw string. This is consistent with # Home Assistant's behavior when evaluating templates result = literal_eval(raw) + except (ValueError, SyntaxError, MemoryError, TypeError): + pass + else: + if isinstance(result, set): + # Sets are not supported, return raw string + return raw + if not isinstance(result, str): return result - except (ValueError, SyntaxError, MemoryError, TypeError): - pass return raw diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml index 795a788f62..6f3bae1ac4 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml @@ -33,3 +33,4 @@ test_list: {{{ "x", "79"}, { "y", "82"}}} - '{{{"AA"}}}' - '"HELLO"' + - '{ 79, 82 }' diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml index 722e116d36..306119b753 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml @@ -34,3 +34,4 @@ test_list: {{{ "x", "${ position.x }"}, { "y", "${ position.y }"}}} - ${ '{{{"AA"}}}' } - ${ '"HELLO"' } + - '{ ${position.x}, ${position.y} }'