From e66d15b71d397258b2c886276e21182d20b4e8e3 Mon Sep 17 00:00:00 2001 From: pavoni Date: Thu, 21 Jan 2016 16:31:23 +0000 Subject: [PATCH 1/7] First drafy of sensor.template. --- homeassistant/components/sensor/template.py | 142 ++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 homeassistant/components/sensor/template.py diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py new file mode 100644 index 00000000000..78714f135d0 --- /dev/null +++ b/homeassistant/components/sensor/template.py @@ -0,0 +1,142 @@ +""" +homeassistant.components.sensor.template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Allows the creation of a sensor that breaks out state_attributes +from other entities. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.template/ +""" +import logging + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_state_change +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, ATTR_UNIT_OF_MEASUREMENT) + +from homeassistant.util import template + +_LOGGER = logging.getLogger(__name__) + +CONF_SENSORS = 'sensors' +CONF_ENTITIES = 'entities' + +DOT = '.' +QUOTED_DOT = '__________' + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the sensors. """ + + sensors = [] + if config.get(CONF_SENSORS) is None: + _LOGGER.error("Missing configuration data for sensor platfoprm") + return False + + for device in config[CONF_SENSORS]: + device_config = config[CONF_SENSORS].get(device) + if device_config is None: + _LOGGER.error("Missing configuration data for sensor %s", device) + continue + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) + unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT) + state_template = device_config.get(CONF_VALUE_TEMPLATE) + if state_template is None: + _LOGGER.error( + "Missing %s for sensor %s", CONF_VALUE_TEMPLATE, device) + continue + dependencies = device_config.get(CONF_ENTITIES, []) + sensors.append( + SensorTemplate( + hass, + device, + friendly_name, + unit_of_measurement, + state_template, + dependencies) + ) + if sensors is None: + _LOGGER.error("No sensors added.") + return False + add_devices(sensors) + return True + + +class SensorTemplate(Entity): + """ Represents a Template Sensor. """ + + # pylint: disable=too-many-arguments + def __init__(self, + hass, + entity_name, + friendly_name, + unit_of_measurement, + state_template, + dependencies): + self.hass = hass + # self.entity_id = entity_name + self._name = entity_name + self._friendly_name = friendly_name + self._unit_of_measurement = unit_of_measurement + self._template = state_template + self._entities = dependencies + self._state = '' + + # Quote entity names in template. So replace sun.sun with sunQUOTEsun + # the template engine uses dots! + for entity in self._entities: + self._template = self._template.replace( + entity, entity.replace(DOT, QUOTED_DOT)) + + def _update_callback(_entity_id, _old_state, _new_state): + """ Called when the target device changes state. """ + self.update_ha_state(True) + + for target in dependencies: + track_state_change(hass, target, _update_callback) + self.update() + + @property + def name(self): + """ Returns the name of the device. """ + return self._name + + @property + def state(self): + """ Returns the state of the device. """ + return self._state + + @property + def unit_of_measurement(self): + """ Returns the unit_of_measurement of the device. """ + return self._unit_of_measurement + + @property + def should_poll(self): + """ Tells Home Assistant not to poll this entity. """ + return False + + @property + def state_attributes(self): + attr = {} + + if self._friendly_name: + attr[ATTR_FRIENDLY_NAME] = self._friendly_name + + return attr + + def update(self): + self._state = self._renderer() + + def _renderer(self): + """Render sensor value.""" + render_dictionary = {} + for entity in self._entities: + hass_entity = self.hass.states.get(entity) + if hass_entity is None: + continue + key = entity.replace(DOT, QUOTED_DOT) + render_dictionary[key] = hass_entity + + return template.render(self.hass, self._template, render_dictionary) From a6f37c032bced633d2098f1e8d6033e31307f637 Mon Sep 17 00:00:00 2001 From: pavoni Date: Thu, 21 Jan 2016 17:35:33 +0000 Subject: [PATCH 2/7] Revise to not need dependencies (or quoting)! --- homeassistant/components/sensor/template.py | 38 ++++----------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 78714f135d0..437cc7b936e 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -10,7 +10,7 @@ https://home-assistant.io/components/sensor.template/ import logging from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_state_change +from homeassistant.core import EVENT_STATE_CHANGED from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, ATTR_UNIT_OF_MEASUREMENT) @@ -19,10 +19,6 @@ from homeassistant.util import template _LOGGER = logging.getLogger(__name__) CONF_SENSORS = 'sensors' -CONF_ENTITIES = 'entities' - -DOT = '.' -QUOTED_DOT = '__________' # pylint: disable=unused-argument @@ -46,15 +42,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error( "Missing %s for sensor %s", CONF_VALUE_TEMPLATE, device) continue - dependencies = device_config.get(CONF_ENTITIES, []) sensors.append( SensorTemplate( hass, device, friendly_name, unit_of_measurement, - state_template, - dependencies) + state_template) ) if sensors is None: _LOGGER.error("No sensors added.") @@ -72,30 +66,20 @@ class SensorTemplate(Entity): entity_name, friendly_name, unit_of_measurement, - state_template, - dependencies): + state_template): + self.hass = hass - # self.entity_id = entity_name self._name = entity_name self._friendly_name = friendly_name self._unit_of_measurement = unit_of_measurement self._template = state_template - self._entities = dependencies self._state = '' - # Quote entity names in template. So replace sun.sun with sunQUOTEsun - # the template engine uses dots! - for entity in self._entities: - self._template = self._template.replace( - entity, entity.replace(DOT, QUOTED_DOT)) - - def _update_callback(_entity_id, _old_state, _new_state): + def _update_callback(_event): """ Called when the target device changes state. """ self.update_ha_state(True) - for target in dependencies: - track_state_change(hass, target, _update_callback) - self.update() + self.hass.bus.listen(EVENT_STATE_CHANGED, _update_callback) @property def name(self): @@ -131,12 +115,4 @@ class SensorTemplate(Entity): def _renderer(self): """Render sensor value.""" - render_dictionary = {} - for entity in self._entities: - hass_entity = self.hass.states.get(entity) - if hass_entity is None: - continue - key = entity.replace(DOT, QUOTED_DOT) - render_dictionary[key] = hass_entity - - return template.render(self.hass, self._template, render_dictionary) + return template.render(self.hass, self._template) From 92afcae9bed81c3afb6ed58b8f7ef38324c795ac Mon Sep 17 00:00:00 2001 From: pavoni Date: Thu, 21 Jan 2016 18:31:44 +0000 Subject: [PATCH 3/7] Add test. --- tests/components/sensor/test_template.py | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/components/sensor/test_template.py diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py new file mode 100644 index 00000000000..15f1be56928 --- /dev/null +++ b/tests/components/sensor/test_template.py @@ -0,0 +1,49 @@ +""" +tests.components.sensor.template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests template sensor. +""" +from unittest.mock import patch + +import pytest + +import homeassistant.core as ha +import homeassistant.components.sensor as sensor + + +@pytest.mark.usefixtures('betamax_session') +class TestSensorYr: + """ Test the Yr sensor. """ + + def setup_method(self, method): + self.hass = ha.HomeAssistant() + self.hass.config.latitude = 32.87336 + self.hass.config.longitude = 117.22743 + + def teardown_method(self, method): + """ Stop down stuff we started. """ + self.hass.stop() + + def test_template(self, betamax_session): + with patch('homeassistant.components.sensor.yr.requests.Session', + return_value=betamax_session): + assert sensor.setup(self.hass, { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test_template_sensor': { + 'value_template': + '{{ states.sensor.test_state.state }}' + } + } + } + }) + + state = self.hass.states.get('sensor.test_template_sensor') + assert state.state == '' + + self.hass.states.set('sensor.test_state', 'Works') + self.hass.pool.block_till_done() + state = self.hass.states.get('sensor.test_template_sensor') + assert state.state == 'Works' From c615272c06e27ee78424eaf649570be3a3c2e012 Mon Sep 17 00:00:00 2001 From: pavoni Date: Thu, 21 Jan 2016 23:17:19 +0000 Subject: [PATCH 4/7] Tidy. --- homeassistant/components/sensor/template.py | 36 ++++++++------------- tests/components/sensor/test_template.py | 23 ++++++------- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 437cc7b936e..ecd6f092097 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -12,9 +12,13 @@ import logging from homeassistant.helpers.entity import Entity from homeassistant.core import EVENT_STATE_CHANGED from homeassistant.const import ( - ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, ATTR_UNIT_OF_MEASUREMENT) + STATE_UNKNOWN, + ATTR_FRIENDLY_NAME, + CONF_VALUE_TEMPLATE, + ATTR_UNIT_OF_MEASUREMENT) from homeassistant.util import template +from homeassistant.exceptions import TemplateError _LOGGER = logging.getLogger(__name__) @@ -30,9 +34,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Missing configuration data for sensor platfoprm") return False - for device in config[CONF_SENSORS]: - device_config = config[CONF_SENSORS].get(device) - if device_config is None: + for device, device_config in config[CONF_SENSORS].items(): + if not isinstance(device_config, dict): _LOGGER.error("Missing configuration data for sensor %s", device) continue friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) @@ -45,7 +48,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors.append( SensorTemplate( hass, - device, friendly_name, unit_of_measurement, state_template) @@ -63,17 +65,15 @@ class SensorTemplate(Entity): # pylint: disable=too-many-arguments def __init__(self, hass, - entity_name, friendly_name, unit_of_measurement, state_template): self.hass = hass - self._name = entity_name - self._friendly_name = friendly_name + self._name = friendly_name self._unit_of_measurement = unit_of_measurement self._template = state_template - self._state = '' + self.update() def _update_callback(_event): """ Called when the target device changes state. """ @@ -101,18 +101,8 @@ class SensorTemplate(Entity): """ Tells Home Assistant not to poll this entity. """ return False - @property - def state_attributes(self): - attr = {} - - if self._friendly_name: - attr[ATTR_FRIENDLY_NAME] = self._friendly_name - - return attr - def update(self): - self._state = self._renderer() - - def _renderer(self): - """Render sensor value.""" - return template.render(self.hass, self._template) + try: + self._state = template.render(self.hass, self._template) + except TemplateError: + self._state = STATE_UNKNOWN diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index 15f1be56928..d2aff600a89 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -12,33 +12,28 @@ import homeassistant.core as ha import homeassistant.components.sensor as sensor -@pytest.mark.usefixtures('betamax_session') class TestSensorYr: """ Test the Yr sensor. """ def setup_method(self, method): self.hass = ha.HomeAssistant() - self.hass.config.latitude = 32.87336 - self.hass.config.longitude = 117.22743 def teardown_method(self, method): """ Stop down stuff we started. """ self.hass.stop() def test_template(self, betamax_session): - with patch('homeassistant.components.sensor.yr.requests.Session', - return_value=betamax_session): - assert sensor.setup(self.hass, { - 'sensor': { - 'platform': 'template', - 'sensors': { - 'test_template_sensor': { - 'value_template': - '{{ states.sensor.test_state.state }}' - } + assert sensor.setup(self.hass, { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test_template_sensor': { + 'value_template': + '{{ states.sensor.test_state.state }}' } } - }) + } + }) state = self.hass.states.get('sensor.test_template_sensor') assert state.state == '' From b1f7b5c6d7c4e0ebf38557bbef301f8574c4e1cd Mon Sep 17 00:00:00 2001 From: pavoni Date: Fri, 22 Jan 2016 09:37:20 +0000 Subject: [PATCH 5/7] Tidy, add test for ValueException logic. --- tests/components/sensor/test_template.py | 27 ++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index d2aff600a89..b4f6ab41c97 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -12,8 +12,8 @@ import homeassistant.core as ha import homeassistant.components.sensor as sensor -class TestSensorYr: - """ Test the Yr sensor. """ +class TestTemplateSensor: + """ Test the Template sensor. """ def setup_method(self, method): self.hass = ha.HomeAssistant() @@ -22,14 +22,14 @@ class TestSensorYr: """ Stop down stuff we started. """ self.hass.stop() - def test_template(self, betamax_session): + def test_template(self): assert sensor.setup(self.hass, { 'sensor': { 'platform': 'template', 'sensors': { 'test_template_sensor': { 'value_template': - '{{ states.sensor.test_state.state }}' + "{{ states.sensor.test_state.state }}" } } } @@ -42,3 +42,22 @@ class TestSensorYr: self.hass.pool.block_till_done() state = self.hass.states.get('sensor.test_template_sensor') assert state.state == 'Works' + + def test_template_syntax_error(self): + assert sensor.setup(self.hass, { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test_template_sensor': { + 'value_template': + "{% if rubbish %}" + } + } + } + }) + + + self.hass.states.set('sensor.test_state', 'Works') + self.hass.pool.block_till_done() + state = self.hass.states.get('sensor.test_template_sensor') + assert state.state == 'unknown' From 87a9fd825230bdbbd88e3305280cdb4118e9bb0a Mon Sep 17 00:00:00 2001 From: pavoni Date: Fri, 22 Jan 2016 11:30:04 +0000 Subject: [PATCH 6/7] Handle race condition on startup. --- homeassistant/components/sensor/template.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index ecd6f092097..eeb764e70a5 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -77,7 +77,10 @@ class SensorTemplate(Entity): def _update_callback(_event): """ Called when the target device changes state. """ - self.update_ha_state(True) + # This can be called before the entity is properly + # initialised, so check before updating state, + if self.entity_id: + self.update_ha_state(True) self.hass.bus.listen(EVENT_STATE_CHANGED, _update_callback) From ad62591f434a750fa4edee40292ba6e698cc497a Mon Sep 17 00:00:00 2001 From: pavoni Date: Fri, 22 Jan 2016 16:30:02 +0000 Subject: [PATCH 7/7] Change error state to be 'error' rather than 'unknown', trace error. --- homeassistant/components/sensor/template.py | 8 +++++--- tests/components/sensor/test_template.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index eeb764e70a5..a75d0a3e656 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -12,7 +12,6 @@ import logging from homeassistant.helpers.entity import Entity from homeassistant.core import EVENT_STATE_CHANGED from homeassistant.const import ( - STATE_UNKNOWN, ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, ATTR_UNIT_OF_MEASUREMENT) @@ -24,6 +23,8 @@ _LOGGER = logging.getLogger(__name__) CONF_SENSORS = 'sensors' +STATE_ERROR = 'error' + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -107,5 +108,6 @@ class SensorTemplate(Entity): def update(self): try: self._state = template.render(self.hass, self._template) - except TemplateError: - self._state = STATE_UNKNOWN + except TemplateError as ex: + self._state = STATE_ERROR + _LOGGER.error(ex) diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index b4f6ab41c97..513117a8a9e 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -60,4 +60,4 @@ class TestTemplateSensor: self.hass.states.set('sensor.test_state', 'Works') self.hass.pool.block_till_done() state = self.hass.states.get('sensor.test_template_sensor') - assert state.state == 'unknown' + assert state.state == 'error'