From f2b303d5099f20db5b62a7954087df94e8b0c6e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 18 Feb 2021 21:05:09 -1000 Subject: [PATCH] Implement percentage step sizes for fans (#46512) Co-authored-by: Paulus Schoutsen --- homeassistant/components/bond/fan.py | 6 + homeassistant/components/comfoconnect/fan.py | 6 + homeassistant/components/demo/fan.py | 10 ++ homeassistant/components/dyson/fan.py | 6 + homeassistant/components/esphome/fan.py | 5 + homeassistant/components/fan/__init__.py | 67 ++++++++++ homeassistant/components/fan/services.yaml | 38 ++++++ homeassistant/components/isy994/fan.py | 17 ++- homeassistant/components/knx/fan.py | 8 ++ homeassistant/components/lutron_caseta/fan.py | 5 + homeassistant/components/ozw/fan.py | 6 + homeassistant/components/smartthings/fan.py | 6 + homeassistant/components/template/fan.py | 13 ++ homeassistant/components/tuya/fan.py | 7 + homeassistant/components/vesync/fan.py | 6 + homeassistant/components/wemo/fan.py | 6 + homeassistant/components/wilight/fan.py | 5 + homeassistant/components/zwave/fan.py | 6 + homeassistant/components/zwave_js/fan.py | 6 + homeassistant/util/percentage.py | 14 +- tests/components/demo/test_fan.py | 123 ++++++++++++++++++ tests/components/fan/common.py | 35 +++++ tests/components/fan/test_init.py | 8 ++ tests/components/template/test_fan.py | 41 ++++++ 24 files changed, 447 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 9b70195db5d..cef2efae690 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -85,6 +86,11 @@ class BondFan(BondEntity, FanEntity): return 0 return ranged_value_to_percentage(self._speed_range, self._speed) + @property + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + return int_states_in_range(self._speed_range) + @property def current_direction(self) -> Optional[str]: """Return fan rotation direction.""" diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 7457d0ffad2..26abd85522a 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -13,6 +13,7 @@ from pycomfoconnect import ( from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -101,6 +102,11 @@ class ComfoConnectFan(FanEntity): return None return ranged_value_to_percentage(SPEED_RANGE, speed) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + def turn_on( self, speed: str = None, percentage=None, preset_mode=None, **kwargs ) -> None: diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index cb6036a8938..6bbd8b81f6d 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -215,6 +215,11 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): """Return the current speed.""" return self._percentage + @property + def speed_count(self) -> Optional[float]: + """Return the number of speeds the fan supports.""" + return 3 + def set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" self._percentage = percentage @@ -270,6 +275,11 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): """Return the current speed.""" return self._percentage + @property + def speed_count(self) -> Optional[float]: + """Return the number of speeds the fan supports.""" + return 3 + async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" self._percentage = percentage diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py index 7a403902ee8..9e49badbc8e 100644 --- a/homeassistant/components/dyson/fan.py +++ b/homeassistant/components/dyson/fan.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components.fan import SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -154,6 +155,11 @@ class DysonFanEntity(DysonEntity, FanEntity): return None return ranged_value_to_percentage(SPEED_RANGE, int(self._device.state.speed)) + @property + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + @property def preset_modes(self): """Return the available preset modes.""" diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 8da52b8d584..092c416acab 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -119,6 +119,11 @@ class EsphomeFan(EsphomeEntity, FanEntity): ORDERED_NAMED_FAN_SPEEDS, self._state.speed ) + @property + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) + @esphome_state_property def oscillating(self) -> None: """Return the oscillation state.""" diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 8d6fcbea2c9..692588cff48 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -2,6 +2,7 @@ from datetime import timedelta import functools as ft import logging +import math from typing import List, Optional import voluptuous as vol @@ -23,6 +24,8 @@ from homeassistant.loader import bind_hass from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, ) _LOGGER = logging.getLogger(__name__) @@ -39,6 +42,8 @@ SUPPORT_DIRECTION = 4 SUPPORT_PRESET_MODE = 8 SERVICE_SET_SPEED = "set_speed" +SERVICE_INCREASE_SPEED = "increase_speed" +SERVICE_DECREASE_SPEED = "decrease_speed" SERVICE_OSCILLATE = "oscillate" SERVICE_SET_DIRECTION = "set_direction" SERVICE_SET_PERCENTAGE = "set_percentage" @@ -54,6 +59,7 @@ DIRECTION_REVERSE = "reverse" ATTR_SPEED = "speed" ATTR_PERCENTAGE = "percentage" +ATTR_PERCENTAGE_STEP = "percentage_step" ATTR_SPEED_LIST = "speed_list" ATTR_OSCILLATING = "oscillating" ATTR_DIRECTION = "direction" @@ -142,6 +148,26 @@ async def async_setup(hass, config: dict): "async_set_speed_deprecated", [SUPPORT_SET_SPEED], ) + component.async_register_entity_service( + SERVICE_INCREASE_SPEED, + { + vol.Optional(ATTR_PERCENTAGE_STEP): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_increase_speed", + [SUPPORT_SET_SPEED], + ) + component.async_register_entity_service( + SERVICE_DECREASE_SPEED, + { + vol.Optional(ATTR_PERCENTAGE_STEP): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_decrease_speed", + [SUPPORT_SET_SPEED], + ) component.async_register_entity_service( SERVICE_OSCILLATE, {vol.Required(ATTR_OSCILLATING): cv.boolean}, @@ -246,6 +272,33 @@ class FanEntity(ToggleEntity): else: await self.async_set_speed(self.percentage_to_speed(percentage)) + async def async_increase_speed(self, percentage_step=None) -> None: + """Increase the speed of the fan.""" + await self._async_adjust_speed(1, percentage_step) + + async def async_decrease_speed(self, percentage_step=None) -> None: + """Decrease the speed of the fan.""" + await self._async_adjust_speed(-1, percentage_step) + + async def _async_adjust_speed(self, modifier, percentage_step) -> None: + """Increase or decrease the speed of the fan.""" + current_percentage = self.percentage or 0 + + if percentage_step is not None: + new_percentage = current_percentage + (percentage_step * modifier) + else: + speed_range = (1, self.speed_count) + speed_index = math.ceil( + percentage_to_ranged_value(speed_range, current_percentage) + ) + new_percentage = ranged_value_to_percentage( + speed_range, speed_index + modifier + ) + + new_percentage = max(0, min(100, new_percentage)) + + await self.async_set_percentage(new_percentage) + @_fan_native def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -408,6 +461,19 @@ class FanEntity(ToggleEntity): return self.speed_to_percentage(self.speed) return 0 + @property + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + speed_list = speed_list_without_preset_modes(self.speed_list) + if speed_list: + return len(speed_list) + return 100 + + @property + def percentage_step(self) -> Optional[float]: + """Return the step size for percentage.""" + return 100 / self.speed_count + @property def speed_list(self) -> list: """Get the list of available speeds.""" @@ -531,6 +597,7 @@ class FanEntity(ToggleEntity): if supported_features & SUPPORT_SET_SPEED: data[ATTR_SPEED] = self.speed data[ATTR_PERCENTAGE] = self.percentage + data[ATTR_PERCENTAGE_STEP] = self.percentage_step if ( supported_features & SUPPORT_PRESET_MODE diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index 2f5802b69f7..ad513b84e8f 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -100,3 +100,41 @@ set_direction: options: - "forward" - "reverse" + +increase_speed: + description: Increase the speed of the fan by one speed or a percentage_step. + fields: + entity_id: + description: Name(s) of the entities to increase speed + example: "fan.living_room" + percentage_step: + advanced: true + required: false + description: Increase speed by a percentage. Should be between 0..100. [optional] + example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider + +decrease_speed: + description: Decrease the speed of the fan by one speed or a percentage_step. + fields: + entity_id: + description: Name(s) of the entities to decrease speed + example: "fan.living_room" + percentage_step: + advanced: true + required: false + description: Decrease speed by a percentage. Should be between 0..100. [optional] + example: 50 + selector: + number: + min: 0 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: slider diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 74ed477d3a7..f565383f007 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -2,12 +2,13 @@ import math from typing import Callable -from pyisy.constants import ISY_VALUE_UNKNOWN +from pyisy.constants import ISY_VALUE_UNKNOWN, PROTO_INSTEON from homeassistant.components.fan import DOMAIN as FAN, SUPPORT_SET_SPEED, FanEntity from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -48,6 +49,13 @@ class ISYFanEntity(ISYNodeEntity, FanEntity): return None return ranged_value_to_percentage(SPEED_RANGE, self._node.status) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + if self._node.protocol == PROTO_INSTEON: + return 3 + return int_states_in_range(SPEED_RANGE) + @property def is_on(self) -> bool: """Get if the fan is on.""" @@ -95,6 +103,13 @@ class ISYFanProgramEntity(ISYProgramEntity, FanEntity): return None return ranged_value_to_percentage(SPEED_RANGE, self._node.status) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + if self._node.protocol == PROTO_INSTEON: + return 3 + return int_states_in_range(SPEED_RANGE) + @property def is_on(self) -> bool: """Get if the fan is on.""" diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index de08b576edd..d0b7b4c5546 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -7,6 +7,7 @@ from xknx.devices.fan import FanSpeedMode from homeassistant.components.fan import SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -68,6 +69,13 @@ class KNXFan(KnxEntity, FanEntity): ) return self._device.current_speed + @property + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + if self._step_range is None: + return super().speed_count + return int_states_in_range(self._step_range) + async def async_turn_on( self, speed: Optional[str] = None, diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 935c8827c84..330ff81d1d2 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -48,6 +48,11 @@ class LutronCasetaFan(LutronCasetaDevice, FanEntity): ORDERED_NAMED_FAN_SPEEDS, self._device["fan_speed"] ) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) + @property def supported_features(self) -> int: """Flag supported features. Speed Only.""" diff --git a/homeassistant/components/ozw/fan.py b/homeassistant/components/ozw/fan.py index be0bd372b65..505959dd343 100644 --- a/homeassistant/components/ozw/fan.py +++ b/homeassistant/components/ozw/fan.py @@ -9,6 +9,7 @@ from homeassistant.components.fan import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -72,6 +73,11 @@ class ZwaveFan(ZWaveDeviceEntity, FanEntity): """ return ranged_value_to_percentage(SPEED_RANGE, self.values.primary.value) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index b09bfe0ad46..12edac36dfe 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -6,6 +6,7 @@ from pysmartthings import Capability from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -79,6 +80,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) + @property + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + @property def supported_features(self) -> int: """Flag supported features.""" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index ac77b3dc333..18a7d8262e0 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -46,6 +46,7 @@ _LOGGER = logging.getLogger(__name__) CONF_FANS = "fans" CONF_SPEED_LIST = "speeds" +CONF_SPEED_COUNT = "speed_count" CONF_PRESET_MODES = "preset_modes" CONF_SPEED_TEMPLATE = "speed_template" CONF_PERCENTAGE_TEMPLATE = "percentage_template" @@ -86,6 +87,7 @@ FAN_SCHEMA = vol.All( vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), vol.Optional( CONF_SPEED_LIST, default=[SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH], @@ -126,6 +128,7 @@ async def _async_create_entities(hass, config): set_direction_action = device_config.get(CONF_SET_DIRECTION_ACTION) speed_list = device_config[CONF_SPEED_LIST] + speed_count = device_config.get(CONF_SPEED_COUNT) preset_modes = device_config.get(CONF_PRESET_MODES) unique_id = device_config.get(CONF_UNIQUE_ID) @@ -148,6 +151,7 @@ async def _async_create_entities(hass, config): set_preset_mode_action, set_oscillating_action, set_direction_action, + speed_count, speed_list, preset_modes, unique_id, @@ -185,6 +189,7 @@ class TemplateFan(TemplateEntity, FanEntity): set_preset_mode_action, set_oscillating_action, set_direction_action, + speed_count, speed_list, preset_modes, unique_id, @@ -260,6 +265,9 @@ class TemplateFan(TemplateEntity, FanEntity): self._unique_id = unique_id + # Number of valid speeds + self._speed_count = speed_count + # List of valid speeds self._speed_list = speed_list @@ -281,6 +289,11 @@ class TemplateFan(TemplateEntity, FanEntity): """Flag supported features.""" return self._supported_features + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return self._speed_count or super().speed_count + @property def speed_list(self) -> list: """Get the list of available speeds.""" diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 12e963f05d3..4c555bb942a 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -102,6 +102,13 @@ class TuyaFanDevice(TuyaDevice, FanEntity): """Oscillate the fan.""" self._tuya.oscillate(oscillating) + @property + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + if self.speeds is None: + return super().speed_count + return len(self.speeds) + @property def oscillating(self): """Return current oscillating status.""" diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 10754007ce6..e9f421215fb 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -6,6 +6,7 @@ from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -77,6 +78,11 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): return ranged_value_to_percentage(SPEED_RANGE, current_level) return None + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + @property def preset_modes(self): """Get the list of available preset modes.""" diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 94dab468a69..1f45194659d 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -10,6 +10,7 @@ from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -130,6 +131,11 @@ class WemoHumidifier(WemoSubscriptionEntity, FanEntity): """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._fan_mode) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + @property def supported_features(self) -> int: """Flag supported features.""" diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index ece79874ccf..d663dc39ded 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -87,6 +87,11 @@ class WiLightFan(WiLightDevice, FanEntity): return None return ordered_list_item_to_percentage(ORDERED_NAMED_FAN_SPEEDS, wl_speed) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return len(ORDERED_NAMED_FAN_SPEEDS) + @property def current_direction(self) -> str: """Return the current direction of the fan.""" diff --git a/homeassistant/components/zwave/fan.py b/homeassistant/components/zwave/fan.py index ea529ccd90b..7fb0fb8e8be 100644 --- a/homeassistant/components/zwave/fan.py +++ b/homeassistant/components/zwave/fan.py @@ -5,6 +5,7 @@ from homeassistant.components.fan import DOMAIN, SUPPORT_SET_SPEED, FanEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -68,6 +69,11 @@ class ZwaveFan(ZWaveDeviceEntity, FanEntity): """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._state) + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index e957d774e56..ae903d9efaf 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.percentage import ( + int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -96,6 +97,11 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): return None return ranged_value_to_percentage(SPEED_RANGE, self.info.primary_value.value) + @property + def speed_count(self) -> Optional[int]: + """Return the number of speeds the fan supports.""" + return int_states_in_range(SPEED_RANGE) + @property def supported_features(self) -> int: """Flag supported features.""" diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index fa4c9dcc252..10a72a85dff 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -67,7 +67,7 @@ def ranged_value_to_percentage( (1,255), 127: 50 (1,255), 10: 4 """ - return int((value * 100) // (low_high_range[1] - low_high_range[0] + 1)) + return int((value * 100) // states_in_range(low_high_range)) def percentage_to_ranged_value( @@ -84,4 +84,14 @@ def percentage_to_ranged_value( (1,255), 50: 127.5 (1,255), 4: 10.2 """ - return (low_high_range[1] - low_high_range[0] + 1) * percentage / 100 + return states_in_range(low_high_range) * percentage / 100 + + +def states_in_range(low_high_range: Tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: Tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 2439d49685c..a788e69b0d3 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -27,6 +27,7 @@ LIMITED_AND_FULL_FAN_ENTITY_IDS = FULL_FAN_ENTITY_IDS + [ FANS_WITH_PRESET_MODES = FULL_FAN_ENTITY_IDS + [ "fan.percentage_limited_fan", ] +PERCENTAGE_MODEL_FANS = ["fan.percentage_full_fan", "fan.percentage_limited_fan"] @pytest.fixture(autouse=True) @@ -397,6 +398,128 @@ async def test_set_percentage(hass, fan_entity_id): assert state.attributes[fan.ATTR_PERCENTAGE] == 33 +@pytest.mark.parametrize("fan_entity_id", LIMITED_AND_FULL_FAN_ENTITY_IDS) +async def test_increase_decrease_speed(hass, fan_entity_id): + """Test increasing and decreasing the percentage speed of the device.""" + state = hass.states.get(fan_entity_id) + assert state.state == STATE_OFF + assert state.attributes[fan.ATTR_PERCENTAGE_STEP] == 100 / 3 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + 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 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 100 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + 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 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_DECREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_OFF + assert state.attributes[fan.ATTR_PERCENTAGE] == 0 + + +@pytest.mark.parametrize("fan_entity_id", PERCENTAGE_MODEL_FANS) +async def test_increase_decrease_speed_with_percentage_step(hass, fan_entity_id): + """Test increasing speed with a percentage step.""" + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_LOW + assert state.attributes[fan.ATTR_PERCENTAGE] == 25 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + assert state.attributes[fan.ATTR_PERCENTAGE] == 50 + + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_INCREASE_SPEED, + {ATTR_ENTITY_ID: fan_entity_id, fan.ATTR_PERCENTAGE_STEP: 25}, + blocking=True, + ) + state = hass.states.get(fan_entity_id) + assert state.attributes[fan.ATTR_SPEED] == fan.SPEED_HIGH + assert state.attributes[fan.ATTR_PERCENTAGE] == 75 + + @pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) async def test_oscillate(hass, fan_entity_id): """Test oscillating the fan.""" diff --git a/tests/components/fan/common.py b/tests/components/fan/common.py index 215849e6aab..c32686b9311 100644 --- a/tests/components/fan/common.py +++ b/tests/components/fan/common.py @@ -7,9 +7,12 @@ from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, ATTR_PRESET_MODE, ATTR_SPEED, DOMAIN, + SERVICE_DECREASE_SPEED, + SERVICE_INCREASE_SPEED, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, @@ -106,6 +109,38 @@ async def async_set_percentage( await hass.services.async_call(DOMAIN, SERVICE_SET_PERCENTAGE, data, blocking=True) +async def async_increase_speed( + hass, entity_id=ENTITY_MATCH_ALL, percentage_step: int = None +) -> None: + """Increase speed for all or specified fan.""" + data = { + key: value + for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_PERCENTAGE_STEP, percentage_step), + ] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_INCREASE_SPEED, data, blocking=True) + + +async def async_decrease_speed( + hass, entity_id=ENTITY_MATCH_ALL, percentage_step: int = None +) -> None: + """Decrease speed for all or specified fan.""" + data = { + key: value + for key, value in [ + (ATTR_ENTITY_ID, entity_id), + (ATTR_PERCENTAGE_STEP, percentage_step), + ] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_DECREASE_SPEED, 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 f5c303bd416..05ced3b8be7 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -19,6 +19,8 @@ def test_fanentity(): assert len(fan.speed_list) == 0 assert len(fan.preset_modes) == 0 assert fan.supported_features == 0 + assert fan.percentage_step == 1 + assert fan.speed_count == 100 assert fan.capability_attributes == {} # Test set_speed not required with pytest.raises(NotImplementedError): @@ -43,6 +45,8 @@ async def test_async_fanentity(hass): assert len(fan.speed_list) == 0 assert len(fan.preset_modes) == 0 assert fan.supported_features == 0 + assert fan.percentage_step == 1 + assert fan.speed_count == 100 assert fan.capability_attributes == {} # Test set_speed not required with pytest.raises(NotImplementedError): @@ -57,3 +61,7 @@ async def test_async_fanentity(hass): await fan.async_turn_on() with pytest.raises(NotImplementedError): await fan.async_turn_off() + with pytest.raises(NotImplementedError): + await fan.async_increase_speed() + with pytest.raises(NotImplementedError): + await fan.async_decrease_speed() diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index b3927ad3118..2b9059017c6 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -203,6 +203,7 @@ async def test_templates_with_entities(hass, calls): "preset_mode_template": "{{ states('input_select.preset_mode') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", + "speed_count": "3", "set_percentage": { "service": "script.fans_set_speed", "data_template": {"percentage": "{{ percentage }}"}, @@ -648,6 +649,46 @@ async def test_set_percentage(hass, calls): _verify(hass, STATE_ON, SPEED_MEDIUM, 50, None, None, None) +async def test_increase_decrease_speed(hass, calls): + """Test set valid increase and derease speed.""" + await _register_components(hass) + + # Turn on fan + await common.async_turn_on(hass, _TEST_FAN) + + # Set fan's percentage speed to 100 + await common.async_set_percentage(hass, _TEST_FAN, 100) + + # verify + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 + + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) + + # Set fan's percentage speed to 66 + await common.async_decrease_speed(hass, _TEST_FAN) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 66 + + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) + + # Set fan's percentage speed to 33 + await common.async_decrease_speed(hass, _TEST_FAN) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 33 + + _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) + + # Set fan's percentage speed to 0 + await common.async_decrease_speed(hass, _TEST_FAN) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 0 + + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + # Set fan's percentage speed to 33 + await common.async_increase_speed(hass, _TEST_FAN) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 33 + + _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) + + async def test_set_invalid_speed_from_initial_stage(hass, calls): """Test set invalid speed when fan is in initial state.""" await _register_components(hass)