Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for Google - Calendar Event Devices."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 from datetime import datetime, timedelta
7 import logging
8 from typing import Any
9 
10 import aiohttp
11 from gcal_sync.api import GoogleCalendarService
12 from gcal_sync.exceptions import ApiException, AuthException
13 from gcal_sync.model import DateOrDatetime, Event
14 import voluptuous as vol
15 import yaml
16 
17 from homeassistant.config_entries import ConfigEntry
18 from homeassistant.const import (
19  CONF_DEVICE_ID,
20  CONF_ENTITIES,
21  CONF_NAME,
22  CONF_OFFSET,
23  Platform,
24 )
25 from homeassistant.core import HomeAssistant, ServiceCall
26 from homeassistant.exceptions import (
27  ConfigEntryAuthFailed,
28  ConfigEntryNotReady,
29  HomeAssistantError,
30 )
31 from homeassistant.helpers import config_entry_oauth2_flow
32 from homeassistant.helpers.aiohttp_client import async_get_clientsession
34 from homeassistant.helpers.entity import generate_entity_id
35 
36 from .api import ApiAuthImpl, get_feature_access
37 from .const import (
38  DATA_SERVICE,
39  DATA_STORE,
40  DOMAIN,
41  EVENT_DESCRIPTION,
42  EVENT_END_DATE,
43  EVENT_END_DATETIME,
44  EVENT_IN,
45  EVENT_IN_DAYS,
46  EVENT_IN_WEEKS,
47  EVENT_LOCATION,
48  EVENT_START_DATE,
49  EVENT_START_DATETIME,
50  EVENT_SUMMARY,
51  EVENT_TYPES_CONF,
52  FeatureAccess,
53 )
54 from .store import LocalCalendarStore
55 
56 _LOGGER = logging.getLogger(__name__)
57 
58 ENTITY_ID_FORMAT = DOMAIN + ".{}"
59 
60 CONF_TRACK_NEW = "track_new_calendar"
61 
62 CONF_CAL_ID = "cal_id"
63 CONF_TRACK = "track"
64 CONF_SEARCH = "search"
65 CONF_IGNORE_AVAILABILITY = "ignore_availability"
66 CONF_MAX_RESULTS = "max_results"
67 
68 DEFAULT_CONF_OFFSET = "!!"
69 
70 EVENT_CALENDAR_ID = "calendar_id"
71 
72 SERVICE_ADD_EVENT = "add_event"
73 
74 YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
75 
76 PLATFORMS = [Platform.CALENDAR]
77 
78 
79 CONFIG_SCHEMA = vol.Schema(cv.removed(DOMAIN), extra=vol.ALLOW_EXTRA)
80 
81 
82 _SINGLE_CALSEARCH_CONFIG = vol.All(
83  cv.deprecated(CONF_MAX_RESULTS),
84  vol.Schema(
85  {
86  vol.Required(CONF_NAME): cv.string,
87  vol.Required(CONF_DEVICE_ID): cv.string,
88  vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean,
89  vol.Optional(CONF_OFFSET): cv.string,
90  vol.Optional(CONF_SEARCH): cv.string,
91  vol.Optional(CONF_TRACK): cv.boolean,
92  vol.Optional(CONF_MAX_RESULTS): cv.positive_int, # Now unused
93  }
94  ),
95 )
96 
97 DEVICE_SCHEMA = vol.Schema(
98  {
99  vol.Required(CONF_CAL_ID): cv.string,
100  vol.Required(CONF_ENTITIES, None): vol.All(
101  cv.ensure_list, [_SINGLE_CALSEARCH_CONFIG]
102  ),
103  },
104  extra=vol.ALLOW_EXTRA,
105 )
106 
107 _EVENT_IN_TYPES = vol.Schema(
108  {
109  vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES_CONF): cv.positive_int,
110  vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES_CONF): cv.positive_int,
111  }
112 )
113 
114 ADD_EVENT_SERVICE_SCHEMA = vol.All(
115  cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN),
116  cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN),
117  {
118  vol.Required(EVENT_CALENDAR_ID): cv.string,
119  vol.Required(EVENT_SUMMARY): cv.string,
120  vol.Optional(EVENT_DESCRIPTION, default=""): cv.string,
121  vol.Optional(EVENT_LOCATION, default=""): cv.string,
122  vol.Inclusive(
123  EVENT_START_DATE, "dates", "Start and end dates must both be specified"
124  ): cv.date,
125  vol.Inclusive(
126  EVENT_END_DATE, "dates", "Start and end dates must both be specified"
127  ): cv.date,
128  vol.Inclusive(
129  EVENT_START_DATETIME,
130  "datetimes",
131  "Start and end datetimes must both be specified",
132  ): cv.datetime,
133  vol.Inclusive(
134  EVENT_END_DATETIME,
135  "datetimes",
136  "Start and end datetimes must both be specified",
137  ): cv.datetime,
138  vol.Optional(EVENT_IN): _EVENT_IN_TYPES,
139  },
140 )
141 
142 
143 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
144  """Set up Google from a config entry."""
145  hass.data.setdefault(DOMAIN, {})
146  hass.data[DOMAIN][entry.entry_id] = {}
147 
148  # Validate google_calendars.yaml (if present) as soon as possible to return
149  # helpful error messages.
150  try:
151  await hass.async_add_executor_job(load_config, hass.config.path(YAML_DEVICES))
152  except vol.Invalid as err:
153  _LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err))
154  return False
155 
156  implementation = (
157  await config_entry_oauth2_flow.async_get_config_entry_implementation(
158  hass, entry
159  )
160  )
161  session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
162  # Force a token refresh to fix a bug where tokens were persisted with
163  # expires_in (relative time delta) and expires_at (absolute time) swapped.
164  # A google session token typically only lasts a few days between refresh.
165  now = datetime.now()
166  if session.token["expires_at"] >= (now + timedelta(days=365)).timestamp():
167  session.token["expires_in"] = 0
168  session.token["expires_at"] = now.timestamp()
169  try:
170  await session.async_ensure_token_valid()
171  except aiohttp.ClientResponseError as err:
172  if 400 <= err.status < 500:
173  raise ConfigEntryAuthFailed from err
174  raise ConfigEntryNotReady from err
175  except aiohttp.ClientError as err:
176  raise ConfigEntryNotReady from err
177 
178  if not async_entry_has_scopes(entry):
179  raise ConfigEntryAuthFailed(
180  "Required scopes are not available, reauth required"
181  )
182  calendar_service = GoogleCalendarService(
183  ApiAuthImpl(async_get_clientsession(hass), session)
184  )
185  hass.data[DOMAIN][entry.entry_id][DATA_SERVICE] = calendar_service
186  hass.data[DOMAIN][entry.entry_id][DATA_STORE] = LocalCalendarStore(
187  hass, entry.entry_id
188  )
189 
190  if entry.unique_id is None:
191  try:
192  primary_calendar = await calendar_service.async_get_calendar("primary")
193  except AuthException as err:
194  raise ConfigEntryAuthFailed from err
195  except ApiException as err:
196  raise ConfigEntryNotReady from err
197 
198  hass.config_entries.async_update_entry(entry, unique_id=primary_calendar.id)
199 
200  # Only expose the add event service if we have the correct permissions
201  if get_feature_access(entry) is FeatureAccess.read_write:
202  await async_setup_add_event_service(hass, calendar_service)
203 
204  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
205 
206  entry.async_on_unload(entry.add_update_listener(async_reload_entry))
207 
208  return True
209 
210 
211 def async_entry_has_scopes(entry: ConfigEntry) -> bool:
212  """Verify that the config entry desired scope is present in the oauth token."""
213  access = get_feature_access(entry)
214  token_scopes = entry.data.get("token", {}).get("scope", [])
215  return access.scope in token_scopes
216 
217 
218 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
219  """Unload a config entry."""
220  if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
221  hass.data[DOMAIN].pop(entry.entry_id)
222  return unload_ok
223 
224 
225 async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
226  """Reload config entry if the access options change."""
227  if not async_entry_has_scopes(entry):
228  await hass.config_entries.async_reload(entry.entry_id)
229 
230 
231 async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
232  """Handle removal of a local storage."""
233  store = LocalCalendarStore(hass, entry.entry_id)
234  await store.async_remove()
235 
236 
238  hass: HomeAssistant,
239  calendar_service: GoogleCalendarService,
240 ) -> None:
241  """Add the service to add events."""
242 
243  async def _add_event(call: ServiceCall) -> None:
244  """Add a new event to calendar."""
245  _LOGGER.warning(
246  "The Google Calendar add_event service has been deprecated, and "
247  "will be removed in a future Home Assistant release. Please move "
248  "calls to the create_event service"
249  )
250 
251  start: DateOrDatetime | None = None
252  end: DateOrDatetime | None = None
253 
254  if EVENT_IN in call.data:
255  if EVENT_IN_DAYS in call.data[EVENT_IN]:
256  now = datetime.now()
257 
258  start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS])
259  end_in = start_in + timedelta(days=1)
260 
261  start = DateOrDatetime(date=start_in)
262  end = DateOrDatetime(date=end_in)
263 
264  elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
265  now = datetime.now()
266 
267  start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
268  end_in = start_in + timedelta(days=1)
269 
270  start = DateOrDatetime(date=start_in)
271  end = DateOrDatetime(date=end_in)
272 
273  elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data:
274  start = DateOrDatetime(date=call.data[EVENT_START_DATE])
275  end = DateOrDatetime(date=call.data[EVENT_END_DATE])
276 
277  elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data:
278  start_dt = call.data[EVENT_START_DATETIME]
279  end_dt = call.data[EVENT_END_DATETIME]
280  start = DateOrDatetime(
281  date_time=start_dt, timezone=str(hass.config.time_zone)
282  )
283  end = DateOrDatetime(date_time=end_dt, timezone=str(hass.config.time_zone))
284 
285  if start is None or end is None:
286  raise ValueError(
287  "Missing required fields to set start or end date/datetime"
288  )
289  event = Event(
290  summary=call.data[EVENT_SUMMARY],
291  description=call.data[EVENT_DESCRIPTION],
292  start=start,
293  end=end,
294  )
295  if location := call.data.get(EVENT_LOCATION):
296  event.location = location
297  try:
298  await calendar_service.async_create_event(
299  call.data[EVENT_CALENDAR_ID],
300  event,
301  )
302  except ApiException as err:
303  raise HomeAssistantError(str(err)) from err
304 
305  hass.services.async_register(
306  DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA
307  )
308 
309 
311  hass: HomeAssistant, calendar: Mapping[str, Any]
312 ) -> dict[str, Any]:
313  """Convert data from Google into DEVICE_SCHEMA."""
314  calendar_info: dict[str, Any] = DEVICE_SCHEMA(
315  {
316  CONF_CAL_ID: calendar["id"],
317  CONF_ENTITIES: [
318  {
319  CONF_NAME: calendar["summary"],
320  CONF_DEVICE_ID: generate_entity_id(
321  "{}", calendar["summary"], hass=hass
322  ),
323  }
324  ],
325  }
326  )
327  return calendar_info
328 
329 
330 def load_config(path: str) -> dict[str, Any]:
331  """Load the google_calendar_devices.yaml."""
332  calendars = {}
333  try:
334  with open(path, encoding="utf8") as file:
335  data = yaml.safe_load(file) or []
336  for calendar in data:
337  calendars[calendar[CONF_CAL_ID]] = DEVICE_SCHEMA(calendar)
338  except FileNotFoundError as err:
339  _LOGGER.debug("Error reading calendar configuration: %s", err)
340  # When YAML file could not be loaded/did not contain a dict
341  return {}
342 
343  return calendars
344 
345 
346 def update_config(path: str, calendar: dict[str, Any]) -> None:
347  """Write the google_calendar_devices.yaml."""
348  try:
349  with open(path, "a", encoding="utf8") as out:
350  out.write("\n")
351  yaml.dump([calendar], out, default_flow_style=False)
352  except FileNotFoundError as err:
353  _LOGGER.debug("Error persisting calendar configuration: %s", err)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
FeatureAccess get_feature_access(ConfigEntry config_entry)
Definition: api.py:158
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:143
None async_remove_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:231
None async_reload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:225
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
None async_setup_add_event_service(HomeAssistant hass, GoogleCalendarService calendar_service)
Definition: __init__.py:240
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:218
dict[str, Any] load_config(str path)
Definition: __init__.py:330
bool async_entry_has_scopes(ConfigEntry entry)
Definition: __init__.py:211
None open(self, **Any kwargs)
Definition: lock.py:86
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
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