From a2c74b978664b627bafc4a43b26aa2be7b15b229 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 21 Apr 2022 08:09:50 -0700 Subject: [PATCH] Add initial implementation of a calendar trigger (#68674) * Add initial implementation of calendar trigger This is an initial implementation of a calendar trigger, that supports triggering on calendar start time. See architecture proposal in: https://github.com/home-assistant/architecture/discussions/700 * Address reviewer feedback * Use f-strings for all tests * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove logging f-strings, and move to main code * Remove mypy ignore * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Update calendar triggers to use new calendar data model * Update tests/components/calendar/test_trigger.py Co-authored-by: Franck Nijhof * Rewrite tests using freezegun Rewrite tests using freezegun and improve edge case handling, and use utc consistently for all alarms. * Update homeassistant/components/calendar/trigger.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/calendar/trigger.py Co-authored-by: Martin Hjelmare * Increase test coverage based on pr feedback Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- homeassistant/components/calendar/__init__.py | 14 + homeassistant/components/calendar/trigger.py | 173 +++++++ tests/components/calendar/test_trigger.py | 425 ++++++++++++++++++ 3 files changed, 612 insertions(+) create mode 100644 homeassistant/components/calendar/trigger.py create mode 100644 tests/components/calendar/test_trigger.py diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index cb4af1ab671..0f122fea55f 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -99,6 +99,20 @@ class CalendarEvent: """Return true if the event is an all day event.""" return not isinstance(self.start, datetime.datetime) + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event.""" + data = { + "start": self.start.isoformat(), + "end": self.end.isoformat(), + "summary": self.summary, + "all_day": self.all_day, + } + if self.description: + data["description"] = self.description + if self.location: + data["location"] = self.location + return data + def _get_datetime_local( dt_or_d: datetime.datetime | datetime.date, diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py new file mode 100644 index 00000000000..b53a85de38f --- /dev/null +++ b/homeassistant/components/calendar/trigger.py @@ -0,0 +1,173 @@ +"""Offer calendar automation rules.""" +from __future__ import annotations + +import datetime +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import ( + async_track_point_in_utc_time, + async_track_time_interval, +) +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from . import DOMAIN, CalendarEntity, CalendarEvent + +_LOGGER = logging.getLogger(__name__) + +EVENT_START = "start" +UPDATE_INTERVAL = datetime.timedelta(minutes=15) + +TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): DOMAIN, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START}), + } +) + + +class CalendarEventListener: + """Helper class to listen to calendar events.""" + + def __init__( + self, + hass: HomeAssistant, + job: HassJob, + trigger_data: dict[str, Any], + entity: CalendarEntity, + ) -> None: + """Initialize CalendarEventListener.""" + self._hass = hass + self._job = job + self._trigger_data = trigger_data + self._entity = entity + self._unsub_event: CALLBACK_TYPE | None = None + self._unsub_refresh: CALLBACK_TYPE | None = None + # Upcoming set of events with their trigger time + self._events: list[tuple[datetime.datetime, CalendarEvent]] = [] + + async def async_attach(self) -> None: + """Attach a calendar event listener.""" + now = dt_util.utcnow() + await self._fetch_events(now) + self._unsub_refresh = async_track_time_interval( + self._hass, self._handle_refresh, UPDATE_INTERVAL + ) + self._listen_next_calendar_event() + + @callback + def async_detach(self) -> None: + """Detach the calendar event listener.""" + self._clear_event_listener() + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + async def _fetch_events(self, now: datetime.datetime) -> None: + """Update the set of eligible events.""" + start_date = now + end_date = now + UPDATE_INTERVAL + _LOGGER.debug("Fetching events between %s, %s", start_date, end_date) + events = await self._entity.async_get_events(self._hass, start_date, end_date) + + # 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 + # filtering above, so exclude any events that have already passed the + # trigger time. + event_list = [ + (dt_util.as_utc(event.start_datetime_local), event) for event in events + ] + event_list.sort(key=lambda x: x[0]) + + self._events.extend( + [ + (trigger_time, event) + for (trigger_time, event) in event_list + if trigger_time > now + ] + ) + _LOGGER.debug("Populated event list %s", self._events) + + @callback + def _listen_next_calendar_event(self) -> None: + """Set up the calendar event listener.""" + if not self._events: + return + + (event_datetime, _event) = self._events[0] + _LOGGER.debug("Scheduling next event trigger @ %s", event_datetime) + self._unsub_event = async_track_point_in_utc_time( + self._hass, + self._handle_calendar_event, + event_datetime, + ) + + def _clear_event_listener(self) -> None: + """Reset the event listener.""" + if self._unsub_event: + self._unsub_event() + self._unsub_event = None + + async def _handle_calendar_event(self, now: datetime.datetime) -> None: + """Handle calendar event.""" + _LOGGER.debug("Calendar event @ %s", now) + + # Consume all events that are eligible to fire + while self._events and self._events[0][0] <= now: + (_fire_time, event) = self._events.pop(0) + _LOGGER.debug("Event: %s", event) + self._hass.async_run_hass_job( + self._job, + {"trigger": {**self._trigger_data, "calendar_event": event.as_dict()}}, + ) + self._clear_event_listener() + self._listen_next_calendar_event() + + async def _handle_refresh(self, now: datetime.datetime) -> None: + """Handle core config update.""" + _LOGGER.debug("Refresh events @ %s", now) + self._clear_event_listener() + await self._fetch_events(now) + self._listen_next_calendar_event() + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: + """Attach trigger for the specified calendar.""" + entity_id = config[CONF_ENTITY_ID] + event_type = config[CONF_EVENT] + + component: EntityComponent = hass.data[DOMAIN] + if not (entity := component.get_entity(entity_id)) or not isinstance( + entity, CalendarEntity + ): + raise HomeAssistantError( + f"Entity does not exist {entity_id} or is not a calendar entity" + ) + + trigger_data = { + **automation_info["trigger_data"], + "platform": DOMAIN, + "event": event_type, + } + + listener = CalendarEventListener(hass, HassJob(action), trigger_data, entity) + await listener.async_attach() + return listener.async_detach diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py new file mode 100644 index 00000000000..61cd76fd6f8 --- /dev/null +++ b/tests/components/calendar/test_trigger.py @@ -0,0 +1,425 @@ +"""Tests for the calendar automation. + +The tests create calendar based automations, set up a fake set of calendar +events, then advance time to exercise that the automation is called. The +tests use a fixture that mocks out events returned by the calendar entity, +and create events using a relative time offset and then advance the clock +forward exercising the triggers. +""" +from __future__ import annotations + +from collections.abc import Callable +import datetime +import logging +import secrets +from typing import Any, Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components import calendar +import homeassistant.components.automation as automation +from homeassistant.components.calendar.trigger import EVENT_START +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed, async_mock_service + +_LOGGER = logging.getLogger(__name__) + + +CALENDAR_ENTITY_ID = "calendar.calendar_2" +CONFIG = {calendar.DOMAIN: {"platform": "demo"}} + +TEST_AUTOMATION_ACTION = { + "service": "test.automation", + "data": { + "platform": "{{ trigger.platform }}", + "event": "{{ trigger.event }}", + "calendar_event": "{{ trigger.calendar_event }}", + }, +} + +# The trigger sets two alarms: One based on the next event and one +# to refresh the schedule. The test advances the time an arbitrary +# amount to trigger either type of event with a small jitter. +TEST_TIME_ADVANCE_INTERVAL = datetime.timedelta(minutes=1) +TEST_UPDATE_INTERVAL = datetime.timedelta(minutes=7) + + +class FakeSchedule: + """Test fixture class for return events in a specific date range.""" + + def __init__(self, hass, freezer): + """Initiailize FakeSchedule.""" + self.hass = hass + self.freezer = freezer + # Map of event start time to event + self.events: list[calendar.CalendarEvent] = [] + + def create_event( + self, + start: datetime.timedelta, + end: datetime.timedelta, + description: str = None, + location: str = None, + ) -> dict[str, Any]: + """Create a new fake event, used by tests.""" + event = calendar.CalendarEvent( + start=start, + end=end, + summary=f"Event {secrets.token_hex(16)}", # Arbitrary unique data + description=description, + location=location, + ) + self.events.append(event) + return event.as_dict() + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime.datetime, + end_date: datetime.datetime, + ) -> list[calendar.CalendarEvent]: + """Get all events in a specific time frame, used by the demo calendar.""" + assert start_date < end_date + values = [] + for event in self.events: + if start_date < event.start < end_date or start_date < event.end < end_date: + values.append(event) + return values + + async def fire_time(self, trigger_time: datetime.datetime) -> None: + """Fire an alarm and wait.""" + _LOGGER.debug(f"Firing alarm @ {trigger_time}") + self.freezer.move_to(trigger_time) + async_fire_time_changed(self.hass, trigger_time) + await self.hass.async_block_till_done() + + async def fire_until(self, end: datetime.timedelta) -> None: + """Simulate the passage of time by firing alarms until the time is reached.""" + while dt_util.utcnow() < end: + self.freezer.tick(TEST_TIME_ADVANCE_INTERVAL) + await self.fire_time(dt_util.utcnow()) + + +@pytest.fixture +def fake_schedule(hass, freezer): + """Fixture that tests can use to make fake events.""" + + # Setup start time for all tests + freezer.move_to("2022-04-19 10:31:02+00:00") + + schedule = FakeSchedule(hass, freezer) + with patch( + "homeassistant.components.demo.calendar.DemoCalendar.async_get_events", + new=schedule.async_get_events, + ): + yield schedule + + +@pytest.fixture(autouse=True) +async def setup_calendar(hass: HomeAssistant, fake_schedule: FakeSchedule) -> None: + """Initialize the demo calendar.""" + assert await async_setup_component(hass, calendar.DOMAIN, CONFIG) + await hass.async_block_till_done() + + +async def create_automation(hass: HomeAssistant, event_type: str) -> None: + """Register an automation.""" + trigger_data = { + "platform": calendar.DOMAIN, + "entity_id": CALENDAR_ENTITY_ID, + "event": event_type, + } + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": trigger_data, + "action": TEST_AUTOMATION_ACTION, + "mode": "queued", + } + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +def calls(hass: HomeAssistant) -> Callable[[], list]: + """Fixture to return payload data for automation calls.""" + service_calls = async_mock_service(hass, "test", "automation") + + def get_trigger_data() -> list: + return [c.data for c in service_calls] + + return get_trigger_data + + +@pytest.fixture(autouse=True) +def mock_update_interval() -> Generator[None, None, None]: + """Fixture to override the update interval for refreshing events.""" + with patch( + "homeassistant.components.calendar.trigger.UPDATE_INTERVAL", + new=TEST_UPDATE_INTERVAL, + ): + yield + + +async def test_event_start_trigger(hass, calls, fake_schedule): + """Test the a calendar trigger based on start time.""" + event_data = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + ) + await create_automation(hass, EVENT_START) + assert len(calls()) == 0 + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data, + } + ] + + +async def test_calendar_trigger_with_no_events(hass, calls, fake_schedule): + """Test a calendar trigger setup with no events.""" + + await create_automation(hass, EVENT_START) + + # No calls, at arbitrary times + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00") + ) + assert len(calls()) == 0 + + +async def test_multiple_events(hass, calls, fake_schedule): + """Test that a trigger fires for multiple events.""" + + event_data1 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + ) + event_data2 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + ) + await create_automation(hass, EVENT_START) + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data1, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data2, + }, + ] + + +async def test_multiple_events_sharing_start_time(hass, calls, fake_schedule): + """Test that a trigger fires for every event sharing a start time.""" + + event_data1 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + ) + event_data2 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + ) + await create_automation(hass, EVENT_START) + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:35:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data1, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data2, + }, + ] + + +async def test_overlap_events(hass, calls, fake_schedule): + """Test that a trigger fires for events that overlap.""" + + event_data1 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + ) + event_data2 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:45:00+00:00"), + ) + await create_automation(hass, EVENT_START) + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:20:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data1, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data2, + }, + ] + + +async def test_invalid_calendar_id(hass, caplog): + """Test creating a trigger with an invalid calendar id.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "action": TEST_AUTOMATION_ACTION, + "trigger": { + "platform": calendar.DOMAIN, + "entity_id": "invalid-calendar-id", + }, + } + }, + ) + await hass.async_block_till_done() + assert "Invalid config for [automation]" in caplog.text + + +async def test_legacy_entity_type(hass, caplog): + """Test creating a trigger with an invalid calendar id.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "action": TEST_AUTOMATION_ACTION, + "trigger": { + "platform": calendar.DOMAIN, + "entity_id": "calendar.calendar_3", + }, + } + }, + ) + await hass.async_block_till_done() + assert "is not a calendar entity" in caplog.text + + +async def test_update_next_event(hass, calls, fake_schedule): + """Test detection of a new event after initial trigger is setup.""" + + event_data1 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00"), + ) + await create_automation(hass, EVENT_START) + + # No calls before event start + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 10:45:00+00:00") + ) + assert len(calls()) == 0 + + # Create a new event between now and when the event fires + event_data2 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00"), + ) + + # Advance past the end of the events + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data2, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data1, + }, + ] + + +async def test_update_missed(hass, calls, fake_schedule): + """Test that new events are missed if they arrive outside the update interval.""" + + event_data1 = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + ) + await create_automation(hass, EVENT_START) + + # Events are refreshed at t+TEST_UPDATE_INTERVAL minutes. A new event is + # added, but the next update happens after the event is already over. + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 10:38:00+00:00") + ) + assert len(calls()) == 0 + + fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 10:40:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 10:55:00+00:00"), + ) + + # Only the first event is returned + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:05:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data1, + }, + ] + + +async def test_event_payload(hass, calls, fake_schedule): + """Test the a calendar trigger based on start time.""" + event_data = fake_schedule.create_event( + start=datetime.datetime.fromisoformat("2022-04-19 11:00:00+00:00"), + end=datetime.datetime.fromisoformat("2022-04-19 11:30:00+00:00"), + description="Description", + location="Location", + ) + await create_automation(hass, EVENT_START) + assert len(calls()) == 0 + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2022-04-19 11:15:00+00:00") + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event_data, + } + ]