Procházet zdrojové kódy

Initial room_cal_ha HomeAssistant integration

Three devices (Room State, Current, Next) with CalDAV-backed reservation
sensors, persistent switch state, thin-update service, and options-driven
sensor defaults with a three-layer merge model.
ZiB před 4 měsíci
revize
f591b76e91

+ 77 - 0
custom_components/room_cal_ha/__init__.py

@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN, SENSOR_KEYS, STORE_CURRENT, STORE_NEXT
+from .coordinator import RoomCalCoordinator
+
+PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.BUTTON]
+
+UPDATE_RESERVATION_SCHEMA = vol.Schema({
+  vol.Required("target"): vol.In([STORE_CURRENT, STORE_NEXT]),
+  vol.Optional("guest_name"): vol.Any(None, cv.string),
+  vol.Optional("check_in_date"): vol.Any(None, cv.date),
+  vol.Optional("check_in_time"): vol.Any(None, cv.time),
+  vol.Optional("check_out_date"): vol.Any(None, cv.date),
+  vol.Optional("check_out_time"): vol.Any(None, cv.time),
+  vol.Optional("service_name"): vol.Any(None, cv.string),
+  vol.Optional("reservation_id"): vol.Any(None, cv.string),
+  vol.Optional("wifi_ssid"): vol.Any(None, cv.string),
+  vol.Optional("wifi_key"): vol.Any(None, cv.string),
+  vol.Optional("door_code"): vol.Any(None, cv.string),
+})
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+  coordinator = RoomCalCoordinator(hass, entry)
+  await coordinator.async_setup()
+
+  hass.data.setdefault(DOMAIN, {})
+  hass.data[DOMAIN][entry.entry_id] = {"coordinator": coordinator}
+
+  await coordinator.async_config_entry_first_refresh()
+  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+  entry.async_on_unload(entry.add_update_listener(_async_options_updated))
+
+  if not hass.services.has_service(DOMAIN, "update_reservation"):
+    async def _handle_update_reservation(call: ServiceCall) -> None:
+      target = call.data["target"]
+      partial = {k: v for k, v in call.data.items() if k != "target" and k in SENSOR_KEYS}
+      for entry_data in hass.data[DOMAIN].values():
+        coord: RoomCalCoordinator = entry_data.get("coordinator")
+        if coord:
+          await coord.async_apply_update(target, partial)
+
+    hass.services.async_register(
+      DOMAIN,
+      "update_reservation",
+      _handle_update_reservation,
+      schema=UPDATE_RESERVATION_SCHEMA,
+    )
+
+  return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+  coordinator: RoomCalCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
+  coordinator.cancel_mode_sync()
+
+  unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+  if unloaded:
+    hass.data[DOMAIN].pop(entry.entry_id)
+    if not hass.data[DOMAIN]:
+      hass.services.async_remove(DOMAIN, "update_reservation")
+      hass.data.pop(DOMAIN)
+
+  return unloaded
+
+
+async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
+  await hass.config_entries.async_reload(entry.entry_id)

+ 41 - 0
custom_components/room_cal_ha/button.py

@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from homeassistant.components.button import ButtonEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .const import CONF_ROOM_STATE_NAME, DEFAULT_ROOM_STATE_NAME, DOMAIN
+from .coordinator import RoomCalCoordinator
+
+
+async def async_setup_entry(
+  hass: HomeAssistant,
+  entry: ConfigEntry,
+  async_add_entities: AddEntitiesCallback,
+) -> None:
+  coordinator: RoomCalCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
+  device_name = entry.data.get(CONF_ROOM_STATE_NAME, DEFAULT_ROOM_STATE_NAME)
+  async_add_entities([ForceRefreshButton(coordinator, entry, device_name)])
+
+
+class ForceRefreshButton(ButtonEntity):
+  _attr_has_entity_name = True
+  _attr_name = "Force Refresh"
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    device_name: str,
+  ) -> None:
+    self._coordinator = coordinator
+    self._attr_unique_id = f"{entry.entry_id}_force_refresh"
+    self._attr_device_info = DeviceInfo(
+      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
+      name=device_name,
+    )
+
+  async def async_press(self) -> None:
+    await self._coordinator.async_request_refresh()

+ 171 - 0
custom_components/room_cal_ha/config_flow.py

