From 192fe58fc8de25df5530dc46054dae3125838e71 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 11 Aug 2020 15:16:28 -0500 Subject: [PATCH] Time trigger can also accept an input_datetime Entity ID (#38698) --- homeassistant/components/automation/time.py | 100 +++++++++++++++++--- tests/components/automation/test_time.py | 90 ++++++++++++++++-- 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index f59ceff81ea..76acaf89c6c 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -1,4 +1,5 @@ """Offer time listening automation rules.""" +from datetime import datetime import logging import voluptuous as vol @@ -6,39 +7,112 @@ import voluptuous as vol from homeassistant.const import CONF_AT, CONF_PLATFORM from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_time_change +from homeassistant.helpers.event import ( + async_track_point_in_time, + async_track_state_change, + async_track_time_change, +) +import homeassistant.util.dt as dt_util # mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) +_TIME_TRIGGER_SCHEMA = vol.Any( + cv.time, + vol.All(str, cv.entity_domain("input_datetime")), + msg="Expected HH:MM, HH:MM:SS or Entity ID from domain 'input_datetime'", +) + TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): "time", - vol.Required(CONF_AT): vol.All(cv.ensure_list, [cv.time]), + vol.Required(CONF_AT): vol.All(cv.ensure_list, [_TIME_TRIGGER_SCHEMA]), } ) async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - at_times = config[CONF_AT] + entities = {} + removes = [] @callback def time_automation_listener(now): """Listen for time changes and calls action.""" hass.async_run_job(action, {"trigger": {"platform": "time", "now": now}}) - removes = [ - async_track_time_change( - hass, - time_automation_listener, - hour=at_time.hour, - minute=at_time.minute, - second=at_time.second, - ) - for at_time in at_times - ] + @callback + def update_entity_trigger(entity_id, old_state=None, new_state=None): + # If a listener was already set up for entity, remove it. + remove = entities.get(entity_id) + if remove: + remove() + removes.remove(remove) + remove = None + + # Check state of entity. If valid, set up a listener. + if new_state: + has_date = new_state.attributes["has_date"] + if has_date: + year = new_state.attributes["year"] + month = new_state.attributes["month"] + day = new_state.attributes["day"] + has_time = new_state.attributes["has_time"] + if has_time: + hour = new_state.attributes["hour"] + minute = new_state.attributes["minute"] + second = new_state.attributes["second"] + else: + # If no time then use midnight. + hour = minute = second = 0 + + if has_date: + # If input_datetime has date, then track point in time. + trigger_dt = dt_util.DEFAULT_TIME_ZONE.localize( + datetime(year, month, day, hour, minute, second) + ) + # Only set up listener if time is now or in the future. + if trigger_dt >= dt_util.now(): + remove = async_track_point_in_time( + hass, time_automation_listener, trigger_dt + ) + elif has_time: + # Else if it has time, then track time change. + remove = async_track_time_change( + hass, + time_automation_listener, + hour=hour, + minute=minute, + second=second, + ) + + # Was a listener set up? + if remove: + removes.append(remove) + + entities[entity_id] = remove + + for at_time in config[CONF_AT]: + if isinstance(at_time, str): + # input_datetime entity + update_entity_trigger(at_time, new_state=hass.states.get(at_time)) + else: + # datetime.time + removes.append( + async_track_time_change( + hass, + time_automation_listener, + hour=at_time.hour, + minute=at_time.minute, + second=at_time.second, + ) + ) + + # Track state changes of any entities. + removes.append( + async_track_state_change(hass, list(entities), update_entity_trigger) + ) @callback def remove_track_time_changes(): diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index c8b95985636..b7540af3673 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -34,8 +34,8 @@ async def test_if_fires_using_at(hass, calls): now = dt_util.utcnow() time_that_will_not_match_right_away = now.replace( - year=now.year + 1, hour=4, minute=59, second=0 - ) + hour=4, minute=59, second=0 + ) + timedelta(2) with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away @@ -59,7 +59,7 @@ async def test_if_fires_using_at(hass, calls): now = dt_util.utcnow() async_fire_time_changed( - hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0) + hass, now.replace(hour=5, minute=0, second=0) + timedelta(2) ) await hass.async_block_till_done() @@ -67,14 +67,90 @@ async def test_if_fires_using_at(hass, calls): assert calls[0].data["some"] == "time - 5" +@pytest.mark.parametrize( + "has_date,has_time", [(True, True), (True, False), (False, True)] +) +async def test_if_fires_using_at_input_datetime(hass, calls, has_date, has_time): + """Test for firing at input_datetime.""" + await async_setup_component( + hass, + "input_datetime", + {"input_datetime": {"trigger": {"has_date": has_date, "has_time": has_time}}}, + ) + + now = dt_util.now() + + trigger_dt = now.replace( + hour=5 if has_time else 0, minute=0, second=0, microsecond=0 + ) + timedelta(2) + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "datetime": str(trigger_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 }}" + 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": "input_datetime.trigger"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + + async_fire_time_changed(hass, trigger_dt) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == f"time-{trigger_dt.day}-{trigger_dt.hour}" + + if has_date: + trigger_dt += timedelta(days=1) + if has_time: + trigger_dt += timedelta(hours=1) + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "datetime": str(trigger_dt.replace(tzinfo=None)), + }, + blocking=True, + ) + + async_fire_time_changed(hass, trigger_dt) + await hass.async_block_till_done() + + assert len(calls) == 2 + assert calls[1].data["some"] == f"time-{trigger_dt.day}-{trigger_dt.hour}" + + async def test_if_fires_using_multiple_at(hass, calls): """Test for firing at.""" now = dt_util.utcnow() time_that_will_not_match_right_away = now.replace( - year=now.year + 1, hour=4, minute=59, second=0 - ) + hour=4, minute=59, second=0 + ) + timedelta(2) with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away @@ -98,7 +174,7 @@ async def test_if_fires_using_multiple_at(hass, calls): now = dt_util.utcnow() async_fire_time_changed( - hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0) + hass, now.replace(hour=5, minute=0, second=0) + timedelta(2) ) await hass.async_block_till_done() @@ -106,7 +182,7 @@ async def test_if_fires_using_multiple_at(hass, calls): assert calls[0].data["some"] == "time - 5" async_fire_time_changed( - hass, now.replace(year=now.year + 1, hour=6, minute=0, second=0) + hass, now.replace(hour=6, minute=0, second=0) + timedelta(2) ) await hass.async_block_till_done()