From 810bf06e16d7ce6320106b96f9f74bd20dd4b8c4 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:06:19 -0400 Subject: [PATCH] Add limited template to at field for time triggers (#126584) * Add limited template to at field for time triggers * fix mypy * Fix comments * fix-tests --------- Co-authored-by: Erik Montnemery --- .../components/homeassistant/triggers/time.py | 32 +++++++- .../homeassistant/triggers/test_time.py | 81 ++++++++++++++++++- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 443d9c65d95..bea6e8a66a7 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -3,7 +3,7 @@ from collections.abc import Callable from datetime import datetime, timedelta from functools import partial -from typing import NamedTuple +from typing import Any, NamedTuple import voluptuous as vol @@ -26,7 +26,8 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, @@ -37,6 +38,7 @@ from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util _TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"])) +_TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) _TIME_TRIGGER_ENTITY_WITH_OFFSET = vol.Schema( { @@ -45,16 +47,29 @@ _TIME_TRIGGER_ENTITY_WITH_OFFSET = vol.Schema( } ) + +def valid_at_template(value: Any) -> template.Template: + """Validate either a jinja2 template, valid time, or valid trigger entity.""" + tpl = cv.template(value) + + if tpl.is_static: + _TIME_AT_SCHEMA(value) + + return tpl + + _TIME_TRIGGER_SCHEMA = vol.Any( cv.time, _TIME_TRIGGER_ENTITY, _TIME_TRIGGER_ENTITY_WITH_OFFSET, + valid_at_template, msg=( "Expected HH:MM, HH:MM:SS, an Entity ID with domain 'input_datetime' or " - "'sensor', or a combination of a timestamp sensor entity and an offset." + "'sensor', a combination of a timestamp sensor entity and an offset, or Limited Template" ), ) + TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "time", @@ -78,6 +93,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" trigger_data = trigger_info["trigger_data"] + variables = trigger_info["variables"] or {} entities: dict[tuple[str, timedelta], CALLBACK_TYPE] = {} removes: list[CALLBACK_TYPE] = [] job = HassJob(action, f"time trigger {trigger_info}") @@ -202,6 +218,16 @@ async def async_attach_trigger( to_track: list[TrackEntity] = [] for at_time in config[CONF_AT]: + if isinstance(at_time, template.Template): + render = template.render_complex(at_time, variables, limited=True) + try: + at_time = _TIME_AT_SCHEMA(render) + except vol.Invalid as exc: + raise HomeAssistantError( + f"Limited Template for 'at' rendered a unexpected value '{render}', expected HH:MM, " + f"HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'" + ) from exc + if isinstance(at_time, str): # entity update_entity_trigger(at_time, new_state=hass.states.get(at_time)) diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 5455b06d1c0..8900998a7b8 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -159,7 +159,10 @@ async def test_if_fires_using_at_input_datetime( @pytest.mark.parametrize( ("conf_at", "trigger_deltas"), [ - (["5:00:00", "6:00:00"], [timedelta(0), timedelta(hours=1)]), + ( + ["5:00:00", "6:00:00", "{{ '7:00:00' }}"], + [timedelta(0), timedelta(hours=1), timedelta(hours=2)], + ), ( [ "5:00:05", @@ -435,10 +438,14 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: assert len(mock_track_time_change.mock_calls) == 3 +@pytest.mark.parametrize( + ("at_sensor"), ["sensor.next_alarm", "{{ 'sensor.next_alarm' }}"] +) async def test_if_fires_using_at_sensor( hass: HomeAssistant, freezer: FrozenDateTimeFactory, service_calls: list[ServiceCall], + at_sensor: str, ) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -461,7 +468,7 @@ async def test_if_fires_using_at_sensor( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "time", "at": "sensor.next_alarm"}, + "trigger": {"platform": "time", "at": at_sensor}, "action": { "service": "test.automation", "data_template": {"some": some_data}, @@ -626,6 +633,9 @@ async def test_if_fires_using_at_sensor_with_offset( {"platform": "time", "at": "input_datetime.bla"}, {"platform": "time", "at": "sensor.bla"}, {"platform": "time", "at": "12:34"}, + {"platform": "time", "at": "{{ '12:34' }}"}, + {"platform": "time", "at": "{{ 'input_datetime.bla' }}"}, + {"platform": "time", "at": "{{ 'sensor.bla' }}"}, {"platform": "time", "at": {"entity_id": "sensor.bla", "offset": "-00:01"}}, { "platform": "time", @@ -724,3 +734,70 @@ async def test_datetime_in_past_on_load( service_calls[2].data["some"] == f"time-{future.day}-{future.hour}-input_datetime.my_trigger" ) + + +@pytest.mark.parametrize( + "trigger", + [ + {"platform": "time", "at": "{{ 'hello world' }}"}, + {"platform": "time", "at": "{{ 74 }}"}, + {"platform": "time", "at": "{{ true }}"}, + {"platform": "time", "at": "{{ 7.5465 }}"}, + ], +) +async def test_if_at_template_renders_bad_value( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + trigger: dict[str, str], +) -> None: + """Test for invalid templates.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": trigger, + "action": { + "service": "test.automation", + }, + } + }, + ) + + await hass.async_block_till_done() + + assert ( + "expected HH:MM, HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'" + in caplog.text + ) + + +@pytest.mark.parametrize( + "trigger", + [ + {"platform": "time", "at": "{{ now().strftime('%H:%M') }}"}, + {"platform": "time", "at": "{{ states('sensor.blah') | int(0) }}"}, + ], +) +async def test_if_at_template_limited_template( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + trigger: dict[str, str], +) -> None: + """Test for invalid templates.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": trigger, + "action": { + "service": "test.automation", + }, + } + }, + ) + + await hass.async_block_till_done() + + assert "is not supported in limited templates" in caplog.text