@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+from typing import Any
+
+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,
+  TextSelector,
+  TextSelectorConfig,
+  TextSelectorType,
+)
+
+from .const import (
+  CONF_CALDAV_ENABLED,
+  CONF_CALDAV_URL,
+  CONF_CALENDAR_NAME,
+  CONF_CURRENT_NAME,
+  CONF_NEXT_NAME,
+  CONF_PASSWORD,
+  CONF_ROOM_STATE_NAME,
+  CONF_USERNAME,
+  DEFAULT_CURRENT_NAME,
+  DEFAULT_NEXT_NAME,
+  DEFAULT_ROOM_STATE_NAME,
+  DOMAIN,
+  SENSOR_KEYS,
+  default_option_key,
+)
+
+
+class RoomCalConfigFlow(ConfigFlow, domain=DOMAIN):
+  VERSION = 1
+
+  async def async_step_user(
+    self, user_input: dict[str, Any] | None = None
+  ) -> ConfigFlowResult:
+    if user_input is not None:
+      return self.async_create_entry(
+        title=user_input[CONF_ROOM_STATE_NAME],
+        data=user_input,
+      )
+
+    return self.async_show_form(
+      step_id="user",
+      data_schema=vol.Schema({
+        vol.Required(CONF_ROOM_STATE_NAME, default=DEFAULT_ROOM_STATE_NAME): str,
+        vol.Required(CONF_CURRENT_NAME, default=DEFAULT_CURRENT_NAME): str,
+        vol.Required(CONF_NEXT_NAME, default=DEFAULT_NEXT_NAME): str,
+      }),
+    )
+
+  async def async_step_reauth(
+    self, entry_data: dict[str, Any]
+  ) -> ConfigFlowResult:
+    return await self.async_step_reauth_confirm()
+
+  async def async_step_reauth_confirm(
+    self, user_input: dict[str, Any] | None = None
+  ) -> ConfigFlowResult:
+    errors: dict[str, str] = {}
+
+    if user_input is not None:
+      try:
+        await self.hass.async_add_executor_job(
+          _test_caldav_auth,
+          self._get_reauth_entry().options.get(CONF_CALDAV_URL, ""),
+          user_input[CONF_USERNAME],
+          user_input[CONF_PASSWORD],
+        )
+      except caldav_error.AuthorizationError:
+        errors["base"] = "invalid_auth"
+      except Exception:
+        errors["base"] = "cannot_connect"
+      else:
+        return self.async_update_reload_and_abort(
+          self._get_reauth_entry(),
+          data_updates={
+            CONF_USERNAME: user_input[CONF_USERNAME],
+            CONF_PASSWORD: user_input[CONF_PASSWORD],
+          },
+        )
+
+    return self.async_show_form(
+      step_id="reauth_confirm",
+      data_schema=vol.Schema({
+        vol.Required(CONF_USERNAME): str,
+        vol.Required(CONF_PASSWORD): TextSelector(
+          TextSelectorConfig(type=TextSelectorType.PASSWORD)
+        ),
+      }),
+      errors=errors,
+    )
+
+  @staticmethod
+  @callback
+  def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
+    return RoomCalOptionsFlow()
+
+
+class RoomCalOptionsFlow(OptionsFlow):
+
+  async def async_step_init(
+    self, user_input: dict[str, Any] | None = None
+  ) -> ConfigFlowResult:
+    return await self.async_step_caldav()
+
+  async def async_step_caldav(
+    self, user_input: dict[str, Any] | None = None
+  ) -> ConfigFlowResult:
+    errors: dict[str, str] = {}
+
+    if user_input is not None:
+      if user_input.get(CONF_CALDAV_ENABLED) and user_input.get(CONF_CALDAV_URL):
+        try:
+          await self.hass.async_add_executor_job(
+            _test_caldav_auth,
+            user_input[CONF_CALDAV_URL],
+            self.config_entry.data.get(CONF_USERNAME, ""),
+            self.config_entry.data.get(CONF_PASSWORD, ""),
+          )
+        except caldav_error.AuthorizationError:
+          errors["base"] = "invalid_auth"
+        except Exception:
+          errors["base"] = "cannot_connect"
+
+      if not errors:
+        self._pending_options = dict(self.config_entry.options)
+        self._pending_options.update(user_input)
+        return await self.async_step_defaults()
+
+    opts = self.config_entry.options
+    return self.async_show_form(
+      step_id="caldav",
+      data_schema=vol.Schema({
+        vol.Required(CONF_CALDAV_ENABLED, default=opts.get(CONF_CALDAV_ENABLED, False)): BooleanSelector(),
+        vol.Optional(CONF_CALDAV_URL, default=opts.get(CONF_CALDAV_URL, "")): str,
+        vol.Optional(CONF_CALENDAR_NAME, default=opts.get(CONF_CALENDAR_NAME, "")): str,
+      }),
+      errors=errors,
+    )
+
+  async def async_step_defaults(
+    self, user_input: dict[str, Any] | None = None
+  ) -> ConfigFlowResult:
+    if user_input is not None:
+      self._pending_options.update(
+        {default_option_key(k): v or None for k, v in user_input.items()}
+      )
+      return self.async_create_entry(data=self._pending_options)
+
+    opts = self.config_entry.options
+    schema_dict: dict[Any, Any] = {}
+    for key in SENSOR_KEYS:
+      opt_key = default_option_key(key)
+      schema_dict[vol.Optional(key, default=opts.get(opt_key, ""))] = str
+
+    return self.async_show_form(
+      step_id="defaults",
+      data_schema=vol.Schema(schema_dict),
+    )
+
+
+def _test_caldav_auth(url: str, username: str, password: str) -> None:
+  """Blocking: verify credentials by fetching the principal."""
+  client = caldav.DAVClient(url=url, username=username, password=password)
+  client.principal()

