Issue/add template fans (#12027)

* add template fan

* add-template: address PR comments

* add-template: remove unused import

* add-template: revert async_track_state_change change

* add-template: use yield from

* Revert "add-template: use yield from"

This reverts commit 1e053714a7.

* add-template: use yield

* add-template: remove unused import

* add-template: remove async_add_job usages

* use components

* add-template: use async/await

* add-template: fix style

* add-template: remove str()

* address pr comments

* fix style
pull/14122/merge
giangvo 2018-05-03 07:45:31 +10:00 committed by Paulus Schoutsen
parent c851dfa2c7
commit ef4498ec27
2 changed files with 873 additions and 0 deletions

View File

@ -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

View File

@ -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()