Просмотр исходного кода

Replace caldav_enabled bool with sync_mode select; extract CalDAV backend

- Options flow: "Enable CalDAV" boolean replaced by "Sync Mode" select
  (CalDAV / API / Manual); options step renamed caldav → sync
- New backends/ package: ReservationBackend ABC with BackendAuthError /
  BackendConnectError; CalDavBackend wraps the moved fetch + parse logic
- Coordinator no longer imports caldav directly; delegates to the backend
  and catches common backend exceptions
ZiB 4 месяцев назад
Родитель
Сommit
f1373c3f5f

+ 9 - 0
custom_components/room_cal_ha/backends/__init__.py

@@ -0,0 +1,9 @@
+from .base import BackendAuthError, BackendConnectError, ReservationBackend
+from .caldav import CalDavBackend
+
+__all__ = [
+  "BackendAuthError",
+  "BackendConnectError",
+  "ReservationBackend",
+  "CalDavBackend",
+]

+ 21 - 0
custom_components/room_cal_ha/backends/base.py

@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+
+from ..models import ReservationData
+
+
+class BackendAuthError(Exception):
+  """Raised when the backend rejects credentials."""
+
+
+class BackendConnectError(Exception):
+  """Raised when the backend cannot be reached."""
+
+
+class ReservationBackend(ABC):
+  """Abstract base for reservation data backends."""
+
+  @abstractmethod
+  async def async_fetch(self) -> tuple[ReservationData | None, ReservationData | None]:
+    """Return (current, next) ReservationData, or None for an empty slot."""

+ 157 - 0
custom_components/room_cal_ha/backends/caldav.py

@@ -0,0 +1,157 @@
+from __future__ import annotations
+
+import json
+import logging
+from datetime import datetime, timedelta
+from typing import TYPE_CHECKING
+
+import caldav
+import caldav.lib.error as caldav_error
+from icalendar import Calendar
+
+from homeassistant.util import dt as dt_util
+
+from ..const import (
+  CONF_CALDAV_URL,
+  CONF_CALENDAR_NAME,
+  CONF_PASSWORD,
+  CONF_USERNAME,
+  RESERVATION_TITLE_PREFIX,
+  X_DATA_PROP,
+)
+from ..models import ReservationData
+from .base import BackendAuthError, BackendConnectError, ReservationBackend
+
+if TYPE_CHECKING:
+  from homeassistant.config_entries import ConfigEntry
+  from homeassistant.core import HomeAssistant
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class CalDavBackend(ReservationBackend):
+
+  def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+    self._hass = hass
+    self._entry = entry
+
+  async def async_fetch(self) -> tuple[ReservationData | None, ReservationData | None]:
+    try:
+      return await self._hass.async_add_executor_job(self._fetch)
+    except caldav_error.AuthorizationError as exc:
+      raise BackendAuthError("CalDAV authorization failed") from exc
+    except Exception as exc:
+      raise BackendConnectError(str(exc)) from exc
+
+  def _fetch(self) -> tuple[ReservationData | None, ReservationData | None]:
+    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 _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

+ 24 - 11
custom_components/room_cal_ha/config_flow.py

@@ -6,24 +6,27 @@ import caldav
 import caldav.lib.error as caldav_error
 import voluptuous as vol
 
-
 from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
 from homeassistant.core import callback
 from homeassistant.helpers.selector import (
-  BooleanSelector,
+  SelectSelector,
+  SelectSelectorConfig,
+  SelectSelectorMode,
   TextSelector,
   TextSelectorConfig,
   TextSelectorType,
 )
 
 from .const import (
-  CONF_CALDAV_ENABLED,
   CONF_CALDAV_URL,
   CONF_CALENDAR_NAME,
   CONF_PASSWORD,
+  CONF_SYNC_MODE,
   CONF_USERNAME,
   DEFAULT_ROOM_STATE_NAME,
   DOMAIN,
+  SYNC_MODE_CALDAV,
+  SYNC_MODE_MANUAL,
 )
 
 
@@ -92,9 +95,9 @@ class RoomCalOptionsFlow(OptionsFlow):
     self, user_input: dict[str, Any] | None = None
   ) -> ConfigFlowResult:
     self._username = self.config_entry.data.get(CONF_USERNAME, "")