+ 47 - 0
custom_components/room_cal_ha/const.py

@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+DOMAIN = "room_cal_ha"
+
+# entry.data keys
+CONF_ROOM_STATE_NAME = "room_state_name"
+CONF_CURRENT_NAME = "current_name"
+CONF_NEXT_NAME = "next_name"
+CONF_USERNAME = "username"
+CONF_PASSWORD = "password"
+
+# entry.options keys
+CONF_CALDAV_ENABLED = "caldav_enabled"
+CONF_CALDAV_URL = "caldav_url"
+CONF_CALENDAR_NAME = "calendar_name"
+
+# Sensor field keys — order is significant (used in UI and store)
+SENSOR_KEYS: list[str] = [
+  "guest_name",
+  "check_in_date",
+  "check_in_time",
+  "check_out_date",
+  "check_out_time",
+  "service_name",
+  "reservation_id",
+  "wifi_ssid",
+  "wifi_key",
+  "door_code",
+]
+
+# entry.options key prefix for admin-configured sensor defaults
+DEFAULT_KEY_PREFIX = "default_"
+
+def default_option_key(sensor_key: str) -> str:
+  return f"{DEFAULT_KEY_PREFIX}{sensor_key}"
+
+# Default device names (suggested in config flow)
+DEFAULT_ROOM_STATE_NAME = "Room State"
+DEFAULT_CURRENT_NAME = "Current"
+DEFAULT_NEXT_NAME = "Next"
+
+# Update interval
+SCAN_INTERVAL_MINUTES = 15
+
+# Store keys
+STORE_CURRENT = "current"
+STORE_NEXT = "next"

+ 303 - 0
custom_components/room_cal_ha/coordinator.py

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

+ 12 - 0
custom_components/room_cal_ha/manifest.json

@@ -0,0 +1,12 @@
+{
+  "domain": "room_cal_ha",
+  "name": "Room Calendar",
+  "version": "0.1.0",
+  "config_flow": true,
+  "documentation": "https://github.com/room-cal-ha/room_cal_ha",
+  "issue_tracker": "https://github.com/room-cal-ha/room_cal_ha/issues",
+  "requirements": ["caldav==1.3.9", "icalendar==5.0.12"],
+  "dependencies": [],
+  "codeowners": [],
+  "iot_class": "cloud_polling"
+}

+ 87 - 0
custom_components/room_cal_ha/models.py

