From 5a8013b58c52a0d991b1ce6c260e95944cc396ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Aug 2020 09:07:58 -0500 Subject: [PATCH] Convert template binary_sensor to use async_track_template_result (#39027) Co-Authored-By: Penny Wood Co-authored-by: Penny Wood --- .../components/template/binary_sensor.py | 223 ++++-------------- homeassistant/components/template/sensor.py | 32 +-- .../components/template/template_entity.py | 44 ++++ .../components/template/test_binary_sensor.py | 161 +++++-------- 4 files changed, 161 insertions(+), 299 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 504cf82297f..8dba37ddace 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -18,20 +18,16 @@ from homeassistant.const import ( CONF_SENSORS, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - EVENT_HOMEASSISTANT_START, - MATCH_ALL, ) from homeassistant.core import callback 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_same_state, - async_track_state_change_event, -) +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.template import result_as_boolean -from . import extract_entities, initialise_templates from .const import CONF_AVAILABILITY_TEMPLATE +from .template_entity import TemplateEntityWithAttributesAvailabilityAndImages _LOGGER = logging.getLogger(__name__) @@ -77,22 +73,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= delay_off = device_config.get(CONF_DELAY_OFF) unique_id = device_config.get(CONF_UNIQUE_ID) - templates = { - CONF_VALUE_TEMPLATE: value_template, - CONF_ICON_TEMPLATE: icon_template, - CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template, - CONF_AVAILABILITY_TEMPLATE: availability_template, - } - - initialise_templates(hass, templates, attribute_templates) - entity_ids = extract_entities( - device, - "binary sensor", - device_config.get(ATTR_ENTITY_ID), - templates, - attribute_templates, - ) - sensors.append( BinarySensorTemplate( hass, @@ -103,7 +83,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= icon_template, entity_picture_template, availability_template, - entity_ids, delay_on, delay_off, attribute_templates, @@ -114,7 +93,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(sensors) -class BinarySensorTemplate(BinarySensorEntity): +class BinarySensorTemplate( + TemplateEntityWithAttributesAvailabilityAndImages, BinarySensorEntity +): """A virtual binary sensor that triggers from another sensor.""" def __init__( @@ -127,54 +108,66 @@ class BinarySensorTemplate(BinarySensorEntity): icon_template, entity_picture_template, availability_template, - entity_ids, delay_on, delay_off, attribute_templates, unique_id, ): """Initialize the Template binary sensor.""" - self.hass = hass + super().__init__( + attribute_templates, + availability_template, + icon_template, + entity_picture_template, + ) self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device, hass=hass) self._name = friendly_name self._device_class = device_class self._template = value_template self._state = None - self._icon_template = icon_template - self._availability_template = availability_template - self._entity_picture_template = entity_picture_template - self._icon = None - self._entity_picture = None - self._entities = entity_ids + self._delay_cancel = None self._delay_on = delay_on self._delay_off = delay_off - self._available = True - self._attribute_templates = attribute_templates - self._attributes = {} self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" - @callback - def template_bsensor_state_listener(event): - """Handle the target device state changes.""" - self.async_check_state() + self.add_template_attribute("_state", self._template, None, self._update_state) + + await super().async_added_to_hass() + + @callback + def _update_state(self, result): + super()._update_state(result) + + if self._delay_cancel: + self._delay_cancel() + self._delay_cancel = None + + state = None if isinstance(result, TemplateError) else result_as_boolean(result) + + if state == self._state: + return + + # state without delay + if ( + state is None + or (state and not self._delay_on) + or (not state and not self._delay_off) + ): + self._state = state + return @callback - def template_bsensor_startup(event): - """Update template on startup.""" - if self._entities != MATCH_ALL: - # Track state change only for valid templates - async_track_state_change_event( - self.hass, self._entities, template_bsensor_state_listener - ) + def _set_state(_): + """Set state of template binary sensor.""" + self._state = state + self.async_write_ha_state() - self.async_check_state() - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, template_bsensor_startup - ) + delay = (self._delay_on if state else self._delay_off).seconds + # state with delay. Cancelled if template result changes. + self._delay_cancel = async_call_later(self.hass, delay, _set_state) @property def name(self): @@ -186,133 +179,7 @@ class BinarySensorTemplate(BinarySensorEntity): """Return the unique id of this binary sensor.""" return self._unique_id - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon - - @property - def entity_picture(self): - """Return the entity_picture to use in the frontend, if any.""" - return self._entity_picture - @property def is_on(self): """Return true if sensor is on.""" return self._state - - @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def available(self): - """Availability indicator.""" - return self._available - - @callback - def _async_render(self): - """Get the state of template.""" - state = None - try: - state = self._template.async_render().lower() == "true" - 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( - "Could not render template %s, the state is unknown", self._name - ) - return - _LOGGER.error("Could not render template %s: %s", self._name, ex) - - attrs = {} - if self._attribute_templates is not None: - for key, value in self._attribute_templates.items(): - try: - attrs[key] = value.async_render() - except TemplateError as err: - _LOGGER.error("Error rendering attribute %s: %s", key, err) - self._attributes = attrs - - templates = { - "_icon": self._icon_template, - "_entity_picture": self._entity_picture_template, - "_available": self._availability_template, - } - - for property_name, template in templates.items(): - if template is None: - continue - - try: - value = template.async_render() - if property_name == "_available": - value = value.lower() == "true" - setattr(self, property_name, value) - except TemplateError as ex: - friendly_property_name = property_name[1:].replace("_", " ") - if ex.args and ex.args[0].startswith( - "UndefinedError: 'None' has no attribute" - ): - # Common during HA startup - so just a warning - _LOGGER.warning( - "Could not render %s template %s, the state is unknown", - friendly_property_name, - self._name, - ) - else: - _LOGGER.error( - "Could not render %s template %s: %s", - friendly_property_name, - self._name, - ex, - ) - return state - - return state - - @callback - def async_check_state(self): - """Update the state from the template.""" - state = self._async_render() - - # return if the state don't change or is invalid - if state is None or state == self.state: - return - - @callback - def set_state(): - """Set state of template binary sensor.""" - self._state = state - self.async_write_ha_state() - - # state without delay - if (state and not self._delay_on) or (not state and not self._delay_off): - set_state() - return - - period = self._delay_on if state else self._delay_off - async_track_same_state( - self.hass, - period, - set_state, - entity_ids=self._entities, - async_check_same_func=lambda *args: self._async_render() == state, - ) - - async def async_update(self): - """Force update of the state from the template.""" - self.async_check_state() diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 3ffd99e58fd..2b0aec1fd53 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -27,7 +27,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity, async_generate_entity_id from .const import CONF_AVAILABILITY_TEMPLATE -from .template_entity import TemplateEntityWithAvailabilityAndImages +from .template_entity import TemplateEntityWithAttributesAvailabilityAndImages CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" @@ -94,7 +94,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= return True -class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity): +class SensorTemplate(TemplateEntityWithAttributesAvailabilityAndImages, Entity): """Representation of a Template Sensor.""" def __init__( @@ -113,7 +113,12 @@ class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity): unique_id, ): """Initialize the sensor.""" - super().__init__(availability_template, icon_template, entity_picture_template) + super().__init__( + attribute_templates, + availability_template, + icon_template, + entity_picture_template, + ) self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, device_id, hass=hass ) @@ -123,8 +128,7 @@ class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity): self._template = state_template self._state = None self._device_class = device_class - self._attribute_templates = attribute_templates - self._attributes = {} + self._unique_id = unique_id async def async_added_to_hass(self): @@ -134,21 +138,8 @@ class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity): if self._friendly_name_template is not None: self.add_template_attribute("_name", self._friendly_name_template) - for key, value in self._attribute_templates.items(): - self._add_attribute_template(key, value) - await super().async_added_to_hass() - @callback - def _add_attribute_template(self, attribute_key, attribute_template): - """Create a template tracker for the attribute.""" - - def _update_attribute(result): - attr_result = None if isinstance(result, TemplateError) else result - self._attributes[attribute_key] = attr_result - - self.add_template_attribute(None, attribute_template, None, _update_attribute) - @callback def _update_state(self, result): super()._update_state(result) @@ -178,8 +169,3 @@ class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity): def unit_of_measurement(self): """Return the unit_of_measurement of the device.""" return self._unit_of_measurement - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 618b240a9d4..24ac1c64db1 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -117,6 +117,7 @@ class _TemplateAttribute: result_info = async_track_template_result( self._entity.hass, self.template, self._handle_result ) + self.async_update = result_info.async_refresh @callback @@ -265,3 +266,46 @@ class TemplateEntityWithAvailabilityAndImages(TemplateEntityWithAvailability): ) await super().async_added_to_hass() + + +class TemplateEntityWithAttributesAvailabilityAndImages( + TemplateEntityWithAvailabilityAndImages +): + """Entity that uses templates to calculate attributes with an attributes, availability, icon, and images template.""" + + def __init__( + self, + attribute_templates, + availability_template, + icon_template, + entity_picture_template, + ): + """Template Entity.""" + super().__init__(availability_template, icon_template, entity_picture_template) + self._attribute_templates = attribute_templates + self._attributes = {} + + @callback + def _add_attribute_template(self, attribute_key, attribute_template): + """Create a template tracker for the attribute.""" + + def _update_attribute(result): + attr_result = None if isinstance(result, TemplateError) else result + self._attributes[attribute_key] = attr_result + + self.add_template_attribute( + attribute_key, attribute_template, None, _update_attribute + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + async def async_added_to_hass(self): + """Register callbacks.""" + + for key, value in self._attribute_templates.items(): + self._add_attribute_template(key, value) + + await super().async_added_to_hass() diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 039a4cd6e5c..45bf0c5edb0 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1,22 +1,16 @@ """The tests for the Template Binary sensor platform.""" from datetime import timedelta +import logging import unittest from unittest import mock -import jinja2 - from homeassistant import setup -from homeassistant.components.template import binary_sensor as template from homeassistant.const import ( EVENT_HOMEASSISTANT_START, - MATCH_ALL, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template as template_hlpr -from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util from tests.common import ( @@ -203,7 +197,8 @@ class TestBinarySensorTemplate(unittest.TestCase): state = self.hass.states.get("binary_sensor.test_template_sensor") assert state.attributes.get("test_attribute") == "It ." - + self.hass.states.set("sensor.test_state", "Works2") + self.hass.block_till_done() self.hass.states.set("sensor.test_state", "Works") self.hass.block_till_done() state = self.hass.states.get("binary_sensor.test_template_sensor") @@ -211,10 +206,10 @@ class TestBinarySensorTemplate(unittest.TestCase): @mock.patch( "homeassistant.components.template.binary_sensor." - "BinarySensorTemplate._async_render" + "BinarySensorTemplate._update_state" ) - def test_match_all(self, _async_render): - """Test MATCH_ALL in template.""" + def test_match_all(self, _update_state): + """Test template that is rerendered on any state lifecycle.""" with assert_setup_component(1): assert setup.setup_component( self.hass, @@ -223,52 +218,27 @@ class TestBinarySensorTemplate(unittest.TestCase): "binary_sensor": { "platform": "template", "sensors": { - "match_all_template_sensor": {"value_template": "{{ 42 }}"} + "match_all_template_sensor": { + "value_template": ( + "{% for state in states %}" + "{% if state.entity_id == 'sensor.humidity' %}" + "{{ state.entity_id }}={{ state.state }}" + "{% endif %}" + "{% endfor %}" + ), + }, }, } }, ) - self.hass.block_till_done() self.hass.start() self.hass.block_till_done() - init_calls = len(_async_render.mock_calls) + init_calls = len(_update_state.mock_calls) self.hass.states.set("sensor.any_state", "update") self.hass.block_till_done() - assert len(_async_render.mock_calls) == init_calls - - def test_attributes(self): - """Test the attributes.""" - vs = run_callback_threadsafe( - self.hass.loop, - template.BinarySensorTemplate, - self.hass, - "parent", - "Parent", - "motion", - template_hlpr.Template("{{ 1 > 1 }}", self.hass), - None, - None, - None, - MATCH_ALL, - None, - None, - None, - None, - ).result() - assert not vs.should_poll - assert "motion" == vs.device_class - assert "Parent" == vs.name - - run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() - assert not vs.is_on - - # pylint: disable=protected-access - vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass) - - run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() - assert vs.is_on + assert len(_update_state.mock_calls) == init_calls def test_event(self): """Test the event.""" @@ -300,33 +270,6 @@ class TestBinarySensorTemplate(unittest.TestCase): state = self.hass.states.get("binary_sensor.test") assert state.state == "on" - @mock.patch("homeassistant.helpers.template.Template.render") - def test_update_template_error(self, mock_render): - """Test the template update error.""" - vs = run_callback_threadsafe( - self.hass.loop, - template.BinarySensorTemplate, - self.hass, - "parent", - "Parent", - "motion", - template_hlpr.Template("{{ 1 > 1 }}", self.hass), - None, - None, - None, - MATCH_ALL, - None, - None, - None, - None, - ).result() - mock_render.side_effect = TemplateError(jinja2.TemplateError("foo")) - run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() - mock_render.side_effect = TemplateError( - jinja2.TemplateError("UndefinedError: 'None' has no attribute") - ) - run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() - async def test_template_delay_on(hass): """Test binary sensor template delay on.""" @@ -525,11 +468,11 @@ async def test_invalid_attribute_template(hass, caplog): ) await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 - await hass.helpers.entity_component.async_update_entity( - "binary_sensor.invalid_template" - ) + await hass.async_start() + await hass.async_block_till_done() - assert ("Error rendering attribute test_attribute") in caplog.text + assert "test_attribute" in caplog.text + assert "TemplateError" in caplog.text async def test_invalid_availability_template_keeps_component_available(hass, caplog): @@ -588,26 +531,6 @@ async def test_no_update_template_match_all(hass, caplog): ) await hass.async_block_till_done() assert len(hass.states.async_all()) == 5 - assert ( - "Template binary sensor 'all_state' has no entity ids " - "configured to track nor were we able to extract the entities to " - "track from the value template" - ) in caplog.text - assert ( - "Template binary sensor 'all_icon' has no entity ids " - "configured to track nor were we able to extract the entities to " - "track from the icon template" - ) in caplog.text - assert ( - "Template binary sensor 'all_entity_picture' has no entity ids " - "configured to track nor were we able to extract the entities to " - "track from the entity_picture template" - ) in caplog.text - assert ( - "Template binary sensor 'all_attribute' has no entity ids " - "configured to track nor were we able to extract the entities to " - "track from the test_attribute template" - ) in caplog.text assert hass.states.get("binary_sensor.all_state").state == "off" assert hass.states.get("binary_sensor.all_icon").state == "off" @@ -673,3 +596,45 @@ async def test_unique_id(hass): await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 + + +async def test_template_validation_error(hass, caplog): + """Test binary sensor template delay on.""" + caplog.set_level(logging.ERROR) + config = { + "binary_sensor": { + "platform": "template", + "sensors": { + "test": { + "friendly_name": "virtual thingy", + "value_template": "True", + "icon_template": "{{ states.sensor.test_state.state }}", + "device_class": "motion", + "delay_on": 5, + }, + }, + }, + } + await setup.async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test") + assert state.attributes.get("icon") == "" + + hass.states.async_set("sensor.test_state", "mdi:check") + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test") + assert state.attributes.get("icon") == "mdi:check" + + hass.states.async_set("sensor.test_state", "invalid_icon") + await hass.async_block_till_done() + assert len(caplog.records) == 1 + assert caplog.records[0].message.startswith( + "Error validating template result 'invalid_icon' from template" + ) + + state = hass.states.get("binary_sensor.test") + assert state.attributes.get("icon") is None