From babfef829d866acf2c7e81e0d855feb2f106d1ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 28 Jan 2021 03:44:36 -0600 Subject: [PATCH] Add support for percentage speeds and preset modes to template fan (#45478) --- homeassistant/components/template/fan.py | 218 +++++++++++++-- tests/components/template/test_fan.py | 323 ++++++++++++++++++++--- 2 files changed, 493 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index fe32d095677..5d01790f21a 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -6,6 +6,8 @@ import voluptuous as vol from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, @@ -13,10 +15,12 @@ from homeassistant.components.fan import ( SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, + SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity, + preset_modes_from_speed_list, ) from homeassistant.const import ( CONF_ENTITY_ID, @@ -42,14 +46,19 @@ _LOGGER = logging.getLogger(__name__) CONF_FANS = "fans" CONF_SPEED_LIST = "speeds" +CONF_PRESET_MODES = "preset_modes" CONF_SPEED_TEMPLATE = "speed_template" +CONF_PERCENTAGE_TEMPLATE = "percentage_template" +CONF_PRESET_MODE_TEMPLATE = "preset_mode_template" CONF_OSCILLATING_TEMPLATE = "oscillating_template" CONF_DIRECTION_TEMPLATE = "direction_template" CONF_ON_ACTION = "turn_on" CONF_OFF_ACTION = "turn_off" +CONF_SET_PERCENTAGE_ACTION = "set_percentage" CONF_SET_SPEED_ACTION = "set_speed" CONF_SET_OSCILLATING_ACTION = "set_oscillating" CONF_SET_DIRECTION_ACTION = "set_direction" +CONF_SET_PRESET_MODE_ACTION = "set_preset_mode" _VALID_STATES = [STATE_ON, STATE_OFF] _VALID_OSC = [True, False] @@ -57,22 +66,31 @@ _VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] FAN_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), + cv.deprecated(CONF_SPEED_LIST), + cv.deprecated(CONF_SPEED_TEMPLATE), + cv.deprecated(CONF_SET_SPEED_ACTION), vol.Schema( { vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_PERCENTAGE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESET_MODE_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_SPEED_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_PERCENTAGE_ACTION): cv.SCRIPT_SCHEMA, + 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_LIST, default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + CONF_SPEED_LIST, + default=[SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH], ): cv.ensure_list, + vol.Optional(CONF_PRESET_MODES): cv.ensure_list, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -93,6 +111,8 @@ async def _async_create_entities(hass, config): state_template = device_config[CONF_VALUE_TEMPLATE] speed_template = device_config.get(CONF_SPEED_TEMPLATE) + percentage_template = device_config.get(CONF_PERCENTAGE_TEMPLATE) + preset_mode_template = device_config.get(CONF_PRESET_MODE_TEMPLATE) oscillating_template = device_config.get(CONF_OSCILLATING_TEMPLATE) direction_template = device_config.get(CONF_DIRECTION_TEMPLATE) availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) @@ -100,10 +120,13 @@ async def _async_create_entities(hass, config): on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] set_speed_action = device_config.get(CONF_SET_SPEED_ACTION) + set_percentage_action = device_config.get(CONF_SET_PERCENTAGE_ACTION) + set_preset_mode_action = device_config.get(CONF_SET_PRESET_MODE_ACTION) set_oscillating_action = device_config.get(CONF_SET_OSCILLATING_ACTION) set_direction_action = device_config.get(CONF_SET_DIRECTION_ACTION) speed_list = device_config[CONF_SPEED_LIST] + preset_modes = device_config.get(CONF_PRESET_MODES) unique_id = device_config.get(CONF_UNIQUE_ID) fans.append( @@ -113,15 +136,20 @@ async def _async_create_entities(hass, config): friendly_name, state_template, speed_template, + percentage_template, + preset_mode_template, oscillating_template, direction_template, availability_template, on_action, off_action, set_speed_action, + set_percentage_action, + set_preset_mode_action, set_oscillating_action, set_direction_action, speed_list, + preset_modes, unique_id, ) ) @@ -146,15 +174,20 @@ class TemplateFan(TemplateEntity, FanEntity): friendly_name, state_template, speed_template, + percentage_template, + preset_mode_template, oscillating_template, direction_template, availability_template, on_action, off_action, set_speed_action, + set_percentage_action, + set_preset_mode_action, set_oscillating_action, set_direction_action, speed_list, + preset_modes, unique_id, ): """Initialize the fan.""" @@ -167,6 +200,8 @@ class TemplateFan(TemplateEntity, FanEntity): self._template = state_template self._speed_template = speed_template + self._percentage_template = percentage_template + self._preset_mode_template = preset_mode_template self._oscillating_template = oscillating_template self._direction_template = direction_template self._supported_features = 0 @@ -182,6 +217,18 @@ class TemplateFan(TemplateEntity, FanEntity): hass, set_speed_action, friendly_name, domain ) + self._set_percentage_script = None + if set_percentage_action: + self._set_percentage_script = Script( + hass, set_percentage_action, friendly_name, domain + ) + + self._set_preset_mode_script = None + if set_preset_mode_action: + self._set_preset_mode_script = Script( + hass, set_preset_mode_action, friendly_name, domain + ) + self._set_oscillating_script = None if set_oscillating_action: self._set_oscillating_script = Script( @@ -196,10 +243,16 @@ class TemplateFan(TemplateEntity, FanEntity): self._state = STATE_OFF self._speed = None + self._percentage = None + self._preset_mode = None self._oscillating = None self._direction = None - if self._speed_template: + if ( + self._speed_template + or self._percentage_template + or self._preset_mode_template + ): self._supported_features |= SUPPORT_SET_SPEED if self._oscillating_template: self._supported_features |= SUPPORT_OSCILLATE @@ -211,6 +264,9 @@ class TemplateFan(TemplateEntity, FanEntity): # List of valid speeds self._speed_list = speed_list + # List of valid preset modes + self._preset_modes = preset_modes + @property def name(self): """Return the display name of this fan.""" @@ -231,6 +287,13 @@ class TemplateFan(TemplateEntity, FanEntity): """Get the list of available speeds.""" return self._speed_list + @property + def preset_modes(self) -> list: + """Get the list of available preset modes.""" + if self._preset_modes is not None: + return self._preset_modes + return preset_modes_from_speed_list(self._speed_list) + @property def is_on(self): """Return true if device is on.""" @@ -241,6 +304,16 @@ class TemplateFan(TemplateEntity, FanEntity): """Return the current speed.""" return self._speed + @property + def preset_mode(self): + """Return the current preset mode.""" + return self._preset_mode + + @property + def percentage(self): + """Return the current speed percentage.""" + return self._percentage + @property def oscillating(self): """Return the oscillation state.""" @@ -251,13 +324,6 @@ class TemplateFan(TemplateEntity, FanEntity): """Return the oscillation state.""" return self._direction - # - # 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, @@ -266,10 +332,21 @@ class TemplateFan(TemplateEntity, FanEntity): **kwargs, ) -> None: """Turn on the fan.""" - await self._on_script.async_run({ATTR_SPEED: speed}, context=self._context) + await self._on_script.async_run( + { + ATTR_SPEED: speed, + ATTR_PERCENTAGE: percentage, + ATTR_PRESET_MODE: preset_mode, + }, + context=self._context, + ) self._state = STATE_ON - if speed is not None: + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + elif percentage is not None: + await self.async_set_percentage(percentage) + elif speed is not None: await self.async_set_speed(speed) # pylint: disable=arguments-differ @@ -280,17 +357,53 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan.""" - if self._set_speed_script is None: + if speed not in self.speed_list: + _LOGGER.error( + "Received invalid speed: %s. Expected: %s", speed, self.speed_list + ) return - if speed in self._speed_list: - self._speed = speed + self._state = STATE_OFF if speed == SPEED_OFF else STATE_ON + self._speed = speed + self._preset_mode = None + self._percentage = self.speed_to_percentage(speed) + + if self._set_speed_script: await self._set_speed_script.async_run( - {ATTR_SPEED: speed}, context=self._context + {ATTR_SPEED: self._speed}, context=self._context ) - else: + + async def async_set_percentage(self, percentage: int) -> None: + """Set the percentage speed of the fan.""" + speed_list = self.speed_list + self._state = STATE_OFF if percentage == 0 else STATE_ON + self._speed = self.percentage_to_speed(percentage) if speed_list else None + self._percentage = percentage + self._preset_mode = None + + if self._set_percentage_script: + await self._set_percentage_script.async_run( + {ATTR_PERCENTAGE: self._percentage}, context=self._context + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset_mode of the fan.""" + if preset_mode not in self.preset_modes: _LOGGER.error( - "Received invalid speed: %s. Expected: %s", speed, self._speed_list + "Received invalid preset_mode: %s. Expected: %s", + preset_mode, + self.preset_modes, + ) + return + + self._state = STATE_ON + self._preset_mode = preset_mode + self._speed = preset_mode + self._percentage = None + + if self._set_preset_mode_script: + await self._set_preset_mode_script.async_run( + {ATTR_PRESET_MODE: self._preset_mode}, context=self._context ) async def async_oscillate(self, oscillating: bool) -> None: @@ -350,6 +463,22 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_added_to_hass(self): """Register callbacks.""" self.add_template_attribute("_state", self._template, None, self._update_state) + if self._preset_mode_template is not None: + self.add_template_attribute( + "_preset_mode", + self._preset_mode_template, + None, + self._update_preset_mode, + none_on_template_error=True, + ) + if self._percentage_template is not None: + self.add_template_attribute( + "_percentage", + self._percentage_template, + None, + self._update_percentage, + none_on_template_error=True, + ) if self._speed_template is not None: self.add_template_attribute( "_speed", @@ -382,14 +511,69 @@ class TemplateFan(TemplateEntity, FanEntity): speed = str(speed) if speed in self._speed_list: + self._state = STATE_OFF if speed == SPEED_OFF else STATE_ON self._speed = speed + self._percentage = self.speed_to_percentage(speed) + self._preset_mode = speed if speed in self.preset_modes else None elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._speed = None + self._percentage = 0 + self._preset_mode = None else: _LOGGER.error( "Received invalid speed: %s. Expected: %s", speed, self._speed_list ) self._speed = None + self._percentage = 0 + self._preset_mode = None + + @callback + def _update_percentage(self, percentage): + # Validate percentage + try: + percentage = int(float(percentage)) + except ValueError: + _LOGGER.error("Received invalid percentage: %s", percentage) + self._speed = None + self._percentage = 0 + self._preset_mode = None + return + + if 0 <= percentage <= 100: + self._state = STATE_OFF if percentage == 0 else STATE_ON + self._percentage = percentage + if self._speed_list: + self._speed = self.percentage_to_speed(percentage) + self._preset_mode = None + else: + _LOGGER.error("Received invalid percentage: %s", percentage) + self._speed = None + self._percentage = 0 + self._preset_mode = None + + @callback + def _update_preset_mode(self, preset_mode): + # Validate preset mode + preset_mode = str(preset_mode) + + if preset_mode in self.preset_modes: + self._state = STATE_ON + self._speed = preset_mode + self._percentage = None + self._preset_mode = preset_mode + elif preset_mode in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + self._speed = None + self._percentage = None + self._preset_mode = None + else: + _LOGGER.error( + "Received invalid preset_mode: %s. Expected: %s", + preset_mode, + self.preset_mode, + ) + self._speed = None + self._percentage = None + self._preset_mode = None @callback def _update_oscillating(self, oscillating): diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 8e7c519def9..b3927ad3118 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -6,12 +6,15 @@ from homeassistant import setup from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, ATTR_SPEED, DIRECTION_FORWARD, DIRECTION_REVERSE, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, + SPEED_OFF, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE @@ -25,6 +28,10 @@ _STATE_INPUT_BOOLEAN = "input_boolean.state" _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" # Represent for fan's speed _SPEED_INPUT_SELECT = "input_select.speed" +# Represent for fan's preset mode +_PRESET_MODE_INPUT_SELECT = "input_select.preset_mode" +# Represent for fan's speed percentage +_PERCENTAGE_INPUT_NUMBER = "input_number.percentage" # Represent for fan's oscillating _OSC_INPUT = "input_select.osc" # Represent for fan's direction @@ -62,7 +69,7 @@ async def test_missing_optional_config(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, None, None, None, None) async def test_missing_value_template_config(hass, calls): @@ -191,9 +198,15 @@ async def test_templates_with_entities(hass, calls): "fans": { "test_fan": { "value_template": value_template, + "percentage_template": "{{ states('input_number.percentage') }}", "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", + "set_percentage": { + "service": "script.fans_set_speed", + "data_template": {"percentage": "{{ percentage }}"}, + }, "turn_on": {"service": "script.fan_on"}, "turn_off": {"service": "script.fan_off"}, } @@ -206,7 +219,7 @@ async def test_templates_with_entities(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_OFF, None, None, None) + _verify(hass, STATE_OFF, None, 0, None, None, None) hass.states.async_set(_STATE_INPUT_BOOLEAN, True) hass.states.async_set(_SPEED_INPUT_SELECT, SPEED_MEDIUM) @@ -214,7 +227,128 @@ async def test_templates_with_entities(hass, calls): hass.states.async_set(_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD) await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) + + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 33) + await hass.async_block_till_done() + _verify(hass, STATE_ON, SPEED_LOW, 33, True, DIRECTION_FORWARD, None) + + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 66) + await hass.async_block_till_done() + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) + + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, 100) + await hass.async_block_till_done() + _verify(hass, STATE_ON, SPEED_HIGH, 100, True, DIRECTION_FORWARD, None) + + hass.states.async_set(_PERCENTAGE_INPUT_NUMBER, "dog") + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, 0, True, DIRECTION_FORWARD, None) + + +async def test_templates_with_entities_and_invalid_percentage(hass, calls): + """Test templates with values from other entities.""" + hass.states.async_set("sensor.percentage", "0") + + with assert_setup_component(1, "fan"): + assert await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "percentage_template": "{{ states('sensor.percentage') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + hass.states.async_set("sensor.percentage", "33") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, SPEED_LOW, 33, None, None, None) + + hass.states.async_set("sensor.percentage", "invalid") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, 0, None, None, None) + + hass.states.async_set("sensor.percentage", "5000") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, 0, None, None, None) + + hass.states.async_set("sensor.percentage", "100") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) + + hass.states.async_set("sensor.percentage", "0") + await hass.async_block_till_done() + + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + +async def test_templates_with_entities_and_preset_modes(hass, calls): + """Test templates with values from other entities.""" + hass.states.async_set("sensor.preset_mode", "0") + + with assert_setup_component(1, "fan"): + assert await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "preset_modes": ["auto", "smart"], + "preset_mode_template": "{{ states('sensor.preset_mode') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, None, None, None, None) + + hass.states.async_set("sensor.preset_mode", "invalid") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, None, None, None, None) + + hass.states.async_set("sensor.preset_mode", "auto") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, "auto", None, None, None, "auto") + + hass.states.async_set("sensor.preset_mode", "smart") + await hass.async_block_till_done() + + _verify(hass, STATE_ON, "smart", None, None, None, "smart") + + hass.states.async_set("sensor.preset_mode", "invalid") + await hass.async_block_till_done() + _verify(hass, STATE_ON, None, None, None, None, None) async def test_template_with_unavailable_entities(hass, calls): @@ -272,7 +406,7 @@ async def test_template_with_unavailable_parameters(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) async def test_availability_template_with_entities(hass, calls): @@ -346,7 +480,7 @@ async def test_templates_with_valid_values(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, True, DIRECTION_FORWARD, None) async def test_templates_invalid_values(hass, calls): @@ -376,7 +510,7 @@ async def test_templates_invalid_values(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_OFF, None, None, None) + _verify(hass, STATE_OFF, None, 0, None, None, None) async def test_invalid_availability_template_keeps_component_available(hass, caplog): @@ -394,6 +528,7 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap "value_template": "{{ 'on' }}", "availability_template": "{{ x - 12 }}", "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", "turn_on": {"service": "script.fan_on"}, @@ -427,14 +562,14 @@ async def test_on_off(hass, calls): # verify assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) # Turn off fan await common.async_turn_off(hass, _TEST_FAN) # verify assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF - _verify(hass, STATE_OFF, None, None, None) + _verify(hass, STATE_OFF, None, 0, None, None, None) async def test_on_with_speed(hass, calls): @@ -446,13 +581,13 @@ async def test_on_with_speed(hass, calls): # verify assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, None, None) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 100 + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) async def test_set_speed(hass, calls): """Test set valid speed.""" - await _register_components(hass) + await _register_components(hass, preset_modes=["auto", "smart"]) # Turn on fan await common.async_turn_on(hass, _TEST_FAN) @@ -462,14 +597,55 @@ async def test_set_speed(hass, calls): # verify assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, None, None) + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) # Set fan's speed to medium await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM) # verify assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM - _verify(hass, STATE_ON, SPEED_MEDIUM, None, None) + _verify(hass, STATE_ON, SPEED_MEDIUM, 66, None, None, None) + + # Set fan's speed to off + await common.async_set_speed(hass, _TEST_FAN, SPEED_OFF) + + # verify + assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_OFF + _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + + +async def test_set_percentage(hass, calls): + """Test set valid speed percentage.""" + 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_set_percentage(hass, _TEST_FAN, 66) + 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 0 + await common.async_set_percentage(hass, _TEST_FAN, 0) + 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 50 + await common.async_turn_on(hass, _TEST_FAN, percentage=50) + assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == 50 + + _verify(hass, STATE_ON, SPEED_MEDIUM, 50, None, None, None) async def test_set_invalid_speed_from_initial_stage(hass, calls): @@ -484,7 +660,7 @@ async def test_set_invalid_speed_from_initial_stage(hass, calls): # verify speed is unchanged assert hass.states.get(_SPEED_INPUT_SELECT).state == "" - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) async def test_set_invalid_speed(hass, calls): @@ -499,14 +675,14 @@ async def test_set_invalid_speed(hass, calls): # verify assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, None, None) + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) # Set fan's speed to 'invalid' await common.async_set_speed(hass, _TEST_FAN, "invalid") # verify speed is unchanged assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - _verify(hass, STATE_ON, SPEED_HIGH, None, None) + _verify(hass, STATE_ON, SPEED_HIGH, 100, None, None, None) async def test_custom_speed_list(hass, calls): @@ -521,14 +697,48 @@ async def test_custom_speed_list(hass, calls): # verify assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", None, None) + _verify(hass, STATE_ON, "1", 33, None, None, None) # Set fan's speed to 'medium' which is invalid await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM) # verify that speed is unchanged assert hass.states.get(_SPEED_INPUT_SELECT).state == "1" - _verify(hass, STATE_ON, "1", None, None) + _verify(hass, STATE_ON, "1", 33, None, None, None) + + +async def test_preset_modes(hass, calls): + """Test preset_modes.""" + await _register_components( + hass, ["off", "low", "medium", "high", "auto", "smart"], ["auto", "smart"] + ) + + # Turn on fan + await common.async_turn_on(hass, _TEST_FAN) + + # Set fan's preset_mode to "auto" + await common.async_set_preset_mode(hass, _TEST_FAN, "auto") + + # verify + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" + + # Set fan's preset_mode to "smart" + await common.async_set_preset_mode(hass, _TEST_FAN, "smart") + + # Verify fan's preset_mode is "smart" + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "smart" + + # Set fan's preset_mode to "invalid" + await common.async_set_preset_mode(hass, _TEST_FAN, "invalid") + + # Verify fan's preset_mode is still "smart" + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "smart" + + # Set fan's preset_mode to "auto" + await common.async_turn_on(hass, _TEST_FAN, preset_mode="auto") + + # verify + assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == "auto" async def test_set_osc(hass, calls): @@ -543,14 +753,14 @@ async def test_set_osc(hass, calls): # verify assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, True, None) + _verify(hass, STATE_ON, None, 0, True, None, None) # Set fan's osc to False await common.async_oscillate(hass, _TEST_FAN, False) # verify assert hass.states.get(_OSC_INPUT).state == "False" - _verify(hass, STATE_ON, None, False, None) + _verify(hass, STATE_ON, None, 0, False, None, None) async def test_set_invalid_osc_from_initial_state(hass, calls): @@ -566,7 +776,7 @@ async def test_set_invalid_osc_from_initial_state(hass, calls): # verify assert hass.states.get(_OSC_INPUT).state == "" - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) async def test_set_invalid_osc(hass, calls): @@ -581,7 +791,7 @@ async def test_set_invalid_osc(hass, calls): # verify assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, True, None) + _verify(hass, STATE_ON, None, 0, True, None, None) # Set fan's osc to None with pytest.raises(vol.Invalid): @@ -589,7 +799,7 @@ async def test_set_invalid_osc(hass, calls): # verify osc is unchanged assert hass.states.get(_OSC_INPUT).state == "True" - _verify(hass, STATE_ON, None, True, None) + _verify(hass, STATE_ON, None, 0, True, None, None) async def test_set_direction(hass, calls): @@ -604,14 +814,14 @@ async def test_set_direction(hass, calls): # verify assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD) + _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) # Set fan's direction to reverse await common.async_set_direction(hass, _TEST_FAN, DIRECTION_REVERSE) # verify assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_REVERSE - _verify(hass, STATE_ON, None, None, DIRECTION_REVERSE) + _verify(hass, STATE_ON, None, 0, None, DIRECTION_REVERSE, None) async def test_set_invalid_direction_from_initial_stage(hass, calls): @@ -626,7 +836,7 @@ async def test_set_invalid_direction_from_initial_stage(hass, calls): # verify direction is unchanged assert hass.states.get(_DIRECTION_INPUT_SELECT).state == "" - _verify(hass, STATE_ON, None, None, None) + _verify(hass, STATE_ON, None, 0, None, None, None) async def test_set_invalid_direction(hass, calls): @@ -641,36 +851,61 @@ async def test_set_invalid_direction(hass, calls): # verify assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD) + _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) # Set fan's direction to 'invalid' await common.async_set_direction(hass, _TEST_FAN, "invalid") # verify direction is unchanged assert hass.states.get(_DIRECTION_INPUT_SELECT).state == DIRECTION_FORWARD - _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD) + _verify(hass, STATE_ON, None, 0, None, DIRECTION_FORWARD, None) def _verify( - hass, expected_state, expected_speed, expected_oscillating, expected_direction + hass, + expected_state, + expected_speed, + expected_percentage, + expected_oscillating, + expected_direction, + expected_preset_mode, ): """Verify fan's state, speed and osc.""" state = hass.states.get(_TEST_FAN) attributes = state.attributes assert state.state == str(expected_state) assert attributes.get(ATTR_SPEED) == expected_speed + assert attributes.get(ATTR_PERCENTAGE) == expected_percentage assert attributes.get(ATTR_OSCILLATING) == expected_oscillating assert attributes.get(ATTR_DIRECTION) == expected_direction + assert attributes.get(ATTR_PRESET_MODE) == expected_preset_mode -async def _register_components(hass, speed_list=None): +async def _register_components(hass, speed_list=None, preset_modes=None): """Register basic components for testing.""" with assert_setup_component(1, "input_boolean"): assert await setup.async_setup_component( hass, "input_boolean", {"input_boolean": {"state": None}} ) - with assert_setup_component(3, "input_select"): + with assert_setup_component(1, "input_number"): + assert await setup.async_setup_component( + hass, + "input_number", + { + "input_number": { + "percentage": { + "min": 0.0, + "max": 100.0, + "name": "Percentage", + "step": 1.0, + "mode": "slider", + } + } + }, + ) + + with assert_setup_component(4, "input_select"): assert await setup.async_setup_component( hass, "input_select", @@ -680,14 +915,21 @@ async def _register_components(hass, speed_list=None): "name": "Speed", "options": [ "", + SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, "1", "2", "3", + "auto", + "smart", ], }, + "preset_mode": { + "name": "Preset Mode", + "options": ["auto", "smart"], + }, "osc": {"name": "oscillating", "options": ["", "True", "False"]}, "direction": { "name": "Direction", @@ -709,6 +951,8 @@ async def _register_components(hass, speed_list=None): test_fan_config = { "value_template": value_template, "speed_template": "{{ states('input_select.speed') }}", + "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "percentage_template": "{{ states('input_number.percentage') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", "turn_on": { @@ -726,6 +970,20 @@ async def _register_components(hass, speed_list=None): "option": "{{ speed }}", }, }, + "set_preset_mode": { + "service": "input_select.select_option", + "data_template": { + "entity_id": _PRESET_MODE_INPUT_SELECT, + "option": "{{ preset_mode }}", + }, + }, + "set_percentage": { + "service": "input_number.set_value", + "data_template": { + "entity_id": _PERCENTAGE_INPUT_NUMBER, + "value": "{{ percentage }}", + }, + }, "set_oscillating": { "service": "input_select.select_option", "data_template": { @@ -745,6 +1003,9 @@ async def _register_components(hass, speed_list=None): if speed_list: test_fan_config["speeds"] = speed_list + if preset_modes: + test_fan_config["preset_modes"] = preset_modes + assert await setup.async_setup_component( hass, "fan",