@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, fields
+from datetime import date, time
+from typing import Any
+
+
+@dataclass
+class ReservationData:
+  guest_name: str | None = None
+  check_in_date: date | None = None
+  check_in_time: time | None = None
+  check_out_date: date | None = None
+  check_out_time: time | None = None
+  service_name: str | None = None
+  reservation_id: str | None = None
+  wifi_ssid: str | None = None
+  wifi_key: str | None = None
+  door_code: str | None = None
+
+  def to_dict(self) -> dict[str, Any]:
+    """Serialize for storage. date/time become ISO strings."""
+    result: dict[str, Any] = {}
+    for f in fields(self):
+      val = getattr(self, f.name)
+      if val is None:
+        result[f.name] = None
+      elif isinstance(val, (date, time)):
+        result[f.name] = val.isoformat()
+      else:
+        result[f.name] = val
+    return result
+
+  @classmethod
+  def from_dict(cls, data: dict[str, Any]) -> ReservationData:
+    """Deserialize from storage."""
+    kwargs: dict[str, Any] = {}
+    for f in fields(cls):
+      val = data.get(f.name)
+      if val is None:
+        kwargs[f.name] = None
+      elif f.name in ("check_in_date", "check_out_date"):
+        kwargs[f.name] = date.fromisoformat(val)
+      elif f.name in ("check_in_time", "check_out_time"):
+        kwargs[f.name] = time.fromisoformat(val)
+      else:
+        kwargs[f.name] = val
+    return cls(**kwargs)
+
+  def apply_partial(self, partial: dict[str, Any]) -> ReservationData:
+    """Return a new ReservationData with fields from partial overlaid."""
+    current = self.to_dict()
+    for k, v in partial.items():
+      if k not in current:
+        continue
+      if isinstance(v, (date, time)):
+        current[k] = v.isoformat()
+      else:
+        current[k] = v
+    return ReservationData.from_dict(current)
+
+
+def merge(
+  option_defaults: ReservationData,
+  caldav_data: ReservationData | None,
+  overrides: ReservationData | None = None,
+) -> ReservationData:
+  """Merge three layers lowest-to-highest: option_defaults < caldav_data < overrides.
+
+  A None field at a given layer means "not set at this layer"; the next lower layer's
+  value is used. Explicit None stored in a lower layer can be overridden by a non-None
+  value in a higher layer.
+  """
+  result: dict[str, Any] = {}
+  for f in fields(ReservationData):
+    key = f.name
+    val = getattr(option_defaults, key)
+    if caldav_data is not None:
+      layer = getattr(caldav_data, key)
+      if layer is not None:
+        val = layer
+    if overrides is not None:
+      layer = getattr(overrides, key)
+      if layer is not None:
+        val = layer
+    result[key] = val
+  return ReservationData(**result)

+ 132 - 0
custom_components/room_cal_ha/sensor.py

