From ef4ef2d3835e644f3bf13113a563d8da67e8a716 Mon Sep 17 00:00:00 2001 From: cribbstechnologies Date: Wed, 24 May 2017 14:32:22 -0400 Subject: [PATCH] Template light (#7657) * starting light template component * linting/flaking * starting unit tests from copypasta * working on unit testing * forgot to commit the test * wrapped up unit testing * adding remote back * updates post running tox * Revert "adding remote back" This reverts commit 852c87ff9694dfc48e92b74fd9dbafbc164a2393. * adding submodule back from origin * updating submodule * removing a line to commit * re-adding line * trying to update line endings * trying to fix line endings * trying a different approach * making requested changes, need to fix tests * flaking * union rather than intersect; makes a big difference * more tests passing, not sure why this one's failing * got it working * most of the requested changes * hopefully done now * sets; the more you know --- homeassistant/components/light/template.py | 236 ++++++++ tests/components/light/test_template.py | 637 +++++++++++++++++++++ 2 files changed, 873 insertions(+) create mode 100644 homeassistant/components/light/template.py create mode 100644 tests/components/light/test_template.py diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py new file mode 100644 index 00000000000..6854fac550e --- /dev/null +++ b/homeassistant/components/light/template.py @@ -0,0 +1,236 @@ +""" +Support for Template lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.template/ +""" +import logging +import asyncio + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ENTITY_ID_FORMAT, Light, SUPPORT_BRIGHTNESS) +from homeassistant.const import ( + CONF_VALUE_TEMPLATE, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_ON, + STATE_OFF, EVENT_HOMEASSISTANT_START, MATCH_ALL +) +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.script import Script + +_LOGGER = logging.getLogger(__name__) +_VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false'] + +CONF_LIGHTS = 'lights' +CONF_ON_ACTION = 'turn_on' +CONF_OFF_ACTION = 'turn_off' +CONF_LEVEL_ACTION = 'set_level' +CONF_LEVEL_TEMPLATE = 'level_template' + + +LIGHT_SCHEMA = vol.Schema({ + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_LEVEL_ACTION, default=None): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_FRIENDLY_NAME, default=None): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_LIGHTS): vol.Schema({cv.slug: LIGHT_SCHEMA}), +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up Template Lights.""" + lights = [] + + for device, device_config in config[CONF_LIGHTS].items(): + friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) + state_template = device_config[CONF_VALUE_TEMPLATE] + on_action = device_config[CONF_ON_ACTION] + off_action = device_config[CONF_OFF_ACTION] + level_action = device_config[CONF_LEVEL_ACTION] + level_template = device_config[CONF_LEVEL_TEMPLATE] + + template_entity_ids = set() + + if state_template is not None: + temp_ids = state_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if level_template is not None: + temp_ids = level_template.extract_entities() + if str(temp_ids) != MATCH_ALL: + template_entity_ids |= set(temp_ids) + + if not template_entity_ids: + template_entity_ids = MATCH_ALL + + entity_ids = device_config.get(CONF_ENTITY_ID, template_entity_ids) + + lights.append( + LightTemplate( + hass, device, friendly_name, state_template, + on_action, off_action, level_action, level_template, + entity_ids) + ) + + if not lights: + _LOGGER.error("No lights added") + return False + + async_add_devices(lights, True) + return True + + +class LightTemplate(Light): + """Representation of a templated Light, including dimmable.""" + + def __init__(self, hass, device_id, friendly_name, state_template, + on_action, off_action, level_action, level_template, + entity_ids): + """Initialize the light.""" + 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._on_script = Script(hass, on_action) + self._off_script = Script(hass, off_action) + self._level_script = Script(hass, level_action) + self._level_template = level_template + + self._state = False + self._brightness = None + self._entities = entity_ids + + if self._template is not None: + self._template.hass = self.hass + if self._level_template is not None: + self._level_template.hass = self.hass + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + if self._level_script is not None: + return SUPPORT_BRIGHTNESS + + return 0 + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + state = yield from async_get_last_state(self.hass, self.entity_id) + if state: + self._state = state.state == STATE_ON + + @callback + def template_light_state_listener(entity, old_state, new_state): + """Handle target device state changes.""" + self.hass.async_add_job(self.async_update_ha_state(True)) + + @callback + def template_light_startup(event): + """Update template on startup.""" + if (self._template is not None or + self._level_template is not None): + async_track_state_change( + self.hass, self._entities, template_light_state_listener) + + self.hass.async_add_job(self.async_update_ha_state(True)) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, template_light_startup) + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + optimistic_set = False + # set optimistic states + if self._template is None: + self._state = True + optimistic_set = True + + if self._level_template is None and ATTR_BRIGHTNESS in kwargs: + _LOGGER.info("Optimistically setting brightness to %s", + kwargs[ATTR_BRIGHTNESS]) + self._brightness = kwargs[ATTR_BRIGHTNESS] + optimistic_set = True + + if ATTR_BRIGHTNESS in kwargs and self._level_script: + self.hass.async_add_job(self._level_script.async_run( + {"brightness": kwargs[ATTR_BRIGHTNESS]})) + else: + self.hass.async_add_job(self._on_script.async_run()) + + if optimistic_set: + self.hass.async_add_job(self.async_update_ha_state()) + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + self.hass.async_add_job(self._off_script.async_run()) + if self._template is None: + self._state = False + self.hass.async_add_job(self.async_update_ha_state()) + + @asyncio.coroutine + def async_update(self): + """Update the state from the template.""" + if self._template is not None: + try: + state = self._template.async_render().lower() + except TemplateError as ex: + _LOGGER.error(ex) + self._state = None + + if state in _VALID_STATES: + self._state = state in ('true', STATE_ON) + else: + _LOGGER.error( + 'Received invalid light is_on state: %s. ' + + 'Expected: %s', + state, ', '.join(_VALID_STATES)) + self._state = None + + if self._level_template is not None: + try: + brightness = self._level_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + self._state = None + + if 0 <= int(brightness) <= 255: + self._brightness = brightness + else: + _LOGGER.error( + 'Received invalid brightness : %s' + + 'Expected: 0-255', + brightness) + self._brightness = None diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py new file mode 100644 index 00000000000..4abee754547 --- /dev/null +++ b/tests/components/light/test_template.py @@ -0,0 +1,637 @@ +"""The tests for the Template light platform.""" +import logging +import asyncio + +from homeassistant.core import callback, State, CoreState +from homeassistant import setup +import homeassistant.components as core +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE + +from tests.common import ( + get_test_home_assistant, assert_setup_component, mock_component) +_LOGGER = logging.getLogger(__name__) + + +class TestTemplateLight: + """Test the Template light.""" + + hass = None + calls = None + # pylint: disable=invalid-name + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + 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() + + def test_template_state_text(self): + """"Test the state text of a template.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': + "{{ states.light.test_state.state }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.set('light.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_ON + + state = self.hass.states.set('light.test_state', STATE_OFF) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + def test_template_state_boolean_on(self): + """Test the setting of the state with boolean on.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{ 1 == 1 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_ON + + def test_template_state_boolean_off(self): + """Test the setting of the state with off.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{ 1 == 2 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + def test_template_syntax_error(self): + """Test templating syntax error.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{%- if false -%}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_invalid_name_does_not_create(self): + """Test invalid name.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'bad name here': { + 'value_template': "{{ 1== 1}}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_invalid_light_does_not_create(self): + """Test invalid light.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'switches': { + 'test_template_light': 'Invalid' + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_no_lights_does_not_create(self): + """Test if there are no lights no creation.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template' + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_template_does_create(self): + """Test missing template.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'light_one': { + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() != [] + + def test_missing_on_does_not_create(self): + """Test missing on.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'bad name here': { + 'value_template': "{{ 1== 1}}", + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_missing_off_does_not_create(self): + """Test missing off.""" + with assert_setup_component(0): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'bad name here': { + 'value_template': "{{ 1== 1}}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + assert self.hass.states.all() == [] + + def test_on_action(self): + """Test on action.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{states.light.test_state.state}}", + 'turn_on': { + 'service': 'test.automation', + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('light.test_state', STATE_OFF) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + core.light.turn_on(self.hass, 'light.test_template_light') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_on_action_optimistic(self): + """Test on action with optimistic state.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'turn_on': { + 'service': 'test.automation', + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('light.test_state', STATE_OFF) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + core.light.turn_on(self.hass, 'light.test_template_light') + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert len(self.calls) == 1 + assert state.state == STATE_ON + + def test_off_action(self): + """Test off action.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{states.light.test_state.state}}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'test.automation', + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + self.hass.states.set('light.test_state', STATE_ON) + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_ON + + core.light.turn_off(self.hass, 'light.test_template_light') + self.hass.block_till_done() + + assert len(self.calls) == 1 + + def test_off_action_optimistic(self): + """Test off action with optimistic state.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'test.automation', + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + core.light.turn_off(self.hass, 'light.test_template_light') + self.hass.block_till_done() + + assert len(self.calls) == 1 + state = self.hass.states.get('light.test_template_light') + assert state.state == STATE_OFF + + def test_level_action_no_template(self): + """Test setting brightness with optimistic template.""" + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': '{{1 == 1}}', + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'test.automation', + 'data_template': { + 'entity_id': 'test.test_state', + 'brightness': '{{brightness}}' + } + }, + } + } + } + }) + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state.attributes.get('brightness') is None + + core.light.turn_on( + self.hass, 'light.test_template_light', **{ATTR_BRIGHTNESS: 124}) + self.hass.block_till_done() + assert len(self.calls) == 1 + assert self.calls[0].data['brightness'] == '124' + + state = self.hass.states.get('light.test_template_light') + _LOGGER.info(str(state.attributes)) + assert state is not None + assert state.attributes.get('brightness') == 124 + + def test_level_template(self): + """Test the template for the level.""" + with assert_setup_component(1): + assert setup.setup_component(self.hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': "{{ 1 == 1 }}", + 'turn_on': { + 'service': 'light.turn_on', + 'entity_id': 'light.test_state' + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'light.turn_on', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + }, + 'level_template': + '{{42}}' + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('light.test_template_light') + assert state is not None + + assert state.attributes.get('brightness') == '42' + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + hass.data[DATA_RESTORE_CACHE] = { + 'light.test_template_light': + State('light.test_template_light', 'on'), + } + + hass.state = CoreState.starting + mock_component(hass, 'recorder') + yield from setup.async_setup_component(hass, 'light', { + 'light': { + 'platform': 'template', + 'lights': { + 'test_template_light': { + 'value_template': + "{{states.light.test_state.state}}", + 'turn_on': { + 'service': 'test.automation', + }, + 'turn_off': { + 'service': 'light.turn_off', + 'entity_id': 'light.test_state' + }, + 'set_level': { + 'service': 'test.automation', + 'data_template': { + 'entity_id': 'light.test_state', + 'brightness': '{{brightness}}' + } + } + } + } + } + }) + + state = hass.states.get('light.test_template_light') + assert state.state == 'on' + + yield from hass.async_start() + yield from hass.async_block_till_done() + + state = hass.states.get('light.test_template_light') + assert state.state == 'off'