-    return await self.async_step_caldav()
+    return await self.async_step_sync()
 
-  async def async_step_caldav(
+  async def async_step_sync(
     self, user_input: dict[str, Any] | None = None
   ) -> ConfigFlowResult:
     errors: dict[str, str] = {}
@@ -105,7 +108,7 @@ class RoomCalOptionsFlow(OptionsFlow):
 
       new_data = {**self.config_entry.data, CONF_USERNAME: self._username}
 
-      if user_input.get(CONF_CALDAV_ENABLED) and user_input.get(CONF_CALDAV_URL):
+      if user_input.get(CONF_SYNC_MODE) == SYNC_MODE_CALDAV and user_input.get(CONF_CALDAV_URL):
         try:
           await self.hass.async_add_executor_job(
             _test_caldav_auth,
@@ -120,15 +123,25 @@ class RoomCalOptionsFlow(OptionsFlow):
         else:
           new_data[CONF_PASSWORD] = password
 
-      self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
-      merged = {**self.config_entry.options, **user_input}
-      return self.async_create_entry(data=merged)
+      if not errors:
+        self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
+        merged = {**self.config_entry.options, **user_input}
+        return self.async_create_entry(data=merged)
 
     opts = self.config_entry.options
     return self.async_show_form(
-      step_id="caldav",
+      step_id="sync",
       data_schema=vol.Schema({
-        vol.Required(CONF_CALDAV_ENABLED, default=opts.get(CONF_CALDAV_ENABLED, False)): BooleanSelector(),
+        vol.Required(CONF_SYNC_MODE, default=opts.get(CONF_SYNC_MODE, SYNC_MODE_MANUAL)): SelectSelector(
+          SelectSelectorConfig(
+            options=[
+              {"value": "caldav", "label": "CalDAV"},
+              {"value": "api", "label": "API"},
+              {"value": "manual", "label": "Manual"},
+            ],
+            mode=SelectSelectorMode.LIST,
+          )
+        ),
         vol.Optional(CONF_CALDAV_URL, default=opts.get(CONF_CALDAV_URL, "")): str,
         vol.Optional(CONF_CALENDAR_NAME, default=opts.get(CONF_CALENDAR_NAME, "")): str,
         vol.Optional(CONF_USERNAME, default=self._username): str,

+ 6 - 1
custom_components/room_cal_ha/const.py

@@ -10,10 +10,15 @@ CONF_USERNAME = "username"
 CONF_PASSWORD = "password"
 
 # entry.options keys
-CONF_CALDAV_ENABLED = "caldav_enabled"
+CONF_SYNC_MODE = "sync_mode"
 CONF_CALDAV_URL = "caldav_url"
 CONF_CALENDAR_NAME = "calendar_name"
 
+# Sync mode values
+SYNC_MODE_CALDAV = "caldav"
+SYNC_MODE_API = "api"
+SYNC_MODE_MANUAL = "manual"
+
 # iCal event selection and data extraction
 RESERVATION_TITLE_PREFIX = "Guest: "
 X_DATA_PROP = "X-ROOMCAL-DATA"

+ 17 - 140
custom_components/room_cal_ha/coordinator.py

@@ -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

+ 3 - 3
custom_components/room_cal_ha/strings.json

@@ -20,10 +20,10 @@
   },
   "options": {
     "step": {
-      "caldav": {
-        "title": "CalDAV Settings",
+      "sync": {
+        "title": "Sync Settings",
         "data": {
-          "caldav_enabled": "Enable CalDAV",
+          "sync_mode": "Sync Mode",
           "caldav_url": "CalDAV URL",
           "calendar_name": "Calendar name",
           "username": "Username",

+ 3 - 3
custom_components/room_cal_ha/translations/en.json

@@ -20,10 +20,10 @@
   },
   "options": {
     "step": {
-      "caldav": {
-        "title": "CalDAV Settings",
+      "sync": {
+        "title": "Sync Settings",
         "data": {
-          "caldav_enabled": "Enable CalDAV",
+          "sync_mode": "Sync Mode",
           "caldav_url": "CalDAV URL",
           "calendar_name": "Calendar name",
           "username": "Username",