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>
pull/39042/head
J. Nick Koston 2020-08-20 09:07:58 -05:00 committed by GitHub
parent 8813f669c2
commit 5a8013b58c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 161 additions and 299 deletions

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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