Allow specifying a super template for async_track_template_result (#58477)

This commit is contained in:
Erik Montnemery 2021-10-27 16:07:17 +02:00 committed by GitHub
parent dfa50a842a
commit bed4096430
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 580 additions and 17 deletions

View file

@ -233,13 +233,27 @@ class TemplateEntity(Entity):
async def _async_template_startup(self, *_) -> None: async def _async_template_startup(self, *_) -> None:
template_var_tups = [] template_var_tups = []
has_availability_template = False
for template, attributes in self._template_attrs.items(): for template, attributes in self._template_attrs.items():
template_var_tups.append(TrackTemplate(template, None)) template_var_tup = TrackTemplate(template, None)
is_availability_template = False
for attribute in attributes: for attribute in attributes:
# pylint: disable-next=protected-access
if attribute._attribute == "_attr_available":
has_availability_template = True
is_availability_template = True
attribute.async_setup() attribute.async_setup()
# Insert the availability template first in the list
if is_availability_template:
template_var_tups.insert(0, template_var_tup)
else:
template_var_tups.append(template_var_tup)
result_info = async_track_template_result( result_info = async_track_template_result(
self.hass, template_var_tups, self._handle_results self.hass,
template_var_tups,
self._handle_results,
has_super_template=has_availability_template,
) )
self.async_on_remove(result_info.async_remove) self.async_on_remove(result_info.async_remove)
self._async_update = result_info.async_refresh self._async_update = result_info.async_refresh

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Iterable from collections.abc import Awaitable, Iterable, Sequence
import copy import copy
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -767,8 +767,9 @@ class _TrackTemplateResultInfo:
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
track_templates: Iterable[TrackTemplate], track_templates: Sequence[TrackTemplate],
action: Callable, action: Callable,
has_super_template: bool = False,
) -> None: ) -> None:
"""Handle removal / refresh of tracker init.""" """Handle removal / refresh of tracker init."""
self.hass = hass self.hass = hass
@ -777,8 +778,9 @@ class _TrackTemplateResultInfo:
for track_template_ in track_templates: for track_template_ in track_templates:
track_template_.template.hass = hass track_template_.template.hass = hass
self._track_templates = track_templates self._track_templates = track_templates
self._has_super_template = has_super_template
self._last_result: dict[Template, str | TemplateError] = {} self._last_result: dict[Template, bool | str | TemplateError] = {}
self._rate_limit = KeyedRateLimit(hass) self._rate_limit = KeyedRateLimit(hass)
self._info: dict[Template, RenderInfo] = {} self._info: dict[Template, RenderInfo] = {}
@ -874,7 +876,7 @@ class _TrackTemplateResultInfo:
) -> bool | TrackTemplateResult: ) -> bool | TrackTemplateResult:
"""Re-render the template if conditions match. """Re-render the template if conditions match.
Returns False if the template was not be re-rendered Returns False if the template was not re-rendered.
Returns True if the template re-rendered and did not Returns True if the template re-rendered and did not
change. change.
@ -953,19 +955,68 @@ class _TrackTemplateResultInfo:
info_changed = False info_changed = False
now = event.time_fired if not replayed and event else dt_util.utcnow() now = event.time_fired if not replayed and event else dt_util.utcnow()
for track_template_ in track_templates or self._track_templates: def _apply_update(
update = self._render_template_if_ready(track_template_, now, event) update: bool | TrackTemplateResult, template: Template
) -> bool:
"""Handle updates of a tracked template."""
if not update: if not update:
continue return False
template = track_template_.template
self._setup_time_listener(template, self._info[template].has_time) self._setup_time_listener(template, self._info[template].has_time)
info_changed = True
if isinstance(update, TrackTemplateResult): if isinstance(update, TrackTemplateResult):
updates.append(update) updates.append(update)
return True
block_updates = False
super_template = self._track_templates[0] if self._has_super_template else None
track_templates = track_templates or self._track_templates
def _super_template_as_boolean(result: bool | str | TemplateError) -> bool:
"""Return True if the result is truthy or a TemplateError."""
if isinstance(result, TemplateError):
return True
return result_as_boolean(result)
# Update the super template first
if super_template is not None:
update = self._render_template_if_ready(super_template, now, event)
info_changed |= _apply_update(update, super_template.template)
if isinstance(update, TrackTemplateResult):
super_result = update.result
else:
super_result = self._last_result.get(super_template.template)
# If the super template did not render to True, don't update other templates
if (
super_result is not None
and _super_template_as_boolean(super_result) is not True
):
block_updates = True
if (
isinstance(update, TrackTemplateResult)
and _super_template_as_boolean(update.last_result) is not True
and _super_template_as_boolean(update.result) is True
):
# Super template changed from not True to True, force re-render
# of all templates in the group
event = None
track_templates = self._track_templates
# Then update the remaining templates unless blocked by the super template
if not block_updates:
for track_template_ in track_templates:
if track_template_ == super_template:
continue
update = self._render_template_if_ready(track_template_, now, event)
info_changed |= _apply_update(update, track_template_.template)
if info_changed: if info_changed:
assert self._track_state_changes assert self._track_state_changes
self._track_state_changes.async_update_listeners( self._track_state_changes.async_update_listeners(
@ -1016,10 +1067,11 @@ TrackTemplateResultListener = Callable[
@bind_hass @bind_hass
def async_track_template_result( def async_track_template_result(
hass: HomeAssistant, hass: HomeAssistant,
track_templates: Iterable[TrackTemplate], track_templates: Sequence[TrackTemplate],
action: TrackTemplateResultListener, action: TrackTemplateResultListener,
raise_on_template_error: bool = False, raise_on_template_error: bool = False,
strict: bool = False, strict: bool = False,
has_super_template: bool = False,
) -> _TrackTemplateResultInfo: ) -> _TrackTemplateResultInfo:
"""Add a listener that fires when the result of a template changes. """Add a listener that fires when the result of a template changes.
@ -1050,13 +1102,18 @@ def async_track_template_result(
tracking. tracking.
strict strict
When set to True, raise on undefined variables. When set to True, raise on undefined variables.
has_super_template
When set to True, the first template will block rendering of other
templates if it doesn't render as True.
Returns Returns
------- -------
Info object used to unregister the listener, and refresh the template. Info object used to unregister the listener, and refresh the template.
""" """
tracker = _TrackTemplateResultInfo(hass, track_templates, action) tracker = _TrackTemplateResultInfo(
hass, track_templates, action, has_super_template
)
tracker.async_setup(raise_on_template_error, strict=strict) tracker.async_setup(raise_on_template_error, strict=strict)
return tracker return tracker

View file

@ -857,7 +857,7 @@ def _resolve_state(
return None return None
def result_as_boolean(template_result: str | None) -> bool: def result_as_boolean(template_result: Any | None) -> bool:
"""Convert the template result to a boolean. """Convert the template result to a boolean.
True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy

View file

@ -37,7 +37,7 @@ from homeassistant.helpers.event import (
async_track_utc_time_change, async_track_utc_time_change,
track_point_in_utc_time, track_point_in_utc_time,
) )
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template, result_as_boolean
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -1022,6 +1022,287 @@ async def test_track_template_result(hass):
assert len(wildercard_runs) == 4 assert len(wildercard_runs) == 4
async def test_track_template_result_super_template(hass):
"""Test tracking template with super template listening to same entity."""
specific_runs = []
specific_runs_availability = []
wildcard_runs = []
wildcard_runs_availability = []
wildercard_runs = []
wildercard_runs_availability = []
template_availability = Template("{{ is_number(states('sensor.test')) }}", hass)
template_condition = Template("{{states.sensor.test.state}}", hass)
template_condition_var = Template(
"{{(states.sensor.test.state|int) + test }}", hass
)
def specific_run_callback(event, updates):
for track_result in updates:
if track_result.template is template_condition:
specific_runs.append(int(track_result.result))
elif track_result.template is template_availability:
specific_runs_availability.append(track_result.result)
async_track_template_result(
hass,
[
TrackTemplate(template_availability, None),
TrackTemplate(template_condition, None),
],
specific_run_callback,
has_super_template=True,
)
@ha.callback
def wildcard_run_callback(event, updates):
for track_result in updates:
if track_result.template is template_condition:
wildcard_runs.append(
(int(track_result.last_result or 0), int(track_result.result))
)
elif track_result.template is template_availability:
wildcard_runs_availability.append(track_result.result)
async_track_template_result(
hass,
[
TrackTemplate(template_availability, None),
TrackTemplate(template_condition, None),
],
wildcard_run_callback,
has_super_template=True,
)
async def wildercard_run_callback(event, updates):
for track_result in updates:
if track_result.template is template_condition_var:
wildercard_runs.append(
(int(track_result.last_result or 0), int(track_result.result))
)
elif track_result.template is template_availability:
wildercard_runs_availability.append(track_result.result)
async_track_template_result(
hass,
[
TrackTemplate(template_availability, None),
TrackTemplate(template_condition_var, {"test": 5}),
],
wildercard_run_callback,
has_super_template=True,
)
await hass.async_block_till_done()
hass.states.async_set("sensor.test", "unavailable")
await hass.async_block_till_done()
assert specific_runs_availability == [False]
assert wildcard_runs_availability == [False]
assert wildercard_runs_availability == [False]
assert specific_runs == []
assert wildcard_runs == []
assert wildercard_runs == []
hass.states.async_set("sensor.test", 5)
await hass.async_block_till_done()
assert specific_runs_availability == [False, True]
assert wildcard_runs_availability == [False, True]
assert wildercard_runs_availability == [False, True]
assert specific_runs == [5]
assert wildcard_runs == [(0, 5)]
assert wildercard_runs == [(0, 10)]
hass.states.async_set("sensor.test", "unknown")
await hass.async_block_till_done()
assert specific_runs_availability == [False, True, False]
assert wildcard_runs_availability == [False, True, False]
assert wildercard_runs_availability == [False, True, False]
hass.states.async_set("sensor.test", 30)
await hass.async_block_till_done()
assert specific_runs_availability == [False, True, False, True]
assert wildcard_runs_availability == [False, True, False, True]
assert wildercard_runs_availability == [False, True, False, True]
assert specific_runs == [5, 30]
assert wildcard_runs == [(0, 5), (5, 30)]
assert wildercard_runs == [(0, 10), (10, 35)]
hass.states.async_set("sensor.test", "other")
await hass.async_block_till_done()
hass.states.async_set("sensor.test", 30)
await hass.async_block_till_done()
assert len(specific_runs) == 2
assert len(wildcard_runs) == 2
assert len(wildercard_runs) == 2
assert len(specific_runs_availability) == 6
assert len(wildcard_runs_availability) == 6
assert len(wildercard_runs_availability) == 6
hass.states.async_set("sensor.test", 30)
await hass.async_block_till_done()
assert len(specific_runs) == 2
assert len(wildcard_runs) == 2
assert len(wildercard_runs) == 2
assert len(specific_runs_availability) == 6
assert len(wildcard_runs_availability) == 6
assert len(wildercard_runs_availability) == 6
hass.states.async_set("sensor.test", 31)
await hass.async_block_till_done()
assert len(specific_runs) == 3
assert len(wildcard_runs) == 3
assert len(wildercard_runs) == 3
assert len(specific_runs_availability) == 6
assert len(wildcard_runs_availability) == 6
assert len(wildercard_runs_availability) == 6
@pytest.mark.parametrize(
"availability_template",
[
"{{ states('sensor.test2') != 'unavailable' }}",
"{% if states('sensor.test2') != 'unavailable' -%} true {%- else -%} false {%- endif %}",
"{% if states('sensor.test2') != 'unavailable' -%} 1 {%- else -%} 0 {%- endif %}",
"{% if states('sensor.test2') != 'unavailable' -%} yes {%- else -%} no {%- endif %}",
"{% if states('sensor.test2') != 'unavailable' -%} on {%- else -%} off {%- endif %}",
"{% if states('sensor.test2') != 'unavailable' -%} enable {%- else -%} disable {%- endif %}",
# This will throw when sensor.test2 is not "unavailable"
"{% if states('sensor.test2') != 'unavailable' -%} {{'a' + 5}} {%- else -%} false {%- endif %}",
],
)
async def test_track_template_result_super_template_2(hass, availability_template):
"""Test tracking template with super template listening to different entities."""
specific_runs = []
specific_runs_availability = []
wildcard_runs = []
wildcard_runs_availability = []
wildercard_runs = []
wildercard_runs_availability = []
template_availability = Template(availability_template)
template_condition = Template("{{states.sensor.test.state}}", hass)
template_condition_var = Template(
"{{(states.sensor.test.state|int) + test }}", hass
)
def _super_template_as_boolean(result):
if isinstance(result, TemplateError):
return True
return result_as_boolean(result)
def specific_run_callback(event, updates):
for track_result in updates:
if track_result.template is template_condition:
specific_runs.append(int(track_result.result))
elif track_result.template is template_availability:
specific_runs_availability.append(
_super_template_as_boolean(track_result.result)
)
async_track_template_result(
hass,
[
TrackTemplate(template_availability, None),
TrackTemplate(template_condition, None),
],
specific_run_callback,
has_super_template=True,
)
@ha.callback
def wildcard_run_callback(event, updates):
for track_result in updates:
if track_result.template is template_condition:
wildcard_runs.append(
(int(track_result.last_result or 0), int(track_result.result))
)
elif track_result.template is template_availability:
wildcard_runs_availability.append(
_super_template_as_boolean(track_result.result)
)
async_track_template_result(
hass,
[
TrackTemplate(template_availability, None),
TrackTemplate(template_condition, None),
],
wildcard_run_callback,
has_super_template=True,
)
async def wildercard_run_callback(event, updates):
for track_result in updates:
if track_result.template is template_condition_var:
wildercard_runs.append(
(int(track_result.last_result or 0), int(track_result.result))
)
elif track_result.template is template_availability:
wildercard_runs_availability.append(
_super_template_as_boolean(track_result.result)
)
async_track_template_result(
hass,
[
TrackTemplate(template_availability, None),
TrackTemplate(template_condition_var, {"test": 5}),
],
wildercard_run_callback,
has_super_template=True,
)
await hass.async_block_till_done()
hass.states.async_set("sensor.test2", "unavailable")
await hass.async_block_till_done()
assert specific_runs_availability == [False]
assert wildcard_runs_availability == [False]
assert wildercard_runs_availability == [False]
assert specific_runs == []
assert wildcard_runs == []
assert wildercard_runs == []
hass.states.async_set("sensor.test", 5)
hass.states.async_set("sensor.test2", "available")
await hass.async_block_till_done()
assert specific_runs_availability == [False, True]
assert wildcard_runs_availability == [False, True]
assert wildercard_runs_availability == [False, True]
assert specific_runs == [5]
assert wildcard_runs == [(0, 5)]
assert wildercard_runs == [(0, 10)]
hass.states.async_set("sensor.test2", "unknown")
await hass.async_block_till_done()
assert specific_runs_availability == [False, True]
assert wildcard_runs_availability == [False, True]
assert wildercard_runs_availability == [False, True]
hass.states.async_set("sensor.test2", "available")
hass.states.async_set("sensor.test", 30)
await hass.async_block_till_done()
assert specific_runs_availability == [False, True]
assert wildcard_runs_availability == [False, True]
assert wildercard_runs_availability == [False, True]
assert specific_runs == [5, 30]
assert wildcard_runs == [(0, 5), (5, 30)]
assert wildercard_runs == [(0, 10), (10, 35)]
async def test_track_template_result_complex(hass): async def test_track_template_result_complex(hass):
"""Test tracking template.""" """Test tracking template."""
specific_runs = [] specific_runs = []
@ -1587,6 +1868,217 @@ async def test_track_template_rate_limit(hass):
assert refresh_runs == [0, 1, 2, 4] assert refresh_runs == [0, 1, 2, 4]
async def test_track_template_rate_limit_super(hass):
"""Test template rate limit with super template."""
template_availability = Template(
"{{ states('sensor.one') != 'unavailable' }}", hass
)
template_refresh = Template("{{ states | count }}", hass)
availability_runs = []
refresh_runs = []
@ha.callback
def refresh_listener(event, updates):
for track_result in updates:
if track_result.template is template_refresh:
refresh_runs.append(track_result.result)
elif track_result.template is template_availability:
availability_runs.append(track_result.result)
info = async_track_template_result(
hass,
[
TrackTemplate(template_availability, None),
TrackTemplate(template_refresh, None, timedelta(seconds=0.1)),
],
refresh_listener,
has_super_template=True,
)
await hass.async_block_till_done()
info.async_refresh()
await hass.async_block_till_done()
assert refresh_runs == [0]
hass.states.async_set("sensor.one", "any")
await hass.async_block_till_done()
assert refresh_runs == [0]
info.async_refresh()
assert refresh_runs == [0, 1]
hass.states.async_set("sensor.two", "any")
await hass.async_block_till_done()
assert refresh_runs == [0, 1]
hass.states.async_set("sensor.one", "unavailable")
await hass.async_block_till_done()
assert refresh_runs == [0, 1]
next_time = dt_util.utcnow() + timedelta(seconds=0.125)
with patch(
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
):
async_fire_time_changed(hass, next_time)
await hass.async_block_till_done()
assert refresh_runs == [0, 1]
hass.states.async_set("sensor.three", "any")
await hass.async_block_till_done()
assert refresh_runs == [0, 1]
hass.states.async_set("sensor.four", "any")
await hass.async_block_till_done()
assert refresh_runs == [0, 1]
# The super template renders as true -> trigger rerendering of all templates
hass.states.async_set("sensor.one", "available")
await hass.async_block_till_done()
assert refresh_runs == [0, 1, 4]
next_time = dt_util.utcnow() + timedelta(seconds=0.125 * 2)
with patch(
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
):
async_fire_time_changed(hass, next_time)
await hass.async_block_till_done()
assert refresh_runs == [0, 1, 4]
hass.states.async_set("sensor.five", "any")
await hass.async_block_till_done()
assert refresh_runs == [0, 1, 4]
async def test_track_template_rate_limit_super_2(hass):
"""Test template rate limit with rate limited super template."""
# Somewhat forced example of a rate limited template
template_availability = Template("{{ states | count % 2 == 1 }}", hass)
template_refresh = Template("{{ states | count }}", hass)
availability_runs = []
refresh_runs = []
@ha.callback
def refresh_listener(event, updates):
for track_result in updates:
if track_result.template is template_refresh:
refresh_runs.append(track_result.result)
elif track_result.template is template_availability:
availability_runs.append(track_result.result)
info = async_track_template_result(
hass,
[
TrackTemplate(template_availability, None, timedelta(seconds=0.1)),
TrackTemplate(template_refresh, None, timedelta(seconds=0.1)),
],
refresh_listener,
has_super_template=True,
)
await hass.async_block_till_done()
info.async_refresh()
await hass.async_block_till_done()
assert refresh_runs == []
hass.states.async_set("sensor.one", "any")
await hass.async_block_till_done()
assert refresh_runs == []
info.async_refresh()
assert refresh_runs == [1]
hass.states.async_set("sensor.two", "any")
await hass.async_block_till_done()
assert refresh_runs == [1]
next_time = dt_util.utcnow() + timedelta(seconds=0.125)
with patch(
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
):
async_fire_time_changed(hass, next_time)
await hass.async_block_till_done()
assert refresh_runs == [1]
hass.states.async_set("sensor.three", "any")
await hass.async_block_till_done()
assert refresh_runs == [1]
hass.states.async_set("sensor.four", "any")
await hass.async_block_till_done()
assert refresh_runs == [1]
hass.states.async_set("sensor.five", "any")
await hass.async_block_till_done()
assert refresh_runs == [1]
next_time = dt_util.utcnow() + timedelta(seconds=0.125 * 2)
with patch(
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
):
async_fire_time_changed(hass, next_time)
await hass.async_block_till_done()
assert refresh_runs == [1, 5]
hass.states.async_set("sensor.six", "any")
await hass.async_block_till_done()
assert refresh_runs == [1, 5]
async def test_track_template_rate_limit_super_3(hass):
"""Test template with rate limited super template."""
# Somewhat forced example of a rate limited template
template_availability = Template("{{ states | count % 2 == 1 }}", hass)
template_refresh = Template("{{ states | count }}", hass)
availability_runs = []
refresh_runs = []
@ha.callback
def refresh_listener(event, updates):
for track_result in updates:
if track_result.template is template_refresh:
refresh_runs.append(track_result.result)
elif track_result.template is template_availability:
availability_runs.append(track_result.result)
info = async_track_template_result(
hass,
[
TrackTemplate(template_availability, None, timedelta(seconds=0.1)),
TrackTemplate(template_refresh, None),
],
refresh_listener,
has_super_template=True,
)
await hass.async_block_till_done()
info.async_refresh()
await hass.async_block_till_done()
assert refresh_runs == []
hass.states.async_set("sensor.one", "any")
await hass.async_block_till_done()
assert refresh_runs == []
info.async_refresh()
assert refresh_runs == [1]
hass.states.async_set("sensor.two", "any")
await hass.async_block_till_done()
# The super template is rate limited so stuck at `True`
assert refresh_runs == [1, 2]
next_time = dt_util.utcnow() + timedelta(seconds=0.125)
with patch(
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
):
async_fire_time_changed(hass, next_time)
await hass.async_block_till_done()
assert refresh_runs == [1, 2]
hass.states.async_set("sensor.three", "any")
await hass.async_block_till_done()
# The super template is rate limited so stuck at `False`
assert refresh_runs == [1, 2]
hass.states.async_set("sensor.four", "any")
await hass.async_block_till_done()
assert refresh_runs == [1, 2]
hass.states.async_set("sensor.five", "any")
await hass.async_block_till_done()
assert refresh_runs == [1, 2]
next_time = dt_util.utcnow() + timedelta(seconds=0.125 * 2)
with patch(
"homeassistant.helpers.ratelimit.dt_util.utcnow", return_value=next_time
):
async_fire_time_changed(hass, next_time)
await hass.async_block_till_done()
assert refresh_runs == [1, 2, 5]
hass.states.async_set("sensor.six", "any")
await hass.async_block_till_done()
assert refresh_runs == [1, 2, 5, 6]
hass.states.async_set("sensor.seven", "any")
await hass.async_block_till_done()
assert refresh_runs == [1, 2, 5, 6, 7]
async def test_track_template_rate_limit_suppress_listener(hass): async def test_track_template_rate_limit_suppress_listener(hass):
"""Test template rate limit will suppress the listener during the rate limit.""" """Test template rate limit will suppress the listener during the rate limit."""
template_refresh = Template("{{ states | count }}", hass) template_refresh = Template("{{ states | count }}", hass)
@ -1749,7 +2241,7 @@ async def test_track_template_has_default_rate_limit(hass):
assert refresh_runs == [1, 2] assert refresh_runs == [1, 2]
async def test_track_template_unavailable_sates_has_default_rate_limit(hass): async def test_track_template_unavailable_states_has_default_rate_limit(hass):
"""Test template watching for unavailable states has a rate limit by default.""" """Test template watching for unavailable states has a rate limit by default."""
hass.states.async_set("sensor.zero", "unknown") hass.states.async_set("sensor.zero", "unknown")
template_refresh = Template( template_refresh = Template(