From 425bdc0ba6439528c96ed2157649db84187be6d6 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 6 Oct 2025 06:50:11 -0600 Subject: [PATCH] Vesync add oscillation to fan (#153297) --- homeassistant/components/vesync/fan.py | 24 +++++++++- .../vesync/snapshots/test_diagnostics.ambr | 2 +- .../components/vesync/snapshots/test_fan.ambr | 5 ++- tests/components/vesync/test_diagnostics.py | 3 ++ tests/components/vesync/test_fan.py | 45 +++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 65a3a5d20e6a..9f3a7bc9ba8a 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -18,7 +18,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from .common import is_fan, is_purifier +from .common import is_fan, is_purifier, rgetattr from .const import ( DOMAIN, VS_COORDINATOR, @@ -90,11 +90,26 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): _attr_name = None _attr_translation_key = "vesync" + def __init__( + self, + device: VeSyncBaseDevice, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the fan.""" + super().__init__(device, coordinator) + if rgetattr(device, "state.oscillation_status") is not None: + self._attr_supported_features |= FanEntityFeature.OSCILLATE + @property def is_on(self) -> bool: """Return True if device is on.""" return self.device.state.device_status == "on" + @property + def oscillating(self) -> bool: + """Return True if device is oscillating.""" + return rgetattr(self.device, "state.oscillation_status") == "on" + @property def percentage(self) -> int | None: """Return the currently set speed.""" @@ -248,3 +263,10 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): if not success: raise HomeAssistantError(self.device.last_response.message) self.schedule_update_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + success = await self.device.toggle_oscillation(oscillating) + if not success: + raise HomeAssistantError(self.device.last_response.message) + self.schedule_update_ha_state() diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 3f01ce765b93..4e41d77e5e3e 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -250,7 +250,7 @@ 'friendly_name': 'Test Fan', 'preset_modes': list([ ]), - 'supported_features': 57, + 'supported_features': 59, }), 'entity_id': 'fan.test_fan', 'last_changed': str, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 88b6bc64ebb3..daacddb32675 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -656,7 +656,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vesync', 'unique_id': 'smarttowerfan', 'unit_of_measurement': None, @@ -670,6 +670,7 @@ 'display_status': 'off', 'friendly_name': 'SmartTowerFan', 'mode': 'normal', + 'oscillating': True, 'percentage': None, 'percentage_step': 8.333333333333334, 'preset_mode': 'normal', @@ -679,7 +680,7 @@ 'normal', 'turbo', ]), - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'fan.smarttowerfan', diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index 31e0e514dd33..7929d838fbeb 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -107,6 +107,9 @@ async def test_async_get_device_diagnostics__single_fan( "home_assistant.entities.6.state.last_changed": (str,), "home_assistant.entities.6.state.last_reported": (str,), "home_assistant.entities.6.state.last_updated": (str,), + "home_assistant.entities.7.state.last_changed": (str,), + "home_assistant.entities.7.state.last_reported": (str,), + "home_assistant.entities.7.state.last_updated": (str,), } ) ) diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index d8903f5f8266..12801d989c0d 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -181,3 +181,48 @@ async def test_set_preset_mode( await hass.async_block_till_done() method_mock.assert_called_once() update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("action", "command"), + [ + ("true", "pyvesync.devices.vesyncfan.VeSyncTowerFan.toggle_oscillation"), + ("false", "pyvesync.devices.vesyncfan.VeSyncTowerFan.toggle_oscillation"), + ], +) +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_oscillation_success( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + action: str, + command: str, + api_response: bool, + expectation, +) -> None: + """Test oscillation on and off.""" + + mock_devices_response(aioclient_mock, "SmartTowerFan") + + with ( + expectation, + patch( + command, new_callable=AsyncMock, return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + "oscillate", + {ATTR_ENTITY_ID: ENTITY_FAN, "oscillating": action}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once()