Add offset support to time trigger (#56838)

This commit is contained in:
Robert Meijers 2021-10-26 15:52:43 +02:00 committed by GitHub
parent d49c5d511b
commit c9966a3b04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 224 additions and 11 deletions

View File

@ -1,5 +1,5 @@
"""Offer time listening automation rules.""" """Offer time listening automation rules."""
from datetime import datetime from datetime import datetime, timedelta
from functools import partial from functools import partial
import voluptuous as vol import voluptuous as vol
@ -8,6 +8,8 @@ from homeassistant.components import sensor
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
CONF_AT, CONF_AT,
CONF_ENTITY_ID,
CONF_OFFSET,
CONF_PLATFORM, CONF_PLATFORM,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
@ -23,9 +25,21 @@ import homeassistant.util.dt as dt_util
# mypy: allow-untyped-defs, no-check-untyped-defs # 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( _TIME_TRIGGER_SCHEMA = vol.Any(
cv.time, 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'", 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 = {} entities = {}
removes = [] removes = []
job = HassJob(action) job = HassJob(action)
offsets = {}
@callback @callback
def time_automation_listener(description, now, *, entity_id=None): 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: if not new_state:
return return
offset = offsets[entity_id] if entity_id in offsets else timedelta(0)
# Check state of entity. If valid, set up a listener. # Check state of entity. If valid, set up a listener.
if new_state.domain == "input_datetime": if new_state.domain == "input_datetime":
if has_date := new_state.attributes["has_date"]: 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 has_date:
# If input_datetime has date, then track point in time. # If input_datetime has date, then track point in time.
trigger_dt = datetime( trigger_dt = (
year, datetime(
month, year,
day, month,
hour, day,
minute, hour,
second, minute,
tzinfo=dt_util.DEFAULT_TIME_ZONE, second,
tzinfo=dt_util.DEFAULT_TIME_ZONE,
)
+ offset
) )
# Only set up listener if time is now or in the future. # Only set up listener if time is now or in the future.
if trigger_dt >= dt_util.now(): if trigger_dt >= dt_util.now():
@ -132,7 +152,7 @@ async def async_attach_trigger(hass, config, action, automation_info):
== sensor.DEVICE_CLASS_TIMESTAMP == sensor.DEVICE_CLASS_TIMESTAMP
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) 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(): if trigger_dt is not None and trigger_dt > dt_util.utcnow():
remove = async_track_point_in_time( remove = async_track_point_in_time(
@ -156,6 +176,15 @@ async def async_attach_trigger(hass, config, action, automation_info):
# entity # entity
to_track.append(at_time) to_track.append(at_time)
update_entity_trigger(at_time, new_state=hass.states.get(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: else:
# datetime.time # datetime.time
removes.append( removes.append(

View File

@ -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): async def test_if_fires_using_multiple_at(hass, calls):
"""Test for firing at.""" """Test for firing at."""
@ -498,12 +588,103 @@ async def test_if_fires_using_at_sensor(hass, calls):
assert len(calls) == 2 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( @pytest.mark.parametrize(
"conf", "conf",
[ [
{"platform": "time", "at": "input_datetime.bla"}, {"platform": "time", "at": "input_datetime.bla"},
{"platform": "time", "at": "sensor.bla"}, {"platform": "time", "at": "sensor.bla"},
{"platform": "time", "at": "12:34"}, {"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): def test_schema_valid(conf):
@ -517,6 +698,9 @@ def test_schema_valid(conf):
{"platform": "time", "at": "binary_sensor.bla"}, {"platform": "time", "at": "binary_sensor.bla"},
{"platform": "time", "at": 745}, {"platform": "time", "at": 745},
{"platform": "time", "at": "25:00"}, {"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): def test_schema_invalid(conf):