Răsfoiți Sursa

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 luni în urmă
părinte
comite
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 caldav.lib.error as caldav_error
 import voluptuous as vol
 import voluptuous as vol
 
 
-
 from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
 from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
 from homeassistant.core import callback
 from homeassistant.core import callback
 from homeassistant.helpers.selector import (
 from homeassistant.helpers.selector import (
-  BooleanSelector,
+  SelectSelector,
+  SelectSelectorConfig,
+  SelectSelectorMode,
   TextSelector,
   TextSelector,
   TextSelectorConfig,
   TextSelectorConfig,
   TextSelectorType,
   TextSelectorType,
 )
 )
 
 
 from .const import (
 from .const import (
-  CONF_CALDAV_ENABLED,
   CONF_CALDAV_URL,
   CONF_CALDAV_URL,
   CONF_CALENDAR_NAME,
   CONF_CALENDAR_NAME,
   CONF_PASSWORD,
   CONF_PASSWORD,
+  CONF_SYNC_MODE,
   CONF_USERNAME,
   CONF_USERNAME,
   DEFAULT_ROOM_STATE_NAME,
   DEFAULT_ROOM_STATE_NAME,
   DOMAIN,
   DOMAIN,
+  SYNC_MODE_CALDAV,
+  SYNC_MODE_MANUAL,
 )
 )
 
 
 
 
@@ -92,9 +95,9 @@ class RoomCalOptionsFlow(OptionsFlow):
     self, user_input: dict[str, Any] | None = None
     self, user_input: dict[str, Any] | None = None
   ) -> ConfigFlowResult:
   ) -> ConfigFlowResult:
     self._username = self.config_entry.data.get(CONF_USERNAME, "")
     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
     self, user_input: dict[str, Any] | None = None
   ) -> ConfigFlowResult:
   ) -> ConfigFlowResult:
     errors: dict[str, str] = {}
     errors: dict[str, str] = {}
@@ -105,7 +108,7 @@ class RoomCalOptionsFlow(OptionsFlow):
 
 
       new_data = {**self.config_entry.data, CONF_USERNAME: self._username}
       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:
         try:
           await self.hass.async_add_executor_job(
           await self.hass.async_add_executor_job(
             _test_caldav_auth,
             _test_caldav_auth,
@@ -120,15 +123,25 @@ class RoomCalOptionsFlow(OptionsFlow):
         else:
         else:
           new_data[CONF_PASSWORD] = password
           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
     opts = self.config_entry.options
     return self.async_show_form(
     return self.async_show_form(
-      step_id="caldav",
+      step_id="sync",
       data_schema=vol.Schema({
       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_CALDAV_URL, default=opts.get(CONF_CALDAV_URL, "")): str,
         vol.Optional(CONF_CALENDAR_NAME, default=opts.get(CONF_CALENDAR_NAME, "")): str,
         vol.Optional(CONF_CALENDAR_NAME, default=opts.get(CONF_CALENDAR_NAME, "")): str,
         vol.Optional(CONF_USERNAME, default=self._username): 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"
 CONF_PASSWORD = "password"
 
 
 # entry.options keys
 # entry.options keys
-CONF_CALDAV_ENABLED = "caldav_enabled"
+CONF_SYNC_MODE = "sync_mode"
 CONF_CALDAV_URL = "caldav_url"
 CONF_CALDAV_URL = "caldav_url"
 CONF_CALENDAR_NAME = "calendar_name"
 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
 # iCal event selection and data extraction
 RESERVATION_TITLE_PREFIX = "Guest: "
 RESERVATION_TITLE_PREFIX = "Guest: "
 X_DATA_PROP = "X-ROOMCAL-DATA"
 X_DATA_PROP = "X-ROOMCAL-DATA"

+ 17 - 140
custom_components/room_cal_ha/coordinator.py

@@ -1,33 +1,24 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-import json
 import logging
 import logging
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 from typing import Any
 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.config_entries import ConfigEntry
 from homeassistant.core import HomeAssistant, callback
 from homeassistant.core import HomeAssistant, callback
 from homeassistant.helpers.event import async_call_later
 from homeassistant.helpers.event import async_call_later
 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
 from homeassistant.util import dt as dt_util
 from homeassistant.util import dt as dt_util
 
 
+from .backends import BackendAuthError, BackendConnectError, CalDavBackend
 from .const import (
 from .const import (
-  CONF_CALDAV_ENABLED,
-  CONF_CALDAV_URL,
-  CONF_CALENDAR_NAME,
-  CONF_PASSWORD,
-  CONF_USERNAME,
+  CONF_SYNC_MODE,
   DOMAIN,
   DOMAIN,
-  RESERVATION_TITLE_PREFIX,
   SCAN_INTERVAL_MINUTES,
   SCAN_INTERVAL_MINUTES,
   STORE_CURRENT,
   STORE_CURRENT,
   STORE_NEXT,
   STORE_NEXT,
-  X_DATA_PROP,
+  SYNC_MODE_CALDAV,
 )
 )
 from .models import ReservationData
 from .models import ReservationData
 from .store import ReservationStore
 from .store import ReservationStore
@@ -44,7 +35,6 @@ class CoordinatorData:
 
 
 
 
 class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
-  """Fetches and merges reservation data from CalDAV."""
 
 
   def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
   def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
     super().__init__(
     super().__init__(
@@ -67,28 +57,29 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     self._guest_mode_switch = switch
     self._guest_mode_switch = switch
 
 
   async def _async_update_data(self) -> CoordinatorData:
   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:
       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)
         self.entry.async_start_reauth(self.hass)
         raise UpdateFailed("CalDAV authorization failed; re-auth required")
         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)
         _LOGGER.warning("CalDAV fetch failed, using cached data: %s", exc)
 
 
     now = dt_util.utcnow()
     now = dt_util.utcnow()
 
 
-    if caldav_fetched:
+    if backend_fetched:
       # Calendar is authoritative: write its result including None (clears the slot).
       # 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._current_last_refresh = now
       self._next_last_refresh = now
       self._next_last_refresh = now
 
 
@@ -104,62 +95,6 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       next_last_refresh=self._next_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():
-      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:
   def _reschedule_mode_sync(self, current: ReservationData, nxt: ReservationData) -> None:
     for cancel in self._mode_sync_handles:
     for cancel in self._mode_sync_handles:
       cancel()
       cancel()
@@ -259,61 +194,3 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     for cancel in self._mode_sync_handles:
     for cancel in self._mode_sync_handles:
       cancel()
       cancel()
     self._mode_sync_handles.clear()
     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": {
   "options": {
     "step": {
     "step": {
-      "caldav": {
-        "title": "CalDAV Settings",
+      "sync": {
+        "title": "Sync Settings",
         "data": {
         "data": {
-          "caldav_enabled": "Enable CalDAV",
+          "sync_mode": "Sync Mode",
           "caldav_url": "CalDAV URL",
           "caldav_url": "CalDAV URL",
           "calendar_name": "Calendar name",
           "calendar_name": "Calendar name",
           "username": "Username",
           "username": "Username",

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

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