@@ -0,0 +1,132 @@
+from __future__ import annotations
+
+from datetime import date, datetime, time
+from typing import Any
+
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import (
+  CONF_CURRENT_NAME,
+  CONF_NEXT_NAME,
+  DEFAULT_CURRENT_NAME,
+  DEFAULT_NEXT_NAME,
+  DOMAIN,
+  STORE_CURRENT,
+  STORE_NEXT,
+)
+from .coordinator import CoordinatorData, RoomCalCoordinator
+from .models import ReservationData
+
+
+async def async_setup_entry(
+  hass: HomeAssistant,
+  entry: ConfigEntry,
+  async_add_entities: AddEntitiesCallback,
+) -> None:
+  coordinator: RoomCalCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
+
+  current_name = entry.data.get(CONF_CURRENT_NAME, DEFAULT_CURRENT_NAME)
+  next_name = entry.data.get(CONF_NEXT_NAME, DEFAULT_NEXT_NAME)
+
+  entities: list[SensorEntity] = []
+
+  for slot, device_name in ((STORE_CURRENT, current_name), (STORE_NEXT, next_name)):
+    device_id = f"{entry.entry_id}_{slot}"
+    device_info = DeviceInfo(
+      identifiers={(DOMAIN, device_id)},
+      name=device_name,
+    )
+    entities += [
+      ReservationStrSensor(coordinator, entry, slot, device_info, "Guest Name", "guest_name"),
+      ReservationDateSensor(coordinator, entry, slot, device_info, "Check-In Date", "check_in_date"),
+      ReservationTimeSensor(coordinator, entry, slot, device_info, "Check-In Time", "check_in_time"),
+      ReservationDateSensor(coordinator, entry, slot, device_info, "Check-Out Date", "check_out_date"),
+      ReservationTimeSensor(coordinator, entry, slot, device_info, "Check-Out Time", "check_out_time"),
+      ReservationStrSensor(coordinator, entry, slot, device_info, "Service Name", "service_name"),
+      ReservationStrSensor(coordinator, entry, slot, device_info, "Reservation ID", "reservation_id"),
+      ReservationStrSensor(coordinator, entry, slot, device_info, "WiFi SSID", "wifi_ssid"),
+      ReservationStrSensor(coordinator, entry, slot, device_info, "WiFi Key", "wifi_key"),
+      ReservationStrSensor(coordinator, entry, slot, device_info, "Door Code", "door_code"),
+      LastRefreshSensor(coordinator, entry, slot, device_info),
+    ]
+
+  async_add_entities(entities)
+
+
+class _ReservationSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
+  _attr_has_entity_name = True
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    slot: str,
+    device_info: DeviceInfo,
+    name: str,
+    field: str,
+  ) -> None:
+    super().__init__(coordinator)
+    self._slot = slot
+    self._field = field
+    self._attr_name = name
+    self._attr_unique_id = f"{entry.entry_id}_{slot}_{field}"
+    self._attr_device_info = device_info
+
+  def _reservation(self) -> ReservationData:
+    data: CoordinatorData = self.coordinator.data
+    return data.current if self._slot == STORE_CURRENT else data.next
+
+
+class ReservationStrSensor(_ReservationSensor):
+  @property
+  def native_value(self) -> str | None:
+    return getattr(self._reservation(), self._field)
+
+
+class ReservationDateSensor(_ReservationSensor):
+  _attr_device_class = SensorDeviceClass.DATE
+
+  @property
+  def native_value(self) -> date | None:
+    return getattr(self._reservation(), self._field)
+
+
+class ReservationTimeSensor(_ReservationSensor):
+  """Time sensor. HA has no TIME device class for sensors; we expose as string ISO."""
+
+  @property
+  def native_value(self) -> str | None:
+    val: time | None = getattr(self._reservation(), self._field)
+    return val.isoformat() if val is not None else None
+
+
+class LastRefreshSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
+  _attr_has_entity_name = True
+  _attr_name = "Last Refresh"
+  _attr_device_class = SensorDeviceClass.TIMESTAMP
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    slot: str,
+    device_info: DeviceInfo,
+  ) -> None:
+    super().__init__(coordinator)
+    self._slot = slot
+    self._attr_unique_id = f"{entry.entry_id}_{slot}_last_refresh"
+    self._attr_device_info = device_info
+
+  @property
+  def native_value(self) -> datetime | None:
+    data: CoordinatorData = self.coordinator.data
+    return (
+      data.current_last_refresh
+      if self._slot == STORE_CURRENT
+      else data.next_last_refresh
+    )

+ 53 - 0
custom_components/room_cal_ha/services.yaml

@@ -0,0 +1,53 @@
+update_reservation:
+  name: Update Reservation
+  description: Apply a partial update to the current or next reservation record. Only the supplied fields are changed; all others retain their existing values.
+  fields:
+    target:
+      name: Target
+      description: Which reservation to update.
+      required: true
+      selector:
+        select:
+          options:
+            - current
+            - next
+    guest_name:
+      name: Guest Name
+      selector:
+        text:
+    check_in_date:
+      name: Check-In Date
+      selector:
+        date:
+    check_in_time:
+      name: Check-In Time
+      selector:
+        time:
+    check_out_date:
+      name: Check-Out Date
+      selector:
+        date:
+    check_out_time:
+      name: Check-Out Time
+      selector:
+        time:
+    service_name:
+      name: Service Name
+      selector:
+        text:
+    reservation_id:
+      name: Reservation ID
+      selector:
+        text:
+    wifi_ssid:
+      name: WiFi SSID
+      selector:
+        text:
+    wifi_key:
+      name: WiFi Key
+      selector:
+        text:
+    door_code:
+      name: Door Code
+      selector:
+        text:

+ 41 - 0
custom_components/room_cal_ha/store.py

@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.storage import Store
+
+from .const import DOMAIN, STORE_CURRENT, STORE_NEXT
+from .models import ReservationData
+
+STORAGE_VERSION = 1
+
+
+class ReservationStore:
+  """Persists current and next ReservationData across HA restarts."""
+
+  def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
+    self._store: Store[dict[str, Any]] = Store(
+      hass,
+      STORAGE_VERSION,
+      f"{DOMAIN}.{entry_id}",
+    )
+    self._data: dict[str, Any] = {STORE_CURRENT: None, STORE_NEXT: None}
+
+  async def async_load(self) -> None:
+    loaded = await self._store.async_load()
+    if loaded:
+      self._data = loaded
+
+  async def async_save(self) -> None:
+    await self._store.async_save(self._data)
+
+  def get(self, key: str) -> ReservationData | None:
+    raw = self._data.get(key)
+    if raw is None:
+      return None
+    return ReservationData.from_dict(raw)
+
+  async def async_set(self, key: str, data: ReservationData) -> None:
+    self._data[key] = data.to_dict()
+    await self.async_save()

