|
|
@@ -1,33 +1,24 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
-import json
|
|
|
import logging
|
|
|
from dataclasses import dataclass
|
|
|
from datetime import datetime, timedelta
|
|
|
from typing import Any
|
|
|
|
|
|
-import caldav
|
|
|
-import caldav.lib.error as caldav_error
|
|
|
-from icalendar import Calendar
|
|
|
-
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
|
from homeassistant.core import HomeAssistant, callback
|
|
|
from homeassistant.helpers.event import async_call_later
|
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
|
|
+from .backends import BackendAuthError, BackendConnectError, CalDavBackend
|
|
|
from .const import (
|
|
|
- CONF_CALDAV_ENABLED,
|
|
|
- CONF_CALDAV_URL,
|
|
|
- CONF_CALENDAR_NAME,
|
|
|
- CONF_PASSWORD,
|
|
|
- CONF_USERNAME,
|
|
|
+ CONF_SYNC_MODE,
|
|
|
DOMAIN,
|
|
|
- RESERVATION_TITLE_PREFIX,
|
|
|
SCAN_INTERVAL_MINUTES,
|
|
|
STORE_CURRENT,
|
|
|
STORE_NEXT,
|
|
|
- X_DATA_PROP,
|
|
|
+ SYNC_MODE_CALDAV,
|
|
|
)
|
|
|
from .models import ReservationData
|
|
|
from .store import ReservationStore
|
|
|
@@ -44,7 +35,6 @@ class CoordinatorData:
|
|
|
|
|
|
|
|
|
class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
- """Fetches and merges reservation data from CalDAV."""
|
|
|
|
|
|
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
|
super().__init__(
|
|
|
@@ -67,28 +57,29 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
self._guest_mode_switch = switch
|
|
|
|
|
|
async def _async_update_data(self) -> CoordinatorData:
|
|
|
- caldav_current: ReservationData | None = None
|
|
|
- caldav_next: ReservationData | None = None
|
|
|
- caldav_fetched = False
|
|
|
+ backend_current: ReservationData | None = None
|
|
|
+ backend_next: ReservationData | None = None
|
|
|
+ backend_fetched = False
|
|
|
+
|
|
|
+ sync_mode = self.entry.options.get(CONF_SYNC_MODE, "")
|
|
|
|
|
|
- if self.entry.options.get(CONF_CALDAV_ENABLED):
|
|
|
+ if sync_mode == SYNC_MODE_CALDAV:
|
|
|
+ backend = CalDavBackend(self.hass, self.entry)
|
|
|
try:
|
|
|
- caldav_current, caldav_next = await self.hass.async_add_executor_job(
|
|
|
- self._fetch_caldav
|
|
|
- )
|
|
|
- caldav_fetched = True
|
|
|
- except caldav_error.AuthorizationError:
|
|
|
+ backend_current, backend_next = await backend.async_fetch()
|
|
|
+ backend_fetched = True
|
|
|
+ except BackendAuthError:
|
|
|
self.entry.async_start_reauth(self.hass)
|
|
|
raise UpdateFailed("CalDAV authorization failed; re-auth required")
|
|
|
- except Exception as exc:
|
|
|
+ except BackendConnectError as exc:
|
|
|
_LOGGER.warning("CalDAV fetch failed, using cached data: %s", exc)
|
|
|
|
|
|
now = dt_util.utcnow()
|
|
|
|
|
|
- if caldav_fetched:
|
|
|
+ if backend_fetched:
|
|
|
# Calendar is authoritative: write its result including None (clears the slot).
|
|
|
- await self.store.async_set(STORE_CURRENT, caldav_current)
|
|
|
- await self.store.async_set(STORE_NEXT, caldav_next)
|
|
|
+ await self.store.async_set(STORE_CURRENT, backend_current)
|
|
|
+ await self.store.async_set(STORE_NEXT, backend_next)
|
|
|
self._current_last_refresh = now
|
|
|
self._next_last_refresh = now
|
|
|
|
|
|
@@ -104,62 +95,6 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
next_last_refresh=self._next_last_refresh,
|
|
|
)
|
|
|
|
|
|
- def _fetch_caldav(self) -> tuple[ReservationData | None, ReservationData | None]:
|
|
|
- """Blocking: connect to CalDAV and parse current/next events."""
|
|
|
- url = self.entry.options.get(CONF_CALDAV_URL, "")
|
|
|
- cal_name = self.entry.options.get(CONF_CALENDAR_NAME, "")
|
|
|
- username = self.entry.data.get(CONF_USERNAME, "")
|
|
|
- password = self.entry.data.get(CONF_PASSWORD, "")
|
|
|
-
|
|
|
- client = caldav.DAVClient(url=url, username=username, password=password)
|
|
|
- principal = client.principal()
|
|
|
-
|
|
|
- calendar = None
|
|
|
- for cal in principal.calendars():
|
|
|
- name = cal.get_display_name()
|
|
|
- if cal_name and name == cal_name:
|
|
|
- calendar = cal
|
|
|
- break
|
|
|
- if not cal_name:
|
|
|
- calendar = cal
|
|
|
- break
|
|
|
-
|
|
|
- if calendar is None:
|
|
|
- _LOGGER.warning("CalDAV calendar %r not found", cal_name)
|
|
|
- return None, None
|
|
|
-
|
|
|
- now = dt_util.now()
|
|
|
- events = calendar.date_search(
|
|
|
- start=now,
|
|
|
- end=now + timedelta(days=90),
|
|
|
- expand=True,
|
|
|
- )
|
|
|
-
|
|
|
- parsed = sorted(
|
|
|
- [e for e in (_parse_event(ev) for ev in events) if e is not None],
|
|
|
- key=lambda r: datetime.combine(r.check_in_date, r.check_in_time or datetime.min.time())
|
|
|
- if r.check_in_date
|
|
|
- else datetime.max,
|
|
|
- )
|
|
|
-
|
|
|
- current: ReservationData | None = None
|
|
|
- nxt: ReservationData | None = None
|
|
|
-
|
|
|
- for res in parsed:
|
|
|
- if res.check_in_date and res.check_out_date:
|
|
|
- check_in = dt_util.as_local(
|
|
|
- datetime.combine(res.check_in_date, res.check_in_time or datetime.min.time())
|
|
|
- )
|
|
|
- check_out = dt_util.as_local(
|
|
|
- datetime.combine(res.check_out_date, res.check_out_time or datetime.max.time().replace(microsecond=0))
|
|
|
- )
|
|
|
- if check_in <= now <= check_out:
|
|
|
- current = res
|
|
|
- elif check_in > now and nxt is None:
|
|
|
- nxt = res
|
|
|
-
|
|
|
- return current, nxt
|
|
|
-
|
|
|
def _reschedule_mode_sync(self, current: ReservationData, nxt: ReservationData) -> None:
|
|
|
for cancel in self._mode_sync_handles:
|
|
|
cancel()
|
|
|
@@ -259,61 +194,3 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
for cancel in self._mode_sync_handles:
|
|
|
cancel()
|
|
|
self._mode_sync_handles.clear()
|
|
|
-
|
|
|
-
|
|
|
-def _parse_event(event: caldav.Event) -> ReservationData | None:
|
|
|
- """Extract ReservationData from a caldav Event. Returns None if not a reservation event."""
|
|
|
- try:
|
|
|
- cal = Calendar.from_ical(event.data)
|
|
|
- for component in cal.walk():
|
|
|
- if component.name != "VEVENT":
|
|
|
- continue
|
|
|
-
|
|
|
- title = str(component.get("SUMMARY", ""))
|
|
|
- if not title.startswith(RESERVATION_TITLE_PREFIX):
|
|
|
- return None
|
|
|
-
|
|
|
- dtstart = component.get("DTSTART")
|
|
|
- if dtstart is None:
|
|
|
- return None
|
|
|
- dt = dtstart.dt
|
|
|
- if not isinstance(dt, datetime):
|
|
|
- return None # all-day event, not a reservation
|
|
|
-
|
|
|
- dt_local = dt_util.as_local(dt) if dt.tzinfo is not None else dt
|
|
|
- check_in_date = dt_local.date()
|
|
|
- check_in_time = dt_local.time().replace(tzinfo=None)
|
|
|
- check_out_date = None
|
|
|
- check_out_time = None
|
|
|
-
|
|
|
- dtend = component.get("DTEND")
|
|
|
- if dtend is not None:
|
|
|
- dt_end = dtend.dt
|
|
|
- if isinstance(dt_end, datetime):
|
|
|
- dt_end_local = dt_util.as_local(dt_end) if dt_end.tzinfo is not None else dt_end
|
|
|
- check_out_date = dt_end_local.date()
|
|
|
- check_out_time = dt_end_local.time().replace(tzinfo=None)
|
|
|
-
|
|
|
- x_data_prop = component.get(X_DATA_PROP)
|
|
|
- x_data_str = str(x_data_prop) if x_data_prop is not None else None
|
|
|
- if not x_data_str:
|
|
|
- return None
|
|
|
-
|
|
|
- try:
|
|
|
- d = json.loads(x_data_str)
|
|
|
- except (ValueError, TypeError):
|
|
|
- _LOGGER.debug("Failed to parse X-ROOMCAL-DATA JSON in event %r", title)
|
|
|
- return None
|
|
|
-
|
|
|
- return ReservationData(
|
|
|
- guest_name=d.get("guest_name"),
|
|
|
- check_in_date=check_in_date,
|
|
|
- check_in_time=check_in_time,
|
|
|
- check_out_date=check_out_date,
|
|
|
- check_out_time=check_out_time,
|
|
|
- wifi_key=d.get("wifi_key"),
|
|
|
- door_code=d.get("door_code"),
|
|
|
- )
|
|
|
- except Exception:
|
|
|
- _LOGGER.debug("Failed to parse event", exc_info=True)
|
|
|
- return None
|