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 <marhje52@gmail.com>

* Remove logging f-strings, and move to main code

* Remove mypy ignore

* Apply suggestions from code review

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

* Update calendar triggers to use new calendar data model

* Update tests/components/calendar/test_trigger.py

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* 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 <marhje52@gmail.com>

* Update homeassistant/components/calendar/trigger.py

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

* Increase test coverage based on pr feedback

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Allen Porter 2022-04-21 08:09:50 -07:00 committed by GitHub
parent 7003862bd8
commit a2c74b9786
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 612 additions and 0 deletions

View File

@ -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,

View File

@ -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

View File

@ -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,
}
]