Update template sensor to use async_track_template_result (#38940)

* Add template entity

* Update template tracking to work for template sensors

* add test for whitespace

* Update homeassistant/helpers/config_validation.py

* revert

* fix

* reduce

* fix _refresh missing decorator

* defer until start

* do not throw errors during startup

* defer tracking until start event

Co-authored-by: Swamp-Ig <github@ninjateaparty.com>
This commit is contained in:
J. Nick Koston 2020-08-20 08:06:41 -05:00 committed by GitHub
parent b7ec0d4884
commit 1381b279f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 295 additions and 151 deletions

View File

@ -20,17 +20,15 @@ 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 Entity, async_generate_entity_id from homeassistant.helpers.entity import Entity, async_generate_entity_id
from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.template import result_as_boolean
from . import extract_entities, initialise_templates
from .const import CONF_AVAILABILITY_TEMPLATE from .const import CONF_AVAILABILITY_TEMPLATE
from .template_entity import TemplateEntity
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
@ -75,23 +73,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
attribute_templates = device_config[CONF_ATTRIBUTE_TEMPLATES] attribute_templates = device_config[CONF_ATTRIBUTE_TEMPLATES]
unique_id = device_config.get(CONF_UNIQUE_ID) unique_id = device_config.get(CONF_UNIQUE_ID)
templates = {
CONF_VALUE_TEMPLATE: state_template,
CONF_ICON_TEMPLATE: icon_template,
CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template,
CONF_FRIENDLY_NAME_TEMPLATE: friendly_name_template,
CONF_AVAILABILITY_TEMPLATE: availability_template,
}
initialise_templates(hass, templates, attribute_templates)
entity_ids = extract_entities(
device,
"sensor",
device_config.get(ATTR_ENTITY_ID),
templates,
attribute_templates,
)
sensors.append( sensors.append(
SensorTemplate( SensorTemplate(
hass, hass,
@ -103,7 +84,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,
device_class, device_class,
attribute_templates, attribute_templates,
unique_id, unique_id,
@ -115,7 +95,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
return True return True
class SensorTemplate(Entity): class SensorTemplate(TemplateEntity, Entity):
"""Representation of a Template Sensor.""" """Representation of a Template Sensor."""
def __init__( def __init__(
@ -129,7 +109,6 @@ class SensorTemplate(Entity):
icon_template, icon_template,
entity_picture_template, entity_picture_template,
availability_template, availability_template,
entity_ids,
device_class, device_class,
attribute_templates, attribute_templates,
unique_id, unique_id,
@ -149,35 +128,66 @@ class SensorTemplate(Entity):
self._availability_template = availability_template self._availability_template = availability_template
self._icon = None self._icon = None
self._entity_picture = None self._entity_picture = None
self._entities = entity_ids
self._device_class = device_class self._device_class = device_class
self._available = True self._available = True
self._attribute_templates = attribute_templates self._attribute_templates = attribute_templates
self._attributes = {} self._attributes = {}
self._unique_id = unique_id self._unique_id = unique_id
super().__init__()
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_sensor_state_listener(event): if self._icon_template is not None:
"""Handle device state changes.""" self.add_template_attribute(
self.async_schedule_update_ha_state(True) "_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon)
)
@callback if self._entity_picture_template is not None:
def template_sensor_startup(event): self.add_template_attribute(
"""Update template on startup.""" "_entity_picture", self._entity_picture_template
if self._entities != MATCH_ALL: )
# Track state change only for valid templates if self._friendly_name_template is not None:
async_track_state_change_event( self.add_template_attribute("_name", self._friendly_name_template)
self.hass, self._entities, template_sensor_state_listener if self._availability_template is not None:
self.add_template_attribute(
"_available", self._availability_template, None, self._update_available
) )
self.async_schedule_update_ha_state(True) for key, value in self._attribute_templates.items():
self._add_attribute_template(key, value)
self.hass.bus.async_listen_once( await super().async_added_to_hass()
EVENT_HOMEASSISTANT_START, template_sensor_startup
) @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):
if isinstance(result, TemplateError):
if not self._availability_template:
self._available = False
self._state = None
return
if not self._availability_template:
self._available = True
self._state = result
@callback
def _update_available(self, result):
if isinstance(result, TemplateError):
self._available = True
return
self._available = result_as_boolean(result)
@property @property
def name(self): def name(self):
@ -228,69 +238,3 @@ class SensorTemplate(Entity):
def should_poll(self): def should_poll(self):
"""No polling needed.""" """No polling needed."""
return False return False
async def async_update(self):
"""Update the state from the template."""
try:
self._state = self._template.async_render()
self._available = True
except TemplateError as ex:
self._available = False
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
)
else:
self._state = None
_LOGGER.error("Could not render template %s: %s", self._name, ex)
attrs = {}
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,
"_name": self._friendly_name_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,
)
continue
try:
setattr(self, property_name, getattr(super(), property_name))
except AttributeError:
_LOGGER.error(
"Could not render %s template %s: %s",
friendly_property_name,
self._name,
ex,
)

View File

@ -0,0 +1,179 @@
"""TemplateEntity utility class."""
import logging
from typing import Any, Callable, Optional, Union
import voluptuous as vol
from homeassistant.core import EVENT_HOMEASSISTANT_START, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.config_validation import match_all
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import Event, async_track_template_result
from homeassistant.helpers.template import Template
_LOGGER = logging.getLogger(__name__)
class _TemplateAttribute:
"""Attribute value linked to template result."""
def __init__(
self,
entity: Entity,
attribute: str,
template: Template,
validator: Callable[[Any], Any] = match_all,
on_update: Optional[Callable[[Any], None]] = None,
):
"""Template attribute."""
self._entity = entity
self._attribute = attribute
self.template = template
self.validator = validator
self.on_update = on_update
self.async_update = None
self.add_complete = False
@callback
def async_setup(self):
"""Config update path for the attribute."""
if self.on_update:
return
if not hasattr(self._entity, self._attribute):
raise AttributeError(f"Attribute '{self._attribute}' does not exist.")
self.on_update = self._default_update
@callback
def _default_update(self, result):
attr_result = None if isinstance(result, TemplateError) else result
setattr(self._entity, self._attribute, attr_result)
@callback
def _write_update_if_added(self):
if self.add_complete:
self._entity.async_write_ha_state()
@callback
def _handle_result(
self,
event: Optional[Event],
template: Template,
last_result: Optional[str],
result: Union[str, TemplateError],
) -> None:
if isinstance(result, TemplateError):
_LOGGER.error(
"TemplateError('%s') "
"while processing template '%s' "
"for attribute '%s' in entity '%s'",
result,
self.template,
self._attribute,
self._entity.entity_id,
)
self.on_update(result)
self._write_update_if_added()
return
if not self.validator:
self.on_update(result)
self._write_update_if_added()
return
try:
validated = self.validator(result)
except vol.Invalid as ex:
_LOGGER.error(
"Error validating template result '%s' "
"from template '%s' "
"for attribute '%s' in entity %s "
"validation message '%s'",
result,
self.template,
self._attribute,
self._entity.entity_id,
ex.msg,
)
self.on_update(None)
self._write_update_if_added()
return
self.on_update(validated)
self._write_update_if_added()
@callback
def async_template_startup(self) -> None:
"""Call from containing entity when added to hass."""
result_info = async_track_template_result(
self._entity.hass, self.template, self._handle_result
)
self.async_update = result_info.async_refresh
@callback
def _remove_from_hass():
result_info.async_remove()
return _remove_from_hass
class TemplateEntity(Entity):
"""Entity that uses templates to calculate attributes."""
def __init__(self):
"""Template Entity."""
self._template_attrs = []
def add_template_attribute(
self,
attribute: str,
template: Template,
validator: Callable[[Any], Any] = match_all,
on_update: Optional[Callable[[Any], None]] = None,
) -> None:
"""
Call in the constructor to add a template linked to a attribute.
Parameters
----------
attribute
The name of the attribute to link to. This attribute must exist
unless a custom on_update method is supplied.
template
The template to calculate.
validator
Validator function to parse the result and ensure it's valid.
on_update
Called to store the template result rather than storing it
the supplied attribute. Passed the result of the validator, or None
if the template or validator resulted in an error.
"""
attribute = _TemplateAttribute(self, attribute, template, validator, on_update)
attribute.async_setup()
self._template_attrs.append(attribute)
async def _async_template_startup(self, _) -> None:
# async_update will not write state
# until "add_complete" is set on the attribute
for attribute in self._template_attrs:
self.async_on_remove(attribute.async_template_startup())
await self.async_update()
for attribute in self._template_attrs:
attribute.add_complete = True
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, self._async_template_startup
)
async def async_update(self) -> None:
"""Call for forced update."""
for attribute in self._template_attrs:
if attribute.async_update:
attribute.async_update()

View File

@ -156,6 +156,17 @@ def boolean(value: Any) -> bool:
raise vol.Invalid(f"invalid boolean value {value}") raise vol.Invalid(f"invalid boolean value {value}")
_WS = re.compile("\\s*")
def whitespace(value: Any) -> str:
"""Validate result contains only whitespace."""
if isinstance(value, str) and _WS.fullmatch(value):
return value
raise vol.Invalid(f"contains non-whitespace: {value}")
def isdevice(value: Any) -> str: def isdevice(value: Any) -> str:
"""Validate that value is a real device.""" """Validate that value is a real device."""
try: try:

View File

@ -405,7 +405,11 @@ def async_track_template(
) -> None: ) -> None:
"""Check if condition is correct and run action.""" """Check if condition is correct and run action."""
if isinstance(result, TemplateError): if isinstance(result, TemplateError):
_LOGGER.exception(result) _LOGGER.error(
"Error while processing template: %s",
template.template,
exc_info=result,
)
return return
if result_as_boolean(last_result) or not result_as_boolean(result): if result_as_boolean(last_result) or not result_as_boolean(result):
@ -444,10 +448,10 @@ class _TrackTemplateResultInfo:
"""Handle removal / refresh of tracker init.""" """Handle removal / refresh of tracker init."""
self.hass = hass self.hass = hass
self._template = template self._template = template
self._template.hass = hass
self._action = action self._action = action
self._variables = variables self._variables = variables
self._last_result: Optional[str] = None self._last_result: Optional[Union[str, TemplateError]] = None
self._last_exception = False
self._all_listener: Optional[Callable] = None self._all_listener: Optional[Callable] = None
self._domains_listener: Optional[Callable] = None self._domains_listener: Optional[Callable] = None
self._entities_listener: Optional[Callable] = None self._entities_listener: Optional[Callable] = None
@ -458,8 +462,11 @@ class _TrackTemplateResultInfo:
"""Activation of template tracking.""" """Activation of template tracking."""
self._info = self._template.async_render_to_info(self._variables) self._info = self._template.async_render_to_info(self._variables)
if self._info.exception: if self._info.exception:
self._last_exception = True _LOGGER.error(
_LOGGER.exception(self._info.exception) "Error while processing template: %s",
self._template.template,
exc_info=self._info.exception,
)
self._create_listeners() self._create_listeners()
self._last_info = self._info self._last_info = self._info
@ -593,26 +600,26 @@ class _TrackTemplateResultInfo:
self._variables = variables self._variables = variables
self._refresh(None) self._refresh(None)
@callback
def _refresh(self, event: Optional[Event]) -> None: def _refresh(self, event: Optional[Event]) -> None:
self._info = self._template.async_render_to_info(self._variables) self._info = self._template.async_render_to_info(self._variables)
self._update_listeners() self._update_listeners()
self._last_info = self._info self._last_info = self._info
try: try:
result = self._info.result result: Union[str, TemplateError] = self._info.result
except TemplateError as ex: except TemplateError as ex:
if not self._last_exception: result = ex
self.hass.async_run_job(
self._action, event, self._template, self._last_result, ex
)
self._last_exception = True
return
self._last_exception = False
# Check to see if the result has changed # Check to see if the result has changed
if result == self._last_result: if result == self._last_result:
return return
if isinstance(result, TemplateError) and isinstance(
self._last_result, TemplateError
):
return
self.hass.async_run_job( self.hass.async_run_job(
self._action, event, self._template, self._last_result, result self._action, event, self._template, self._last_result, result
) )

View File

@ -3,6 +3,8 @@ from datetime import timedelta
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.components.template import binary_sensor as template
from homeassistant.const import ( from homeassistant.const import (
@ -318,10 +320,10 @@ class TestBinarySensorTemplate(unittest.TestCase):
None, None,
None, None,
).result() ).result()
mock_render.side_effect = TemplateError("foo") mock_render.side_effect = TemplateError(jinja2.TemplateError("foo"))
run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
mock_render.side_effect = TemplateError( mock_render.side_effect = TemplateError(
"UndefinedError: 'None' has no attribute" jinja2.TemplateError("UndefinedError: 'None' has no attribute")
) )
run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()

View File

@ -544,9 +544,13 @@ 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
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity("sensor.invalid_template") await hass.helpers.entity_component.async_update_entity("sensor.invalid_template")
assert ("Error rendering attribute test_attribute") in caplog.text assert "TemplateError" in caplog.text
assert "test_attribute" 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):
@ -577,7 +581,7 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap
async def test_no_template_match_all(hass, caplog): async def test_no_template_match_all(hass, caplog):
"""Test that we do not allow sensors that match on all.""" """Test that we allow static templates."""
hass.states.async_set("sensor.test_sensor", "startup") hass.states.async_set("sensor.test_sensor", "startup")
await async_setup_component( await async_setup_component(
@ -617,31 +621,6 @@ async def test_no_template_match_all(hass, caplog):
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all()) == 6 assert len(hass.states.async_all()) == 6
assert (
"Template sensor 'invalid_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 sensor 'invalid_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 sensor 'invalid_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 sensor 'invalid_friendly_name' has no entity ids "
"configured to track nor were we able to extract the entities to "
"track from the friendly_name template"
) in caplog.text
assert (
"Template sensor 'invalid_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("sensor.invalid_state").state == "unknown" assert hass.states.get("sensor.invalid_state").state == "unknown"
assert hass.states.get("sensor.invalid_icon").state == "unknown" assert hass.states.get("sensor.invalid_icon").state == "unknown"

View File

@ -1092,3 +1092,24 @@ def test_script(caplog):
cv.script_action(data) cv.script_action(data)
assert msg in str(excinfo.value) assert msg in str(excinfo.value)
def test_whitespace():
"""Test whitespace validation."""
schema = vol.Schema(cv.whitespace)
for value in (
None,
"" "T",
"negative",
"lock",
"tr ue",
[],
[1, 2],
{"one": "two"},
):
with pytest.raises(vol.MultipleInvalid):
schema(value)
for value in (" ", " "):
assert schema(value)

View File

@ -4,6 +4,7 @@ import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from astral import Astral from astral import Astral
import jinja2
import pytest import pytest
from homeassistant.components import sun from homeassistant.components import sun
@ -942,7 +943,7 @@ async def test_track_template_result_errors(hass, caplog):
hass.states.async_set("switch.not_exist", "on") hass.states.async_set("switch.not_exist", "on")
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(syntax_error_runs) == 0 assert len(syntax_error_runs) == 1
assert len(not_exist_runs) == 2 assert len(not_exist_runs) == 2
assert not_exist_runs[1][0].data.get("entity_id") == "switch.not_exist" assert not_exist_runs[1][0].data.get("entity_id") == "switch.not_exist"
assert not_exist_runs[1][1] == template_not_exist assert not_exist_runs[1][1] == template_not_exist
@ -950,7 +951,7 @@ async def test_track_template_result_errors(hass, caplog):
assert not_exist_runs[1][3] == "on" assert not_exist_runs[1][3] == "on"
with patch.object(Template, "async_render") as render: with patch.object(Template, "async_render") as render:
render.side_effect = TemplateError("Test") render.side_effect = TemplateError(jinja2.TemplateError())
hass.states.async_set("switch.not_exist", "off") hass.states.async_set("switch.not_exist", "off")
await hass.async_block_till_done() await hass.async_block_till_done()