diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index f817c1d0714..6dda6800c62 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -9,7 +9,7 @@ from pyvesync.vesyncswitch import VeSyncWallSwitch from homeassistant.core import HomeAssistant -from .const import VeSyncHumidifierDevice +from .const import VeSyncFanDevice, VeSyncHumidifierDevice _LOGGER = logging.getLogger(__name__) @@ -58,6 +58,12 @@ def is_humidifier(device: VeSyncBaseDevice) -> bool: return isinstance(device, VeSyncHumidifierDevice) +def is_fan(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a fan.""" + + return isinstance(device, VeSyncFanDevice) + + def is_outlet(device: VeSyncBaseDevice) -> bool: """Check if the device represents an outlet.""" diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index ff55bcf2e37..08db4463e07 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -1,6 +1,12 @@ """Constants for VeSync Component.""" -from pyvesync.vesyncfan import VeSyncHumid200300S, VeSyncSuperior6000S +from pyvesync.vesyncfan import ( + VeSyncAir131, + VeSyncAirBaseV2, + VeSyncAirBypass, + VeSyncHumid200300S, + VeSyncSuperior6000S, +) DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" @@ -30,6 +36,27 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" VS_HUMIDIFIER_MODE_MANUAL = "manual" VS_HUMIDIFIER_MODE_SLEEP = "sleep" +VS_FAN_MODE_AUTO = "auto" +VS_FAN_MODE_SLEEP = "sleep" +VS_FAN_MODE_ADVANCED_SLEEP = "advancedSleep" +VS_FAN_MODE_TURBO = "turbo" +VS_FAN_MODE_PET = "pet" +VS_FAN_MODE_MANUAL = "manual" +VS_FAN_MODE_NORMAL = "normal" + +# not a full list as manual is used as speed not present +VS_FAN_MODE_PRESET_LIST_HA = [ + VS_FAN_MODE_AUTO, + VS_FAN_MODE_SLEEP, + VS_FAN_MODE_ADVANCED_SLEEP, + VS_FAN_MODE_TURBO, + VS_FAN_MODE_PET, + VS_FAN_MODE_NORMAL, +] +NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +NIGHT_LIGHT_LEVEL_DIM = "dim" +NIGHT_LIGHT_LEVEL_OFF = "off" + FAN_NIGHT_LIGHT_LEVEL_DIM = "dim" FAN_NIGHT_LIGHT_LEVEL_OFF = "off" FAN_NIGHT_LIGHT_LEVEL_ON = "on" @@ -41,6 +68,10 @@ HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF = "off" VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S """Humidifier device types""" +VeSyncFanDevice = VeSyncAirBypass | VeSyncAirBypass | VeSyncAirBaseV2 | VeSyncAir131 +"""Fan device types""" + + DEV_TYPE_TO_HA = { "wifi-switch-1.3": "outlet", "ESW03-USA": "outlet", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index daf734d50a8..d9336552744 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,6 +11,7 @@ from pyvesync.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( @@ -19,43 +20,27 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range +from .common import is_fan from .const import ( - DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, + VS_FAN_MODE_ADVANCED_SLEEP, + VS_FAN_MODE_AUTO, + VS_FAN_MODE_MANUAL, + VS_FAN_MODE_NORMAL, + VS_FAN_MODE_PET, + VS_FAN_MODE_PRESET_LIST_HA, + VS_FAN_MODE_SLEEP, + VS_FAN_MODE_TURBO, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) -FAN_MODE_AUTO = "auto" -FAN_MODE_SLEEP = "sleep" -FAN_MODE_PET = "pet" -FAN_MODE_TURBO = "turbo" -FAN_MODE_ADVANCED_SLEEP = "advancedSleep" -FAN_MODE_NORMAL = "normal" - - -PRESET_MODES = { - "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core200S": [FAN_MODE_SLEEP], - "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "EverestAir": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO], - "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], - "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], - "SmartTowerFan": [ - FAN_MODE_ADVANCED_SLEEP, - FAN_MODE_AUTO, - FAN_MODE_TURBO, - FAN_MODE_NORMAL, - ], -} SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), "Core200S": (1, 3), @@ -97,13 +82,8 @@ def _setup_entities( coordinator: VeSyncDataCoordinator, ): """Check if device is fan and add entity.""" - entities = [ - VeSyncFanHA(dev, coordinator) - for dev in devices - if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type, "")) == "fan" - ] - async_add_entities(entities, update_before_add=True) + async_add_entities(VeSyncFanHA(dev, coordinator) for dev in devices if is_fan(dev)) class VeSyncFanHA(VeSyncBaseEntity, FanEntity): @@ -118,13 +98,6 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): _attr_name = None _attr_translation_key = "vesync" - def __init__( - self, fan: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize the VeSync fan device.""" - super().__init__(fan, coordinator) - self.smartfan = fan - @property def is_on(self) -> bool: """Return True if device is on.""" @@ -134,8 +107,8 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): def percentage(self) -> int | None: """Return the current speed.""" if ( - self.smartfan.mode == "manual" - and (current_level := self.smartfan.fan_level) is not None + self.device.mode == VS_FAN_MODE_MANUAL + and (current_level := self.device.fan_level) is not None ): return ranged_value_to_percentage( SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], current_level @@ -152,13 +125,21 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): @property def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" - return PRESET_MODES[SKU_TO_BASE_DEVICE[self.device.device_type]] + if hasattr(self.device, "modes"): + return sorted( + [ + mode + for mode in self.device.modes + if mode in VS_FAN_MODE_PRESET_LIST_HA + ] + ) + return [] @property def preset_mode(self) -> str | None: """Get the current preset mode.""" - if self.smartfan.mode in (FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_TURBO): - return self.smartfan.mode + if self.device.mode in VS_FAN_MODE_PRESET_LIST_HA: + return self.device.mode return None @property @@ -166,65 +147,73 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): """Return the state attributes of the fan.""" attr = {} - if hasattr(self.smartfan, "active_time"): - attr["active_time"] = self.smartfan.active_time + if hasattr(self.device, "active_time"): + attr["active_time"] = self.device.active_time - if hasattr(self.smartfan, "screen_status"): - attr["screen_status"] = self.smartfan.screen_status + if hasattr(self.device, "screen_status"): + attr["screen_status"] = self.device.screen_status - if hasattr(self.smartfan, "child_lock"): - attr["child_lock"] = self.smartfan.child_lock + if hasattr(self.device, "child_lock"): + attr["child_lock"] = self.device.child_lock - if hasattr(self.smartfan, "night_light"): - attr["night_light"] = self.smartfan.night_light + if hasattr(self.device, "night_light"): + attr["night_light"] = self.device.night_light - if hasattr(self.smartfan, "mode"): - attr["mode"] = self.smartfan.mode + if hasattr(self.device, "mode"): + attr["mode"] = self.device.mode return attr def set_percentage(self, percentage: int) -> None: """Set the speed of the device.""" if percentage == 0: - self.smartfan.turn_off() - return + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + elif not self.device.is_on: + success = self.device.turn_on() + if not success: + raise HomeAssistantError("An error occurred while turning on.") - if not self.smartfan.is_on: - self.smartfan.turn_on() - - self.smartfan.manual_mode() - self.smartfan.change_fan_speed( + success = self.device.manual_mode() + if not success: + raise HomeAssistantError("An error occurred while manual mode.") + success = self.device.change_fan_speed( math.ceil( percentage_to_ranged_value( SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage ) ) ) + if not success: + raise HomeAssistantError("An error occurred while changing fan speed.") self.schedule_update_ha_state() def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of device.""" - if preset_mode not in self.preset_modes: + if preset_mode not in VS_FAN_MODE_PRESET_LIST_HA: raise ValueError( f"{preset_mode} is not one of the valid preset modes: " - f"{self.preset_modes}" + f"{VS_FAN_MODE_PRESET_LIST_HA}" ) - if not self.smartfan.is_on: - self.smartfan.turn_on() + if not self.device.is_on: + self.device.turn_on() - if preset_mode == FAN_MODE_AUTO: - self.smartfan.auto_mode() - elif preset_mode == FAN_MODE_SLEEP: - self.smartfan.sleep_mode() - elif preset_mode == FAN_MODE_ADVANCED_SLEEP: - self.smartfan.advanced_sleep_mode() - elif preset_mode == FAN_MODE_PET: - self.smartfan.pet_mode() - elif preset_mode == FAN_MODE_TURBO: - self.smartfan.turbo_mode() - elif preset_mode == FAN_MODE_NORMAL: - self.smartfan.normal_mode() + if preset_mode == VS_FAN_MODE_AUTO: + success = self.device.auto_mode() + elif preset_mode == VS_FAN_MODE_SLEEP: + success = self.device.sleep_mode() + elif preset_mode == VS_FAN_MODE_ADVANCED_SLEEP: + success = self.device.advanced_sleep_mode() + elif preset_mode == VS_FAN_MODE_PET: + success = self.device.pet_mode() + elif preset_mode == VS_FAN_MODE_TURBO: + success = self.device.turbo_mode() + elif preset_mode == VS_FAN_MODE_NORMAL: + success = self.device.normal_mode() + if not success: + raise HomeAssistantError("An error occurred while setting preset mode.") self.schedule_update_ha_state() @@ -244,4 +233,7 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self.device.turn_off() + success = self.device.turn_off() + if not success: + raise HomeAssistantError("An error occurred while turning off.") + self.schedule_update_ha_state() diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 5795c977120..cf2f49ff28f 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -15,6 +15,8 @@ ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" +ENTITY_FAN = "fan.SmartTowerFan" + ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index df6ebbdf6e7..32f23101755 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -198,6 +198,26 @@ async def install_humidifier_device( await hass.async_block_till_done() +@pytest.fixture(name="fan_config_entry") +async def fan_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `SmartTowerFan`.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + + device_name = "SmartTowerFan" + mock_multiple_device_responses(requests_mock, [device_name]) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 92473647a39..412bd8a1b2e 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -640,8 +640,8 @@ 'preset_modes': list([ 'advancedSleep', 'auto', - 'turbo', 'normal', + 'turbo', ]), }), 'config_entry_id': , @@ -682,12 +682,12 @@ 'night_light': 'off', 'percentage': None, 'percentage_step': 7.6923076923076925, - 'preset_mode': None, + 'preset_mode': 'normal', 'preset_modes': list([ 'advancedSleep', 'auto', - 'turbo', 'normal', + 'turbo', ]), 'screen_status': False, 'supported_features': , diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 4d444036a60..ccc8c5cd595 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -1,17 +1,24 @@ """Tests for the fan module.""" +from contextlib import nullcontext +from unittest.mock import patch + import pytest import requests_mock from syrupy import SnapshotAssertion -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.fan import ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_FAN, mock_devices_response from tests.common import MockConfigEntry +NoException = nullcontext() + @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) async def test_fan_state( @@ -49,3 +56,105 @@ async def test_fan_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncTowerFan.turn_on"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + ], +) +async def test_turn_on_off_success( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test turn_on and turn_off method.""" + + with ( + patch(command, return_value=True) as method_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_FAN}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncTowerFan.turn_on"), + (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + ], +) +async def test_turn_on_off_raises_error( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + action: str, + command: str, +) -> None: + """Test turn_on and turn_off raises errors when fails.""" + + # returns False indicating failure in which case raises HomeAssistantError. + with ( + patch( + command, + return_value=False, + ) as method_mock, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + FAN_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_FAN}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_set_preset_mode( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + api_response: bool, + expectation, +) -> None: + """Test handling of value in set_preset_mode method. Does this via turn on as it increases test coverage.""" + + # If VeSyncTowerFan.normal_mode fails (returns False), then HomeAssistantError is raised + with ( + expectation, + patch( + "pyvesync.vesyncfan.VeSyncTowerFan.normal_mode", + 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, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PRESET_MODE: "normal"}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once()