diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index e59d0234beb..6e33aa6d161 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -118,7 +118,20 @@ class BondFan(BondEntity, FanEntity): self._device.device_id, Action.set_speed(bond_speed) ) - async def async_turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" _LOGGER.debug("Fan async_turn_on called with speed %s", speed) diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index b5eac4f9afe..18549c52d35 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -102,7 +102,16 @@ class ComfoConnectFan(FanEntity): """List of available fan modes.""" return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, speed: str = None, percentage=None, preset_mode=None, **kwargs + ) -> None: """Turn on the fan.""" if speed is None: speed = SPEED_LOW diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index d92addff5bd..1ca4c8ff9c2 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -107,7 +107,20 @@ class DeconzFan(DeconzDevice, FanEntity): await self._device.set_speed(SPEEDS[speed]) - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on fan.""" if not speed: speed = convert_speed(self._default_on_speed) diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index ee49f0a2e99..bd6661b6c2b 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -1,14 +1,20 @@ """Demo fan platform that has a fake fan.""" +from typing import List, Optional + from homeassistant.components.fan import ( SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, + SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, + SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, ) -from homeassistant.const import STATE_OFF + +PRESET_MODE_AUTO = "auto" +PRESET_MODE_SMART = "smart" FULL_SUPPORT = SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_DIRECTION LIMITED_SUPPORT = SUPPORT_SET_SPEED @@ -18,8 +24,55 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the demo fan platform.""" async_add_entities( [ - DemoFan(hass, "fan1", "Living Room Fan", FULL_SUPPORT), - DemoFan(hass, "fan2", "Ceiling Fan", LIMITED_SUPPORT), + # These fans implement the old model + DemoFan( + hass, + "fan1", + "Living Room Fan", + FULL_SUPPORT, + None, + [ + SPEED_OFF, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_HIGH, + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + ], + ), + DemoFan( + hass, + "fan2", + "Ceiling Fan", + LIMITED_SUPPORT, + None, + [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH], + ), + # These fans implement the newer model + AsyncDemoPercentageFan( + hass, + "fan3", + "Percentage Full Fan", + FULL_SUPPORT, + [PRESET_MODE_AUTO, PRESET_MODE_SMART], + None, + ), + DemoPercentageFan( + hass, + "fan4", + "Percentage Limited Fan", + LIMITED_SUPPORT, + [PRESET_MODE_AUTO, PRESET_MODE_SMART], + None, + ), + AsyncDemoPercentageFan( + hass, + "fan5", + "Preset Only Limited Fan", + SUPPORT_PRESET_MODE, + [PRESET_MODE_AUTO, PRESET_MODE_SMART], + [], + ), ] ) @@ -29,21 +82,30 @@ async def async_setup_entry(hass, config_entry, async_add_entities): await async_setup_platform(hass, {}, async_add_entities) -class DemoFan(FanEntity): - """A demonstration fan component.""" +class BaseDemoFan(FanEntity): + """A demonstration fan component that uses legacy fan speeds.""" def __init__( - self, hass, unique_id: str, name: str, supported_features: int + self, + hass, + unique_id: str, + name: str, + supported_features: int, + preset_modes: Optional[List[str]], + speed_list: Optional[List[str]], ) -> None: """Initialize the entity.""" self.hass = hass self._unique_id = unique_id self._supported_features = supported_features - self._speed = STATE_OFF + self._speed = SPEED_OFF + self._percentage = 0 + self._speed_list = speed_list + self._preset_modes = preset_modes + self._preset_mode = None self._oscillating = None self._direction = None self._name = name - if supported_features & SUPPORT_OSCILLATE: self._oscillating = False if supported_features & SUPPORT_DIRECTION: @@ -64,17 +126,42 @@ class DemoFan(FanEntity): """No polling needed for a demo fan.""" return False + @property + def current_direction(self) -> str: + """Fan direction.""" + return self._direction + + @property + def oscillating(self) -> bool: + """Oscillating.""" + return self._oscillating + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + +class DemoFan(BaseDemoFan, FanEntity): + """A demonstration fan component that uses legacy fan speeds.""" + @property def speed(self) -> str: """Return the current speed.""" return self._speed @property - def speed_list(self) -> list: - """Get the list of available speeds.""" - return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + def speed_list(self): + """Return the speed list.""" + return self._speed_list - def turn_on(self, speed: str = None, **kwargs) -> None: + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the entity.""" if speed is None: speed = SPEED_MEDIUM @@ -83,7 +170,7 @@ class DemoFan(FanEntity): def turn_off(self, **kwargs) -> None: """Turn off the entity.""" self.oscillate(False) - self.set_speed(STATE_OFF) + self.set_speed(SPEED_OFF) def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" @@ -100,17 +187,124 @@ class DemoFan(FanEntity): self._oscillating = oscillating self.schedule_update_ha_state() - @property - def current_direction(self) -> str: - """Fan direction.""" - return self._direction + +class DemoPercentageFan(BaseDemoFan, FanEntity): + """A demonstration fan component that uses percentages.""" @property - def oscillating(self) -> bool: - """Oscillating.""" - return self._oscillating + def percentage(self) -> str: + """Return the current speed.""" + return self._percentage + + def set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + self._percentage = percentage + self._preset_mode = None + self.schedule_update_ha_state() @property - def supported_features(self) -> int: - """Flag supported features.""" - return self._supported_features + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., auto, smart, interval, favorite.""" + return self._preset_mode + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return self._preset_modes + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode in self.preset_modes: + self._preset_mode = preset_mode + self._percentage = None + self.schedule_update_ha_state() + else: + raise ValueError(f"Invalid preset mode: {preset_mode}") + + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the entity.""" + if preset_mode: + self.set_preset_mode(preset_mode) + return + + if percentage is None: + percentage = 67 + + self.set_percentage(percentage) + + def turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + self.set_percentage(0) + + +class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): + """An async demonstration fan component that uses percentages.""" + + @property + def percentage(self) -> str: + """Return the current speed.""" + return self._percentage + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + self._percentage = percentage + self._preset_mode = None + self.async_write_ha_state() + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., auto, smart, interval, favorite.""" + return self._preset_mode + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return self._preset_modes + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode not in self.preset_modes: + raise ValueError( + "{preset_mode} is not a valid preset_mode: {self.preset_modes}" + ) + self._preset_mode = preset_mode + self._percentage = None + self.async_write_ha_state() + + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the entity.""" + if preset_mode: + await self.async_set_preset_mode(preset_mode) + return + + if percentage is None: + percentage = 67 + + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + await self.async_oscillate(False) + await self.async_set_percentage(0) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + self._direction = direction + self.async_write_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + self._oscillating = oscillating + self.async_write_ha_state() diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py index 6690f77390d..7a57a75523e 100644 --- a/homeassistant/components/dyson/fan.py +++ b/homeassistant/components/dyson/fan.py @@ -233,7 +233,20 @@ class DysonPureCoolLinkEntity(DysonFanEntity): """Initialize the fan.""" super().__init__(device, DysonPureCoolState) - def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" _LOGGER.debug("Turn on fan %s with speed %s", self.name, speed) if speed is not None: @@ -299,7 +312,20 @@ class DysonPureCoolEntity(DysonFanEntity): """Initialize the fan.""" super().__init__(device, DysonPureCoolV2State) - def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" _LOGGER.debug("Turn on fan %s", self.name) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 32f445d23a2..c7abac576e7 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -79,7 +79,20 @@ class EsphomeFan(EsphomeEntity, FanEntity): self._static_info.key, speed=_fan_speeds.from_hass(speed) ) - async def async_turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" if speed == SPEED_OFF: await self.async_turn_off() diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 90a3d030703..7b6b083c964 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -2,7 +2,7 @@ from datetime import timedelta import functools as ft import logging -from typing import Optional +from typing import List, Optional import voluptuous as vol @@ -20,6 +20,10 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) _LOGGER = logging.getLogger(__name__) @@ -32,10 +36,13 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" SUPPORT_SET_SPEED = 1 SUPPORT_OSCILLATE = 2 SUPPORT_DIRECTION = 4 +SUPPORT_PRESET_MODE = 8 SERVICE_SET_SPEED = "set_speed" SERVICE_OSCILLATE = "oscillate" SERVICE_SET_DIRECTION = "set_direction" +SERVICE_SET_PERCENTAGE = "set_percentage" +SERVICE_SET_PRESET_MODE = "set_preset_mode" SPEED_OFF = "off" SPEED_LOW = "low" @@ -46,9 +53,47 @@ DIRECTION_FORWARD = "forward" DIRECTION_REVERSE = "reverse" ATTR_SPEED = "speed" +ATTR_PERCENTAGE = "percentage" ATTR_SPEED_LIST = "speed_list" ATTR_OSCILLATING = "oscillating" ATTR_DIRECTION = "direction" +ATTR_PRESET_MODE = "preset_mode" +ATTR_PRESET_MODES = "preset_modes" + +# Invalid speeds do not conform to the entity model, but have crept +# into core integrations at some point so we are temporarily +# accommodating them in the transition to percentages. +_NOT_SPEED_OFF = "off" +_NOT_SPEED_AUTO = "auto" +_NOT_SPEED_SMART = "smart" +_NOT_SPEED_INTERVAL = "interval" +_NOT_SPEED_IDLE = "idle" +_NOT_SPEED_FAVORITE = "favorite" + +_NOT_SPEEDS_FILTER = { + _NOT_SPEED_OFF, + _NOT_SPEED_AUTO, + _NOT_SPEED_SMART, + _NOT_SPEED_INTERVAL, + _NOT_SPEED_IDLE, + _NOT_SPEED_FAVORITE, +} + +_FAN_NATIVE = "_fan_native" + +OFF_SPEED_VALUES = [SPEED_OFF, None] + + +class NoValidSpeedsError(ValueError): + """Exception class when there are no valid speeds.""" + + +class NotValidSpeedError(ValueError): + """Exception class when the speed in not in the speed list.""" + + +class NotValidPresetModeError(ValueError): + """Exception class when the preset_mode in not in the preset_modes list.""" @bind_hass @@ -56,7 +101,7 @@ def is_on(hass, entity_id: str) -> bool: """Return if the fans are on based on the statemachine.""" state = hass.states.get(entity_id) if ATTR_SPEED in state.attributes: - return state.attributes[ATTR_SPEED] not in [SPEED_OFF, None] + return state.attributes[ATTR_SPEED] not in OFF_SPEED_VALUES return state.state == STATE_ON @@ -68,15 +113,27 @@ async def async_setup(hass, config: dict): await component.async_setup(config) + # After the transition to percentage and preset_modes concludes, + # switch this back to async_turn_on and remove async_turn_on_compat component.async_register_entity_service( - SERVICE_TURN_ON, {vol.Optional(ATTR_SPEED): cv.string}, "async_turn_on" + SERVICE_TURN_ON, + { + vol.Optional(ATTR_SPEED): cv.string, + vol.Optional(ATTR_PERCENTAGE): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(ATTR_PRESET_MODE): cv.string, + }, + "async_turn_on_compat", ) component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") + # After the transition to percentage and preset_modes concludes, + # remove this service component.async_register_entity_service( SERVICE_SET_SPEED, {vol.Required(ATTR_SPEED): cv.string}, - "async_set_speed", + "async_set_speed_deprecated", [SUPPORT_SET_SPEED], ) component.async_register_entity_service( @@ -91,6 +148,22 @@ async def async_setup(hass, config: dict): "async_set_direction", [SUPPORT_DIRECTION], ) + component.async_register_entity_service( + SERVICE_SET_PERCENTAGE, + { + vol.Required(ATTR_PERCENTAGE): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_percentage", + [SUPPORT_SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_SET_PRESET_MODE, + {vol.Required(ATTR_PRESET_MODE): cv.string}, + "async_set_preset_mode", + [SUPPORT_SET_SPEED, SUPPORT_PRESET_MODE], + ) return True @@ -105,19 +178,91 @@ async def async_unload_entry(hass, entry): return await hass.data[DOMAIN].async_unload_entry(entry) +def _fan_native(method): + """Native fan method not overridden.""" + setattr(method, _FAN_NATIVE, True) + return method + + class FanEntity(ToggleEntity): """Representation of a fan.""" + @_fan_native def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" raise NotImplementedError() + async def async_set_speed_deprecated(self, speed: str): + """Set the speed of the fan.""" + _LOGGER.warning( + "fan.set_speed is deprecated, use fan.set_percentage or fan.set_preset_mode instead." + ) + await self.async_set_speed(speed) + + @_fan_native async def async_set_speed(self, speed: str): """Set the speed of the fan.""" if speed == SPEED_OFF: await self.async_turn_off() + return + + if speed in self.preset_modes: + if not hasattr(self.async_set_preset_mode, _FAN_NATIVE): + await self.async_set_preset_mode(speed) + return + if not hasattr(self.set_preset_mode, _FAN_NATIVE): + await self.hass.async_add_executor_job(self.set_preset_mode, speed) + return else: - await self.hass.async_add_executor_job(self.set_speed, speed) + if not hasattr(self.async_set_percentage, _FAN_NATIVE): + await self.async_set_percentage(self.speed_to_percentage(speed)) + return + if not hasattr(self.set_percentage, _FAN_NATIVE): + await self.hass.async_add_executor_job( + self.set_percentage, self.speed_to_percentage(speed) + ) + return + + await self.hass.async_add_executor_job(self.set_speed, speed) + + @_fan_native + def set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + raise NotImplementedError() + + @_fan_native + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage == 0: + await self.async_turn_off() + elif not hasattr(self.set_percentage, _FAN_NATIVE): + await self.hass.async_add_executor_job(self.set_percentage, percentage) + else: + await self.async_set_speed(self.percentage_to_speed(percentage)) + + @_fan_native + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + self._valid_preset_mode_or_raise(preset_mode) + self.set_speed(preset_mode) + + @_fan_native + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if not hasattr(self.set_preset_mode, _FAN_NATIVE): + await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) + return + + self._valid_preset_mode_or_raise(preset_mode) + await self.async_set_speed(preset_mode) + + def _valid_preset_mode_or_raise(self, preset_mode): + """Raise NotValidPresetModeError on invalid preset_mode.""" + preset_modes = self.preset_modes + if preset_mode not in preset_modes: + raise NotValidPresetModeError( + f"The preset_mode {preset_mode} is not a valid preset_mode: {preset_modes}" + ) def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" @@ -128,18 +273,75 @@ class FanEntity(ToggleEntity): await self.hass.async_add_executor_job(self.set_direction, direction) # pylint: disable=arguments-differ - def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + def turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" raise NotImplementedError() # pylint: disable=arguments-differ - async def async_turn_on(self, speed: Optional[str] = None, **kwargs): + async def async_turn_on_compat( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: + """Turn on the fan. + + This _compat version wraps async_turn_on with + backwards and forward compatibility. + + After the transition to percentage and preset_modes concludes, it + should be removed. + """ + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + speed = preset_mode + percentage = None + elif speed is not None: + _LOGGER.warning( + "Calling fan.turn_on with the speed argument is deprecated, use percentage or preset_mode instead." + ) + if speed in self.preset_modes: + preset_mode = speed + percentage = None + else: + percentage = self.speed_to_percentage(speed) + elif percentage is not None: + speed = self.percentage_to_speed(percentage) + + await self.async_turn_on( + speed=speed, + percentage=percentage, + preset_mode=preset_mode, + **kwargs, + ) + + # pylint: disable=arguments-differ + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs, + ) -> None: """Turn on the fan.""" if speed == SPEED_OFF: await self.async_turn_off() else: await self.hass.async_add_executor_job( - ft.partial(self.turn_on, speed, **kwargs) + ft.partial( + self.turn_on, + speed=speed, + percentage=percentage, + preset_mode=preset_mode, + **kwargs, + ) ) def oscillate(self, oscillating: bool) -> None: @@ -155,15 +357,57 @@ class FanEntity(ToggleEntity): """Return true if the entity is on.""" return self.speed not in [SPEED_OFF, None] + @property + def _implemented_percentage(self): + """Return true if percentage has been implemented.""" + return not hasattr(self.set_percentage, _FAN_NATIVE) or not hasattr( + self.async_set_percentage, _FAN_NATIVE + ) + + @property + def _implemented_preset_mode(self): + """Return true if preset_mode has been implemented.""" + return not hasattr(self.set_preset_mode, _FAN_NATIVE) or not hasattr( + self.async_set_preset_mode, _FAN_NATIVE + ) + + @property + def _implemented_speed(self): + """Return true if speed has been implemented.""" + return not hasattr(self.set_speed, _FAN_NATIVE) or not hasattr( + self.async_set_speed, _FAN_NATIVE + ) + @property def speed(self) -> Optional[str]: """Return the current speed.""" + if self._implemented_preset_mode: + preset_mode = self.preset_mode + if preset_mode: + return preset_mode + if self._implemented_percentage: + return self.percentage_to_speed(self.percentage) return None + @property + def percentage(self) -> Optional[int]: + """Return the current speed as a percentage.""" + if not self._implemented_preset_mode: + if self.speed in self.preset_modes: + return None + if not self._implemented_percentage: + return self.speed_to_percentage(self.speed) + return 0 + @property def speed_list(self) -> list: """Get the list of available speeds.""" - return [] + speeds = [] + if self._implemented_percentage: + speeds += [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + if self._implemented_preset_mode: + speeds += self.preset_modes + return speeds @property def current_direction(self) -> Optional[str]: @@ -178,9 +422,79 @@ class FanEntity(ToggleEntity): @property def capability_attributes(self): """Return capability attributes.""" + attrs = {} if self.supported_features & SUPPORT_SET_SPEED: - return {ATTR_SPEED_LIST: self.speed_list} - return {} + attrs[ATTR_SPEED_LIST] = self.speed_list + + if ( + self.supported_features & SUPPORT_SET_SPEED + or self.supported_features & SUPPORT_PRESET_MODE + ): + attrs[ATTR_PRESET_MODES] = self.preset_modes + + return attrs + + def speed_to_percentage(self, speed: str) -> int: + """ + Map a speed to a percentage. + + Officially this should only have to deal with the 4 pre-defined speeds: + + return { + SPEED_OFF: 0, + SPEED_LOW: 33, + SPEED_MEDIUM: 66, + SPEED_HIGH: 100, + }[speed] + + Unfortunately lots of fans make up their own speeds. So the default + mapping is more dynamic. + """ + if speed in OFF_SPEED_VALUES: + return 0 + + speed_list = speed_list_without_preset_modes(self.speed_list) + + if speed_list and speed not in speed_list: + raise NotValidSpeedError(f"The speed {speed} is not a valid speed.") + + try: + return ordered_list_item_to_percentage(speed_list, speed) + except ValueError as ex: + raise NoValidSpeedsError( + f"The speed_list {speed_list} does not contain any valid speeds." + ) from ex + + def percentage_to_speed(self, percentage: int) -> str: + """ + Map a percentage onto self.speed_list. + + Officially, this should only have to deal with 4 pre-defined speeds. + + if value == 0: + return SPEED_OFF + elif value <= 33: + return SPEED_LOW + elif value <= 66: + return SPEED_MEDIUM + else: + return SPEED_HIGH + + Unfortunately there is currently a high degree of non-conformancy. + Until fans have been corrected a more complicated and dynamic + mapping is used. + """ + if percentage == 0: + return SPEED_OFF + + speed_list = speed_list_without_preset_modes(self.speed_list) + + try: + return percentage_to_ordered_list_item(speed_list, percentage) + except ValueError as ex: + raise NoValidSpeedsError( + f"The speed_list {speed_list} does not contain any valid speeds." + ) from ex @property def state_attributes(self) -> dict: @@ -196,6 +510,13 @@ class FanEntity(ToggleEntity): if supported_features & SUPPORT_SET_SPEED: data[ATTR_SPEED] = self.speed + data[ATTR_PERCENTAGE] = self.percentage + + if ( + supported_features & SUPPORT_PRESET_MODE + or supported_features & SUPPORT_SET_SPEED + ): + data[ATTR_PRESET_MODE] = self.preset_mode return data @@ -203,3 +524,72 @@ class FanEntity(ToggleEntity): def supported_features(self) -> int: """Flag supported features.""" return 0 + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., auto, smart, interval, favorite. + + Requires SUPPORT_SET_SPEED. + """ + speed = self.speed + if speed in self.preset_modes: + return speed + return None + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes. + + Requires SUPPORT_SET_SPEED. + """ + return preset_modes_from_speed_list(self.speed_list) + + +def speed_list_without_preset_modes(speed_list: List): + """Filter out non-speeds from the speed list. + + The goal is to get the speeds in a list from lowest to + highest by removing speeds that are not valid or out of order + so we can map them to percentages. + + Examples: + input: ["off", "low", "low-medium", "medium", "medium-high", "high", "auto"] + output: ["low", "low-medium", "medium", "medium-high", "high"] + + input: ["off", "auto", "low", "medium", "high"] + output: ["low", "medium", "high"] + + input: ["off", "1", "2", "3", "4", "5", "6", "7", "smart"] + output: ["1", "2", "3", "4", "5", "6", "7"] + + input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"] + output: ["Silent", "Medium", "High", "Strong"] + """ + + return [speed for speed in speed_list if speed.lower() not in _NOT_SPEEDS_FILTER] + + +def preset_modes_from_speed_list(speed_list: List): + """Filter out non-preset modes from the speed list. + + The goal is to return only preset modes. + + Examples: + input: ["off", "low", "low-medium", "medium", "medium-high", "high", "auto"] + output: ["auto"] + + input: ["off", "auto", "low", "medium", "high"] + output: ["auto"] + + input: ["off", "1", "2", "3", "4", "5", "6", "7", "smart"] + output: ["smart"] + + input: ["Auto", "Silent", "Favorite", "Idle", "Medium", "High", "Strong"] + output: ["Auto", "Favorite", "Idle"] + """ + + return [ + speed + for speed in speed_list + if speed.lower() in _NOT_SPEEDS_FILTER and speed.lower() != SPEED_OFF + ] diff --git a/homeassistant/components/fan/reproduce_state.py b/homeassistant/components/fan/reproduce_state.py index 55ae78d90f6..b5f0fca47b7 100644 --- a/homeassistant/components/fan/reproduce_state.py +++ b/homeassistant/components/fan/reproduce_state.py @@ -17,10 +17,14 @@ from homeassistant.helpers.typing import HomeAssistantType from . import ( ATTR_DIRECTION, ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_SET_SPEED, ) @@ -31,6 +35,8 @@ ATTRIBUTES = { # attribute: service ATTR_DIRECTION: SERVICE_SET_DIRECTION, ATTR_OSCILLATING: SERVICE_OSCILLATE, ATTR_SPEED: SERVICE_SET_SPEED, + ATTR_PERCENTAGE: SERVICE_SET_PERCENTAGE, + ATTR_PRESET_MODE: SERVICE_SET_PRESET_MODE, } diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index 1fb88a36d2c..760aaabcf4a 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -9,6 +9,26 @@ set_speed: description: Speed setting example: "low" +set_preset_mode: + description: Set preset mode for a fan device. + fields: + entity_id: + description: Name(s) of entities to change. + example: "fan.kitchen" + preset_mode: + description: New value of preset mode + example: "auto" + +set_percentage: + description: Sets fan speed percentage. + fields: + entity_id: + description: Name(s) of the entities to set + example: "fan.living_room" + percentage: + description: Percentage speed setting + example: 25 + turn_on: description: Turns fan on. fields: @@ -18,6 +38,12 @@ turn_on: speed: description: Speed setting example: "high" + percentage: + description: Percentage speed setting + example: 75 + preset_mode: + description: Preset mode setting + example: "auto" turn_off: description: Turns fan off. diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 476d0f2c8e5..828347e4b89 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -130,9 +130,17 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): {CharacteristicsTypes.SWING_MODE: 1 if oscillating else 0} ) - async def async_turn_on(self, speed=None, **kwargs): + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ): """Turn the specified fan on.""" - characteristics = {} if not self.is_on: diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index fb741d7a4b0..f9d1c381f49 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -63,7 +63,20 @@ class InsteonFanEntity(InsteonEntity, FanEntity): """Flag supported features.""" return SUPPORT_SET_SPEED - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" if speed is None: speed = SPEED_MEDIUM diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 384ff22403a..c94c2f607bd 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -71,7 +71,20 @@ class ISYFanEntity(ISYNodeEntity, FanEntity): """Send the set speed command to the ISY994 fan device.""" self._node.turn_on(val=STATE_TO_VALUE.get(speed, 255)) - def turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Send the turn on command to the ISY994 fan device.""" self.set_speed(speed) @@ -108,7 +121,20 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity): if not self._actions.run_then(): _LOGGER.error("Unable to turn off the fan") - def turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Send the turn off command to ISY994 fan program.""" if not self._actions.run_else(): _LOGGER.error("Unable to turn on the fan") diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 045cd35cd17..80472535d51 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -75,7 +75,20 @@ class LutronCasetaFan(LutronCasetaDevice, FanEntity): """Flag supported features. Speed Only.""" return SUPPORT_SET_SPEED - async def async_turn_on(self, speed: str = None, **kwargs): + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ): """Turn the fan on.""" if speed is None: speed = SPEED_MEDIUM diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e5cebd43714..f96180b0982 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -317,7 +317,20 @@ class MqttFan(MqttEntity, FanEntity): """Return the oscillation state.""" return self._oscillation - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the entity. This method is a coroutine. diff --git a/homeassistant/components/ozw/fan.py b/homeassistant/components/ozw/fan.py index 818bd710496..5043379303f 100644 --- a/homeassistant/components/ozw/fan.py +++ b/homeassistant/components/ozw/fan.py @@ -57,7 +57,16 @@ class ZwaveFan(ZWaveDeviceEntity, FanEntity): self._previous_speed = speed self.values.primary.send_value(SPEED_TO_VALUE[speed]) - async def async_turn_on(self, speed=None, **kwargs): + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ): """Turn the device on.""" if speed is None: # Value 255 tells device to return to previous value diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index e366f6bb3e3..bff9862c8f6 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -50,7 +50,20 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the fan on.""" if speed is not None: value = SPEED_TO_VALUE[speed] diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index e46198b051b..40c244944ce 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -86,7 +86,14 @@ class SmartyFan(FanEntity): self._speed = speed self._state = True - def turn_on(self, speed=None, **kwargs): + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on(self, speed=None, percentage=None, preset_mode=None, **kwargs): """Turn on the fan.""" _LOGGER.debug("Turning on fan. Speed is %s", speed) if speed is None: diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index bdcc00dc764..1d7aa9f38cb 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -79,7 +79,16 @@ class TasmotaFan( else: self._tasmota_entity.set_speed(HA_TO_TASMOTA_SPEED_MAP[speed]) - async def async_turn_on(self, speed=None, **kwargs): + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ): """Turn the fan on.""" # Tasmota does not support turning a fan on with implicit speed await self.async_set_speed(speed or fan.SPEED_MEDIUM) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index fa67f60dac0..fe32d095677 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -251,8 +251,20 @@ class TemplateFan(TemplateEntity, FanEntity): """Return the oscillation state.""" return self._direction - # pylint: disable=arguments-differ - async def async_turn_on(self, speed: str = None) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" await self._on_script.async_run({ATTR_SPEED: speed}, context=self._context) self._state = STATE_ON diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index e88349cf795..a66e1ff92a4 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -75,7 +75,20 @@ class TuyaFanDevice(TuyaDevice, FanEntity): else: self._tuya.set_speed(speed) - def turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" if speed is not None: self.set_speed(speed) diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index c79ee15db59..525bf00f50e 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -137,7 +137,20 @@ class ValloxFan(FanEntity): self._available = False _LOGGER.error("Error updating fan: %s", err) - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the device on.""" _LOGGER.debug("Turn on: %s", speed) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 7cc3f00e1a0..1e2cc08473c 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -107,7 +107,20 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): self.smartfan.manual_mode() self.smartfan.change_fan_speed(FAN_SPEEDS.index(speed)) - def turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the device on.""" self.smartfan.turn_on() self.set_speed(speed) diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 0dca71a0d8d..cef51a1e3b1 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -185,7 +185,20 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): self._available = False self.wemo.reconnect_with_device() - def turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the switch on.""" if speed is None: try: diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index 6d8ad88d6c0..d59b9398d9e 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -94,7 +94,20 @@ class WiLightFan(WiLightDevice, FanEntity): self._direction = self._status["direction"] return self._direction - async def async_turn_on(self, speed: str = None, **kwargs): + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" if speed is None: await self._client.set_fan_direction(self._index, self._direction) diff --git a/homeassistant/components/wink/fan.py b/homeassistant/components/wink/fan.py index 535e7094fcd..3aab66e353d 100644 --- a/homeassistant/components/wink/fan.py +++ b/homeassistant/components/wink/fan.py @@ -40,7 +40,20 @@ class WinkFanDevice(WinkDevice, FanEntity): """Set the speed of the fan.""" self.wink.set_state(True, speed) - def turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn on the fan.""" self.wink.set_state(True, speed) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index e2a1b3b8143..0d07654e61b 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -718,7 +718,20 @@ class XiaomiGenericDevice(FanEntity): return False - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: """Turn the device on.""" if speed: # If operation mode was set the device must not be turned on. diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index b25d1c1aa39..bc5714ef08c 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -95,7 +95,16 @@ class BaseFan(FanEntity): """Flag supported features.""" return SUPPORT_SET_SPEED - async def async_turn_on(self, speed: str = None, **kwargs) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ) -> None: """Turn the entity on.""" if speed is None: speed = SPEED_MEDIUM diff --git a/homeassistant/components/zwave/fan.py b/homeassistant/components/zwave/fan.py index df6f5d8b8f5..1827996241d 100644 --- a/homeassistant/components/zwave/fan.py +++ b/homeassistant/components/zwave/fan.py @@ -58,7 +58,14 @@ class ZwaveFan(ZWaveDeviceEntity, FanEntity): """Set the speed of the fan.""" self.node.set_dimmer(self.values.primary.value_id, SPEED_TO_VALUE[speed]) - def turn_on(self, speed=None, **kwargs): + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + def turn_on(self, speed=None, percentage=None, preset_mode=None, **kwargs): """Turn the device on.""" if speed is None: # Value 255 tells device to return to previous value diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 7113272d2ea..29a93b38b8c 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -72,7 +72,20 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): target_value = self.get_zwave_value("targetValue") await self.info.node.async_set_value(target_value, SPEED_TO_VALUE[speed]) - async def async_turn_on(self, speed: Optional[str] = None, **kwargs: Any) -> None: + # + # The fan entity model has changed to use percentages and preset_modes + # instead of speeds. + # + # Please review + # https://developers.home-assistant.io/docs/core/entity/fan/ + # + async def async_turn_on( + self, + speed: Optional[str] = None, + percentage: Optional[int] = None, + preset_mode: Optional[str] = None, + **kwargs: Any, + ) -> None: """Turn the device on.""" if speed is None: # Value 255 tells device to return to previous value diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py new file mode 100644 index 00000000000..fa4c9dcc252 --- /dev/null +++ b/homeassistant/util/percentage.py @@ -0,0 +1,87 @@ +"""Percentage util functions.""" + +from typing import List, Tuple + + +def ordered_list_item_to_percentage(ordered_list: List[str], item: str) -> int: + """Determine the percentage of an item in an ordered list. + + When using this utility for fan speeds, do not include "off" + + Given the list: ["low", "medium", "high", "very_high"], this + function will return the following when when the item is passed + in: + + low: 25 + medium: 50 + high: 75 + very_high: 100 + + """ + if item not in ordered_list: + raise ValueError + + list_len = len(ordered_list) + list_position = ordered_list.index(item) + 1 + return (list_position * 100) // list_len + + +def percentage_to_ordered_list_item(ordered_list: List[str], percentage: int) -> str: + """Find the item that most closely matches the percentage in an ordered list. + + When using this utility for fan speeds, do not include "off" + + Given the list: ["low", "medium", "high", "very_high"], this + function will return the following when when the item is passed + in: + + 1-25: low + 26-50: medium + 51-75: high + 76-100: very_high + """ + list_len = len(ordered_list) + if not list_len: + raise ValueError + + for offset, speed in enumerate(ordered_list): + list_position = offset + 1 + upper_bound = (list_position * 100) // list_len + if percentage <= upper_bound: + return speed + + return ordered_list[-1] + + +def ranged_value_to_percentage( + low_high_range: Tuple[float, float], value: float +) -> int: + """Given a range of low and high values convert a single value to a percentage. + + When using this utility for fan speeds, do not include 0 if it is off + + Given a low value of 1 and a high value of 255 this function + will return: + + (1,255), 255: 100 + (1,255), 127: 50 + (1,255), 10: 4 + """ + return int((value * 100) // (low_high_range[1] - low_high_range[0] + 1)) + + +def percentage_to_ranged_value( + low_high_range: Tuple[float, float], percentage: int +) -> float: + """Given a range of low and high values convert a percentage to a single value. + + When using this utility for fan speeds, do not include 0 if it is off + + Given a low value of 1 and a high value of 255 this function + will return: + + (1,255), 100: 255 + (1,255), 50: 127.5 + (1,255), 4: 10.2 + """ + return (low_high_range[1] - low_high_range[0] + 1) * percentage / 100 diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 2dca57d3e6b..5297e64bda9 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -2,6 +2,7 @@ import pytest from homeassistant.components import fan +from homeassistant.components.demo.fan import PRESET_MODE_AUTO, PRESET_MODE_SMART from homeassistant.const import ( ATTR_ENTITY_ID, ENTITY_MATCH_ALL, @@ -12,7 +13,15 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -FAN_ENTITY_ID = "fan.living_room_fan" +FULL_FAN_ENTITY_IDS = ["fan.living_room_fan", "fan.percentage_full_fan"] +FANS_WITH_PRESET_MODE_ONLY = ["fan.preset_only_limited_fan"] +LIMITED_AND_FULL_FAN_ENTITY_IDS = FULL_FAN_ENTITY_IDS + [ + "fan.ceiling_fan", + "fan.percentage_limited_fan", +] +FANS_WITH_PRESET_MODES = FULL_FAN_ENTITY_IDS + [ + "fan.percentage_limited_fan", +] @pytest.fixture(autouse=True) @@ -22,124 +31,338 @@ async def setup_comp(hass): await hass.async_block_till_done() -async def test_turn_on(hass): +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_turn_on(hass, fan_entity_id): """Test turning on the device.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_ON + +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_turn_on_with_speed_and_percentage(hass, fan_entity_id): + """Test turning on the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_SPEED: fan.SPEED_HIGH}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_SPEED: fan.SPEED_HIGH}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_ON assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 -async def test_turn_off(hass): +@pytest.mark.parametrize("fan_entity_id", FANS_WITH_PRESET_MODE_ONLY) +async def test_turn_on_with_preset_mode_only(hass, fan_entity_id): + """Test turning on the device with a preset_mode and no speed setting.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_PRESET_MODES] == [ + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + ] + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_SMART}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_SMART + + await hass.services.async_call( + fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + with pytest.raises(ValueError): + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + +@pytest.mark.parametrize("fan_entity_id", FANS_WITH_PRESET_MODES) +async def test_turn_on_with_preset_mode_and_speed(hass, fan_entity_id): + """Test turning on the device with a preset_mode and speed.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_PERCENTAGE] is None + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_SPEED_LIST] == [ + fan.SPEED_OFF, + fan.SPEED_LOW, + fan.SPEED_MEDIUM, + fan.SPEED_HIGH, + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + ] + assert state.attributes[fan.ATTR_PRESET_MODES] == [ + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + ] + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 100}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_SMART}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == PRESET_MODE_SMART + assert state.attributes[fan.ATTR_PERCENTAGE] is None + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_SMART + + await hass.services.async_call( + fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + with pytest.raises(ValueError): + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 + assert state.attributes[fan.ATTR_PRESET_MODE] is None + + +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_turn_off(hass, fan_entity_id): """Test turning off the device.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_ON await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF -async def test_turn_off_without_entity_id(hass): +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_turn_off_without_entity_id(hass, fan_entity_id): """Test turning off all fans.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_ON await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_MATCH_ALL}, blocking=True ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF -async def test_set_direction(hass): +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_set_direction(hass, fan_entity_id): """Test setting the direction of the device.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_DIRECTION, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_DIRECTION: fan.DIRECTION_REVERSE}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_DIRECTION: fan.DIRECTION_REVERSE}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_DIRECTION] == fan.DIRECTION_REVERSE -async def test_set_speed(hass): +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_set_speed(hass, fan_entity_id): """Test setting the speed of the device.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF await hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_SPEED, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_SPEED: fan.SPEED_LOW}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_SPEED: fan.SPEED_LOW}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW -async def test_oscillate(hass): +@pytest.mark.parametrize("fan_entity_id", FANS_WITH_PRESET_MODES) +async def test_set_preset_mode(hass, fan_entity_id): + """Test setting the preset mode of the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: PRESET_MODE_AUTO}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_SPEED] == PRESET_MODE_AUTO + assert state.attributes[fan.ATTR_PERCENTAGE] is None + assert state.attributes[fan.ATTR_PRESET_MODE] == PRESET_MODE_AUTO + + +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_set_preset_mode_invalid(hass, fan_entity_id): + """Test setting a invalid preset mode for the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + + with pytest.raises(ValueError): + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + + with pytest.raises(ValueError): + await hass.services.async_call( + fan.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_set_percentage(hass, fan_entity_id): + """Test setting the percentage speed of the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE: 33}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + + +@pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) +async def test_oscillate(hass, fan_entity_id): """Test oscillating the fan.""" - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF assert not state.attributes.get(fan.ATTR_OSCILLATING) await hass.services.async_call( fan.DOMAIN, fan.SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_OSCILLATING: True}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_OSCILLATING: True}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_OSCILLATING] is True await hass.services.async_call( fan.DOMAIN, fan.SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: FAN_ENTITY_ID, fan.ATTR_OSCILLATING: False}, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_OSCILLATING: False}, blocking=True, ) - state = hass.states.get(FAN_ENTITY_ID) + state = hass.states.get(fan_entity_id) assert state.attributes[fan.ATTR_OSCILLATING] is False -async def test_is_on(hass): +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_is_on(hass, fan_entity_id): """Test is on service call.""" - assert not fan.is_on(hass, FAN_ENTITY_ID) + assert not fan.is_on(hass, fan_entity_id) await hass.services.async_call( - fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_ENTITY_ID}, blocking=True + fan.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: fan_entity_id}, blocking=True ) - assert fan.is_on(hass, FAN_ENTITY_ID) + assert fan.is_on(hass, fan_entity_id) diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 4d8079b9db9..04832f4adc7 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -70,16 +70,19 @@ ENTITY_IDS_BY_NUMBER = { "8": "media_player.lounge_room", "9": "fan.living_room_fan", "10": "fan.ceiling_fan", - "11": "cover.living_room_window", - "12": "climate.hvac", - "13": "climate.heatpump", - "14": "climate.ecobee", - "15": "light.no_brightness", - "16": "humidifier.humidifier", - "17": "humidifier.dehumidifier", - "18": "humidifier.hygrostat", - "19": "scene.light_on", - "20": "scene.light_off", + "11": "fan.percentage_full_fan", + "12": "fan.percentage_limited_fan", + "13": "fan.preset_only_limited_fan", + "14": "cover.living_room_window", + "15": "climate.hvac", + "16": "climate.heatpump", + "17": "climate.ecobee", + "18": "light.no_brightness", + "19": "humidifier.humidifier", + "20": "humidifier.dehumidifier", + "21": "humidifier.hygrostat", + "22": "scene.light_on", + "23": "scene.light_off", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} diff --git a/tests/components/fan/common.py b/tests/components/fan/common.py index 70a2c7e43d3..215849e6aab 100644 --- a/tests/components/fan/common.py +++ b/tests/components/fan/common.py @@ -6,10 +6,14 @@ components. Instead call the service directly. from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_SET_SPEED, ) from homeassistant.const import ( @@ -20,11 +24,22 @@ from homeassistant.const import ( ) -async def async_turn_on(hass, entity_id=ENTITY_MATCH_ALL, speed: str = None) -> None: +async def async_turn_on( + hass, + entity_id=ENTITY_MATCH_ALL, + speed: str = None, + percentage: int = None, + preset_mode: str = None, +) -> None: """Turn all or specified fan on.""" data = { key: value - for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_SPEED, speed)] + for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_SPEED, speed), + (ATTR_PERCENTAGE, percentage), + (ATTR_PRESET_MODE, preset_mode), + ] if value is not None } @@ -65,6 +80,32 @@ async def async_set_speed(hass, entity_id=ENTITY_MATCH_ALL, speed: str = None) - await hass.services.async_call(DOMAIN, SERVICE_SET_SPEED, data, blocking=True) +async def async_set_preset_mode( + hass, entity_id=ENTITY_MATCH_ALL, preset_mode: str = None +) -> None: + """Set preset mode for all or specified fan.""" + data = { + key: value + for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PRESET_MODE, preset_mode)] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_SET_PRESET_MODE, data, blocking=True) + + +async def async_set_percentage( + hass, entity_id=ENTITY_MATCH_ALL, percentage: int = None +) -> None: + """Set percentage for all or specified fan.""" + data = { + key: value + for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_PERCENTAGE, percentage)] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_SET_PERCENTAGE, data, blocking=True) + + async def async_set_direction( hass, entity_id=ENTITY_MATCH_ALL, direction: str = None ) -> None: diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index a8beed73a07..f5c303bd416 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -2,7 +2,7 @@ import pytest -from homeassistant.components.fan import FanEntity +from homeassistant.components.fan import FanEntity, NotValidPresetModeError class BaseFan(FanEntity): @@ -17,6 +17,7 @@ def test_fanentity(): fan = BaseFan() assert fan.state == "off" assert len(fan.speed_list) == 0 + assert len(fan.preset_modes) == 0 assert fan.supported_features == 0 assert fan.capability_attributes == {} # Test set_speed not required @@ -24,7 +25,35 @@ def test_fanentity(): fan.oscillate(True) with pytest.raises(NotImplementedError): fan.set_speed("slow") + with pytest.raises(NotImplementedError): + fan.set_percentage(0) + with pytest.raises(NotValidPresetModeError): + fan.set_preset_mode("auto") with pytest.raises(NotImplementedError): fan.turn_on() with pytest.raises(NotImplementedError): fan.turn_off() + + +async def test_async_fanentity(hass): + """Test async fan entity methods.""" + fan = BaseFan() + fan.hass = hass + assert fan.state == "off" + assert len(fan.speed_list) == 0 + assert len(fan.preset_modes) == 0 + assert fan.supported_features == 0 + assert fan.capability_attributes == {} + # Test set_speed not required + with pytest.raises(NotImplementedError): + await fan.async_oscillate(True) + with pytest.raises(NotImplementedError): + await fan.async_set_speed("slow") + with pytest.raises(NotImplementedError): + await fan.async_set_percentage(0) + with pytest.raises(NotValidPresetModeError): + await fan.async_set_preset_mode("auto") + with pytest.raises(NotImplementedError): + await fan.async_turn_on() + with pytest.raises(NotImplementedError): + await fan.async_turn_off() diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index cb11f1ceaac..4bef45cf0ee 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -245,6 +245,27 @@ DEMO_DEVICES = [ "type": "action.devices.types.FAN", "willReportState": False, }, + { + "id": "fan.percentage_full_fan", + "name": {"name": "Percentage Full Fan"}, + "traits": ["action.devices.traits.FanSpeed", "action.devices.traits.OnOff"], + "type": "action.devices.types.FAN", + "willReportState": False, + }, + { + "id": "fan.percentage_limited_fan", + "name": {"name": "Percentage Limited Fan"}, + "traits": ["action.devices.traits.FanSpeed", "action.devices.traits.OnOff"], + "type": "action.devices.types.FAN", + "willReportState": False, + }, + { + "id": "fan.preset_only_limited_fan", + "name": {"name": "Preset Only Limited Fan"}, + "traits": ["action.devices.traits.OnOff"], + "type": "action.devices.types.FAN", + "willReportState": False, + }, { "id": "climate.hvac", "name": {"name": "Hvac"}, diff --git a/tests/util/test_percentage.py b/tests/util/test_percentage.py new file mode 100644 index 00000000000..4ad28f8567c --- /dev/null +++ b/tests/util/test_percentage.py @@ -0,0 +1,158 @@ +"""Test Home Assistant percentage conversions.""" + +import math + +import pytest + +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +SPEED_LOW = "low" +SPEED_MEDIUM = "medium" +SPEED_HIGH = "high" + +SPEED_1 = SPEED_LOW +SPEED_2 = SPEED_MEDIUM +SPEED_3 = SPEED_HIGH +SPEED_4 = "very_high" +SPEED_5 = "storm" +SPEED_6 = "hurricane" +SPEED_7 = "solar_wind" + +LEGACY_ORDERED_LIST = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] +SMALL_ORDERED_LIST = [SPEED_1, SPEED_2, SPEED_3, SPEED_4] +LARGE_ORDERED_LIST = [SPEED_1, SPEED_2, SPEED_3, SPEED_4, SPEED_5, SPEED_6, SPEED_7] + + +async def test_ordered_list_percentage_round_trip(): + """Test we can round trip.""" + for ordered_list in (SMALL_ORDERED_LIST, LARGE_ORDERED_LIST): + for i in range(1, 100): + ordered_list_item_to_percentage( + ordered_list, percentage_to_ordered_list_item(ordered_list, i) + ) == i + + +async def test_ordered_list_item_to_percentage(): + """Test percentage of an item in an ordered list.""" + + assert ordered_list_item_to_percentage(LEGACY_ORDERED_LIST, SPEED_LOW) == 33 + assert ordered_list_item_to_percentage(LEGACY_ORDERED_LIST, SPEED_MEDIUM) == 66 + assert ordered_list_item_to_percentage(LEGACY_ORDERED_LIST, SPEED_HIGH) == 100 + + assert ordered_list_item_to_percentage(SMALL_ORDERED_LIST, SPEED_1) == 25 + assert ordered_list_item_to_percentage(SMALL_ORDERED_LIST, SPEED_2) == 50 + assert ordered_list_item_to_percentage(SMALL_ORDERED_LIST, SPEED_3) == 75 + assert ordered_list_item_to_percentage(SMALL_ORDERED_LIST, SPEED_4) == 100 + + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_1) == 14 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_2) == 28 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_3) == 42 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_4) == 57 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_5) == 71 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_6) == 85 + assert ordered_list_item_to_percentage(LARGE_ORDERED_LIST, SPEED_7) == 100 + + with pytest.raises(ValueError): + assert ordered_list_item_to_percentage([], SPEED_1) + + +async def test_percentage_to_ordered_list_item(): + """Test item that most closely matches the percentage in an ordered list.""" + + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 1) == SPEED_1 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 25) == SPEED_1 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 26) == SPEED_2 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 50) == SPEED_2 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 51) == SPEED_3 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 75) == SPEED_3 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 76) == SPEED_4 + assert percentage_to_ordered_list_item(SMALL_ORDERED_LIST, 100) == SPEED_4 + + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 17) == SPEED_LOW + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 33) == SPEED_LOW + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 50) == SPEED_MEDIUM + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 66) == SPEED_MEDIUM + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 84) == SPEED_HIGH + assert percentage_to_ordered_list_item(LEGACY_ORDERED_LIST, 100) == SPEED_HIGH + + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 1) == SPEED_1 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 14) == SPEED_1 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 25) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 26) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 28) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 29) == SPEED_3 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 41) == SPEED_3 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 42) == SPEED_3 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 43) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 56) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 50) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 51) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 75) == SPEED_6 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 76) == SPEED_6 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 100) == SPEED_7 + + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 1) == SPEED_1 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 25) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 26) == SPEED_2 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 50) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 51) == SPEED_4 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 75) == SPEED_6 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 76) == SPEED_6 + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 100) == SPEED_7 + + assert percentage_to_ordered_list_item(LARGE_ORDERED_LIST, 100.1) == SPEED_7 + + with pytest.raises(ValueError): + assert percentage_to_ordered_list_item([], 100) + + +async def test_ranged_value_to_percentage_large(): + """Test a large range of low and high values convert a single value to a percentage.""" + range = (1, 255) + + assert ranged_value_to_percentage(range, 255) == 100 + assert ranged_value_to_percentage(range, 127) == 49 + assert ranged_value_to_percentage(range, 10) == 3 + assert ranged_value_to_percentage(range, 1) == 0 + + +async def test_percentage_to_ranged_value_large(): + """Test a large range of low and high values convert a percentage to a single value.""" + range = (1, 255) + + assert percentage_to_ranged_value(range, 100) == 255 + assert percentage_to_ranged_value(range, 50) == 127.5 + assert percentage_to_ranged_value(range, 4) == 10.2 + + assert math.ceil(percentage_to_ranged_value(range, 100)) == 255 + assert math.ceil(percentage_to_ranged_value(range, 50)) == 128 + assert math.ceil(percentage_to_ranged_value(range, 4)) == 11 + + +async def test_ranged_value_to_percentage_small(): + """Test a small range of low and high values convert a single value to a percentage.""" + range = (1, 6) + + assert ranged_value_to_percentage(range, 1) == 16 + assert ranged_value_to_percentage(range, 2) == 33 + assert ranged_value_to_percentage(range, 3) == 50 + assert ranged_value_to_percentage(range, 4) == 66 + assert ranged_value_to_percentage(range, 5) == 83 + assert ranged_value_to_percentage(range, 6) == 100 + + +async def test_percentage_to_ranged_value_small(): + """Test a small range of low and high values convert a percentage to a single value.""" + range = (1, 6) + + assert math.ceil(percentage_to_ranged_value(range, 16)) == 1 + assert math.ceil(percentage_to_ranged_value(range, 33)) == 2 + assert math.ceil(percentage_to_ranged_value(range, 50)) == 3 + assert math.ceil(percentage_to_ranged_value(range, 66)) == 4 + assert math.ceil(percentage_to_ranged_value(range, 83)) == 5 + assert math.ceil(percentage_to_ranged_value(range, 100)) == 6