diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index c3e13047aad246f2a1af93d6d7f3601ff96a0141..313ae86581692db225d4a4e6e148f0230edbe323 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -26,6 +26,9 @@ SENSOR_CLASSES = [ 'light', # Lightness threshold 'power', # Power, over-current, etc 'safety', # Generic on=unsafe, off=safe + 'heat', # On means hot (or too hot) + 'cold', # On means cold (or too cold) + 'moving', # On means moving, Off means stopped ] # Maps discovered services to their platforms diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py new file mode 100644 index 0000000000000000000000000000000000000000..f5a8899af964d0b8efe504b0aad113e60be2f02d --- /dev/null +++ b/homeassistant/components/binary_sensor/template.py @@ -0,0 +1,122 @@ +""" +homeassistant.components.binary_sensor.template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Support for exposing a templated binary_sensor +""" +import logging + +from homeassistant.components.binary_sensor import (BinarySensorDevice, + DOMAIN, + SENSOR_CLASSES) +from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE +from homeassistant.core import EVENT_STATE_CHANGED +from homeassistant.exceptions import TemplateError +from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers import template +from homeassistant.util import slugify + +ENTITY_ID_FORMAT = DOMAIN + '.{}' +CONF_SENSORS = 'sensors' +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup template binary sensors.""" + + sensors = [] + if config.get(CONF_SENSORS) is None: + _LOGGER.error('Missing configuration data for binary_sensor platform') + return False + + for device, device_config in config[CONF_SENSORS].items(): + + if device != slugify(device): + _LOGGER.error('Found invalid key for binary_sensor.template: %s. ' + 'Use %s instead', device, slugify(device)) + continue + + if not isinstance(device_config, dict): + _LOGGER.error('Missing configuration data for binary_sensor %s', + device) + continue + + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) + sensor_class = device_config.get('sensor_class') + value_template = device_config.get(CONF_VALUE_TEMPLATE) + + if sensor_class not in SENSOR_CLASSES: + _LOGGER.error('Sensor class is not valid') + continue + + if value_template is None: + _LOGGER.error( + 'Missing %s for sensor %s', CONF_VALUE_TEMPLATE, device) + continue + + sensors.append( + BinarySensorTemplate( + hass, + device, + friendly_name, + sensor_class, + value_template) + ) + if not sensors: + _LOGGER.error('No sensors added') + return False + add_devices(sensors) + + return True + + +class BinarySensorTemplate(BinarySensorDevice): + """A virtual binary_sensor that triggers from another sensor.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, device, friendly_name, sensor_class, + value_template): + self._hass = hass + self._device = device + self._name = friendly_name + self._sensor_class = sensor_class + self._template = value_template + self._state = None + + self.entity_id = generate_entity_id( + ENTITY_ID_FORMAT, device, + hass=hass) + + _LOGGER.info('Started template sensor %s', device) + hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) + + def _event_listener(self, event): + self.update_ha_state(True) + + @property + def should_poll(self): + return False + + @property + def sensor_class(self): + return self._sensor_class + + @property + def name(self): + return self._name + + @property + def is_on(self): + return self._state + + def update(self): + try: + value = template.render(self._hass, self._template) + except TemplateError as ex: + if ex.args and ex.args[0].startswith( + "UndefinedError: 'None' has no attribute"): + # Common during HA startup - so just a warning + _LOGGER.warning(ex) + return + _LOGGER.error(ex) + value = 'false' + self._state = value.lower() == 'true' diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index bc80fd89a81713935b0b8bc6a9ed4d8c7300d2de..9c9ebd429137633104db74f404d65bf5f17d8785 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -52,6 +52,7 @@ def render(hass, template, variables=None, **kwargs): return ENV.from_string(template, { 'closest': location_methods.closest, 'distance': location_methods.distance, + 'float': forgiving_float, 'is_state': hass.states.is_state, 'is_state_attr': hass.states.is_state_attr, 'now': dt_util.as_local(utcnow), @@ -240,6 +241,14 @@ def multiply(value, amount): return value +def forgiving_float(value): + """Try to convert value to a float.""" + try: + return float(value) + except (ValueError, TypeError): + return value + + class TemplateEnvironment(ImmutableSandboxedEnvironment): """Home Assistant template environment.""" diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py new file mode 100644 index 0000000000000000000000000000000000000000..7fc18a930f1f09ab0658dbe1f5934b1fccdfabf0 --- /dev/null +++ b/tests/components/binary_sensor/test_template.py @@ -0,0 +1,111 @@ +""" +tests.components.binary_sensor.test_template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Tests for template binary_sensor. +""" + +import unittest +from unittest import mock + +from homeassistant.components.binary_sensor import template +from homeassistant.exceptions import TemplateError + + +class TestBinarySensorTemplate(unittest.TestCase): + @mock.patch.object(template, 'BinarySensorTemplate') + def test_setup(self, mock_template): + config = { + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': '{{ foo }}', + 'sensor_class': 'motion', + }, + } + } + hass = mock.MagicMock() + add_devices = mock.MagicMock() + result = template.setup_platform(hass, config, add_devices) + self.assertTrue(result) + mock_template.assert_called_once_with(hass, 'test', 'virtual thingy', + 'motion', '{{ foo }}') + add_devices.assert_called_once_with([mock_template.return_value]) + + def test_setup_no_sensors(self): + config = {} + result = template.setup_platform(None, config, None) + self.assertFalse(result) + + def test_setup_invalid_device(self): + config = { + 'sensors': { + 'foo bar': {}, + }, + } + result = template.setup_platform(None, config, None) + self.assertFalse(result) + + def test_setup_invalid_sensor_class(self): + config = { + 'sensors': { + 'test': { + 'value_template': '{{ foo }}', + 'sensor_class': 'foobarnotreal', + }, + }, + } + result = template.setup_platform(None, config, None) + self.assertFalse(result) + + def test_setup_invalid_missing_template(self): + config = { + 'sensors': { + 'test': { + 'sensor_class': 'motion', + }, + }, + } + result = template.setup_platform(None, config, None) + self.assertFalse(result) + + def test_attributes(self): + hass = mock.MagicMock() + vs = template.BinarySensorTemplate(hass, 'parent', 'Parent', + 'motion', '{{ 1 > 1 }}') + self.assertFalse(vs.should_poll) + self.assertEqual('motion', vs.sensor_class) + self.assertEqual('Parent', vs.name) + + vs.update() + self.assertFalse(vs.is_on) + + vs._template = "{{ 2 > 1 }}" + vs.update() + self.assertTrue(vs.is_on) + + def test_event(self): + hass = mock.MagicMock() + vs = template.BinarySensorTemplate(hass, 'parent', 'Parent', + 'motion', '{{ 1 > 1 }}') + with mock.patch.object(vs, 'update_ha_state') as mock_update: + vs._event_listener(None) + mock_update.assert_called_once_with(True) + + def test_update(self): + hass = mock.MagicMock() + vs = template.BinarySensorTemplate(hass, 'parent', 'Parent', + 'motion', '{{ 2 > 1 }}') + self.assertEqual(None, vs._state) + vs.update() + self.assertTrue(vs._state) + + @mock.patch('homeassistant.helpers.template.render') + def test_update_template_error(self, mock_render): + hass = mock.MagicMock() + vs = template.BinarySensorTemplate(hass, 'parent', 'Parent', + 'motion', '{{ 1 > 1 }}') + mock_render.side_effect = TemplateError('foo') + vs.update() + mock_render.side_effect = TemplateError( + "UndefinedError: 'None' has no attribute") + vs.update() diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 1420ac44b6bf367492682e82ebdab801986f4d90..4aefdcf4fc5cd5aff93dcaf0f797c3495d92effa 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -51,6 +51,21 @@ class TestUtilTemplate(unittest.TestCase): {% for state in states.sensor %}{{ state.state }}{% endfor %} """)) + def test_float(self): + self.hass.states.set('sensor.temperature', '12') + + self.assertEqual( + '12.0', + template.render( + self.hass, + '{{ float(states.sensor.temperature.state) }}')) + + self.assertEqual( + 'True', + template.render( + self.hass, + '{{ float(states.sensor.temperature.state) > 11 }}')) + def test_rounding_value(self): self.hass.states.set('sensor.temperature', 12.78)