From 44e9783c7c45ff9cbaf3c716932a1a06808632cf Mon Sep 17 00:00:00 2001 From: Matt Schmitt Date: Mon, 14 May 2018 16:37:49 -0400 Subject: [PATCH] Add support for direction to fan template (#14371) * Initial commit * Update and add tests --- homeassistant/components/fan/template.py | 93 ++++++++++++--- tests/components/fan/test_template.py | 141 +++++++++++++++++++---- 2 files changed, 197 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/fan/template.py b/homeassistant/components/fan/template.py index 31b335eb2bc..a40437e719b 100644 --- a/homeassistant/components/fan/template.py +++ b/homeassistant/components/fan/template.py @@ -18,11 +18,10 @@ from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import ToggleEntity -from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, - SPEED_HIGH, SUPPORT_SET_SPEED, - SUPPORT_OSCILLATE, FanEntity, - ATTR_SPEED, ATTR_OSCILLATING, - ENTITY_ID_FORMAT) +from homeassistant.components.fan import ( + SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, + FanEntity, ATTR_SPEED, ATTR_OSCILLATING, ENTITY_ID_FORMAT, + SUPPORT_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE, ATTR_DIRECTION) from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.script import Script @@ -33,25 +32,30 @@ CONF_FANS = 'fans' CONF_SPEED_LIST = 'speeds' CONF_SPEED_TEMPLATE = 'speed_template' CONF_OSCILLATING_TEMPLATE = 'oscillating_template' +CONF_DIRECTION_TEMPLATE = 'direction_template' CONF_ON_ACTION = 'turn_on' CONF_OFF_ACTION = 'turn_off' CONF_SET_SPEED_ACTION = 'set_speed' CONF_SET_OSCILLATING_ACTION = 'set_oscillating' +CONF_SET_DIRECTION_ACTION = 'set_direction' _VALID_STATES = [STATE_ON, STATE_OFF] _VALID_OSC = [True, False] +_VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] FAN_SCHEMA = 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_OSCILLATING_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_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_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional( CONF_SPEED_LIST, @@ -80,18 +84,21 @@ async def async_setup_platform( oscillating_template = device_config.get( CONF_OSCILLATING_TEMPLATE ) + direction_template = device_config.get(CONF_DIRECTION_TEMPLATE) 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_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] entity_ids = set() manual_entity_ids = device_config.get(CONF_ENTITY_ID) - for template in (state_template, speed_template, oscillating_template): + for template in (state_template, speed_template, oscillating_template, + direction_template): if template is None: continue template.hass = hass @@ -114,8 +121,9 @@ async def async_setup_platform( TemplateFan( hass, device, friendly_name, state_template, speed_template, oscillating_template, - on_action, off_action, set_speed_action, - set_oscillating_action, speed_list, entity_ids + direction_template, on_action, off_action, set_speed_action, + set_oscillating_action, set_direction_action, speed_list, + entity_ids ) ) @@ -127,8 +135,9 @@ class TemplateFan(FanEntity): def __init__(self, hass, device_id, friendly_name, state_template, speed_template, oscillating_template, - on_action, off_action, set_speed_action, - set_oscillating_action, speed_list, entity_ids): + direction_template, on_action, off_action, set_speed_action, + set_oscillating_action, set_direction_action, speed_list, + entity_ids): """Initialize the fan.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -138,6 +147,7 @@ class TemplateFan(FanEntity): self._template = state_template self._speed_template = speed_template self._oscillating_template = oscillating_template + self._direction_template = direction_template self._supported_features = 0 self._on_script = Script(hass, on_action) @@ -151,9 +161,14 @@ class TemplateFan(FanEntity): if set_oscillating_action: self._set_oscillating_script = Script(hass, set_oscillating_action) + self._set_direction_script = None + if set_direction_action: + self._set_direction_script = Script(hass, set_direction_action) + self._state = STATE_OFF self._speed = None self._oscillating = None + self._direction = None self._template.hass = self.hass if self._speed_template: @@ -162,6 +177,9 @@ class TemplateFan(FanEntity): if self._oscillating_template: self._oscillating_template.hass = self.hass self._supported_features |= SUPPORT_OSCILLATE + if self._direction_template: + self._direction_template.hass = self.hass + self._supported_features |= SUPPORT_DIRECTION self._entities = entity_ids # List of valid speeds @@ -197,6 +215,11 @@ class TemplateFan(FanEntity): """Return the oscillation state.""" return self._oscillating + @property + def direction(self): + """Return the oscillation state.""" + return self._direction + @property def should_poll(self): """Return the polling state.""" @@ -236,10 +259,30 @@ class TemplateFan(FanEntity): if self._set_oscillating_script is None: return - await self._set_oscillating_script.async_run( - {ATTR_OSCILLATING: oscillating} - ) - self._oscillating = oscillating + if oscillating in _VALID_OSC: + self._oscillating = oscillating + await self._set_oscillating_script.async_run( + {ATTR_OSCILLATING: oscillating}) + else: + _LOGGER.error( + 'Received invalid oscillating value: %s. ' + + 'Expected: %s.', + oscillating, ', '.join(_VALID_OSC)) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + if self._set_direction_script is None: + return + + if direction in _VALID_DIRECTIONS: + self._direction = direction + await self._set_direction_script.async_run( + {ATTR_DIRECTION: direction}) + else: + _LOGGER.error( + 'Received invalid direction: %s. ' + + 'Expected: %s.', + direction, ', '.join(_VALID_DIRECTIONS)) async def async_added_to_hass(self): """Register callbacks.""" @@ -308,6 +351,7 @@ class TemplateFan(FanEntity): oscillating = self._oscillating_template.async_render() except TemplateError as ex: _LOGGER.error(ex) + oscillating = None self._state = None # Validate osc @@ -322,3 +366,24 @@ class TemplateFan(FanEntity): 'Received invalid oscillating: %s. ' + 'Expected: True/False.', oscillating) self._oscillating = None + + # Update direction if 'direction_template' is configured + if self._direction_template is not None: + try: + direction = self._direction_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + direction = None + self._state = None + + # Validate speed + if direction in _VALID_DIRECTIONS: + self._direction = direction + elif direction == STATE_UNKNOWN: + self._direction = None + else: + _LOGGER.error( + 'Received invalid direction: %s. ' + + 'Expected: %s.', + direction, ', '.join(_VALID_DIRECTIONS)) + self._direction = None diff --git a/tests/components/fan/test_template.py b/tests/components/fan/test_template.py index 719a3f96aed..53eb9e8e2d4 100644 --- a/tests/components/fan/test_template.py +++ b/tests/components/fan/test_template.py @@ -6,7 +6,8 @@ from homeassistant import setup import homeassistant.components as components from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.components.fan import ( - ATTR_SPEED, ATTR_OSCILLATING, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH) + ATTR_SPEED, ATTR_OSCILLATING, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + ATTR_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE) from tests.common import ( get_test_home_assistant, assert_setup_component) @@ -20,6 +21,8 @@ _STATE_INPUT_BOOLEAN = 'input_boolean.state' _SPEED_INPUT_SELECT = 'input_select.speed' # Represent for fan's oscillating _OSC_INPUT = 'input_select.osc' +# Represent for fan's direction +_DIRECTION_INPUT_SELECT = 'input_select.direction' class TestTemplateFan: @@ -71,7 +74,7 @@ class TestTemplateFan: self.hass.start() self.hass.block_till_done() - self._verify(STATE_ON, None, None) + self._verify(STATE_ON, None, None, None) def test_missing_value_template_config(self): """Test: missing 'value_template' will fail.""" @@ -185,6 +188,8 @@ class TestTemplateFan: "{{ states('input_select.speed') }}", 'oscillating_template': "{{ states('input_select.osc') }}", + 'direction_template': + "{{ states('input_select.direction') }}", 'turn_on': { 'service': 'script.fan_on' }, @@ -199,14 +204,15 @@ class TestTemplateFan: self.hass.start() self.hass.block_till_done() - self._verify(STATE_OFF, None, None) + self._verify(STATE_OFF, None, None, None) self.hass.states.set(_STATE_INPUT_BOOLEAN, True) self.hass.states.set(_SPEED_INPUT_SELECT, SPEED_MEDIUM) self.hass.states.set(_OSC_INPUT, 'True') + self.hass.states.set(_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD) self.hass.block_till_done() - self._verify(STATE_ON, SPEED_MEDIUM, True) + self._verify(STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) def test_templates_with_valid_values(self): """Test templates with valid values.""" @@ -222,6 +228,8 @@ class TestTemplateFan: "{{ 'medium' }}", 'oscillating_template': "{{ 1 == 1 }}", + 'direction_template': + "{{ 'forward' }}", 'turn_on': { 'service': 'script.fan_on' @@ -237,7 +245,7 @@ class TestTemplateFan: self.hass.start() self.hass.block_till_done() - self._verify(STATE_ON, SPEED_MEDIUM, True) + self._verify(STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) def test_templates_invalid_values(self): """Test templates with invalid values.""" @@ -253,6 +261,8 @@ class TestTemplateFan: "{{ '0' }}", 'oscillating_template': "{{ 'xyz' }}", + 'direction_template': + "{{ 'right' }}", 'turn_on': { 'service': 'script.fan_on' @@ -268,7 +278,7 @@ class TestTemplateFan: self.hass.start() self.hass.block_till_done() - self._verify(STATE_OFF, None, None) + self._verify(STATE_OFF, None, None, None) # End of template tests # @@ -283,7 +293,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - self._verify(STATE_ON, None, None) + self._verify(STATE_ON, None, None, None) # Turn off fan components.fan.turn_off(self.hass, _TEST_FAN) @@ -291,7 +301,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF - self._verify(STATE_OFF, None, None) + self._verify(STATE_OFF, None, None, None) def test_on_with_speed(self): """Test turn on with speed.""" @@ -304,7 +314,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - self._verify(STATE_ON, SPEED_HIGH, None) + self._verify(STATE_ON, SPEED_HIGH, None, None) def test_set_speed(self): """Test set valid speed.""" @@ -320,7 +330,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - self._verify(STATE_ON, SPEED_HIGH, None) + self._verify(STATE_ON, SPEED_HIGH, None, None) # Set fan's speed to medium components.fan.set_speed(self.hass, _TEST_FAN, SPEED_MEDIUM) @@ -328,7 +338,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM - self._verify(STATE_ON, SPEED_MEDIUM, None) + self._verify(STATE_ON, SPEED_MEDIUM, None, None) def test_set_invalid_speed_from_initial_stage(self): """Test set invalid speed when fan is in initial state.""" @@ -344,7 +354,7 @@ class TestTemplateFan: # verify speed is unchanged assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '' - self._verify(STATE_ON, None, None) + self._verify(STATE_ON, None, None, None) def test_set_invalid_speed(self): """Test set invalid speed when fan has valid speed.""" @@ -360,7 +370,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - self._verify(STATE_ON, SPEED_HIGH, None) + self._verify(STATE_ON, SPEED_HIGH, None, None) # Set fan's speed to 'invalid' components.fan.set_speed(self.hass, _TEST_FAN, 'invalid') @@ -368,7 +378,7 @@ class TestTemplateFan: # verify speed is unchanged assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - self._verify(STATE_ON, SPEED_HIGH, None) + self._verify(STATE_ON, SPEED_HIGH, None, None) def test_custom_speed_list(self): """Test set custom speed list.""" @@ -384,7 +394,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '1' - self._verify(STATE_ON, '1', None) + self._verify(STATE_ON, '1', None, None) # Set fan's speed to 'medium' which is invalid components.fan.set_speed(self.hass, _TEST_FAN, SPEED_MEDIUM) @@ -392,7 +402,7 @@ class TestTemplateFan: # verify that speed is unchanged assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '1' - self._verify(STATE_ON, '1', None) + self._verify(STATE_ON, '1', None, None) def test_set_osc(self): """Test set oscillating.""" @@ -408,7 +418,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_OSC_INPUT).state == 'True' - self._verify(STATE_ON, None, True) + self._verify(STATE_ON, None, True, None) # Set fan's osc to False components.fan.oscillate(self.hass, _TEST_FAN, False) @@ -416,7 +426,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_OSC_INPUT).state == 'False' - self._verify(STATE_ON, None, False) + self._verify(STATE_ON, None, False, None) def test_set_invalid_osc_from_initial_state(self): """Test set invalid oscillating when fan is in initial state.""" @@ -432,7 +442,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_OSC_INPUT).state == '' - self._verify(STATE_ON, None, None) + self._verify(STATE_ON, None, None, None) def test_set_invalid_osc(self): """Test set invalid oscillating when fan has valid osc.""" @@ -448,7 +458,7 @@ class TestTemplateFan: # verify assert self.hass.states.get(_OSC_INPUT).state == 'True' - self._verify(STATE_ON, None, True) + self._verify(STATE_ON, None, True, None) # Set fan's osc to False components.fan.oscillate(self.hass, _TEST_FAN, None) @@ -456,15 +466,85 @@ class TestTemplateFan: # verify osc is unchanged assert self.hass.states.get(_OSC_INPUT).state == 'True' - self._verify(STATE_ON, None, True) + self._verify(STATE_ON, None, True, None) - def _verify(self, expected_state, expected_speed, expected_oscillating): + def test_set_direction(self): + """Test set valid direction.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to forward + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_FORWARD) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state \ + == DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + # Set fan's direction to reverse + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_REVERSE) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state \ + == DIRECTION_REVERSE + self._verify(STATE_ON, None, None, DIRECTION_REVERSE) + + def test_set_invalid_direction_from_initial_stage(self): + """Test set invalid direction when fan is in initial state.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to 'invalid' + components.fan.set_direction(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify direction is unchanged + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == '' + self._verify(STATE_ON, None, None, None) + + def test_set_invalid_direction(self): + """Test set invalid direction when fan has valid direction.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to forward + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_FORWARD) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == \ + DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + # Set fan's direction to 'invalid' + components.fan.set_direction(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify direction is unchanged + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == \ + DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + def _verify(self, expected_state, expected_speed, expected_oscillating, + expected_direction): """Verify fan's state, speed and osc.""" state = self.hass.states.get(_TEST_FAN) attributes = state.attributes assert state.state == expected_state assert attributes.get(ATTR_SPEED, None) == expected_speed assert attributes.get(ATTR_OSCILLATING, None) == expected_oscillating + assert attributes.get(ATTR_DIRECTION, None) == expected_direction def _register_components(self, speed_list=None): """Register basic components for testing.""" @@ -475,7 +555,7 @@ class TestTemplateFan: {'input_boolean': {'state': None}} ) - with assert_setup_component(2, 'input_select'): + with assert_setup_component(3, 'input_select'): assert setup.setup_component(self.hass, 'input_select', { 'input_select': { 'speed': { @@ -488,6 +568,11 @@ class TestTemplateFan: 'name': 'oscillating', 'options': ['', 'True', 'False'] }, + + 'direction': { + 'name': 'Direction', + 'options': ['', DIRECTION_FORWARD, DIRECTION_REVERSE] + }, } }) @@ -506,6 +591,8 @@ class TestTemplateFan: "{{ states('input_select.speed') }}", 'oscillating_template': "{{ states('input_select.osc') }}", + 'direction_template': + "{{ states('input_select.direction') }}", 'turn_on': { 'service': 'input_boolean.turn_on', @@ -530,6 +617,14 @@ class TestTemplateFan: 'entity_id': _OSC_INPUT, 'option': '{{ oscillating }}' } + }, + 'set_direction': { + 'service': 'input_select.select_option', + + 'data_template': { + 'entity_id': _DIRECTION_INPUT_SELECT, + 'option': '{{ direction }}' + } } }