From 59f69ac5caeb3bef5296bbd1072661a424497ed2 Mon Sep 17 00:00:00 2001 From: dhewg Date: Sun, 15 Jun 2025 20:16:33 +0200 Subject: [PATCH] [fan] fix initial FanCall to properly set speed (#8277) --- esphome/components/fan/fan.cpp | 55 ++++--- .../fixtures/host_mode_fan_preset.yaml | 34 ++++ .../integration/test_host_mode_fan_preset.py | 152 ++++++++++++++++++ 3 files changed, 218 insertions(+), 23 deletions(-) create mode 100644 tests/integration/fixtures/host_mode_fan_preset.yaml create mode 100644 tests/integration/test_host_mode_fan_preset.py diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 87bf4939a0..25f710f893 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -41,39 +41,48 @@ void FanCall::perform() { void FanCall::validate_() { auto traits = this->parent_.get_traits(); - if (this->speed_.has_value()) + if (this->speed_.has_value()) { this->speed_ = clamp(*this->speed_, 1, traits.supported_speed_count()); - if (this->binary_state_.has_value() && *this->binary_state_) { - // when turning on, if neither current nor new speed available, set speed to 100% - if (traits.supports_speed() && !this->parent_.state && this->parent_.speed == 0 && !this->speed_.has_value()) { - this->speed_ = traits.supported_speed_count(); - } - } - - if (this->oscillating_.has_value() && !traits.supports_oscillation()) { - ESP_LOGW(TAG, "'%s' - This fan does not support oscillation!", this->parent_.get_name().c_str()); - this->oscillating_.reset(); - } - - if (this->speed_.has_value() && !traits.supports_speed()) { - ESP_LOGW(TAG, "'%s' - This fan does not support speeds!", this->parent_.get_name().c_str()); - this->speed_.reset(); - } - - if (this->direction_.has_value() && !traits.supports_direction()) { - ESP_LOGW(TAG, "'%s' - This fan does not support directions!", this->parent_.get_name().c_str()); - this->direction_.reset(); + // 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(); if (preset_modes.find(this->preset_mode_) == preset_modes.end()) { - ESP_LOGW(TAG, "'%s' - This fan does not support preset mode '%s'!", this->parent_.get_name().c_str(), - this->preset_mode_.c_str()); + ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str()); this->preset_mode_.clear(); } } + + // 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() + // ...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) { diff --git a/tests/integration/fixtures/host_mode_fan_preset.yaml b/tests/integration/fixtures/host_mode_fan_preset.yaml new file mode 100644 index 0000000000..003f4a7760 --- /dev/null +++ b/tests/integration/fixtures/host_mode_fan_preset.yaml @@ -0,0 +1,34 @@ +esphome: + name: host-test + +host: + +api: + +logger: + +# Test fan with preset modes and speed settings +fan: + - platform: template + name: "Test Fan with Presets" + id: test_fan_presets + speed_count: 5 + preset_modes: + - "Eco" + - "Sleep" + - "Turbo" + has_oscillating: true + has_direction: true + + - platform: template + name: "Test Fan Simple" + id: test_fan_simple + speed_count: 3 + has_oscillating: false + has_direction: false + + - platform: template + name: "Test Fan No Speed" + id: test_fan_no_speed + has_oscillating: true + has_direction: false diff --git a/tests/integration/test_host_mode_fan_preset.py b/tests/integration/test_host_mode_fan_preset.py new file mode 100644 index 0000000000..1d956a7290 --- /dev/null +++ b/tests/integration/test_host_mode_fan_preset.py @@ -0,0 +1,152 @@ +"""Integration test for fan preset mode behavior.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import FanInfo, FanState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_fan_preset( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test fan preset mode behavior according to Home Assistant guidelines.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Get all fan entities + entities = await client.list_entities_services() + fans: list[FanInfo] = [] + for entity_list in entities: + for entity in entity_list: + if isinstance(entity, FanInfo): + fans.append(entity) + + # Create a map of fan names to entity info + fan_map = {fan.name: fan for fan in fans} + + # Verify we have our test fans + assert "Test Fan with Presets" in fan_map + assert "Test Fan Simple" in fan_map + assert "Test Fan No Speed" in fan_map + + # Get fan with presets + fan_presets = fan_map["Test Fan with Presets"] + assert fan_presets.supports_speed is True + assert fan_presets.supported_speed_count == 5 + assert fan_presets.supports_oscillation is True + assert fan_presets.supports_direction is True + assert set(fan_presets.supported_preset_modes) == {"Eco", "Sleep", "Turbo"} + + # Subscribe to states + states: dict[int, FanState] = {} + state_event = asyncio.Event() + + def on_state(state: FanState) -> None: + if isinstance(state, FanState): + states[state.key] = state + state_event.set() + + client.subscribe_states(on_state) + + # Test 1: Turn on fan without speed or preset - should set speed to 100% + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=True, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.speed_level == 5 # Should be max speed (100%) + assert fan_state.preset_mode == "" + + # Turn off + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=False, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + # Test 2: Turn on fan with preset mode - should NOT set speed to 100% + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=True, + preset_mode="Eco", + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Eco" + # Speed should be whatever the preset sets, not forced to 100% + + # Test 3: Setting speed should clear preset mode + state_event.clear() + client.fan_command( + key=fan_presets.key, + speed_level=3, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.speed_level == 3 + assert fan_state.preset_mode == "" # Preset mode should be cleared + + # Test 4: Setting preset mode should work when fan is already on + state_event.clear() + client.fan_command( + key=fan_presets.key, + preset_mode="Sleep", + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Sleep" + + # Turn off + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=False, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + # Test 5: Turn on fan with specific speed + state_event.clear() + client.fan_command( + key=fan_presets.key, + state=True, + speed_level=2, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_presets.key] + assert fan_state.state is True + assert fan_state.speed_level == 2 + assert fan_state.preset_mode == "" + + # Test 6: Test fan with no speed support + fan_no_speed = fan_map["Test Fan No Speed"] + assert fan_no_speed.supports_speed is False + + state_event.clear() + client.fan_command( + key=fan_no_speed.key, + state=True, + ) + await asyncio.wait_for(state_event.wait(), timeout=2.0) + + fan_state = states[fan_no_speed.key] + assert fan_state.state is True + # No speed should be set for fans that don't support speed