Add calendar trigger offsets (#70963)

* Add support for calendar trigger offsets

* Add offset end test

* Update homeassistant/components/calendar/trigger.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Always include offset in trigger data

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2022-04-30 17:21:30 -07:00 committed by GitHub
parent 7b2947bad7
commit 285fdeb581
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 101 additions and 14 deletions

View File

@ -11,7 +11,7 @@ from homeassistant.components.automation import (
AutomationActionType, AutomationActionType,
AutomationTriggerInfo, AutomationTriggerInfo,
) )
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_PLATFORM from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
@ -36,6 +36,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
vol.Required(CONF_PLATFORM): DOMAIN, vol.Required(CONF_PLATFORM): DOMAIN,
vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}), vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
} }
) )
@ -50,12 +51,14 @@ class CalendarEventListener:
trigger_data: dict[str, Any], trigger_data: dict[str, Any],
entity: CalendarEntity, entity: CalendarEntity,
event_type: str, event_type: str,
offset: datetime.timedelta,
) -> None: ) -> None:
"""Initialize CalendarEventListener.""" """Initialize CalendarEventListener."""
self._hass = hass self._hass = hass
self._job = job self._job = job
self._trigger_data = trigger_data self._trigger_data = trigger_data
self._entity = entity self._entity = entity
self._offset = offset
self._unsub_event: CALLBACK_TYPE | None = None self._unsub_event: CALLBACK_TYPE | None = None
self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_refresh: CALLBACK_TYPE | None = None
# Upcoming set of events with their trigger time # Upcoming set of events with their trigger time
@ -81,10 +84,18 @@ class CalendarEventListener:
async def _fetch_events(self, last_endtime: datetime.datetime) -> None: async def _fetch_events(self, last_endtime: datetime.datetime) -> None:
"""Update the set of eligible events.""" """Update the set of eligible events."""
# Use a sliding window for selecting in scope events in the next interval. The event
# search range is offset, then the fire time of the returned events are offset again below.
# Event time ranges are exclusive so the end time is expanded by 1sec # Event time ranges are exclusive so the end time is expanded by 1sec
end_time = last_endtime + UPDATE_INTERVAL + datetime.timedelta(seconds=1) start_time = last_endtime - self._offset
_LOGGER.debug("Fetching events between %s, %s", last_endtime, end_time) end_time = start_time + UPDATE_INTERVAL + datetime.timedelta(seconds=1)
events = await self._entity.async_get_events(self._hass, last_endtime, end_time) _LOGGER.debug(
"Fetching events between %s, %s (offset=%s)",
start_time,
end_time,
self._offset,
)
events = await self._entity.async_get_events(self._hass, start_time, end_time)
# Build list of events and the appropriate time to trigger an alarm. The # Build list of events and the appropriate time to trigger an alarm. The
# returned events may have already started but matched the start/end time # returned events may have already started but matched the start/end time
@ -92,13 +103,14 @@ class CalendarEventListener:
# trigger time. # trigger time.
event_list = [] event_list = []
for event in events: for event in events:
event_time = ( event_fire_time = (
event.start_datetime_local event.start_datetime_local
if self._event_type == EVENT_START if self._event_type == EVENT_START
else event.end_datetime_local else event.end_datetime_local
) )
if event_time > last_endtime: event_fire_time += self._offset
event_list.append((event_time, event)) if event_fire_time > last_endtime:
event_list.append((event_fire_time, event))
event_list.sort(key=lambda x: x[0]) event_list.sort(key=lambda x: x[0])
self._events = event_list self._events = event_list
_LOGGER.debug("Populated event list %s", self._events) _LOGGER.debug("Populated event list %s", self._events)
@ -109,12 +121,12 @@ class CalendarEventListener:
if not self._events: if not self._events:
return return
(event_datetime, _event) = self._events[0] (event_fire_time, _event) = self._events[0]
_LOGGER.debug("Scheduling next event trigger @ %s", event_datetime) _LOGGER.debug("Scheduled alarm for %s", event_fire_time)
self._unsub_event = async_track_point_in_utc_time( self._unsub_event = async_track_point_in_utc_time(
self._hass, self._hass,
self._handle_calendar_event, self._handle_calendar_event,
event_datetime, event_fire_time,
) )
def _clear_event_listener(self) -> None: def _clear_event_listener(self) -> None:
@ -160,6 +172,7 @@ async def async_attach_trigger(
"""Attach trigger for the specified calendar.""" """Attach trigger for the specified calendar."""
entity_id = config[CONF_ENTITY_ID] entity_id = config[CONF_ENTITY_ID]
event_type = config[CONF_EVENT] event_type = config[CONF_EVENT]
offset = config[CONF_OFFSET]
component: EntityComponent = hass.data[DOMAIN] component: EntityComponent = hass.data[DOMAIN]
if not (entity := component.get_entity(entity_id)) or not isinstance( if not (entity := component.get_entity(entity_id)) or not isinstance(
@ -173,10 +186,10 @@ async def async_attach_trigger(
**automation_info["trigger_data"], **automation_info["trigger_data"],
"platform": DOMAIN, "platform": DOMAIN,
"event": event_type, "event": event_type,
"offset": offset,
} }
listener = CalendarEventListener( listener = CalendarEventListener(
hass, HassJob(action), trigger_data, entity, event_type hass, HassJob(action), trigger_data, entity, event_type, offset
) )
await listener.async_attach() await listener.async_attach()
return listener.async_detach return listener.async_detach

View File

@ -126,13 +126,15 @@ async def setup_calendar(hass: HomeAssistant, fake_schedule: FakeSchedule) -> No
await hass.async_block_till_done() await hass.async_block_till_done()
async def create_automation(hass: HomeAssistant, event_type: str) -> None: async def create_automation(hass: HomeAssistant, event_type: str, offset=None) -> None:
"""Register an automation.""" """Register an automation."""
trigger_data = { trigger_data = {
"platform": calendar.DOMAIN, "platform": calendar.DOMAIN,
"entity_id": CALENDAR_ENTITY_ID, "entity_id": CALENDAR_ENTITY_ID,
"event": event_type, "event": event_type,
} }
if offset:
trigger_data["offset"] = offset
assert await async_setup_component( assert await async_setup_component(
hass, hass,
automation.DOMAIN, automation.DOMAIN,
@ -178,7 +180,43 @@ async def test_event_start_trigger(hass, calls, fake_schedule):
assert len(calls()) == 0 assert len(calls()) == 0
await fake_schedule.fire_until( await fake_schedule.fire_until(
datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00") datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"),
)
assert calls() == [
{
"platform": "calendar",
"event": EVENT_START,
"calendar_event": event_data,
}
]
@pytest.mark.parametrize(
"offset_str, offset_delta",
[
("-01:00", datetime.timedelta(hours=-1)),
("+01:00", datetime.timedelta(hours=1)),
],
)
async def test_event_start_trigger_with_offset(
hass, calls, fake_schedule, offset_str, offset_delta
):
"""Test the a calendar trigger based on start time with an offset."""
event_data = fake_schedule.create_event(
start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"),
end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"),
)
await create_automation(hass, EVENT_START, offset=offset_str)
# No calls yet
await fake_schedule.fire_until(
datetime.datetime.fromisoformat("2022-04-19 11:55:00+00:00") + offset_delta,
)
assert len(calls()) == 0
# Event has started w/ offset
await fake_schedule.fire_until(
datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta,
) )
assert calls() == [ assert calls() == [
{ {
@ -216,6 +254,42 @@ async def test_event_end_trigger(hass, calls, fake_schedule):
] ]
@pytest.mark.parametrize(
"offset_str, offset_delta",
[
("-01:00", datetime.timedelta(hours=-1)),
("+01:00", datetime.timedelta(hours=1)),
],
)
async def test_event_end_trigger_with_offset(
hass, calls, fake_schedule, offset_str, offset_delta
):
"""Test the a calendar trigger based on end time with an offset."""
event_data = fake_schedule.create_event(
start=datetime.datetime.fromisoformat("2022-04-19 12:00:00+00:00"),
end=datetime.datetime.fromisoformat("2022-04-19 12:30:00+00:00"),
)
await create_automation(hass, EVENT_END, offset=offset_str)
# No calls yet
await fake_schedule.fire_until(
datetime.datetime.fromisoformat("2022-04-19 12:05:00+00:00") + offset_delta,
)
assert len(calls()) == 0
# Event has started w/ offset
await fake_schedule.fire_until(
datetime.datetime.fromisoformat("2022-04-19 12:35:00+00:00") + offset_delta,
)
assert calls() == [
{
"platform": "calendar",
"event": EVENT_END,
"calendar_event": event_data,
}
]
async def test_calendar_trigger_with_no_events(hass, calls, fake_schedule): async def test_calendar_trigger_with_no_events(hass, calls, fake_schedule):
"""Test a calendar trigger setup with no events.""" """Test a calendar trigger setup with no events."""