core/homeassistant/components/google/calendar.py

570 lines
20 KiB
Python

"""Support for Google Calendar Search binary sensors."""
from __future__ import annotations
from collections.abc import Mapping
import dataclasses
from datetime import datetime, timedelta
import logging
from typing import Any, cast
from gcal_sync.api import Range, SyncEventsRequest
from gcal_sync.exceptions import ApiException
from gcal_sync.model import (
AccessRole,
Calendar,
DateOrDatetime,
Event,
EventTypeEnum,
ResponseStatus,
)
from gcal_sync.store import ScopedCalendarStore
from gcal_sync.sync import CalendarEventSyncManager
from homeassistant.components.calendar import (
CREATE_EVENT_SCHEMA,
ENTITY_ID_FORMAT,
EVENT_DESCRIPTION,
EVENT_END,
EVENT_LOCATION,
EVENT_RRULE,
EVENT_START,
EVENT_SUMMARY,
CalendarEntity,
CalendarEntityDescription,
CalendarEntityFeature,
CalendarEvent,
extract_offset,
is_offset_reached,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from . import (
CONF_IGNORE_AVAILABILITY,
CONF_SEARCH,
CONF_TRACK,
DEFAULT_CONF_OFFSET,
DOMAIN,
YAML_DEVICES,
get_calendar_info,
load_config,
update_config,
)
from .api import get_feature_access
from .const import (
DATA_SERVICE,
DATA_STORE,
EVENT_END_DATE,
EVENT_END_DATETIME,
EVENT_IN,
EVENT_IN_DAYS,
EVENT_IN_WEEKS,
EVENT_START_DATE,
EVENT_START_DATETIME,
FeatureAccess,
)
from .coordinator import CalendarQueryUpdateCoordinator, CalendarSyncUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
# Avoid syncing super old data on initial syncs. Note that old but active
# recurring events are still included.
SYNC_EVENT_MIN_TIME = timedelta(days=-90)
# Events have a transparency that determine whether or not they block time on calendar.
# When an event is opaque, it means "Show me as busy" which is the default. Events that
# are not opaque are ignored by default.
OPAQUE = "opaque"
# Google calendar prefixes recurrence rules with RRULE: which
# we need to strip when working with the frontend recurrence rule values
RRULE_PREFIX = "RRULE:"
SERVICE_CREATE_EVENT = "create_event"
@dataclasses.dataclass(frozen=True, kw_only=True)
class GoogleCalendarEntityDescription(CalendarEntityDescription):
"""Google calendar entity description."""
name: str | None
entity_id: str | None
read_only: bool
ignore_availability: bool
offset: str | None
search: str | None
local_sync: bool
device_id: str
working_location: bool = False
def _get_entity_descriptions(
hass: HomeAssistant,
config_entry: ConfigEntry,
calendar_item: Calendar,
calendar_info: Mapping[str, Any],
) -> list[GoogleCalendarEntityDescription]:
"""Create entity descriptions for the calendar.
The entity descriptions are based on the type of Calendar from the API
and optional calendar_info yaml configuration that is the older way to
configure calendars before they supported UI based config.
The yaml config may map one calendar to multiple entities and they do not
have a unique id. The yaml config also supports additional options like
offsets or search.
"""
calendar_id = calendar_item.id
num_entities = len(calendar_info[CONF_ENTITIES])
entity_descriptions = []
for data in calendar_info[CONF_ENTITIES]:
if num_entities > 1:
key = ""
else:
key = calendar_id
entity_enabled = data.get(CONF_TRACK, True)
if not entity_enabled:
_LOGGER.warning(
"The 'track' option in google_calendars.yaml has been deprecated."
" The setting has been imported to the UI, and should now be"
" removed from google_calendars.yaml"
)
read_only = not (
calendar_item.access_role.is_writer
and get_feature_access(config_entry) is FeatureAccess.read_write
)
# Prefer calendar sync down of resources when possible. However,
# sync does not work for search. Also free-busy calendars denormalize
# recurring events as individual events which is not efficient for sync
local_sync = True
if (
search := data.get(CONF_SEARCH)
) or calendar_item.access_role == AccessRole.FREE_BUSY_READER:
read_only = True
local_sync = False
entity_description = GoogleCalendarEntityDescription(
key=key,
name=data[CONF_NAME].capitalize(),
entity_id=generate_entity_id(
ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass
),
read_only=read_only,
ignore_availability=data.get(CONF_IGNORE_AVAILABILITY, False),
offset=data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET),
search=search,
local_sync=local_sync,
entity_registry_enabled_default=entity_enabled,
device_id=data[CONF_DEVICE_ID],
)
entity_descriptions.append(entity_description)
_LOGGER.debug(
"calendar_item.primary=%s, search=%s, calendar_item.access_role=%s - %s",
calendar_item.primary,
search,
calendar_item.access_role,
local_sync,
)
if calendar_item.primary and local_sync:
_LOGGER.debug("work location entity")
# Create an optional disabled by default entity for Work Location
entity_descriptions.append(
dataclasses.replace(
entity_description,
key=f"{key}-work-location",
translation_key="working_location",
working_location=True,
name=None,
entity_id=None,
entity_registry_enabled_default=False,
)
)
return entity_descriptions
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the google calendar platform."""
calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE]
store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE]
try:
result = await calendar_service.async_list_calendars()
except ApiException as err:
raise PlatformNotReady(str(err)) from err
entity_registry = er.async_get(hass)
registry_entries = er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
)
entity_entry_map = {
entity_entry.unique_id: entity_entry for entity_entry in registry_entries
}
# Yaml configuration may override objects from the API
calendars = await hass.async_add_executor_job(
load_config, hass.config.path(YAML_DEVICES)
)
new_calendars = []
entities = []
for calendar_item in result.items:
calendar_id = calendar_item.id
if calendars and calendar_id in calendars:
calendar_info = calendars[calendar_id]
else:
calendar_info = get_calendar_info(
hass, calendar_item.dict(exclude_unset=True)
)
new_calendars.append(calendar_info)
for entity_description in _get_entity_descriptions(
hass, config_entry, calendar_item, calendar_info
):
unique_id = (
f"{config_entry.unique_id}-{entity_description.key}"
if entity_description.key
else None
)
# Migrate to new unique_id format which supports
# multiple config entries as of 2022.7
for old_unique_id in (
calendar_id,
f"{calendar_id}-{entity_description.device_id}",
):
if not (entity_entry := entity_entry_map.get(old_unique_id)):
continue
if unique_id:
_LOGGER.debug(
"Migrating unique_id for %s from %s to %s",
entity_entry.entity_id,
old_unique_id,
unique_id,
)
entity_registry.async_update_entity(
entity_entry.entity_id, new_unique_id=unique_id
)
else:
_LOGGER.debug(
"Removing entity registry entry for %s from %s",
entity_entry.entity_id,
old_unique_id,
)
entity_registry.async_remove(
entity_entry.entity_id,
)
_LOGGER.debug("Creating entity with unique_id=%s", unique_id)
coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator
if not entity_description.local_sync:
coordinator = CalendarQueryUpdateCoordinator(
hass,
calendar_service,
entity_description.name or entity_description.key,
calendar_id,
entity_description.search,
)
else:
request_template = SyncEventsRequest(
calendar_id=calendar_id,
start_time=dt_util.now() + SYNC_EVENT_MIN_TIME,
)
sync = CalendarEventSyncManager(
calendar_service,
store=ScopedCalendarStore(
store, unique_id or entity_description.device_id
),
request_template=request_template,
)
coordinator = CalendarSyncUpdateCoordinator(
hass,
sync,
entity_description.name or entity_description.key,
)
entities.append(
GoogleCalendarEntity(
coordinator,
calendar_id,
entity_description,
unique_id,
)
)
async_add_entities(entities)
if calendars and new_calendars:
def append_calendars_to_config() -> None:
path = hass.config.path(YAML_DEVICES)
for calendar in new_calendars:
update_config(path, calendar)
await hass.async_add_executor_job(append_calendars_to_config)
platform = entity_platform.async_get_current_platform()
if (
any(calendar_item.access_role.is_writer for calendar_item in result.items)
and get_feature_access(config_entry) is FeatureAccess.read_write
):
platform.async_register_entity_service(
SERVICE_CREATE_EVENT,
CREATE_EVENT_SCHEMA,
async_create_event,
required_features=CalendarEntityFeature.CREATE_EVENT,
)
class GoogleCalendarEntity(
CoordinatorEntity[CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator],
CalendarEntity,
):
"""A calendar event entity."""
entity_description: GoogleCalendarEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator,
calendar_id: str,
entity_description: GoogleCalendarEntityDescription,
unique_id: str | None,
) -> None:
"""Create the Calendar event device."""
super().__init__(coordinator)
_LOGGER.debug("entity_description.entity_id=%s", entity_description.entity_id)
_LOGGER.debug("entity_description=%s", entity_description)
self.calendar_id = calendar_id
self.entity_description = entity_description
self._ignore_availability = entity_description.ignore_availability
self._offset = entity_description.offset
self._event: CalendarEvent | None = None
if entity_description.entity_id:
self.entity_id = entity_description.entity_id
self._attr_unique_id = unique_id
if not entity_description.read_only:
self._attr_supported_features = (
CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT
)
@property
def extra_state_attributes(self) -> dict[str, bool]:
"""Return the device state attributes."""
return {"offset_reached": self.offset_reached}
@property
def offset_reached(self) -> bool:
"""Return whether or not the event offset was reached."""
(event, offset_value) = self._event_with_offset()
if event is not None and offset_value is not None:
return is_offset_reached(event.start_datetime_local, offset_value)
return False
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
(event, _) = self._event_with_offset()
return event
def _event_filter(self, event: Event) -> bool:
"""Return True if the event is visible and not declined."""
if any(
attendee.is_self and attendee.response_status == ResponseStatus.DECLINED
for attendee in event.attendees
):
return False
if event.event_type == EventTypeEnum.WORKING_LOCATION:
return self.entity_description.working_location
if self._ignore_availability:
return True
return event.transparency == OPAQUE
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
# We do not ask for an update with async_add_entities()
# because it will update disabled entities. This is started as a
# task to let if sync in the background without blocking startup
self.coordinator.config_entry.async_create_background_task(
self.hass,
self.coordinator.async_request_refresh(),
"google.calendar-refresh",
)
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
result_items = await self.coordinator.async_get_events(start_date, end_date)
return [
_get_calendar_event(event)
for event in filter(self._event_filter, result_items)
]
def _event_with_offset(
self,
) -> tuple[CalendarEvent | None, timedelta | None]:
"""Get the calendar event and offset if any."""
if api_event := next(
filter(
self._event_filter,
self.coordinator.upcoming or [],
),
None,
):
event = _get_calendar_event(api_event)
if self._offset:
(event.summary, offset_value) = extract_offset(
event.summary, self._offset
)
return event, offset_value
return None, None
async def async_create_event(self, **kwargs: Any) -> None:
"""Add a new event to calendar."""
dtstart = kwargs[EVENT_START]
dtend = kwargs[EVENT_END]
start: DateOrDatetime
end: DateOrDatetime
if isinstance(dtstart, datetime):
start = DateOrDatetime(
date_time=dt_util.as_local(dtstart),
timezone=str(dt_util.get_default_time_zone()),
)
end = DateOrDatetime(
date_time=dt_util.as_local(dtend),
timezone=str(dt_util.get_default_time_zone()),
)
else:
start = DateOrDatetime(date=dtstart)
end = DateOrDatetime(date=dtend)
event = Event.parse_obj(
{
EVENT_SUMMARY: kwargs[EVENT_SUMMARY],
"start": start,
"end": end,
EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION),
}
)
if location := kwargs.get(EVENT_LOCATION):
event.location = location
if rrule := kwargs.get(EVENT_RRULE):
event.recurrence = [f"{RRULE_PREFIX}{rrule}"]
try:
await cast(
CalendarSyncUpdateCoordinator, self.coordinator
).sync.store_service.async_add_event(event)
except ApiException as err:
raise HomeAssistantError(f"Error while creating event: {err!s}") from err
await self.coordinator.async_refresh()
async def async_delete_event(
self,
uid: str,
recurrence_id: str | None = None,
recurrence_range: str | None = None,
) -> None:
"""Delete an event on the calendar."""
range_value: Range = Range.NONE
if recurrence_range == Range.THIS_AND_FUTURE:
range_value = Range.THIS_AND_FUTURE
await cast(
CalendarSyncUpdateCoordinator, self.coordinator
).sync.store_service.async_delete_event(
ical_uuid=uid,
event_id=recurrence_id,
recurrence_range=range_value,
)
await self.coordinator.async_refresh()
def _get_calendar_event(event: Event) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
rrule: str | None = None
# Home Assistant expects a single RRULE: and all other rule types are unsupported or ignored
if (
len(event.recurrence) == 1
and (raw_rule := event.recurrence[0])
and raw_rule.startswith(RRULE_PREFIX)
):
rrule = raw_rule.removeprefix(RRULE_PREFIX)
return CalendarEvent(
uid=event.ical_uuid,
recurrence_id=event.id if event.recurring_event_id else None,
rrule=rrule,
summary=event.summary,
start=event.start.value,
end=event.end.value,
description=event.description,
location=event.location,
)
async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> None:
"""Add a new event to calendar."""
start: DateOrDatetime | None = None
end: DateOrDatetime | None = None
hass = entity.hass
if EVENT_IN in call.data:
if EVENT_IN_DAYS in call.data[EVENT_IN]:
now = datetime.now()
start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
end_in = start_in + timedelta(days=1)
start = DateOrDatetime(date=start_in)
end = DateOrDatetime(date=end_in)
elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
now = datetime.now()
start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
end_in = start_in + timedelta(days=1)
start = DateOrDatetime(date=start_in)
end = DateOrDatetime(date=end_in)
elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data:
start = DateOrDatetime(date=call.data[EVENT_START_DATE])
end = DateOrDatetime(date=call.data[EVENT_END_DATE])
elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data:
start_dt = call.data[EVENT_START_DATETIME]
end_dt = call.data[EVENT_END_DATETIME]
start = DateOrDatetime(date_time=start_dt, timezone=str(hass.config.time_zone))
end = DateOrDatetime(date_time=end_dt, timezone=str(hass.config.time_zone))
if start is None or end is None:
raise ValueError("Missing required fields to set start or end date/datetime")
event = Event(
summary=call.data[EVENT_SUMMARY],
description=call.data[EVENT_DESCRIPTION],
start=start,
end=end,
)
if location := call.data.get(EVENT_LOCATION):
event.location = location
try:
await cast(
CalendarSyncUpdateCoordinator, entity.coordinator
).sync.api.async_create_event(
entity.calendar_id,
event,
)
except ApiException as err:
raise HomeAssistantError(str(err)) from err
entity.async_write_ha_state()