From f5e4b138140a52d5caf13634a26e92f9574cfd5e Mon Sep 17 00:00:00 2001
From: Paulus Schoutsen <balloob@gmail.com>
Date: Thu, 29 Apr 2021 09:25:34 -0700
Subject: [PATCH] Add auto_off to binary sensor template entity (#49615)

---
 .../components/template/binary_sensor.py      | 73 +++++++++++++------
 .../components/template/test_binary_sensor.py | 20 ++++-
 2 files changed, 71 insertions(+), 22 deletions(-)

diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py
index 2e1d2f71590..4d316388eae 100644
--- a/homeassistant/components/template/binary_sensor.py
+++ b/homeassistant/components/template/binary_sensor.py
@@ -2,6 +2,7 @@
 from __future__ import annotations
 
 from datetime import timedelta
+from functools import partial
 import logging
 
 import voluptuous as vol
@@ -49,6 +50,7 @@ from .trigger_entity import TriggerEntity
 
 CONF_DELAY_ON = "delay_on"
 CONF_DELAY_OFF = "delay_off"
+CONF_AUTO_OFF = "auto_off"
 CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
 
 LEGACY_FIELDS = {
@@ -74,6 +76,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema(
         vol.Optional(CONF_UNIQUE_ID): cv.string,
         vol.Optional(CONF_DELAY_ON): vol.Any(cv.positive_time_period, cv.template),
         vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template),
+        vol.Optional(CONF_AUTO_OFF): vol.Any(cv.positive_time_period, cv.template),
     }
 )
 
@@ -353,15 +356,13 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity):
         """Initialize the entity."""
         super().__init__(hass, coordinator, config)
 
-        if isinstance(config.get(CONF_DELAY_ON), template.Template):
-            self._to_render.append(CONF_DELAY_ON)
-            self._parse_result.add(CONF_DELAY_ON)
-
-        if isinstance(config.get(CONF_DELAY_OFF), template.Template):
-            self._to_render.append(CONF_DELAY_OFF)
-            self._parse_result.add(CONF_DELAY_OFF)
+        for key in (CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF):
+            if isinstance(config.get(key), template.Template):
+                self._to_render.append(key)
+                self._parse_result.add(key)
 
         self._delay_cancel = None
+        self._auto_off_cancel = None
         self._state = False
 
     @property
@@ -378,22 +379,23 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity):
             self._delay_cancel()
             self._delay_cancel = None
 
+        if self._auto_off_cancel:
+            self._auto_off_cancel()
+            self._auto_off_cancel = None
+
         if not self.available:
+            self.async_write_ha_state()
             return
 
         raw = self._rendered.get(CONF_STATE)
         state = template.result_as_boolean(raw)
 
-        if state == self._state:
-            return
-
         key = CONF_DELAY_ON if state else CONF_DELAY_OFF
         delay = self._rendered.get(key) or self._config.get(key)
 
         # state without delay. None means rendering failed.
-        if state is None or delay is None:
-            self._state = state
-            self.async_write_ha_state()
+        if self._state == state or state is None or delay is None:
+            self._set_state(state)
             return
 
         if not isinstance(delay, timedelta):
@@ -405,14 +407,43 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity):
                 )
                 return
 
-        @callback
-        def _set_state(_):
-            """Set state of template binary sensor."""
-            self._state = state
-            self.async_set_context(self.coordinator.data["context"])
-            self.async_write_ha_state()
-
         # state with delay. Cancelled if new trigger received
         self._delay_cancel = async_call_later(
-            self.hass, delay.total_seconds(), _set_state
+            self.hass, delay.total_seconds(), partial(self._set_state, state)
+        )
+
+    @callback
+    def _set_state(self, state, _=None):
+        """Set up auto off."""
+        self._state = state
+        self.async_set_context(self.coordinator.data["context"])
+        self.async_write_ha_state()
+
+        if not state:
+            return
+
+        auto_off_time = self._rendered.get(CONF_AUTO_OFF) or self._config.get(
+            CONF_AUTO_OFF
+        )
+
+        if auto_off_time is None:
+            return
+
+        if not isinstance(auto_off_time, timedelta):
+            try:
+                auto_off_time = cv.positive_time_period(auto_off_time)
+            except vol.Invalid as err:
+                logging.getLogger(__name__).warning(
+                    "Error rendering %s template: %s", CONF_AUTO_OFF, err
+                )
+                return
+
+        @callback
+        def _auto_off(_):
+            """Set state of template binary sensor."""
+            self._state = False
+            self.async_write_ha_state()
+
+        self._auto_off_cancel = async_call_later(
+            self.hass, auto_off_time.total_seconds(), _auto_off
         )
diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py
index 70356405867..ccadef5aa96 100644
--- a/tests/components/template/test_binary_sensor.py
+++ b/tests/components/template/test_binary_sensor.py
@@ -965,7 +965,8 @@ async def test_trigger_entity(hass):
                             "picture": "{{ '/local/dogs.png' }}",
                             "icon": "{{ 'mdi:pirate' }}",
                             "attributes": {
-                                "plus_one": "{{ trigger.event.data.beer + 1 }}"
+                                "plus_one": "{{ trigger.event.data.beer + 1 }}",
+                                "another": "{{ trigger.event.data.uno_mas or 1 }}",
                             },
                         }
                     ],
@@ -1021,8 +1022,16 @@ async def test_trigger_entity(hass):
     assert state.attributes.get("icon") == "mdi:pirate"
     assert state.attributes.get("entity_picture") == "/local/dogs.png"
     assert state.attributes.get("plus_one") == 3
+    assert state.attributes.get("another") == 1
     assert state.context is context
 
+    # Even if state itself didn't change, attributes might have changed
+    hass.bus.async_fire("test_event", {"beer": 2, "uno_mas": "si"})
+    await hass.async_block_till_done()
+    state = hass.states.get("binary_sensor.via_list")
+    assert state.state == "on"
+    assert state.attributes.get("another") == "si"
+
 
 async def test_template_with_trigger_templated_delay_on(hass):
     """Test binary sensor template with template delay on."""
@@ -1034,6 +1043,7 @@ async def test_template_with_trigger_templated_delay_on(hass):
                 "state": "{{ trigger.event.data.beer == 2 }}",
                 "device_class": "motion",
                 "delay_on": '{{ ({ "seconds": 6 / 2 }) }}',
+                "auto_off": '{{ ({ "seconds": 1 + 1 }) }}',
             },
         }
     }
@@ -1054,3 +1064,11 @@ async def test_template_with_trigger_templated_delay_on(hass):
 
     state = hass.states.get("binary_sensor.test")
     assert state.state == "on"
+
+    # Now wait for the auto-off
+    future = dt_util.utcnow() + timedelta(seconds=2)
+    async_fire_time_changed(hass, future)
+    await hass.async_block_till_done()
+
+    state = hass.states.get("binary_sensor.test")
+    assert state.state == "off"