diff --git a/homeassistant/components/fan/template.py b/homeassistant/components/fan/template.py new file mode 100644 index 00000000000..31b335eb2bc --- /dev/null +++ b/homeassistant/components/fan/template.py @@ -0,0 +1,324 @@ +""" +Support for Template fans. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/fan.template/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.const import ( + CONF_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, CONF_ENTITY_ID, + STATE_ON, STATE_OFF, MATCH_ALL, EVENT_HOMEASSISTANT_START, + STATE_UNKNOWN) + +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.helpers.entity import async_generate_entity_id +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) + +CONF_FANS = 'fans' +CONF_SPEED_LIST = 'speeds' +CONF_SPEED_TEMPLATE = 'speed_template' +CONF_OSCILLATING_TEMPLATE = 'oscillating_template' +CONF_ON_ACTION = 'turn_on' +CONF_OFF_ACTION = 'turn_off' +CONF_SET_SPEED_ACTION = 'set_speed' +CONF_SET_OSCILLATING_ACTION = 'set_oscillating' + +_VALID_STATES = [STATE_ON, STATE_OFF] +_VALID_OSC = [True, False] + +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.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_SPEED_LIST, + default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + ): cv.ensure_list, + + vol.Optional(CONF_ENTITY_ID): cv.entity_ids +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FANS): vol.Schema({cv.slug: FAN_SCHEMA}), +}) + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None +): + """Set up the Template Fans.""" + fans = [] + + for device, device_config in config[CONF_FANS].items(): + friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) + + state_template = device_config[CONF_VALUE_TEMPLATE] + speed_template = device_config.get(CONF_SPEED_TEMPLATE) + oscillating_template = device_config.get( + CONF_OSCILLATING_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) + + 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): + if template is None: + continue + template.hass = hass + + if entity_ids == MATCH_ALL or manual_entity_ids is not None: + continue + + template_entity_ids = template.extract_entities() + if template_entity_ids == MATCH_ALL: + entity_ids = MATCH_ALL + else: + entity_ids |= set(template_entity_ids) + + if manual_entity_ids is not None: + entity_ids = manual_entity_ids + elif entity_ids != MATCH_ALL: + entity_ids = list(entity_ids) + + fans.append( + 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 + ) + ) + + async_add_devices(fans) + + +class TemplateFan(FanEntity): + """A template fan component.""" + + 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): + """Initialize the fan.""" + self.hass = hass + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, device_id, hass=hass) + self._name = friendly_name + + self._template = state_template + self._speed_template = speed_template + self._oscillating_template = oscillating_template + self._supported_features = 0 + + self._on_script = Script(hass, on_action) + self._off_script = Script(hass, off_action) + + self._set_speed_script = None + if set_speed_action: + self._set_speed_script = Script(hass, set_speed_action) + + self._set_oscillating_script = None + if set_oscillating_action: + self._set_oscillating_script = Script(hass, set_oscillating_action) + + self._state = STATE_OFF + self._speed = None + self._oscillating = None + + self._template.hass = self.hass + if self._speed_template: + self._speed_template.hass = self.hass + self._supported_features |= SUPPORT_SET_SPEED + if self._oscillating_template: + self._oscillating_template.hass = self.hass + self._supported_features |= SUPPORT_OSCILLATE + + self._entities = entity_ids + # List of valid speeds + self._speed_list = speed_list + + @property + def name(self): + """Return the display name of this fan.""" + return self._name + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def speed_list(self: ToggleEntity) -> list: + """Get the list of available speeds.""" + return self._speed_list + + @property + def is_on(self): + """Return true if device is on.""" + return self._state == STATE_ON + + @property + def speed(self): + """Return the current speed.""" + return self._speed + + @property + def oscillating(self): + """Return the oscillation state.""" + return self._oscillating + + @property + def should_poll(self): + """Return the polling state.""" + return False + + # pylint: disable=arguments-differ + async def async_turn_on(self, speed: str = None) -> None: + """Turn on the fan.""" + await self._on_script.async_run() + self._state = STATE_ON + + if speed is not None: + await self.async_set_speed(speed) + + # pylint: disable=arguments-differ + async def async_turn_off(self) -> None: + """Turn off the fan.""" + await self._off_script.async_run() + self._state = STATE_OFF + + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if self._set_speed_script is None: + return + + if speed in self._speed_list: + self._speed = speed + await self._set_speed_script.async_run({ATTR_SPEED: speed}) + else: + _LOGGER.error( + 'Received invalid speed: %s. ' + + 'Expected: %s.', + speed, self._speed_list) + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation of the fan.""" + if self._set_oscillating_script is None: + return + + await self._set_oscillating_script.async_run( + {ATTR_OSCILLATING: oscillating} + ) + self._oscillating = oscillating + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def template_fan_state_listener(entity, old_state, new_state): + """Handle target device state changes.""" + self.async_schedule_update_ha_state(True) + + @callback + def template_fan_startup(event): + """Update template on startup.""" + self.hass.helpers.event.async_track_state_change( + self._entities, template_fan_state_listener) + + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_fan_startup) + + async def async_update(self): + """Update the state from the template.""" + # Update state + try: + state = self._template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + state = None + self._state = None + + # Validate state + if state in _VALID_STATES: + self._state = state + elif state == STATE_UNKNOWN: + self._state = None + else: + _LOGGER.error( + 'Received invalid fan is_on state: %s. ' + + 'Expected: %s.', + state, ', '.join(_VALID_STATES)) + self._state = None + + # Update speed if 'speed_template' is configured + if self._speed_template is not None: + try: + speed = self._speed_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + speed = None + self._state = None + + # Validate speed + if speed in self._speed_list: + self._speed = speed + elif speed == STATE_UNKNOWN: + self._speed = None + else: + _LOGGER.error( + 'Received invalid speed: %s. ' + + 'Expected: %s.', + speed, self._speed_list) + self._speed = None + + # Update oscillating if 'oscillating_template' is configured + if self._oscillating_template is not None: + try: + oscillating = self._oscillating_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + self._state = None + + # Validate osc + if oscillating == 'True' or oscillating is True: + self._oscillating = True + elif oscillating == 'False' or oscillating is False: + self._oscillating = False + elif oscillating == STATE_UNKNOWN: + self._oscillating = None + else: + _LOGGER.error( + 'Received invalid oscillating: %s. ' + + 'Expected: True/False.', oscillating) + self._oscillating = None diff --git a/tests/components/fan/test_template.py b/tests/components/fan/test_template.py new file mode 100644 index 00000000000..719a3f96aed --- /dev/null +++ b/tests/components/fan/test_template.py @@ -0,0 +1,549 @@ +"""The tests for the Template fan platform.""" +import logging + +from homeassistant.core import callback +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) + +from tests.common import ( + get_test_home_assistant, assert_setup_component) +_LOGGER = logging.getLogger(__name__) + + +_TEST_FAN = 'fan.test_fan' +# Represent for fan's state +_STATE_INPUT_BOOLEAN = 'input_boolean.state' +# Represent for fan's speed +_SPEED_INPUT_SELECT = 'input_select.speed' +# Represent for fan's oscillating +_OSC_INPUT = 'input_select.osc' + + +class TestTemplateFan: + """Test the Template light.""" + + hass = None + calls = None + # pylint: disable=invalid-name + + def setup_method(self, method): + """Setup.""" + self.hass = get_test_home_assistant() + + self.calls = [] + + @callback + def record_call(service): + """Track function calls..""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + # Configuration tests # + def test_missing_optional_config(self): + """Test: missing optional template is ok.""" + with assert_setup_component(1, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': "{{ 'on' }}", + + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self._verify(STATE_ON, None, None) + + def test_missing_value_template_config(self): + """Test: missing 'value_template' will fail.""" + with assert_setup_component(0, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_turn_on_config(self): + """Test: missing 'turn_on' will fail.""" + with assert_setup_component(0, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': "{{ 'on' }}", + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_turn_off_config(self): + """Test: missing 'turn_off' will fail.""" + with assert_setup_component(0, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': "{{ 'on' }}", + 'turn_on': { + 'service': 'script.fan_on' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_invalid_config(self): + """Test: missing 'turn_off' will fail.""" + with assert_setup_component(0, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': "{{ 'on' }}", + 'turn_on': { + 'service': 'script.fan_on' + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + # End of configuration tests # + + # Template tests # + def test_templates_with_entities(self): + """Test tempalates with values from other entities.""" + value_template = """ + {% if is_state('input_boolean.state', 'True') %} + {{ 'on' }} + {% else %} + {{ 'off' }} + {% endif %} + """ + + with assert_setup_component(1, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': value_template, + 'speed_template': + "{{ states('input_select.speed') }}", + 'oscillating_template': + "{{ states('input_select.osc') }}", + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self._verify(STATE_OFF, 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.block_till_done() + + self._verify(STATE_ON, SPEED_MEDIUM, True) + + def test_templates_with_valid_values(self): + """Test templates with valid values.""" + with assert_setup_component(1, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': + "{{ 'on' }}", + 'speed_template': + "{{ 'medium' }}", + 'oscillating_template': + "{{ 1 == 1 }}", + + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self._verify(STATE_ON, SPEED_MEDIUM, True) + + def test_templates_invalid_values(self): + """Test templates with invalid values.""" + with assert_setup_component(1, 'fan'): + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': { + 'value_template': + "{{ 'abc' }}", + 'speed_template': + "{{ '0' }}", + 'oscillating_template': + "{{ 'xyz' }}", + + 'turn_on': { + 'service': 'script.fan_on' + }, + 'turn_off': { + 'service': 'script.fan_off' + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self._verify(STATE_OFF, None, None) + + # End of template tests # + + # Function tests # + def test_on_off(self): + """Test turn on and turn off.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON + self._verify(STATE_ON, None, None) + + # Turn off fan + components.fan.turn_off(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF + self._verify(STATE_OFF, None, None) + + def test_on_with_speed(self): + """Test turn on with speed.""" + self._register_components() + + # Turn on fan with high speed + components.fan.turn_on(self.hass, _TEST_FAN, SPEED_HIGH) + self.hass.block_till_done() + + # 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) + + def test_set_speed(self): + """Test set valid speed.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's speed to high + components.fan.set_speed(self.hass, _TEST_FAN, SPEED_HIGH) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + self._verify(STATE_ON, SPEED_HIGH, None) + + # Set fan's speed to medium + components.fan.set_speed(self.hass, _TEST_FAN, SPEED_MEDIUM) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM + self._verify(STATE_ON, SPEED_MEDIUM, None) + + def test_set_invalid_speed_from_initial_stage(self): + """Test set invalid speed 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 speed to 'invalid' + components.fan.set_speed(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify speed is unchanged + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '' + self._verify(STATE_ON, None, None) + + def test_set_invalid_speed(self): + """Test set invalid speed when fan has valid speed.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's speed to high + components.fan.set_speed(self.hass, _TEST_FAN, SPEED_HIGH) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + self._verify(STATE_ON, SPEED_HIGH, None) + + # Set fan's speed to 'invalid' + components.fan.set_speed(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify speed is unchanged + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH + self._verify(STATE_ON, SPEED_HIGH, None) + + def test_custom_speed_list(self): + """Test set custom speed list.""" + self._register_components(['1', '2', '3']) + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's speed to '1' + components.fan.set_speed(self.hass, _TEST_FAN, '1') + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '1' + self._verify(STATE_ON, '1', None) + + # Set fan's speed to 'medium' which is invalid + components.fan.set_speed(self.hass, _TEST_FAN, SPEED_MEDIUM) + self.hass.block_till_done() + + # verify that speed is unchanged + assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '1' + self._verify(STATE_ON, '1', None) + + def test_set_osc(self): + """Test set oscillating.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's osc to True + components.fan.oscillate(self.hass, _TEST_FAN, True) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_OSC_INPUT).state == 'True' + self._verify(STATE_ON, None, True) + + # Set fan's osc to False + components.fan.oscillate(self.hass, _TEST_FAN, False) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_OSC_INPUT).state == 'False' + self._verify(STATE_ON, None, False) + + def test_set_invalid_osc_from_initial_state(self): + """Test set invalid oscillating 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 osc to 'invalid' + components.fan.oscillate(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_OSC_INPUT).state == '' + self._verify(STATE_ON, None, None) + + def test_set_invalid_osc(self): + """Test set invalid oscillating when fan has valid osc.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's osc to True + components.fan.oscillate(self.hass, _TEST_FAN, True) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_OSC_INPUT).state == 'True' + self._verify(STATE_ON, None, True) + + # Set fan's osc to False + components.fan.oscillate(self.hass, _TEST_FAN, None) + self.hass.block_till_done() + + # verify osc is unchanged + assert self.hass.states.get(_OSC_INPUT).state == 'True' + self._verify(STATE_ON, None, True) + + def _verify(self, expected_state, expected_speed, expected_oscillating): + """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 + + def _register_components(self, speed_list=None): + """Register basic components for testing.""" + with assert_setup_component(1, 'input_boolean'): + assert setup.setup_component( + self.hass, + 'input_boolean', + {'input_boolean': {'state': None}} + ) + + with assert_setup_component(2, 'input_select'): + assert setup.setup_component(self.hass, 'input_select', { + 'input_select': { + 'speed': { + 'name': 'Speed', + 'options': ['', SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + '1', '2', '3'] + }, + + 'osc': { + 'name': 'oscillating', + 'options': ['', 'True', 'False'] + }, + } + }) + + with assert_setup_component(1, 'fan'): + value_template = """ + {% if is_state('input_boolean.state', 'on') %} + {{ 'on' }} + {% else %} + {{ 'off' }} + {% endif %} + """ + + test_fan_config = { + 'value_template': value_template, + 'speed_template': + "{{ states('input_select.speed') }}", + 'oscillating_template': + "{{ states('input_select.osc') }}", + + 'turn_on': { + 'service': 'input_boolean.turn_on', + 'entity_id': _STATE_INPUT_BOOLEAN + }, + 'turn_off': { + 'service': 'input_boolean.turn_off', + 'entity_id': _STATE_INPUT_BOOLEAN + }, + 'set_speed': { + 'service': 'input_select.select_option', + + 'data_template': { + 'entity_id': _SPEED_INPUT_SELECT, + 'option': '{{ speed }}' + } + }, + 'set_oscillating': { + 'service': 'input_select.select_option', + + 'data_template': { + 'entity_id': _OSC_INPUT, + 'option': '{{ oscillating }}' + } + } + } + + if speed_list: + test_fan_config['speeds'] = speed_list + + assert setup.setup_component(self.hass, 'fan', { + 'fan': { + 'platform': 'template', + 'fans': { + 'test_fan': test_fan_config + } + } + }) + + self.hass.start() + self.hass.block_till_done()