|
@@ -0,0 +1,303 @@
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+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 .const import (
|
|
|
|
|
+ CONF_CALDAV_ENABLED,
|
|
|
|
|
+ CONF_CALDAV_URL,
|
|
|
|
|
+ CONF_CALENDAR_NAME,
|
|
|
|
|
+ CONF_PASSWORD,
|
|
|
|
|
+ CONF_USERNAME,
|
|
|
|
|
+ DOMAIN,
|
|
|
|
|
+ SCAN_INTERVAL_MINUTES,
|
|
|
|
|
+ SENSOR_KEYS,
|
|
|
|
|
+ STORE_CURRENT,
|
|
|
|
|
+ STORE_NEXT,
|
|
|
|
|
+ default_option_key,
|
|
|
|
|
+)
|
|
|
|
|
+from .models import ReservationData, merge
|
|
|
|
|
+from .store import ReservationStore
|
|
|
|
|
+
|
|
|
|
|
+_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass
|
|
|
|
|
+class CoordinatorData:
|
|
|
|
|
+ current: ReservationData
|
|
|
|
|
+ next: ReservationData
|
|
|
|
|
+ current_last_refresh: datetime | None
|
|
|
|
|
+ next_last_refresh: datetime | None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
|
|
+ """Fetches and merges reservation data from CalDAV."""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
|
|
|
+ super().__init__(
|
|
|
|
|
+ hass,
|
|
|
|
|
+ _LOGGER,
|
|
|
|
|
+ name=DOMAIN,
|
|
|
|
|
+ update_interval=timedelta(minutes=SCAN_INTERVAL_MINUTES),
|
|
|
|
|
+ )
|
|
|
|
|
+ self.entry = entry
|
|
|
|
|
+ self.store = ReservationStore(hass, entry.entry_id)
|
|
|
|
|
+ self._mode_sync_handles: list[Any] = []
|
|
|
|
|
+ self._guest_mode_switch: Any = None # injected by switch.py after platform setup
|
|
|
|
|
+ self._current_last_refresh: datetime | None = None
|
|
|
|
|
+ self._next_last_refresh: datetime | None = None
|
|
|
|
|
+
|
|
|
|
|
+ async def async_setup(self) -> None:
|
|
|
|
|
+ await self.store.async_load()
|
|
|
|
|
+
|
|
|
|
|
+ def inject_guest_mode_switch(self, switch: Any) -> None:
|
|
|
|
|
+ self._guest_mode_switch = switch
|
|
|
|
|
+
|
|
|
|
|
+ def _option_defaults(self) -> ReservationData:
|
|
|
|
|
+ opts = self.entry.options
|
|
|
|
|
+ kwargs: dict[str, Any] = {}
|
|
|
|
|
+ for key in SENSOR_KEYS:
|
|
|
|
|
+ kwargs[key] = opts.get(default_option_key(key))
|
|
|
|
|
+ return ReservationData(**kwargs)
|
|
|
|
|
+
|
|
|
|
|
+ async def _async_update_data(self) -> CoordinatorData:
|
|
|
|
|
+ option_defaults = self._option_defaults()
|
|
|
|
|
+
|
|
|
|
|
+ caldav_current: ReservationData | None = None
|
|
|
|
|
+ caldav_next: ReservationData | None = None
|
|
|
|
|
+
|
|
|
|
|
+ if self.entry.options.get(CONF_CALDAV_ENABLED):
|
|
|
|
|
+ try:
|
|
|
|
|
+ caldav_current, caldav_next = await self.hass.async_add_executor_job(
|
|
|
|
|
+ self._fetch_caldav
|
|
|
|
|
+ )
|
|
|
|
|
+ except caldav_error.AuthorizationError:
|
|
|
|
|
+ self.entry.async_start_reauth(self.hass)
|
|
|
|
|
+ raise UpdateFailed("CalDAV authorization failed; re-auth required")
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ _LOGGER.warning("CalDAV fetch failed, using cached data: %s", exc)
|
|
|
|
|
+
|
|
|
|
|
+ stored_current = self.store.get(STORE_CURRENT)
|
|
|
|
|
+ stored_next = self.store.get(STORE_NEXT)
|
|
|
|
|
+
|
|
|
|
|
+ now = dt_util.utcnow()
|
|
|
|
|
+
|
|
|
|
|
+ # When CalDAV returned data, always persist it and refresh the timestamp.
|
|
|
|
|
+ # When CalDAV was skipped/failed, serve from store without touching the timestamp.
|
|
|
|
|
+ if caldav_current is not None:
|
|
|
|
|
+ await self.store.async_set(STORE_CURRENT, caldav_current)
|
|
|
|
|
+ stored_current = caldav_current
|
|
|
|
|
+ self._current_last_refresh = now
|
|
|
|
|
+
|
|
|
|
|
+ if caldav_next is not None:
|
|
|
|
|
+ await self.store.async_set(STORE_NEXT, caldav_next)
|
|
|
|
|
+ stored_next = caldav_next
|
|
|
|
|
+ self._next_last_refresh = now
|
|
|
|
|
+
|
|
|
|
|
+ new_current = merge(option_defaults, stored_current)
|
|
|
|
|
+ new_next = merge(option_defaults, stored_next)
|
|
|
|
|
+
|
|
|
|
|
+ self._reschedule_mode_sync(new_current)
|
|
|
|
|
+
|
|
|
|
|
+ return CoordinatorData(
|
|
|
|
|
+ current=new_current,
|
|
|
|
|
+ next=new_next,
|
|
|
|
|
+ current_last_refresh=self._current_last_refresh,
|
|
|
|
|
+ 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():
|
|
|
|
|
+ if cal_name and cal.name == cal_name:
|
|
|
|
|
+ calendar = cal
|
|
|
|
|
+ break
|
|
|
|
|
+ if not cal_name:
|
|
|
|
|
+ calendar = cal
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ if calendar is None:
|
|
|
|
|
+ _LOGGER.warning("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) -> None:
|
|
|
|
|
+ for cancel in self._mode_sync_handles:
|
|
|
|
|
+ cancel()
|
|
|
|
|
+ self._mode_sync_handles.clear()
|
|
|
|
|
+
|
|
|
|
|
+ if not self._is_mode_sync_on():
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ now = dt_util.now()
|
|
|
|
|
+
|
|
|
|
|
+ if current.check_in_date and current.check_in_time:
|
|
|
|
|
+ check_in = dt_util.as_local(
|
|
|
|
|
+ datetime.combine(current.check_in_date, current.check_in_time)
|
|
|
|
|
+ )
|
|
|
|
|
+ delay = (check_in - now).total_seconds()
|
|
|
|
|
+ if delay > 0:
|
|
|
|
|
+ self._mode_sync_handles.append(
|
|
|
|
|
+ async_call_later(self.hass, delay, self._async_activate_guest_mode)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if current.check_out_date and current.check_out_time:
|
|
|
|
|
+ check_out = dt_util.as_local(
|
|
|
|
|
+ datetime.combine(current.check_out_date, current.check_out_time)
|
|
|
|
|
+ )
|
|
|
|
|
+ delay = (check_out - now).total_seconds()
|
|
|
|
|
+ if delay > 0:
|
|
|
|
|
+ self._mode_sync_handles.append(
|
|
|
|
|
+ async_call_later(self.hass, delay, self._async_deactivate_guest_mode)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def _is_mode_sync_on(self) -> bool:
|
|
|
|
|
+ from .switch import ModeSyncSwitch # avoid circular at module level
|
|
|
|
|
+ for entity in self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("switches", []):
|
|
|
|
|
+ if isinstance(entity, ModeSyncSwitch):
|
|
|
|
|
+ return entity.is_on
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ @callback
|
|
|
|
|
+ def _async_activate_guest_mode(self, _now: datetime) -> None:
|
|
|
|
|
+ if self._guest_mode_switch is not None:
|
|
|
|
|
+ self.hass.async_create_task(self._guest_mode_switch.async_turn_on())
|
|
|
|
|
+
|
|
|
|
|
+ @callback
|
|
|
|
|
+ def _async_deactivate_guest_mode(self, _now: datetime) -> None:
|
|
|
|
|
+ if self._guest_mode_switch is not None:
|
|
|
|
|
+ self.hass.async_create_task(self._guest_mode_switch.async_turn_off())
|
|
|
|
|
+
|
|
|
|
|
+ async def async_apply_update(self, target: str, partial: dict[str, Any]) -> None:
|
|
|
|
|
+ """Apply a thin update (service call) to current or next."""
|
|
|
|
|
+ stored = self.store.get(target) or ReservationData()
|
|
|
|
|
+ updated = stored.apply_partial(partial)
|
|
|
|
|
+ await self.store.async_set(target, updated)
|
|
|
|
|
+
|
|
|
|
|
+ option_defaults = self._option_defaults()
|
|
|
|
|
+ now = dt_util.utcnow()
|
|
|
|
|
+
|
|
|
|
|
+ if target == STORE_CURRENT:
|
|
|
|
|
+ self._current_last_refresh = now
|
|
|
|
|
+ else:
|
|
|
|
|
+ self._next_last_refresh = now
|
|
|
|
|
+
|
|
|
|
|
+ current = merge(option_defaults, self.store.get(STORE_CURRENT))
|
|
|
|
|
+ nxt = merge(option_defaults, self.store.get(STORE_NEXT))
|
|
|
|
|
+
|
|
|
|
|
+ self.async_set_updated_data(CoordinatorData(
|
|
|
|
|
+ current=current,
|
|
|
|
|
+ next=nxt,
|
|
|
|
|
+ current_last_refresh=self._current_last_refresh,
|
|
|
|
|
+ next_last_refresh=self._next_last_refresh,
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ def cancel_mode_sync(self) -> None:
|
|
|
|
|
+ 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 unusable."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ cal = Calendar.from_ical(event.data)
|
|
|
|
|
+ for component in cal.walk():
|
|
|
|
|
+ if component.name != "VEVENT":
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ def get(prop: str) -> str | None:
|
|
|
|
|
+ val = component.get(prop)
|
|
|
|
|
+ return str(val) if val is not None else None
|
|
|
|
|
+
|
|
|
|
|
+ dtstart = component.get("DTSTART")
|
|
|
|
|
+ dtend = component.get("DTEND")
|
|
|
|
|
+
|
|
|
|
|
+ check_in_date: Any = None
|
|
|
|
|
+ check_in_time: Any = None
|
|
|
|
|
+ check_out_date: Any = None
|
|
|
|
|
+ check_out_time: Any = None
|
|
|
|
|
+
|
|
|
|
|
+ if dtstart:
|
|
|
|
|
+ dt = dtstart.dt
|
|
|
|
|
+ if isinstance(dt, datetime):
|
|
|
|
|
+ check_in_date = dt.date()
|
|
|
|
|
+ check_in_time = dt.time()
|
|
|
|
|
+ else:
|
|
|
|
|
+ check_in_date = dt
|
|
|
|
|
+
|
|
|
|
|
+ if dtend:
|
|
|
|
|
+ dt = dtend.dt
|
|
|
|
|
+ if isinstance(dt, datetime):
|
|
|
|
|
+ check_out_date = dt.date()
|
|
|
|
|
+ check_out_time = dt.time()
|
|
|
|
|
+ else:
|
|
|
|
|
+ check_out_date = dt
|
|
|
|
|
+
|
|
|
|
|
+ return ReservationData(
|
|
|
|
|
+ guest_name=get("SUMMARY"),
|
|
|
|
|
+ check_in_date=check_in_date,
|
|
|
|
|
+ check_in_time=check_in_time,
|
|
|
|
|
+ check_out_date=check_out_date,
|
|
|
|
|
+ check_out_time=check_out_time,
|
|
|
|
|
+ service_name=get("CATEGORIES"),
|
|
|
|
|
+ reservation_id=get("UID"),
|
|
|
|
|
+ wifi_ssid=get("X-WIFI-SSID"),
|
|
|
|
|
+ wifi_key=get("X-WIFI-KEY"),
|
|
|
|
|
+ door_code=get("X-DOOR-CODE"),
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ _LOGGER.debug("Failed to parse event", exc_info=True)
|
|
|
|
|
+ return None
|