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>
This commit is contained in:
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_SENSORS,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
EVENT_HOMEASSISTANT_START,
MATCH_ALL,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.event import ( from homeassistant.helpers.event import async_call_later
async_track_same_state, from homeassistant.helpers.template import result_as_boolean
async_track_state_change_event,
)
from . import extract_entities, initialise_templates
from .const import CONF_AVAILABILITY_TEMPLATE from .const import CONF_AVAILABILITY_TEMPLATE
from .template_entity import TemplateEntityWithAttributesAvailabilityAndImages
_LOGGER = logging.getLogger(__name__) _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) delay_off = device_config.get(CONF_DELAY_OFF)
unique_id = device_config.get(CONF_UNIQUE_ID) 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( sensors.append(
BinarySensorTemplate( BinarySensorTemplate(
hass, hass,
@ -103,7 +83,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
icon_template, icon_template,
entity_picture_template, entity_picture_template,
availability_template, availability_template,
entity_ids,
delay_on, delay_on,
delay_off, delay_off,
attribute_templates, attribute_templates,
@ -114,7 +93,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities(sensors) async_add_entities(sensors)
class BinarySensorTemplate(BinarySensorEntity): class BinarySensorTemplate(
TemplateEntityWithAttributesAvailabilityAndImages, BinarySensorEntity
):
"""A virtual binary sensor that triggers from another sensor.""" """A virtual binary sensor that triggers from another sensor."""
def __init__( def __init__(
@ -127,54 +108,66 @@ class BinarySensorTemplate(BinarySensorEntity):
icon_template, icon_template,
entity_picture_template, entity_picture_template,
availability_template, availability_template,
entity_ids,
delay_on, delay_on,
delay_off, delay_off,
attribute_templates, attribute_templates,
unique_id, unique_id,
): ):
"""Initialize the Template binary sensor.""" """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.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device, hass=hass)
self._name = friendly_name self._name = friendly_name
self._device_class = device_class self._device_class = device_class
self._template = value_template self._template = value_template
self._state = None self._state = None
self._icon_template = icon_template self._delay_cancel = None
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_on = delay_on self._delay_on = delay_on
self._delay_off = delay_off self._delay_off = delay_off
self._available = True
self._attribute_templates = attribute_templates
self._attributes = {}
self._unique_id = unique_id self._unique_id = unique_id
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Register callbacks.""" """Register callbacks."""
@callback self.add_template_attribute("_state", self._template, None, self._update_state)
def template_bsensor_state_listener(event):
"""Handle the target device state changes.""" await super().async_added_to_hass()
self.async_check_state()
@callback @callback
def template_bsensor_startup(event): def _update_state(self, result):
"""Update template on startup.""" super()._update_state(result)
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
)
self.async_check_state() if self._delay_cancel:
self._delay_cancel()
self._delay_cancel = None
self.hass.bus.async_listen_once( state = None if isinstance(result, TemplateError) else result_as_boolean(result)
EVENT_HOMEASSISTANT_START, template_bsensor_startup
) 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 @property
def name(self): def name(self):
@ -186,133 +179,7 @@ class BinarySensorTemplate(BinarySensorEntity):
"""Return the unique id of this binary sensor.""" """Return the unique id of this binary sensor."""
return self._unique_id 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 @property
def is_on(self): def is_on(self):
"""Return true if sensor is on.""" """Return true if sensor is on."""
return self._state 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 homeassistant.helpers.entity import Entity, async_generate_entity_id
from .const import CONF_AVAILABILITY_TEMPLATE from .const import CONF_AVAILABILITY_TEMPLATE
from .template_entity import TemplateEntityWithAvailabilityAndImages from .template_entity import TemplateEntityWithAttributesAvailabilityAndImages
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
@ -94,7 +94,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
return True return True
class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity): class SensorTemplate(TemplateEntityWithAttributesAvailabilityAndImages, Entity):
"""Representation of a Template Sensor.""" """Representation of a Template Sensor."""
def __init__( def __init__(
@ -113,7 +113,12 @@ class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity):
unique_id, unique_id,
): ):
"""Initialize the sensor.""" """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( self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, device_id, hass=hass ENTITY_ID_FORMAT, device_id, hass=hass
) )
@ -123,8 +128,7 @@ class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity):
self._template = state_template self._template = state_template
self._state = None self._state = None
self._device_class = device_class self._device_class = device_class
self._attribute_templates = attribute_templates
self._attributes = {}
self._unique_id = unique_id self._unique_id = unique_id
async def async_added_to_hass(self): async def async_added_to_hass(self):
@ -134,21 +138,8 @@ class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity):
if self._friendly_name_template is not None: if self._friendly_name_template is not None:
self.add_template_attribute("_name", self._friendly_name_template) 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() 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 @callback
def _update_state(self, result): def _update_state(self, result):
super()._update_state(result) super()._update_state(result)
@ -178,8 +169,3 @@ class SensorTemplate(TemplateEntityWithAvailabilityAndImages, Entity):
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit_of_measurement of the device.""" """Return the unit_of_measurement of the device."""
return self._unit_of_measurement 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( result_info = async_track_template_result(
self._entity.hass, self.template, self._handle_result self._entity.hass, self.template, self._handle_result
) )
self.async_update = result_info.async_refresh self.async_update = result_info.async_refresh
@callback @callback
@ -265,3 +266,46 @@ class TemplateEntityWithAvailabilityAndImages(TemplateEntityWithAvailability):
) )
await super().async_added_to_hass() 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.""" """The tests for the Template Binary sensor platform."""
from datetime import timedelta from datetime import timedelta
import logging
import unittest import unittest
from unittest import mock from unittest import mock
import jinja2
from homeassistant import setup from homeassistant import setup
from homeassistant.components.template import binary_sensor as template
from homeassistant.const import ( from homeassistant.const import (
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_START,
MATCH_ALL,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
STATE_UNAVAILABLE, 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 import homeassistant.util.dt as dt_util
from tests.common import ( from tests.common import (
@ -203,7 +197,8 @@ class TestBinarySensorTemplate(unittest.TestCase):
state = self.hass.states.get("binary_sensor.test_template_sensor") state = self.hass.states.get("binary_sensor.test_template_sensor")
assert state.attributes.get("test_attribute") == "It ." 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.states.set("sensor.test_state", "Works")
self.hass.block_till_done() self.hass.block_till_done()
state = self.hass.states.get("binary_sensor.test_template_sensor") state = self.hass.states.get("binary_sensor.test_template_sensor")
@ -211,10 +206,10 @@ class TestBinarySensorTemplate(unittest.TestCase):
@mock.patch( @mock.patch(
"homeassistant.components.template.binary_sensor." "homeassistant.components.template.binary_sensor."
"BinarySensorTemplate._async_render" "BinarySensorTemplate._update_state"
) )
def test_match_all(self, _async_render): def test_match_all(self, _update_state):
"""Test MATCH_ALL in template.""" """Test template that is rerendered on any state lifecycle."""
with assert_setup_component(1): with assert_setup_component(1):
assert setup.setup_component( assert setup.setup_component(
self.hass, self.hass,
@ -223,52 +218,27 @@ class TestBinarySensorTemplate(unittest.TestCase):
"binary_sensor": { "binary_sensor": {
"platform": "template", "platform": "template",
"sensors": { "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.start()
self.hass.block_till_done() 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.states.set("sensor.any_state", "update")
self.hass.block_till_done() self.hass.block_till_done()
assert len(_async_render.mock_calls) == init_calls assert len(_update_state.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
def test_event(self): def test_event(self):
"""Test the event.""" """Test the event."""
@ -300,33 +270,6 @@ class TestBinarySensorTemplate(unittest.TestCase):
state = self.hass.states.get("binary_sensor.test") state = self.hass.states.get("binary_sensor.test")
assert state.state == "on" 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): async def test_template_delay_on(hass):
"""Test binary sensor template delay on.""" """Test binary sensor template delay on."""
@ -525,11 +468,11 @@ async def test_invalid_attribute_template(hass, caplog):
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all()) == 2 assert len(hass.states.async_all()) == 2
await hass.helpers.entity_component.async_update_entity( await hass.async_start()
"binary_sensor.invalid_template" 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): 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() await hass.async_block_till_done()
assert len(hass.states.async_all()) == 5 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_state").state == "off"
assert hass.states.get("binary_sensor.all_icon").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() await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1 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