mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +00:00 
			
		
		
		
	[fan] fix initial FanCall to properly set speed (#8277)
This commit is contained in:
		| @@ -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) { | ||||
|   | ||||
							
								
								
									
										34
									
								
								tests/integration/fixtures/host_mode_fan_preset.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								tests/integration/fixtures/host_mode_fan_preset.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										152
									
								
								tests/integration/test_host_mode_fan_preset.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								tests/integration/test_host_mode_fan_preset.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
		Reference in New Issue
	
	Block a user