+ 84 - 0
custom_components/room_cal_ha/strings.json

@@ -0,0 +1,84 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "title": "Set up Room Calendar",
+        "description": "Name the three devices this integration will create.",
+        "data": {
+          "room_state_name": "Room State device name",
+          "current_name": "Current reservation device name",
+          "next_name": "Next reservation device name"
+        }
+      },
+      "reauth_confirm": {
+        "title": "Re-authenticate CalDAV",
+        "description": "Enter your CalDAV credentials to restore the connection.",
+        "data": {
+          "username": "Username",
+          "password": "Password"
+        }
+      }
+    },
+    "error": {
+      "invalid_auth": "Invalid credentials.",
+      "cannot_connect": "Cannot connect to the CalDAV server."
+    },
+    "abort": {
+      "already_configured": "This integration is already configured."
+    }
+  },
+  "options": {
+    "step": {
+      "caldav": {
+        "title": "CalDAV Settings",
+        "data": {
+          "caldav_enabled": "Enable CalDAV",
+          "caldav_url": "CalDAV URL",
+          "calendar_name": "Calendar name"
+        }
+      },
+      "defaults": {
+        "title": "Sensor Defaults",
+        "description": "Optional fallback values used when a field is not provided by the calendar or a thin update.",
+        "data": {
+          "guest_name": "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",
+          "service_name": "Service Name",
+          "reservation_id": "Reservation ID",
+          "wifi_ssid": "WiFi SSID",
+          "wifi_key": "WiFi Key",
+          "door_code": "Door Code"
+        }
+      }
+    },
+    "error": {
+      "invalid_auth": "Invalid credentials.",
+      "cannot_connect": "Cannot connect to the CalDAV server."
+    }
+  },
+  "services": {
+    "update_reservation": {
+      "name": "Update Reservation",
+      "description": "Apply a partial update to the current or next reservation record.",
+      "fields": {
+        "target": {
+          "name": "Target",
+          "description": "Which reservation to update: 'current' or 'next'."
+        },
+        "guest_name": { "name": "Guest Name", "description": "Override guest name." },
+        "check_in_date": { "name": "Check-In Date", "description": "Override check-in date." },
+        "check_in_time": { "name": "Check-In Time", "description": "Override check-in time." },
+        "check_out_date": { "name": "Check-Out Date", "description": "Override check-out date." },
+        "check_out_time": { "name": "Check-Out Time", "description": "Override check-out time." },
+        "service_name": { "name": "Service Name", "description": "Override service name." },
+        "reservation_id": { "name": "Reservation ID", "description": "Override reservation ID." },
+        "wifi_ssid": { "name": "WiFi SSID", "description": "Override WiFi SSID." },
+        "wifi_key": { "name": "WiFi Key", "description": "Override WiFi key." },
+        "door_code": { "name": "Door Code", "description": "Override door code." }
+      }
+    }
+  }
+}

+ 85 - 0
custom_components/room_cal_ha/switch.py

