Home Assistant Unofficial Reference 2024.12.1
calendar.py
Go to the documentation of this file.
1 """Support for Google Calendar Search binary sensors."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 import dataclasses
7 from datetime import datetime, timedelta
8 import logging
9 from typing import Any, cast
10 
11 from gcal_sync.api import Range, SyncEventsRequest
12 from gcal_sync.exceptions import ApiException
13 from gcal_sync.model import (
14  AccessRole,
15  Calendar,
16  DateOrDatetime,
17  Event,
18  EventTypeEnum,
19  ResponseStatus,
20 )
21 from gcal_sync.store import ScopedCalendarStore
22 from gcal_sync.sync import CalendarEventSyncManager
23 
25  CREATE_EVENT_SCHEMA,
26  ENTITY_ID_FORMAT,
27  EVENT_DESCRIPTION,
28  EVENT_END,
29  EVENT_LOCATION,
30  EVENT_RRULE,
31  EVENT_START,
32  EVENT_SUMMARY,
33  CalendarEntity,
34  CalendarEntityDescription,
35  CalendarEntityFeature,
36  CalendarEvent,
37  extract_offset,
38  is_offset_reached,
39 )
40 from homeassistant.config_entries import ConfigEntry
41 from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
42 from homeassistant.core import HomeAssistant, ServiceCall
43 from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
44 from homeassistant.helpers import entity_platform, entity_registry as er
45 from homeassistant.helpers.entity import generate_entity_id
46 from homeassistant.helpers.entity_platform import AddEntitiesCallback
47 from homeassistant.helpers.update_coordinator import CoordinatorEntity
48 from homeassistant.util import dt as dt_util
49 
50 from . import (
51  CONF_IGNORE_AVAILABILITY,
52  CONF_SEARCH,
53  CONF_TRACK,
54  DEFAULT_CONF_OFFSET,
55  DOMAIN,
56  YAML_DEVICES,
57  get_calendar_info,
58  load_config,
59  update_config,
60 )
61 from .api import get_feature_access
62 from .const import (
63  DATA_SERVICE,
64  DATA_STORE,
65  EVENT_END_DATE,
66  EVENT_END_DATETIME,
67  EVENT_IN,
68  EVENT_IN_DAYS,
69  EVENT_IN_WEEKS,
70  EVENT_START_DATE,
71  EVENT_START_DATETIME,
72  FeatureAccess,
73 )
74 from .coordinator import CalendarQueryUpdateCoordinator, CalendarSyncUpdateCoordinator
75 
76 _LOGGER = logging.getLogger(__name__)
77 
78 # Avoid syncing super old data on initial syncs. Note that old but active
79 # recurring events are still included.
80 SYNC_EVENT_MIN_TIME = timedelta(days=-90)
81 
82 # Events have a transparency that determine whether or not they block time on calendar.
83 # When an event is opaque, it means "Show me as busy" which is the default. Events that
84 # are not opaque are ignored by default.
85 OPAQUE = "opaque"
86 
87 # Google calendar prefixes recurrence rules with RRULE: which
88 # we need to strip when working with the frontend recurrence rule values
89 RRULE_PREFIX = "RRULE:"
90 
91 SERVICE_CREATE_EVENT = "create_event"
92 
93 
94 @dataclasses.dataclass(frozen=True, kw_only=True)
96  """Google calendar entity description."""
97 
98  name: str | None
99  entity_id: str | None
100  read_only: bool
101  ignore_availability: bool
102  offset: str | None
103  search: str | None
104  local_sync: bool
105  device_id: str
106  working_location: bool = False
107 
108 
110  hass: HomeAssistant,
111  config_entry: ConfigEntry,
112  calendar_item: Calendar,
113  calendar_info: Mapping[str, Any],
114 ) -> list[GoogleCalendarEntityDescription]:
115  """Create entity descriptions for the calendar.
116 
117  The entity descriptions are based on the type of Calendar from the API
118  and optional calendar_info yaml configuration that is the older way to
119  configure calendars before they supported UI based config.
120 
121  The yaml config may map one calendar to multiple entities and they do not
122  have a unique id. The yaml config also supports additional options like
123  offsets or search.
124  """
125  calendar_id = calendar_item.id
126  num_entities = len(calendar_info[CONF_ENTITIES])
127  entity_descriptions = []
128  for data in calendar_info[CONF_ENTITIES]:
129  if num_entities > 1:
130  key = ""
131  else:
132  key = calendar_id
133  entity_enabled = data.get(CONF_TRACK, True)
134  if not entity_enabled:
135  _LOGGER.warning(
136  "The 'track' option in google_calendars.yaml has been deprecated."
137  " The setting has been imported to the UI, and should now be"
138  " removed from google_calendars.yaml"
139  )
140  read_only = not (
141  calendar_item.access_role.is_writer
142  and get_feature_access(config_entry) is FeatureAccess.read_write
143  )
144  # Prefer calendar sync down of resources when possible. However,
145  # sync does not work for search. Also free-busy calendars denormalize
146  # recurring events as individual events which is not efficient for sync
147  local_sync = True
148  if (
149  search := data.get(CONF_SEARCH)
150  ) or calendar_item.access_role == AccessRole.FREE_BUSY_READER:
151  read_only = True
152  local_sync = False
153  entity_description = GoogleCalendarEntityDescription(
154  key=key,
155  name=data[CONF_NAME].capitalize(),
156  entity_id=generate_entity_id(
157  ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass
158  ),
159  read_only=read_only,
160  ignore_availability=data.get(CONF_IGNORE_AVAILABILITY, False),
161  offset=data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET),
162  search=search,
163  local_sync=local_sync,
164  entity_registry_enabled_default=entity_enabled,
165  device_id=data[CONF_DEVICE_ID],
166  )
167  entity_descriptions.append(entity_description)
168  _LOGGER.debug(
169  "calendar_item.primary=%s, search=%s, calendar_item.access_role=%s - %s",
170  calendar_item.primary,
171  search,
172  calendar_item.access_role,
173  local_sync,
174  )
175  if calendar_item.primary and local_sync:
176  _LOGGER.debug("work location entity")
177  # Create an optional disabled by default entity for Work Location
178  entity_descriptions.append(
179  dataclasses.replace(
180  entity_description,
181  key=f"{key}-work-location",
182  translation_key="working_location",
183  working_location=True,
184  name=None,
185  entity_id=None,
186  entity_registry_enabled_default=False,
187  )
188  )
189  return entity_descriptions
190 
191 
193  hass: HomeAssistant,
194  config_entry: ConfigEntry,
195  async_add_entities: AddEntitiesCallback,
196 ) -> None:
197  """Set up the google calendar platform."""
198  calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE]
199  store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE]
200  try:
201  result = await calendar_service.async_list_calendars()
202  except ApiException as err:
203  raise PlatformNotReady(str(err)) from err
204 
205  entity_registry = er.async_get(hass)
206  registry_entries = er.async_entries_for_config_entry(
207  entity_registry, config_entry.entry_id
208  )
209  entity_entry_map = {
210  entity_entry.unique_id: entity_entry for entity_entry in registry_entries
211  }
212 
213  # Yaml configuration may override objects from the API
214  calendars = await hass.async_add_executor_job(
215  load_config, hass.config.path(YAML_DEVICES)
216  )
217  new_calendars = []
218  entities = []
219  for calendar_item in result.items:
220  calendar_id = calendar_item.id
221  if calendars and calendar_id in calendars:
222  calendar_info = calendars[calendar_id]
223  else:
224  calendar_info = get_calendar_info(
225  hass, calendar_item.dict(exclude_unset=True)
226  )
227  new_calendars.append(calendar_info)
228 
229  for entity_description in _get_entity_descriptions(
230  hass, config_entry, calendar_item, calendar_info
231  ):
232  unique_id = (
233  f"{config_entry.unique_id}-{entity_description.key}"
234  if entity_description.key
235  else None
236  )
237  # Migrate to new unique_id format which supports
238  # multiple config entries as of 2022.7
239  for old_unique_id in (
240  calendar_id,
241  f"{calendar_id}-{entity_description.device_id}",
242  ):
243  if not (entity_entry := entity_entry_map.get(old_unique_id)):
244  continue
245  if unique_id:
246  _LOGGER.debug(
247  "Migrating unique_id for %s from %s to %s",
248  entity_entry.entity_id,
249  old_unique_id,
250  unique_id,
251  )
252  entity_registry.async_update_entity(
253  entity_entry.entity_id, new_unique_id=unique_id
254  )
255  else:
256  _LOGGER.debug(
257  "Removing entity registry entry for %s from %s",
258  entity_entry.entity_id,
259  old_unique_id,
260  )
261  entity_registry.async_remove(
262  entity_entry.entity_id,
263  )
264  _LOGGER.debug("Creating entity with unique_id=%s", unique_id)
265  coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator
266  if not entity_description.local_sync:
267  coordinator = CalendarQueryUpdateCoordinator(
268  hass,
269  calendar_service,
270  entity_description.name or entity_description.key,
271  calendar_id,
272  entity_description.search,
273  )
274  else:
275  request_template = SyncEventsRequest(
276  calendar_id=calendar_id,
277  start_time=dt_util.now() + SYNC_EVENT_MIN_TIME,
278  )
279  sync = CalendarEventSyncManager(
280  calendar_service,
281  store=ScopedCalendarStore(
282  store, unique_id or entity_description.device_id
283  ),
284  request_template=request_template,
285  )
286  coordinator = CalendarSyncUpdateCoordinator(
287  hass,
288  sync,
289  entity_description.name or entity_description.key,
290  )
291  entities.append(
293  coordinator,
294  calendar_id,
295  entity_description,
296  unique_id,
297  )
298  )
299 
300  async_add_entities(entities)
301 
302  if calendars and new_calendars:
303 
304  def append_calendars_to_config() -> None:
305  path = hass.config.path(YAML_DEVICES)
306  for calendar in new_calendars:
307  update_config(path, calendar)
308 
309  await hass.async_add_executor_job(append_calendars_to_config)
310 
311  platform = entity_platform.async_get_current_platform()
312  if (
313  any(calendar_item.access_role.is_writer for calendar_item in result.items)
314  and get_feature_access(config_entry) is FeatureAccess.read_write
315  ):
316  platform.async_register_entity_service(
317  SERVICE_CREATE_EVENT,
318  CREATE_EVENT_SCHEMA,
319  async_create_event,
320  required_features=CalendarEntityFeature.CREATE_EVENT,
321  )
322 
323 
325  CoordinatorEntity[CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator],
326  CalendarEntity,
327 ):
328  """A calendar event entity."""
329 
330  entity_description: GoogleCalendarEntityDescription
331  _attr_has_entity_name = True
332 
333  def __init__(
334  self,
335  coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator,
336  calendar_id: str,
337  entity_description: GoogleCalendarEntityDescription,
338  unique_id: str | None,
339  ) -> None:
340  """Create the Calendar event device."""
341  super().__init__(coordinator)
342  _LOGGER.debug("entity_description.entity_id=%s", entity_description.entity_id)
343  _LOGGER.debug("entity_description=%s", entity_description)
344  self.calendar_idcalendar_idcalendar_id = calendar_id
345  self.entity_descriptionentity_description = entity_description
346  self._ignore_availability_ignore_availability = entity_description.ignore_availability
347  self._offset_offset = entity_description.offset
348  self._event: CalendarEvent | None = None
349  if entity_description.entity_id:
350  self.entity_identity_identity_id = entity_description.entity_id
351  self._attr_unique_id_attr_unique_id = unique_id
352  if not entity_description.read_only:
353  self._attr_supported_features_attr_supported_features = (
354  CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT
355  )
356 
357  @property
358  def extra_state_attributes(self) -> dict[str, bool]:
359  """Return the device state attributes."""
360  return {"offset_reached": self.offset_reachedoffset_reached}
361 
362  @property
363  def offset_reached(self) -> bool:
364  """Return whether or not the event offset was reached."""
365  (event, offset_value) = self._event_with_offset_event_with_offset()
366  if event is not None and offset_value is not None:
367  return is_offset_reached(event.start_datetime_local, offset_value)
368  return False
369 
370  @property
371  def event(self) -> CalendarEvent | None:
372  """Return the next upcoming event."""
373  (event, _) = self._event_with_offset_event_with_offset()
374  return event
375 
376  def _event_filter(self, event: Event) -> bool:
377  """Return True if the event is visible and not declined."""
378 
379  if any(
380  attendee.is_self and attendee.response_status == ResponseStatus.DECLINED
381  for attendee in event.attendees
382  ):
383  return False
384 
385  if event.event_type == EventTypeEnum.WORKING_LOCATION:
386  return self.entity_descriptionentity_description.working_location
387  if self._ignore_availability_ignore_availability:
388  return True
389  return event.transparency == OPAQUE
390 
391  async def async_added_to_hass(self) -> None:
392  """When entity is added to hass."""
393  await super().async_added_to_hass()
394 
395  # We do not ask for an update with async_add_entities()
396  # because it will update disabled entities. This is started as a
397  # task to let if sync in the background without blocking startup
398  self.coordinator.config_entry.async_create_background_task(
399  self.hasshasshass,
400  self.coordinator.async_request_refresh(),
401  "google.calendar-refresh",
402  )
403 
404  async def async_get_events(
405  self, hass: HomeAssistant, start_date: datetime, end_date: datetime
406  ) -> list[CalendarEvent]:
407  """Get all events in a specific time frame."""
408  result_items = await self.coordinator.async_get_events(start_date, end_date)
409  return [
410  _get_calendar_event(event)
411  for event in filter(self._event_filter_event_filter, result_items)
412  ]
413 
415  self,
416  ) -> tuple[CalendarEvent | None, timedelta | None]:
417  """Get the calendar event and offset if any."""
418  if api_event := next(
419  filter(
420  self._event_filter_event_filter,
421  self.coordinator.upcoming or [],
422  ),
423  None,
424  ):
425  event = _get_calendar_event(api_event)
426  if self._offset_offset:
427  (event.summary, offset_value) = extract_offset(
428  event.summary, self._offset_offset
429  )
430  return event, offset_value
431  return None, None
432 
433  async def async_create_event(self, **kwargs: Any) -> None:
434  """Add a new event to calendar."""
435  dtstart = kwargs[EVENT_START]
436  dtend = kwargs[EVENT_END]
437  start: DateOrDatetime
438  end: DateOrDatetime
439  if isinstance(dtstart, datetime):
440  start = DateOrDatetime(
441  date_time=dt_util.as_local(dtstart),
442  timezone=str(dt_util.get_default_time_zone()),
443  )
444  end = DateOrDatetime(
445  date_time=dt_util.as_local(dtend),
446  timezone=str(dt_util.get_default_time_zone()),
447  )
448  else:
449  start = DateOrDatetime(date=dtstart)
450  end = DateOrDatetime(date=dtend)
451  event = Event.parse_obj(
452  {
453  EVENT_SUMMARY: kwargs[EVENT_SUMMARY],
454  "start": start,
455  "end": end,
456  EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION),
457  }
458  )
459  if location := kwargs.get(EVENT_LOCATION):
460  event.location = location
461  if rrule := kwargs.get(EVENT_RRULE):
462  event.recurrence = [f"{RRULE_PREFIX}{rrule}"]
463 
464  try:
465  await cast(
466  CalendarSyncUpdateCoordinator, self.coordinator
467  ).sync.store_service.async_add_event(event)
468  except ApiException as err:
469  raise HomeAssistantError(f"Error while creating event: {err!s}") from err
470  await self.coordinator.async_refresh()
471 
473  self,
474  uid: str,
475  recurrence_id: str | None = None,
476  recurrence_range: str | None = None,
477  ) -> None:
478  """Delete an event on the calendar."""
479  range_value: Range = Range.NONE
480  if recurrence_range == Range.THIS_AND_FUTURE:
481  range_value = Range.THIS_AND_FUTURE
482  await cast(
483  CalendarSyncUpdateCoordinator, self.coordinator
484  ).sync.store_service.async_delete_event(
485  ical_uuid=uid,
486  event_id=recurrence_id,
487  recurrence_range=range_value,
488  )
489  await self.coordinator.async_refresh()
490 
491 
492 def _get_calendar_event(event: Event) -> CalendarEvent:
493  """Return a CalendarEvent from an API event."""
494  rrule: str | None = None
495  # Home Assistant expects a single RRULE: and all other rule types are unsupported or ignored
496  if (
497  len(event.recurrence) == 1
498  and (raw_rule := event.recurrence[0])
499  and raw_rule.startswith(RRULE_PREFIX)
500  ):
501  rrule = raw_rule.removeprefix(RRULE_PREFIX)
502  return CalendarEvent(
503  uid=event.ical_uuid,
504  recurrence_id=event.id if event.recurring_event_id else None,
505  rrule=rrule,
506  summary=event.summary,
507  start=event.start.value,
508  end=event.end.value,
509  description=event.description,
510  location=event.location,
511  )
512 
513 
514 async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> None:
515  """Add a new event to calendar."""
516  start: DateOrDatetime | None = None
517  end: DateOrDatetime | None = None
518  hass = entity.hass
519 
520  if EVENT_IN in call.data:
521  if EVENT_IN_DAYS in call.data[EVENT_IN]:
522  now = datetime.now()
523 
524  start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
525  end_in = start_in + timedelta(days=1)
526 
527  start = DateOrDatetime(date=start_in)
528  end = DateOrDatetime(date=end_in)
529 
530  elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
531  now = datetime.now()
532 
533  start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
534  end_in = start_in + timedelta(days=1)
535 
536  start = DateOrDatetime(date=start_in)
537  end = DateOrDatetime(date=end_in)
538 
539  elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data:
540  start = DateOrDatetime(date=call.data[EVENT_START_DATE])
541  end = DateOrDatetime(date=call.data[EVENT_END_DATE])
542 
543  elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data:
544  start_dt = call.data[EVENT_START_DATETIME]
545  end_dt = call.data[EVENT_END_DATETIME]
546  start = DateOrDatetime(date_time=start_dt, timezone=str(hass.config.time_zone))
547  end = DateOrDatetime(date_time=end_dt, timezone=str(hass.config.time_zone))
548 
549  if start is None or end is None:
550  raise ValueError("Missing required fields to set start or end date/datetime")
551 
552  event = Event(
553  summary=call.data[EVENT_SUMMARY],
554  description=call.data[EVENT_DESCRIPTION],
555  start=start,
556  end=end,
557  )
558  if location := call.data.get(EVENT_LOCATION):
559  event.location = location
560  try:
561  await cast(
562  CalendarSyncUpdateCoordinator, entity.coordinator
563  ).sync.api.async_create_event(
564  entity.calendar_id,
565  event,
566  )
567  except ApiException as err:
568  raise HomeAssistantError(str(err)) from err
569  entity.async_write_ha_state()
None async_delete_event(self, str uid, str|None recurrence_id=None, str|None recurrence_range=None)
Definition: calendar.py:477
list[CalendarEvent] async_get_events(self, HomeAssistant hass, datetime start_date, datetime end_date)
Definition: calendar.py:406
tuple[CalendarEvent|None, timedelta|None] _event_with_offset(self)
Definition: calendar.py:416
None __init__(self, CalendarSyncUpdateCoordinator|CalendarQueryUpdateCoordinator coordinator, str calendar_id, GoogleCalendarEntityDescription entity_description, str|None unique_id)
Definition: calendar.py:339
tuple[str, datetime.timedelta] extract_offset(str summary, str offset_prefix)
Definition: __init__.py:454
bool is_offset_reached(datetime.datetime start, datetime.timedelta offset_time)
Definition: __init__.py:479
FeatureAccess get_feature_access(ConfigEntry config_entry)
Definition: api.py:158
None async_create_event(GoogleCalendarEntity entity, ServiceCall call)
Definition: calendar.py:514
CalendarEvent _get_calendar_event(Event event)
Definition: calendar.py:492
list[GoogleCalendarEntityDescription] _get_entity_descriptions(HomeAssistant hass, ConfigEntry config_entry, Calendar calendar_item, Mapping[str, Any] calendar_info)
Definition: calendar.py:114
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: calendar.py:196
dict[str, Any] get_calendar_info(HomeAssistant hass, Mapping[str, Any] calendar)
Definition: __init__.py:312
None update_config(str path, dict[str, Any] calendar)
Definition: __init__.py:346
str generate_entity_id(str entity_id_format, str|None name, list[str]|None current_ids=None, HomeAssistant|None hass=None)
Definition: entity.py:108