From bed4096430ebb0b549c4a72cd88ac6de57a7a3b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 27 Oct 2021 16:07:17 +0200 Subject: [PATCH] Allow specifying a super template for async_track_template_result (#58477) --- .../components/template/template_entity.py | 18 +- homeassistant/helpers/event.py | 81 ++- homeassistant/helpers/template.py | 2 +- tests/helpers/test_event.py | 496 +++++++++++++++++- 4 files changed, 580 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 42517b00d4a..70d7b4af7dd 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -233,13 +233,27 @@ class TemplateEntity(Entity): async def _async_template_startup(self, *_) -> None: template_var_tups = [] + has_availability_template = False 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: + # pylint: disable-next=protected-access + if attribute._attribute == "_attr_available": + has_availability_template = True + is_availability_template = True 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( - 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_update = result_info.async_refresh diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b37a79a83ec..64cf3bf20bb 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Iterable +from collections.abc import Awaitable, Iterable, Sequence import copy from dataclasses import dataclass from datetime import datetime, timedelta @@ -767,8 +767,9 @@ class _TrackTemplateResultInfo: def __init__( self, hass: HomeAssistant, - track_templates: Iterable[TrackTemplate], + track_templates: Sequence[TrackTemplate], action: Callable, + has_super_template: bool = False, ) -> None: """Handle removal / refresh of tracker init.""" self.hass = hass @@ -777,8 +778,9 @@ class _TrackTemplateResultInfo: for track_template_ in track_templates: track_template_.template.hass = hass 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._info: dict[Template, RenderInfo] = {} @@ -874,7 +876,7 @@ class _TrackTemplateResultInfo: ) -> bool | TrackTemplateResult: """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 change. @@ -953,19 +955,68 @@ class _TrackTemplateResultInfo: info_changed = False now = event.time_fired if not replayed and event else dt_util.utcnow() - for track_template_ in track_templates or self._track_templates: - update = self._render_template_if_ready(track_template_, now, event) + def _apply_update( + update: bool | TrackTemplateResult, template: Template + ) -> bool: + """Handle updates of a tracked template.""" if not update: - continue + return False - template = track_template_.template self._setup_time_listener(template, self._info[template].has_time) - info_changed = True - if isinstance(update, TrackTemplateResult): 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: assert self._track_state_changes self._track_state_changes.async_update_listeners( @@ -1016,10 +1067,11 @@ TrackTemplateResultListener = Callable[ @bind_hass def async_track_template_result( hass: HomeAssistant, - track_templates: Iterable[TrackTemplate], + track_templates: Sequence[TrackTemplate], action: TrackTemplateResultListener, raise_on_template_error: bool = False, strict: bool = False, + has_super_template: bool = False, ) -> _TrackTemplateResultInfo: """Add a listener that fires when the result of a template changes. @@ -1050,13 +1102,18 @@ def async_track_template_result( tracking. strict 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 ------- 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) return tracker diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index a977b4a2c87..a181fbbfb44 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -857,7 +857,7 @@ def _resolve_state( 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. True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index fb7464d405f..cf2e5ac13b8 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -37,7 +37,7 @@ from homeassistant.helpers.event import ( async_track_utc_time_change, 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 import homeassistant.util.dt as dt_util @@ -1022,6 +1022,287 @@ async def test_track_template_result(hass): 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): """Test tracking template.""" specific_runs = [] @@ -1587,6 +1868,217 @@ async def test_track_template_rate_limit(hass): 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): """Test template rate limit will suppress the listener during the rate limit.""" 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] -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.""" hass.states.async_set("sensor.zero", "unknown") template_refresh = Template(