#include "fan.h" #include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" namespace esphome { namespace fan { static const char *const TAG = "fan"; // Compat: shared empty vector for getter when no preset modes are set. // Remove in 2026.11.0 when deprecated FanTraits setters are removed // and getter can return const vector * instead of const vector &. static const std::vector EMPTY_PRESET_MODES; // NOLINT const std::vector &FanTraits::supported_preset_modes() const { if (this->preset_modes_) { return *this->preset_modes_; } // Compat: fall back to owned vector from deprecated setters. Remove in 2026.11.0 (change return to const vector *). if (!this->compat_preset_modes_.empty()) { return this->compat_preset_modes_; } return EMPTY_PRESET_MODES; } // Fan direction strings indexed by FanDirection enum (0-1): FORWARD, REVERSE, plus UNKNOWN PROGMEM_STRING_TABLE(FanDirectionStrings, "FORWARD", "REVERSE", "UNKNOWN"); const LogString *fan_direction_to_string(FanDirection direction) { return FanDirectionStrings::get_log_str(static_cast(direction), FanDirectionStrings::LAST_INDEX); } FanCall &FanCall::set_preset_mode(const std::string &preset_mode) { return this->set_preset_mode(preset_mode.data(), preset_mode.size()); } FanCall &FanCall::set_preset_mode(const char *preset_mode) { return this->set_preset_mode(preset_mode, preset_mode ? strlen(preset_mode) : 0); } FanCall &FanCall::set_preset_mode(const char *preset_mode, size_t len) { if (preset_mode == nullptr || len == 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, len); 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(), (int) len, preset_mode); this->preset_mode_ = nullptr; } return *this; } void FanCall::perform() { ESP_LOGV(TAG, "'%s' - Setting:", this->parent_.get_name().c_str()); this->validate_(); if (this->binary_state_.has_value()) { ESP_LOGV(TAG, " State: %s", ONOFF(*this->binary_state_)); } if (this->oscillating_.has_value()) { ESP_LOGV(TAG, " Oscillating: %s", YESNO(*this->oscillating_)); } if (this->speed_.has_value()) { ESP_LOGV(TAG, " Speed: %d", *this->speed_); } if (this->direction_.has_value()) { ESP_LOGV(TAG, " Direction: %s", LOG_STR_ARG(fan_direction_to_string(*this->direction_))); } if (this->preset_mode_ != nullptr) { ESP_LOGV(TAG, " Preset Mode: %s", this->preset_mode_); } this->parent_.control(*this); } void FanCall::validate_() { auto traits = this->parent_.get_traits(); if (this->speed_.has_value()) { this->speed_ = clamp(*this->speed_, 1, traits.supported_speed_count()); // https://developers.home-assistant.io/docs/core/entity/fan/#preset-modes // "Manually setting a speed must disable any set preset mode" 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->has_preset_mode() && !this->parent_.has_preset_mode() // ...and neither current nor new speed is available... && traits.supports_speed() && this->parent_.speed == 0 && !this->speed_.has_value()) { // ...set speed to 100% this->speed_ = traits.supported_speed_count(); } if (this->oscillating_.has_value() && !traits.supports_oscillation()) { ESP_LOGW(TAG, "%s: Oscillation not supported", this->parent_.get_name().c_str()); this->oscillating_.reset(); } if (this->speed_.has_value() && !traits.supports_speed()) { ESP_LOGW(TAG, "%s: Speed control not supported", this->parent_.get_name().c_str()); this->speed_.reset(); } if (this->direction_.has_value() && !traits.supports_direction()) { ESP_LOGW(TAG, "%s: Direction control not supported", this->parent_.get_name().c_str()); this->direction_.reset(); } } FanCall FanRestoreState::to_call(Fan &fan) { auto call = fan.make_call(); call.set_state(this->state); call.set_oscillating(this->oscillating); call.set_speed(this->speed); call.set_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()) { call.set_preset_mode(preset_modes[this->preset_mode]); } } return call; } void FanRestoreState::apply(Fan &fan) { fan.state = this->state; fan.oscillating = this->oscillating; 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 from traits const auto &preset_modes = traits.supported_preset_modes(); if (this->preset_mode < preset_modes.size()) { fan.set_preset_mode_(preset_modes[this->preset_mode]); } } fan.publish_state(); } FanCall Fan::turn_on() { return this->make_call().set_state(true); } 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->find_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0); } const char *Fan::find_preset_mode_(const char *preset_mode, size_t len) { if (preset_mode == nullptr || len == 0) { return nullptr; } if (this->supported_preset_modes_) { for (const char *mode : *this->supported_preset_modes_) { if (strncmp(mode, preset_mode, len) == 0 && mode[len] == '\0') { return mode; } } return nullptr; } // Fallback for deprecated path: external components may set modes on FanTraits directly return this->get_traits().find_preset_mode(preset_mode, len); } bool Fan::set_preset_mode_(const char *preset_mode, size_t len) { if (preset_mode == nullptr || len == 0) { // Treat nullptr or empty string as clearing the preset mode (no valid preset is "") if (this->preset_mode_ == nullptr) { return false; // No change } this->clear_preset_mode_(); return true; } const char *validated = this->find_preset_mode_(preset_mode, len); if (validated == nullptr || this->preset_mode_ == validated) { return false; // Preset mode not supported or no change } this->preset_mode_ = validated; return true; } bool Fan::set_preset_mode_(const char *preset_mode) { return this->set_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0); } bool Fan::set_preset_mode_(const std::string &preset_mode) { return this->set_preset_mode_(preset_mode.data(), preset_mode.size()); } bool Fan::set_preset_mode_(StringRef preset_mode) { // Safe: find_preset_mode_ only uses the input for comparison and returns // a pointer from traits, so the input StringRef's lifetime doesn't matter. return this->set_preset_mode_(preset_mode.c_str(), preset_mode.size()); } void Fan::clear_preset_mode_() { this->preset_mode_ = nullptr; } void Fan::apply_preset_mode_(const FanCall &call) { if (call.has_preset_mode()) { this->set_preset_mode_(call.get_preset_mode()); } else if (call.get_speed().has_value()) { // Manually setting speed clears preset (per Home Assistant convention) this->clear_preset_mode_(); } } void Fan::publish_state() { auto traits = this->get_traits(); ESP_LOGV(TAG, "'%s' >>\n" " State: %s", this->name_.c_str(), ONOFF(this->state)); if (traits.supports_speed()) { ESP_LOGV(TAG, " Speed: %d", this->speed); } if (traits.supports_oscillation()) { ESP_LOGV(TAG, " Oscillating: %s", YESNO(this->oscillating)); } if (traits.supports_direction()) { ESP_LOGV(TAG, " Direction: %s", LOG_STR_ARG(fan_direction_to_string(this->direction))); } if (this->preset_mode_ != nullptr) { ESP_LOGV(TAG, " Preset Mode: %s", this->preset_mode_); } this->state_callback_.call(); #if defined(USE_FAN) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_fan_update(this); #endif this->save_state_(); } // Random 32-bit value, change this every time the layout of the FanRestoreState struct changes. constexpr uint32_t RESTORE_STATE_VERSION = 0x71700ABB; optional Fan::restore_state_() { FanRestoreState recovered{}; this->rtc_ = this->make_entity_preference(RESTORE_STATE_VERSION); bool restored = this->rtc_.load(&recovered); if (!restored) { // No valid saved data; ensure preset_mode sentinel is set recovered.preset_mode = FanRestoreState::NO_PRESET; } switch (this->restore_mode_) { case FanRestoreMode::NO_RESTORE: return {}; case FanRestoreMode::ALWAYS_OFF: recovered.state = false; return recovered; case FanRestoreMode::ALWAYS_ON: recovered.state = true; return recovered; case FanRestoreMode::RESTORE_DEFAULT_OFF: recovered.state = restored ? recovered.state : false; return recovered; case FanRestoreMode::RESTORE_DEFAULT_ON: recovered.state = restored ? recovered.state : true; return recovered; case FanRestoreMode::RESTORE_INVERTED_DEFAULT_OFF: recovered.state = restored ? !recovered.state : false; return recovered; case FanRestoreMode::RESTORE_INVERTED_DEFAULT_ON: recovered.state = restored ? !recovered.state : true; return recovered; } return {}; } void Fan::save_state_() { if (this->restore_mode_ == FanRestoreMode::NO_RESTORE) { return; } FanRestoreState state{}; state.state = this->state; state.oscillating = this->oscillating; state.speed = this->speed; state.direction = this->direction; state.preset_mode = FanRestoreState::NO_PRESET; if (this->has_preset_mode()) { if (this->supported_preset_modes_) { // New path: search Fan-owned vector directly for (size_t i = 0; i < this->supported_preset_modes_->size(); i++) { if ((*this->supported_preset_modes_)[i] == this->preset_mode_) { state.preset_mode = i; break; } } } else { // Compat: fall back to traits for deprecated path. Remove in 2026.11.0. // Pointer comparison works because preset_mode_ and the compat vector both // hold pointers to string literals in .rodata (stable addresses). auto traits = this->get_traits(); const auto &preset_modes = traits.supported_preset_modes(); for (size_t i = 0; i < preset_modes.size(); i++) { if (preset_modes[i] == this->preset_mode_) { state.preset_mode = i; break; } } } } this->rtc_.save(&state); } void Fan::dump_traits_(const char *tag, const char *prefix) { auto traits = this->get_traits(); if (traits.supports_speed()) { ESP_LOGCONFIG(tag, "%s Speed: YES\n" "%s Speed count: %d", prefix, prefix, traits.supported_speed_count()); } if (traits.supports_oscillation()) { ESP_LOGCONFIG(tag, "%s Oscillation: YES", prefix); } if (traits.supports_direction()) { ESP_LOGCONFIG(tag, "%s Direction: YES", prefix); } if (traits.supports_preset_modes()) { ESP_LOGCONFIG(tag, "%s Supported presets:", prefix); for (const char *s : traits.supported_preset_modes()) ESP_LOGCONFIG(tag, "%s - %s", prefix, s); } } } // namespace fan } // namespace esphome