1 """Offer calendar automation rules."""
3 from __future__
import annotations
5 from collections.abc
import Awaitable, Callable, Coroutine
6 from dataclasses
import dataclass
11 import voluptuous
as vol
19 async_track_point_in_time,
20 async_track_time_interval,
26 from .
import CalendarEntity, CalendarEvent
27 from .const
import DATA_COMPONENT, DOMAIN
29 _LOGGER = logging.getLogger(__name__)
33 UPDATE_INTERVAL = datetime.timedelta(minutes=15)
35 TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
37 vol.Required(CONF_PLATFORM): DOMAIN,
38 vol.Required(CONF_ENTITY_ID): cv.entity_id,
39 vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
40 vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
49 """An event that is queued to be fired in the future."""
51 trigger_time: datetime.datetime
57 """A time range part of start/end dates, used for considering active events."""
59 start: datetime.datetime
60 """The start datetime of the interval."""
62 end: datetime.datetime
63 """The end datetime (exclusive) of the interval."""
65 def with_offset(self, offset: datetime.timedelta) -> Timespan:
66 """Return a new interval shifted by the specified offset."""
67 return Timespan(self.start + offset, self.end + offset)
70 """Return true if the trigger time is within the time span."""
71 return self.start <= trigger < self.end
74 self, now: datetime.datetime, interval: datetime.timedelta
76 """Return a subsequent time span following the current time span.
78 This effectively gives us a cursor like interface for advancing through
79 time using the interval as a hint. The returned span may have a
80 different interval than the one specified. For example, time span may
81 be longer during a daylight saving time transition, or may extend due to
82 drift if the current interval is old. The returned time span is
83 adjacent and non-overlapping.
85 return Timespan(self.end,
max(self.end, now) + interval)
88 """Return a string representing the half open interval time span."""
89 return f
"[{self.start}, {self.end})"
92 type EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]]
93 type QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]]
96 def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity:
97 """Get the calendar entity for the provided entity_id."""
98 component: EntityComponent[CalendarEntity] = hass.data[DATA_COMPONENT]
99 if not (entity := component.get_entity(entity_id))
or not isinstance(
100 entity, CalendarEntity
103 f
"Entity does not exist {entity_id} or is not a calendar entity"
109 """Build an async_get_events wrapper to fetch events during a time span."""
111 async
def async_get_events(timespan: Timespan) -> list[CalendarEvent]:
112 """Return events active in the specified time span."""
115 end_time = timespan.end + datetime.timedelta(seconds=1)
116 return await entity.async_get_events(hass, timespan.start, end_time)
118 return async_get_events
122 fetcher: EventFetcher, event_type: str, offset: datetime.timedelta
123 ) -> QueuedEventFetcher:
124 """Build a fetcher that produces a schedule of upcoming trigger events."""
126 def get_trigger_time(event: CalendarEvent) -> datetime.datetime:
127 if event_type == EVENT_START:
128 return event.start_datetime_local
129 return event.end_datetime_local
131 async
def async_get_events(timespan: Timespan) -> list[QueuedCalendarEvent]:
132 """Get calendar event triggers eligible to fire in the time span."""
133 offset_timespan = timespan.with_offset(-1 * offset)
134 active_events = await fetcher(offset_timespan)
140 for trigger_time, event
in zip(
141 map(get_trigger_time, active_events), active_events, strict=
False
143 if trigger_time
not in offset_timespan:
148 "Scan events @ %s%s found %s eligible of %s active",
150 f
" (offset={offset})" if offset
else "",
154 results.sort(key=
lambda x: x.trigger_time)
157 return async_get_events
161 """Helper class to listen to calendar events.
163 This listener will poll every UPDATE_INTERVAL to fetch a set of upcoming
164 calendar events in the upcoming window of time, putting them into a queue.
165 The queue is drained by scheduling an alarm for the next upcoming event
166 trigger time, one event at a time.
172 job: HassJob[..., Coroutine[Any, Any,
None]],
173 trigger_data: dict[str, Any],
174 fetcher: QueuedEventFetcher,
176 """Initialize CalendarEventListener."""
180 self.
_unsub_event_unsub_event: CALLBACK_TYPE |
None =
None
185 self._events: list[QueuedCalendarEvent] = []
188 """Attach a calendar event listener."""
197 """Detach the calendar event listener."""
205 """Set up the calendar event listener."""
210 "Scheduled next event trigger for %s", self._events[0].trigger_time
215 self._events[0].trigger_time,
219 """Reset the event listener."""
225 """Handle calendar event."""
226 _LOGGER.debug(
"Calendar event @ %s", dt_util.as_local(now))
232 """Dispatch all events that are eligible to fire."""
233 while self._events
and self._events[0].trigger_time <= now:
234 queued_event = self._events.pop(0)
235 _LOGGER.debug(
"Dispatching event: %s", queued_event.event)
236 self.
_hass_hass.async_run_hass_job(
241 "calendar_event": queued_event.event.as_dict(),
247 """Handle core config update."""
248 now = dt_util.as_local(now_utc)
249 _LOGGER.debug(
"Refresh events @ %s", now)
257 except HomeAssistantError
as ex:
258 _LOGGER.error(
"Calendar trigger failed to fetch events: %s", ex)
265 action: TriggerActionType,
266 trigger_info: TriggerInfo,
268 """Attach trigger for the specified calendar."""
269 entity_id = config[CONF_ENTITY_ID]
270 event_type = config[CONF_EVENT]
271 offset = config[CONF_OFFSET]
277 **trigger_info[
"trigger_data"],
288 await listener.async_attach()
289 return listener.async_detach
None _handle_calendar_event(self, datetime.datetime now)
None _clear_event_listener(self)
None _listen_next_calendar_event(self)
None __init__(self, HomeAssistant hass, HassJob[..., Coroutine[Any, Any, None]] job, dict[str, Any] trigger_data, QueuedEventFetcher fetcher)
None _dispatch_events(self, datetime.datetime now)
None _handle_refresh(self, datetime.datetime now_utc)
Timespan with_offset(self, datetime.timedelta offset)
Timespan next_upcoming(self, datetime.datetime now, datetime.timedelta interval)
bool __contains__(self, datetime.datetime trigger)
EventFetcher event_fetcher(HomeAssistant hass, str entity_id)
CalendarEntity get_entity(HomeAssistant hass, str entity_id)
CALLBACK_TYPE async_attach_trigger(HomeAssistant hass, ConfigType config, TriggerActionType action, TriggerInfo trigger_info)
QueuedEventFetcher queued_event_fetcher(EventFetcher fetcher, str event_type, datetime.timedelta offset)
CALLBACK_TYPE async_track_point_in_time(HomeAssistant hass, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action, datetime point_in_time)
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)