From 31c21126a8437c3914f6b505ec2d24ff864b2d05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2020 04:02:43 -0500 Subject: [PATCH] Implement time tracking in templates (#41147) Co-authored-by: Anders Melchiorsen --- homeassistant/helpers/event.py | 41 ++- homeassistant/helpers/template.py | 25 +- .../components/websocket_api/test_commands.py | 35 ++- tests/helpers/test_event.py | 245 +++++++++++++++++- tests/helpers/test_template.py | 60 +++-- 5 files changed, 374 insertions(+), 32 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index a3eb981140d..e15d3ed90ee 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -753,6 +753,7 @@ class _TrackTemplateResultInfo: self._rate_limit = KeyedRateLimit(hass) self._info: Dict[Template, RenderInfo] = {} self._track_state_changes: Optional[_TrackStateChangeFiltered] = None + self._time_listeners: Dict[Template, Callable] = {} def async_setup(self, raise_on_template_error: bool) -> None: """Activation of template tracking.""" @@ -773,6 +774,7 @@ class _TrackTemplateResultInfo: self._track_state_changes = async_track_state_change_filtered( self.hass, _render_infos_to_track_states(self._info.values()), self._refresh ) + self._update_time_listeners() _LOGGER.debug( "Template group %s listens for %s", self._track_templates, @@ -783,7 +785,38 @@ class _TrackTemplateResultInfo: def listeners(self) -> Dict: """State changes that will cause a re-render.""" assert self._track_state_changes - return self._track_state_changes.listeners + return { + **self._track_state_changes.listeners, + "time": bool(self._time_listeners), + } + + @callback + def _setup_time_listener(self, template: Template, has_time: bool) -> None: + if template in self._time_listeners: + self._time_listeners.pop(template)() + + # now() or utcnow() has left the scope of the template + if not has_time: + return + + track_templates = [ + track_template_ + for track_template_ in self._track_templates + if track_template_.template == template + ] + + @callback + def _refresh_from_time(now: datetime) -> None: + self._refresh(None, track_templates=track_templates) + + self._time_listeners[template] = async_call_later( + self.hass, 60.45, _refresh_from_time + ) + + @callback + def _update_time_listeners(self) -> None: + for template, info in self._info.items(): + self._setup_time_listener(template, info.has_time) @callback def async_remove(self) -> None: @@ -791,6 +824,8 @@ class _TrackTemplateResultInfo: assert self._track_state_changes self._track_state_changes.async_remove() self._rate_limit.async_remove() + for template in list(self._time_listeners): + self._time_listeners.pop(template)() @callback def async_refresh(self) -> None: @@ -889,7 +924,11 @@ class _TrackTemplateResultInfo: if not update: continue + template = track_template_.template + self._setup_time_listener(template, self._info[template].has_time) + info_changed = True + if isinstance(update, TrackTemplateResult): updates.append(update) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 592fc1fccd1..8920060d8e2 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -203,10 +203,11 @@ class RenderInfo: self.domains_lifecycle = set() self.entities = set() self.rate_limit = None + self.has_time = False def __repr__(self) -> str: """Representation of RenderInfo.""" - return f"" + return f" has_time={self.has_time}" def _filter_domains_and_entities(self, entity_id: str) -> bool: """Template should re-render if the entity state changes when we match specific domains or entities.""" @@ -961,6 +962,24 @@ def state_attr(hass, entity_id, name): return None +def now(hass): + """Record fetching now.""" + render_info = hass.data.get(_RENDER_INFO) + if render_info is not None: + render_info.has_time = True + + return dt_util.now() + + +def utcnow(hass): + """Record fetching utcnow.""" + render_info = hass.data.get(_RENDER_INFO) + if render_info is not None: + render_info.has_time = True + + return dt_util.utcnow() + + def forgiving_round(value, precision=0, method="common"): """Round accepted strings.""" try: @@ -1291,9 +1310,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["atan"] = arc_tangent self.globals["atan2"] = arc_tangent2 self.globals["float"] = forgiving_float - self.globals["now"] = dt_util.now self.globals["as_local"] = dt_util.as_local - self.globals["utcnow"] = dt_util.utcnow self.globals["as_timestamp"] = forgiving_as_timestamp self.globals["relative_time"] = relative_time self.globals["timedelta"] = timedelta @@ -1324,6 +1341,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["is_state_attr"] = hassfunction(is_state_attr) self.globals["state_attr"] = hassfunction(state_attr) self.globals["states"] = AllStates(hass) + self.globals["utcnow"] = hassfunction(utcnow) + self.globals["now"] = hassfunction(now) def is_safe_callable(self, obj): """Test if callback is safe.""" diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index b166b2bb95e..721d178430e 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -417,7 +417,12 @@ async def test_render_template_renders_template(hass, websocket_client): event = msg["event"] assert event == { "result": "State is: on", - "listeners": {"all": False, "domains": [], "entities": ["light.test"]}, + "listeners": { + "all": False, + "domains": [], + "entities": ["light.test"], + "time": False, + }, } hass.states.async_set("light.test", "off") @@ -427,7 +432,12 @@ async def test_render_template_renders_template(hass, websocket_client): event = msg["event"] assert event == { "result": "State is: off", - "listeners": {"all": False, "domains": [], "entities": ["light.test"]}, + "listeners": { + "all": False, + "domains": [], + "entities": ["light.test"], + "time": False, + }, } @@ -456,7 +466,12 @@ async def test_render_template_manual_entity_ids_no_longer_needed( event = msg["event"] assert event == { "result": "State is: on", - "listeners": {"all": False, "domains": [], "entities": ["light.test"]}, + "listeners": { + "all": False, + "domains": [], + "entities": ["light.test"], + "time": False, + }, } hass.states.async_set("light.test", "off") @@ -466,7 +481,12 @@ async def test_render_template_manual_entity_ids_no_longer_needed( event = msg["event"] assert event == { "result": "State is: off", - "listeners": {"all": False, "domains": [], "entities": ["light.test"]}, + "listeners": { + "all": False, + "domains": [], + "entities": ["light.test"], + "time": False, + }, } @@ -553,7 +573,12 @@ async def test_render_template_with_delayed_error(hass, websocket_client, caplog event = msg["event"] assert event == { "result": "on", - "listeners": {"all": False, "domains": [], "entities": ["sensor.test"]}, + "listeners": { + "all": False, + "domains": [], + "entities": ["sensor.test"], + "time": False, + }, } msg = await websocket_client.receive_json() diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 0a4b85edcf4..11fe96a454b 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -992,7 +992,12 @@ async def test_track_template_result_complex(hass): ) await hass.async_block_till_done() - assert info.listeners == {"all": True, "domains": set(), "entities": set()} + assert info.listeners == { + "all": True, + "domains": set(), + "entities": set(), + "time": False, + } hass.states.async_set("sensor.domain", "light") await hass.async_block_till_done() @@ -1003,6 +1008,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": {"light"}, "entities": {"sensor.domain"}, + "time": False, } hass.states.async_set("sensor.domain", "lock") @@ -1013,6 +1019,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": {"lock"}, "entities": {"sensor.domain"}, + "time": False, } hass.states.async_set("sensor.domain", "all") @@ -1021,7 +1028,12 @@ async def test_track_template_result_complex(hass): assert "light.one" in specific_runs[2] assert "lock.one" in specific_runs[2] assert "sensor.domain" in specific_runs[2] - assert info.listeners == {"all": True, "domains": set(), "entities": set()} + assert info.listeners == { + "all": True, + "domains": set(), + "entities": set(), + "time": False, + } hass.states.async_set("sensor.domain", "light") await hass.async_block_till_done() @@ -1031,6 +1043,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": {"light"}, "entities": {"sensor.domain"}, + "time": False, } hass.states.async_set("light.two", "on") @@ -1043,6 +1056,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": {"light"}, "entities": {"sensor.domain"}, + "time": False, } hass.states.async_set("light.three", "on") @@ -1056,6 +1070,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": {"light"}, "entities": {"sensor.domain"}, + "time": False, } hass.states.async_set("sensor.domain", "lock") @@ -1066,6 +1081,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": {"lock"}, "entities": {"sensor.domain"}, + "time": False, } hass.states.async_set("sensor.domain", "single_binary_sensor") @@ -1076,6 +1092,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": set(), "entities": {"binary_sensor.single", "sensor.domain"}, + "time": False, } hass.states.async_set("binary_sensor.single", "binary_sensor_on") @@ -1086,6 +1103,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": set(), "entities": {"binary_sensor.single", "sensor.domain"}, + "time": False, } hass.states.async_set("sensor.domain", "lock") @@ -1096,6 +1114,7 @@ async def test_track_template_result_complex(hass): "all": False, "domains": {"lock"}, "entities": {"sensor.domain"}, + "time": False, } @@ -1128,7 +1147,12 @@ async def test_track_template_result_with_wildcard(hass): hass.states.async_set("cover.office_window", "open") await hass.async_block_till_done() assert len(specific_runs) == 1 - assert info.listeners == {"all": True, "domains": set(), "entities": set()} + assert info.listeners == { + "all": True, + "domains": set(), + "entities": set(), + "time": False, + } assert "cover.office_drapes=closed" in specific_runs[0] assert "cover.office_window=open" in specific_runs[0] @@ -1177,6 +1201,7 @@ async def test_track_template_result_with_group(hass): "sensor.power_2", "sensor.power_3", }, + "time": False, } hass.states.async_set("sensor.power_1", 100.1) @@ -1223,7 +1248,12 @@ async def test_track_template_result_and_conditional(hass): hass, [TrackTemplate(template, None)], specific_run_callback ) await hass.async_block_till_done() - assert info.listeners == {"all": False, "domains": set(), "entities": {"light.a"}} + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"light.a"}, + "time": False, + } hass.states.async_set("light.b", "on") await hass.async_block_till_done() @@ -1237,6 +1267,7 @@ async def test_track_template_result_and_conditional(hass): "all": False, "domains": set(), "entities": {"light.a", "light.b"}, + "time": False, } hass.states.async_set("light.b", "off") @@ -1247,6 +1278,7 @@ async def test_track_template_result_and_conditional(hass): "all": False, "domains": set(), "entities": {"light.a", "light.b"}, + "time": False, } hass.states.async_set("light.a", "off") @@ -1324,6 +1356,7 @@ async def test_track_template_result_iterator(hass): "all": False, "domains": {"sensor"}, "entities": set(), + "time": False, } hass.states.async_set("sensor.test", 6) @@ -1664,6 +1697,7 @@ async def test_track_template_unavailable_sates_has_default_rate_limit(hass): info.async_refresh() await hass.async_block_till_done() assert refresh_runs == [1, 2, 3] + info.async_remove() async def test_specifically_referenced_entity_is_not_rate_limited(hass): @@ -1702,6 +1736,7 @@ async def test_specifically_referenced_entity_is_not_rate_limited(hass): hass.states.async_set("sensor.one", "none") await hass.async_block_till_done() assert refresh_runs == ["1_none", "1_any", "3_none"] + info.async_remove() async def test_track_two_templates_with_different_rate_limits(hass): @@ -1766,6 +1801,7 @@ async def test_track_two_templates_with_different_rate_limits(hass): await hass.async_block_till_done() assert refresh_runs[template_one] == [0, 1, 2] assert refresh_runs[template_five] == [0, 1] + info.async_remove() async def test_string(hass): @@ -1988,6 +2024,207 @@ async def test_async_track_template_result_raise_on_template_error(hass): ) +async def test_track_template_with_time(hass): + """Test tracking template with time.""" + + hass.states.async_set("switch.test", "on") + specific_runs = [] + template_complex = Template("{{ states.switch.test.state and now() }}", hass) + + def specific_run_callback(event, updates): + specific_runs.append(updates.pop().result) + + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() + + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"switch.test"}, + "time": True, + } + + await hass.async_block_till_done() + now = dt_util.utcnow() + async_fire_time_changed(hass, now + timedelta(seconds=61)) + async_fire_time_changed(hass, now + timedelta(seconds=61 * 2)) + await hass.async_block_till_done() + assert specific_runs[-1] != specific_runs[0] + info.async_remove() + + +async def test_track_template_with_time_default(hass): + """Test tracking template with time.""" + + specific_runs = [] + template_complex = Template("{{ now() }}", hass) + + def specific_run_callback(event, updates): + specific_runs.append(updates.pop().result) + + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() + + assert info.listeners == { + "all": False, + "domains": set(), + "entities": set(), + "time": True, + } + + await hass.async_block_till_done() + now = dt_util.utcnow() + async_fire_time_changed(hass, now + timedelta(seconds=2)) + async_fire_time_changed(hass, now + timedelta(seconds=4)) + await hass.async_block_till_done() + assert len(specific_runs) < 2 + async_fire_time_changed(hass, now + timedelta(minutes=2)) + await hass.async_block_till_done() + async_fire_time_changed(hass, now + timedelta(minutes=4)) + await hass.async_block_till_done() + assert len(specific_runs) >= 2 + assert specific_runs[-1] != specific_runs[0] + info.async_remove() + + +async def test_track_template_with_time_that_leaves_scope(hass): + """Test tracking template with time.""" + + hass.states.async_set("binary_sensor.washing_machine", "on") + specific_runs = [] + template_complex = Template( + """ + {% if states.binary_sensor.washing_machine.state == "on" %} + {{ now() }} + {% else %} + {{ states.binary_sensor.washing_machine.last_updated }} + {% endif %} + """, + hass, + ) + + def specific_run_callback(event, updates): + specific_runs.append(updates.pop().result) + + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() + + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } + + hass.states.async_set("binary_sensor.washing_machine", "off") + await hass.async_block_till_done() + + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": False, + } + + hass.states.async_set("binary_sensor.washing_machine", "on") + await hass.async_block_till_done() + + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } + + # Verify we do not update a second time + # if the state change happens + callback_count_before_time_change = len(specific_runs) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=59)) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change + + # Verify we do update on the next time change + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61)) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change + 1 + + info.async_remove() + + +async def test_async_track_template_result_multiple_templates_mixing_listeners(hass): + """Test tracking multiple templates with mixing listener types.""" + + template_1 = Template("{{ states.switch.test.state == 'on' }}") + template_2 = Template("{{ now() and True }}") + + refresh_runs = [] + + @ha.callback + def refresh_listener(event, updates): + refresh_runs.append(updates) + + now = dt_util.utcnow() + + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC + ) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + info = async_track_template_result( + hass, + [ + TrackTemplate(template_1, None), + TrackTemplate(template_2, None), + ], + refresh_listener, + ) + + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"switch.test"}, + "time": True, + } + hass.states.async_set("switch.test", "on") + await hass.async_block_till_done() + + assert refresh_runs == [ + [ + TrackTemplateResult(template_1, None, True), + ] + ] + + refresh_runs = [] + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert refresh_runs == [ + [ + TrackTemplateResult(template_1, True, False), + ] + ] + + refresh_runs = [] + next_time = time_that_will_not_match_right_away + timedelta(hours=25) + with patch("homeassistant.util.dt.utcnow", return_value=next_time): + async_fire_time_changed(hass, next_time) + await hass.async_block_till_done() + + assert refresh_runs == [ + [ + TrackTemplateResult(template_2, None, True), + ] + ] + + async def test_track_same_state_simple_no_trigger(hass): """Test track_same_change with no trigger.""" callback_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 5f67dc2e372..33f0e3f7f04 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -856,10 +856,26 @@ def test_now(mock_is_safe, hass): """Test now method.""" now = dt_util.now() with patch("homeassistant.util.dt.now", return_value=now): - assert ( - now.isoformat() - == template.Template("{{ now().isoformat() }}", hass).async_render() - ) + info = template.Template("{{ now().isoformat() }}", hass).async_render_to_info() + assert now.isoformat() == info.result() + + assert info.has_time is True + + +@patch( + "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", + return_value=True, +) +def test_utcnow(mock_is_safe, hass): + """Test now method.""" + utcnow = dt_util.utcnow() + with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + info = template.Template( + "{{ utcnow().isoformat() }}", hass + ).async_render_to_info() + assert utcnow.isoformat() == info.result() + + assert info.has_time is True @patch( @@ -966,20 +982,6 @@ def test_timedelta(mock_is_safe, hass): ) -@patch( - "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", - return_value=True, -) -def test_utcnow(mock_is_safe, hass): - """Test utcnow method.""" - now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - assert ( - now.isoformat() - == template.Template("{{ utcnow().isoformat() }}", hass).async_render() - ) - - def test_regex_match(hass): """Test regex_match method.""" tpl = template.Template( @@ -1629,6 +1631,7 @@ async def test_async_render_to_info_with_wildcard_matching_state(hass): hass.states.async_set("cover.office_skylight", "open") hass.states.async_set("cover.x_skylight", "open") hass.states.async_set("binary_sensor.door", "open") + await hass.async_block_till_done() info = render_to_info(hass, template_complex_str) @@ -2458,6 +2461,18 @@ async def test_lifecycle(hass): hass.states.async_set("sun.sun", "above", {"elevation": 50, "next_rising": "later"}) for i in range(2): hass.states.async_set(f"sensor.sensor{i}", "on") + hass.states.async_set("sensor.removed", "off") + + await hass.async_block_till_done() + + hass.states.async_set("sun.sun", "below", {"elevation": 60, "next_rising": "later"}) + for i in range(2): + hass.states.async_set(f"sensor.sensor{i}", "off") + + hass.states.async_set("sensor.new", "off") + hass.states.async_remove("sensor.removed") + + await hass.async_block_till_done() tmp = template.Template("{{ states | count }}", hass) @@ -2465,6 +2480,8 @@ async def test_lifecycle(hass): assert info.all_states is False assert info.all_states_lifecycle is True assert info.rate_limit is None + assert info.has_time is False + assert info.entities == set() assert info.domains == set() assert info.domains_lifecycle == set() @@ -2539,9 +2556,15 @@ async def test_template_errors(hass): with pytest.raises(TemplateError): template.Template("{{ now() | rando }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ utcnow() | rando }}", hass).async_render() + with pytest.raises(TemplateError): template.Template("{{ now() | random }}", hass).async_render() + with pytest.raises(TemplateError): + template.Template("{{ utcnow() | random }}", hass).async_render() + async def test_state_attributes(hass): """Test state attributes.""" @@ -2624,7 +2647,6 @@ async def test_legacy_templates(hass): ) await async_process_ha_core_config(hass, {"legacy_templates": True}) - assert ( template.Template("{{ states.sensor.temperature.state }}", hass).async_render() == "12"