diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 90780489d7b..be7ded37dc2 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -1,5 +1,5 @@ """Offer time listening automation rules.""" -from datetime import datetime +from datetime import datetime, timedelta from functools import partial import voluptuous as vol @@ -8,6 +8,8 @@ from homeassistant.components import sensor from homeassistant.const import ( ATTR_DEVICE_CLASS, CONF_AT, + CONF_ENTITY_ID, + CONF_OFFSET, CONF_PLATFORM, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -23,9 +25,21 @@ import homeassistant.util.dt as dt_util # mypy: allow-untyped-defs, no-check-untyped-defs +_TIME_TRIGGER_ENTITY_REFERENCE = vol.All( + str, cv.entity_domain(["input_datetime", "sensor"]) +) + +_TIME_TRIGGER_WITH_OFFSET_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): _TIME_TRIGGER_ENTITY_REFERENCE, + vol.Required(CONF_OFFSET): cv.time_period, + } +) + _TIME_TRIGGER_SCHEMA = vol.Any( cv.time, - vol.All(str, cv.entity_domain(["input_datetime", "sensor"])), + _TIME_TRIGGER_ENTITY_REFERENCE, + _TIME_TRIGGER_WITH_OFFSET_SCHEMA, msg="Expected HH:MM, HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'", ) @@ -43,6 +57,7 @@ async def async_attach_trigger(hass, config, action, automation_info): entities = {} removes = [] job = HassJob(action) + offsets = {} @callback def time_automation_listener(description, now, *, entity_id=None): @@ -77,6 +92,8 @@ async def async_attach_trigger(hass, config, action, automation_info): if not new_state: return + offset = offsets[entity_id] if entity_id in offsets else timedelta(0) + # Check state of entity. If valid, set up a listener. if new_state.domain == "input_datetime": if has_date := new_state.attributes["has_date"]: @@ -93,14 +110,17 @@ async def async_attach_trigger(hass, config, action, automation_info): if has_date: # If input_datetime has date, then track point in time. - trigger_dt = datetime( - year, - month, - day, - hour, - minute, - second, - tzinfo=dt_util.DEFAULT_TIME_ZONE, + trigger_dt = ( + datetime( + year, + month, + day, + hour, + minute, + second, + tzinfo=dt_util.DEFAULT_TIME_ZONE, + ) + + offset ) # Only set up listener if time is now or in the future. if trigger_dt >= dt_util.now(): @@ -132,7 +152,7 @@ async def async_attach_trigger(hass, config, action, automation_info): == sensor.DEVICE_CLASS_TIMESTAMP and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): - trigger_dt = dt_util.parse_datetime(new_state.state) + trigger_dt = dt_util.parse_datetime(new_state.state) + offset if trigger_dt is not None and trigger_dt > dt_util.utcnow(): remove = async_track_point_in_time( @@ -156,6 +176,15 @@ async def async_attach_trigger(hass, config, action, automation_info): # entity to_track.append(at_time) update_entity_trigger(at_time, new_state=hass.states.get(at_time)) + elif isinstance(at_time, dict) and CONF_OFFSET in at_time: + # entity with offset + entity_id = at_time.get(CONF_ENTITY_ID) + to_track.append(entity_id) + offsets[entity_id] = at_time.get(CONF_OFFSET) + update_entity_trigger( + entity_id, + new_state=hass.states.get(entity_id), + ) else: # datetime.time removes.append( diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 499fcf8611e..7961ce25026 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -150,6 +150,96 @@ async def test_if_fires_using_at_input_datetime(hass, calls, has_date, has_time) ) +@pytest.mark.parametrize( + "offset,delta", + [ + ("00:00:10", timedelta(seconds=10)), + ("-00:00:10", timedelta(seconds=-10)), + ({"minutes": 5}, timedelta(minutes=5)), + ], +) +async def test_if_fires_using_at_input_datetime_with_offset(hass, calls, offset, delta): + """Test for firing at input_datetime.""" + await async_setup_component( + hass, + "input_datetime", + {"input_datetime": {"trigger": {"has_date": True, "has_time": True}}}, + ) + now = dt_util.now() + + set_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) + trigger_dt = set_dt + delta + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "datetime": str(set_dt.replace(tzinfo=None)), + }, + blocking=True, + ) + + time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) + + some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{ trigger.now.minute }}-{{ trigger.now.second }}-{{trigger.entity_id}}" + with patch( + "homeassistant.util.dt.utcnow", + return_value=dt_util.as_utc(time_that_will_not_match_right_away), + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": { + "entity_id": "input_datetime.trigger", + "offset": offset, + }, + }, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-{trigger_dt.minute}-{trigger_dt.second}-input_datetime.trigger" + ) + + set_dt += timedelta(days=1, hours=1) + trigger_dt += timedelta(days=1, hours=1) + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "datetime": str(set_dt.replace(tzinfo=None)), + }, + blocking=True, + ) + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(calls) == 2 + assert ( + calls[1].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-{trigger_dt.minute}-{trigger_dt.second}-input_datetime.trigger" + ) + + async def test_if_fires_using_multiple_at(hass, calls): """Test for firing at.""" @@ -498,12 +588,103 @@ async def test_if_fires_using_at_sensor(hass, calls): assert len(calls) == 2 +@pytest.mark.parametrize( + "offset,delta", + [ + ("00:00:10", timedelta(seconds=10)), + ("-00:00:10", timedelta(seconds=-10)), + ({"minutes": 5}, timedelta(minutes=5)), + ], +) +async def test_if_fires_using_at_sensor_with_offset(hass, calls, offset, delta): + """Test for firing at sensor time.""" + now = dt_util.now() + + start_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) + trigger_dt = start_dt + delta + + hass.states.async_set( + "sensor.next_alarm", + start_dt.isoformat(), + {ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TIMESTAMP}, + ) + + time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) + + some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{ trigger.now.minute }}-{{ trigger.now.second }}-{{trigger.entity_id}}" + with patch( + "homeassistant.util.dt.utcnow", + return_value=dt_util.as_utc(time_that_will_not_match_right_away), + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": { + "entity_id": "sensor.next_alarm", + "offset": offset, + }, + }, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-{trigger_dt.minute}-{trigger_dt.second}-sensor.next_alarm" + ) + + start_dt += timedelta(days=1, hours=1) + trigger_dt += timedelta(days=1, hours=1) + + hass.states.async_set( + "sensor.next_alarm", + start_dt.isoformat(), + {ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TIMESTAMP}, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(calls) == 2 + assert ( + calls[1].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-{trigger_dt.minute}-{trigger_dt.second}-sensor.next_alarm" + ) + + @pytest.mark.parametrize( "conf", [ {"platform": "time", "at": "input_datetime.bla"}, {"platform": "time", "at": "sensor.bla"}, {"platform": "time", "at": "12:34"}, + { + "platform": "time", + "at": {"entity_id": "input_datetime.bla", "offset": "00:01"}, + }, + {"platform": "time", "at": {"entity_id": "sensor.bla", "offset": "-00:01"}}, + { + "platform": "time", + "at": [{"entity_id": "input_datetime.bla", "offset": "01:00:00"}], + }, + { + "platform": "time", + "at": [{"entity_id": "sensor.bla", "offset": "-01:00:00"}], + }, ], ) def test_schema_valid(conf): @@ -517,6 +698,9 @@ def test_schema_valid(conf): {"platform": "time", "at": "binary_sensor.bla"}, {"platform": "time", "at": 745}, {"platform": "time", "at": "25:00"}, + {"platform": "time", "at": {"entity_id": "input_datetime.bla", "offset": "0:"}}, + {"platform": "time", "at": {"entity_id": "input_datetime.bla", "offset": "a"}}, + {"platform": "time", "at": {"entity_id": "13:00:00", "offset": "0:10"}}, ], ) def test_schema_invalid(conf):