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_() { | void FanCall::validate_() { | ||||||
|   auto traits = this->parent_.get_traits(); |   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()); |     this->speed_ = clamp(*this->speed_, 1, traits.supported_speed_count()); | ||||||
|  |  | ||||||
|   if (this->binary_state_.has_value() && *this->binary_state_) { |     // https://developers.home-assistant.io/docs/core/entity/fan/#preset-modes | ||||||
|     // when turning on, if neither current nor new speed available, set speed to 100% |     // "Manually setting a speed must disable any set preset mode" | ||||||
|     if (traits.supports_speed() && !this->parent_.state && this->parent_.speed == 0 && !this->speed_.has_value()) { |     this->preset_mode_.clear(); | ||||||
|       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(); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (!this->preset_mode_.empty()) { |   if (!this->preset_mode_.empty()) { | ||||||
|     const auto &preset_modes = traits.supported_preset_modes(); |     const auto &preset_modes = traits.supported_preset_modes(); | ||||||
|     if (preset_modes.find(this->preset_mode_) == preset_modes.end()) { |     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(), |       ESP_LOGW(TAG, "%s: Preset mode '%s' not supported", this->parent_.get_name().c_str(), this->preset_mode_.c_str()); | ||||||
|                this->preset_mode_.c_str()); |  | ||||||
|       this->preset_mode_.clear(); |       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) { | 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