From 5a8013b58c52a0d991b1ce6c260e95944cc396ff Mon Sep 17 00:00:00 2001
From: "J. Nick Koston" <nick@koston.org>
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 <Swamp-Ig@users.noreply.github.com>

Co-authored-by: Penny Wood <Swamp-Ig@users.noreply.github.com>
---
 .../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)
 
-        @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
-                )
+        await super().async_added_to_hass()
+
+    @callback
+    def _update_state(self, result):
+        super()._update_state(result)
 
-            self.async_check_state()
+        if self._delay_cancel:
+            self._delay_cancel()
+            self._delay_cancel = None
 
-        self.hass.bus.async_listen_once(
-            EVENT_HOMEASSISTANT_START, template_bsensor_startup
-        )
+        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 _set_state(_):
+            """Set state of template binary sensor."""
+            self._state = state
+            self.async_write_ha_state()
+
+        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
-- 
GitLab