@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from .const import CONF_ROOM_STATE_NAME, DEFAULT_ROOM_STATE_NAME, DOMAIN
+from .coordinator import RoomCalCoordinator
+
+
+async def async_setup_entry(
+  hass: HomeAssistant,
+  entry: ConfigEntry,
+  async_add_entities: AddEntitiesCallback,
+) -> None:
+  coordinator: RoomCalCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
+  device_name = entry.data.get(CONF_ROOM_STATE_NAME, DEFAULT_ROOM_STATE_NAME)
+
+  guest_mode = GuestModeSwitch(coordinator, entry, device_name)
+  mode_sync = ModeSyncSwitch(coordinator, entry, device_name)
+
+  coordinator.inject_guest_mode_switch(guest_mode)
+
+  switches = [guest_mode, mode_sync]
+  hass.data[DOMAIN][entry.entry_id]["switches"] = switches
+  async_add_entities(switches)
+
+
+class _RoomStateSwitch(SwitchEntity, RestoreEntity):
+  _attr_has_entity_name = True
+  _state: bool = False
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    device_name: str,
+  ) -> None:
+    self._coordinator = coordinator
+    self._entry = entry
+    self._attr_device_info = DeviceInfo(
+      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
+      name=device_name,
+    )
+
+  async def async_added_to_hass(self) -> None:
+    await super().async_added_to_hass()
+    last = await self.async_get_last_state()
+    if last is not None:
+      self._state = last.state == "on"
+
+  @property
+  def is_on(self) -> bool:
+    return self._state
+
+  async def async_turn_on(self, **kwargs: object) -> None:
+    self._state = True
+    self.async_write_ha_state()
+
+  async def async_turn_off(self, **kwargs: object) -> None:
+    self._state = False
+    self.async_write_ha_state()
+
+
+class GuestModeSwitch(_RoomStateSwitch):
+  _attr_name = "Guest Mode"
+
+  def __init__(self, coordinator: RoomCalCoordinator, entry: ConfigEntry, device_name: str) -> None:
+    super().__init__(coordinator, entry, device_name)
+    self._attr_unique_id = f"{entry.entry_id}_guest_mode"
+
+
+class ModeSyncSwitch(_RoomStateSwitch):
+  _attr_name = "Mode Sync"
+
+  def __init__(self, coordinator: RoomCalCoordinator, entry: ConfigEntry, device_name: str) -> None:
+    super().__init__(coordinator, entry, device_name)
+    self._attr_unique_id = f"{entry.entry_id}_mode_sync"
+
+  async def async_turn_off(self, **kwargs: object) -> None:
+    await super().async_turn_off(**kwargs)
+    self._coordinator.cancel_mode_sync()

+ 84 - 0
custom_components/room_cal_ha/translations/en.json

@@ -0,0 +1,84 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "title": "Set up Room Calendar",
+        "description": "Name the three devices this integration will create.",
+        "data": {
+          "room_state_name": "Room State device name",
+          "current_name": "Current reservation device name",
+          "next_name": "Next reservation device name"
+        }
+      },
+      "reauth_confirm": {
+        "title": "Re-authenticate CalDAV",
+        "description": "Enter your CalDAV credentials to restore the connection.",
+        "data": {
+          "username": "Username",
+          "password": "Password"
+        }
+      }
+    },
+    "error": {
+      "invalid_auth": "Invalid credentials.",
+      "cannot_connect": "Cannot connect to the CalDAV server."
+    },
+    "abort": {
+      "already_configured": "This integration is already configured."
+    }
+  },
+  "options": {
+    "step": {
+      "caldav": {
+        "title": "CalDAV Settings",
+        "data": {
+          "caldav_enabled": "Enable CalDAV",
+          "caldav_url": "CalDAV URL",
+          "calendar_name": "Calendar name"
+        }
+      },
+      "defaults": {
+        "title": "Sensor Defaults",
+        "description": "Optional fallback values used when a field is not provided by the calendar or a thin update.",
+        "data": {
+          "guest_name": "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",
+          "service_name": "Service Name",
+          "reservation_id": "Reservation ID",
+          "wifi_ssid": "WiFi SSID",
+          "wifi_key": "WiFi Key",
+          "door_code": "Door Code"
+        }
+      }
+    },
+    "error": {
+      "invalid_auth": "Invalid credentials.",
+      "cannot_connect": "Cannot connect to the CalDAV server."
+    }
+  },
+  "services": {
+    "update_reservation": {
+      "name": "Update Reservation",
+      "description": "Apply a partial update to the current or next reservation record.",
+      "fields": {
+        "target": {
+          "name": "Target",
+          "description": "Which reservation to update: 'current' or 'next'."
+        },
+        "guest_name": { "name": "Guest Name", "description": "Override guest name." },
+        "check_in_date": { "name": "Check-In Date", "description": "Override check-in date." },
+        "check_in_time": { "name": "Check-In Time", "description": "Override check-in time." },
+        "check_out_date": { "name": "Check-Out Date", "description": "Override check-out date." },
+        "check_out_time": { "name": "Check-Out Time", "description": "Override check-out time." },
+        "service_name": { "name": "Service Name", "description": "Override service name." },
+        "reservation_id": { "name": "Reservation ID", "description": "Override reservation ID." },
+        "wifi_ssid": { "name": "WiFi SSID", "description": "Override WiFi SSID." },
+        "wifi_key": { "name": "WiFi Key", "description": "Override WiFi key." },
+        "door_code": { "name": "Door Code", "description": "Override door code." }
+      }
+    }
+  }
+}