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

Add ephemeral reservation override system with Test Next Guest

Introduces a general override layer that presents different reservation data
to all entities without writing to the Store. Backends continue updating the
Store normally during an override; ending it reconciles from Store using the
same startup logic path (async_reconcile_from_store).

- models.py: OverrideData(current, next=None) dataclass
- coordinator.py: _override: OverrideData | None; async_start_override();
  async_end_override(); async_reconcile_from_store() shared helper replaces
  inline logic in async_post_setup_reconcile; async_apply_update routes thin
  updates to override record when active
- switch.py: OverrideReservationSwitch reflects override_active; turn-off
  ends the override
- button.py: TestNextGuestButton starts override from Next data with
  check_in set to now()-1h, then runs full check-in sequence (WiFi + guest mode)
- CoordinatorData gains override_active: bool field
ZiB 4 месяцев назад
Родитель
Сommit
b088c85f75

+ 2 - 0
custom_components/room_cal_ha/binary_sensor.py

@@ -96,3 +96,5 @@ class UnifiAvailableSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEn
       if hub.available:
         return True
     return False
+
+

+ 38 - 1
custom_components/room_cal_ha/button.py

@@ -1,13 +1,18 @@
 from __future__ import annotations
 
+from datetime import timedelta
+
 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 homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.util import dt as dt_util
 
-from .const import CONF_ROOM_NAME, DOMAIN
+from .const import CONF_ROOM_NAME, DOMAIN, STORE_NEXT
 from .coordinator import RoomCalCoordinator
+from .models import OverrideData
 
 
 async def async_setup_entry(
@@ -20,6 +25,7 @@ async def async_setup_entry(
   async_add_entities([
     ForceRefreshButton(coordinator, entry, device_name),
     PromoteFromNextButton(coordinator, entry, device_name),
+    TestNextGuestButton(coordinator, entry, device_name),
   ])
 
 
@@ -63,3 +69,34 @@ class PromoteFromNextButton(ButtonEntity):
 
   async def async_press(self) -> None:
     await self._coordinator.async_promote_next_to_current()
+
+
+class TestNextGuestButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
+  _attr_has_entity_name = True
+  _attr_name = "Test Next Guest"
+  _attr_icon = "mdi:test-tube"
+  __test__ = False  # prevent pytest from collecting this as a test class
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    device_name: str,
+  ) -> None:
+    super().__init__(coordinator)
+    self._attr_unique_id = f"{entry.entry_id}_test_next_guest"
+    self._attr_device_info = DeviceInfo(
+      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
+      name=device_name,
+    )
+
+  async def async_press(self) -> None:
+    next_data = self.coordinator.store.get(STORE_NEXT)
+    if not next_data:
+      return
+    check_in_dt = dt_util.now().replace(tzinfo=None) - timedelta(hours=1)
+    override_current = next_data.apply_partial({
+      "check_in_date": check_in_dt.date(),
+      "check_in_time": check_in_dt.time().replace(second=0, microsecond=0),
+    })
+    await self.coordinator.async_start_override(OverrideData(current=override_current))

+ 140 - 44
custom_components/room_cal_ha/coordinator.py

@@ -24,7 +24,7 @@ from .const import (
   SYNC_MODE_API,
   SYNC_MODE_CALDAV,
 )
-from .models import ApiStatusData, ReservationData
+from .models import ApiStatusData, OverrideData, ReservationData
 from dataclasses import fields as dataclass_fields
 from .store import ReservationStore
 
@@ -38,6 +38,7 @@ class CoordinatorData:
   current_last_refresh: datetime | None
   next_last_refresh: datetime | None
   api_status: ApiStatusData | None = None
+  override_active: bool = False
 
 
 class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
@@ -56,6 +57,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     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
+    self._override: OverrideData | None = None
 
   async def async_setup(self) -> None:
     await self.store.async_load()
@@ -107,14 +109,25 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       self._current_last_refresh = now
       self._next_last_refresh = now
 
-    new_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
-    new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
+    store_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
+    store_next = self.store.get(STORE_NEXT) or self._empty_reservation()
+
+    if self._override is not None:
+      displayed_next = self._override.next if self._override.next is not None else store_next
+      return CoordinatorData(
+        current=self._override.current,
+        next=displayed_next,
+        current_last_refresh=self._current_last_refresh,
+        next_last_refresh=self._next_last_refresh,
+        api_status=api_status,
+        override_active=True,
+      )
 
-    self._reschedule_mode_sync(new_current, new_next)
+    self._reschedule_mode_sync(store_current, store_next)
 
     return CoordinatorData(
-      current=new_current,
-      next=new_next,
+      current=store_current,
+      next=store_next,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       api_status=api_status,
@@ -167,51 +180,55 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       return "pending"
     return None
 
-  async def async_post_setup_reconcile(self) -> None:
-    """
-    Called from async_setup_entry after all platforms are set up and switch states
-    are restored.  Silently corrects guest_mode._state if mode_sync is on and the
-    stored reservation data implies a different state than what was restored, then
-    commits a single HA state write via GuestModeSwitch.commit_state().
+  async def async_reconcile_from_store(self, *, _startup: bool = False) -> None:
+    """Load store state, apply elapsed transitions, set room state, reschedule mode sync.
+
+    Called at startup (via async_post_setup_reconcile) and when leaving override mode.
+    _startup=True uses direct state mutation on the guest mode switch instead of
+    async_turn_on/off, because HA state writes are deferred until commit_state().
     """
     current = self.store.get(STORE_CURRENT) or self._empty_reservation()
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
-    data_changed = False
 
     if self._is_mode_sync_on():
-      # If next check-in elapsed while HA was down, promote it now.
       if self._reservation_window_state(nxt) in ("active", "elapsed"):
         await self.store.async_set(STORE_CURRENT, nxt)
         self._current_last_refresh = dt_util.utcnow()
         current = nxt
-        data_changed = True
 
       state = self._reservation_window_state(current)
-
+      guest_on: bool | None = None
       if state == "elapsed":
         await self.store.async_set(STORE_CURRENT, None)
         self._current_last_refresh = dt_util.utcnow()
         current = self._empty_reservation()
-        if self._guest_mode_switch is not None:
-          self._guest_mode_switch._state = False
-        data_changed = True
+        guest_on = False
       elif state == "active":
-        if self._guest_mode_switch is not None:
-          self._guest_mode_switch._state = True
-        data_changed = True
-
-      new_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
-      new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
-      self._reschedule_mode_sync(new_current, new_next)
-
-      if data_changed:
-        self.async_set_updated_data(CoordinatorData(
-          current=new_current,
-          next=new_next,
-          current_last_refresh=self._current_last_refresh,
-          next_last_refresh=self._next_last_refresh,
-        ))
+        guest_on = True
+
+      if self._guest_mode_switch is not None and guest_on is not None:
+        if _startup:
+          self._guest_mode_switch._state = guest_on
+        elif guest_on:
+          await self._guest_mode_switch.async_turn_on()
+        else:
+          await self._guest_mode_switch.async_turn_off()
+
+    real_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
+    real_next = self.store.get(STORE_NEXT) or self._empty_reservation()
+    self._reschedule_mode_sync(real_current, real_next)
+    self.async_set_updated_data(CoordinatorData(
+      current=real_current,
+      next=real_next,
+      current_last_refresh=self._current_last_refresh,
+      next_last_refresh=self._next_last_refresh,
+    ))
 
+  async def async_post_setup_reconcile(self) -> None:
+    """Called from async_setup_entry after all platforms are set up and switch states
+    are restored. Reconciles room state from the store, then commits the guest mode
+    switch state in a single HA write."""
+    await self.async_reconcile_from_store(_startup=True)
     if self._guest_mode_switch is not None:
       self._guest_mode_switch.commit_state()
 
@@ -273,24 +290,103 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       if self._guest_mode_switch is not None:
         await self._guest_mode_switch.async_turn_on()
 
+  async def async_start_override(self, override: OverrideData) -> None:
+    """Activate a temporary override. Runs the full check-in sequence (WiFi + guest mode)."""
+    self._override = override
+    self.cancel_mode_sync()
+    displayed_next = (
+      override.next if override.next is not None
+      else self.store.get(STORE_NEXT) or self._empty_reservation()
+    )
+    self.async_set_updated_data(CoordinatorData(
+      current=override.current,
+      next=displayed_next,
+      current_last_refresh=self._current_last_refresh,
+      next_last_refresh=self._next_last_refresh,
+      api_status=self.data.api_status if self.data else None,
+      override_active=True,
+    ))
+    if isinstance(override.current.wifi_ssid, str) and isinstance(override.current.wifi_key, str):
+      from .wifi import async_set_wifi_key
+      try:
+        await async_set_wifi_key(self.hass, override.current.wifi_ssid, override.current.wifi_key)
+      except Exception as err:
+        _LOGGER.warning("Failed to set WiFi key during override: %s", err)
+    if self._guest_mode_switch is not None:
+      await self._guest_mode_switch.async_turn_on()
+
+  async def async_end_override(self) -> None:
+    """Clear the active override and reconcile room state from the store."""
+    self._override = None
+    await self.async_reconcile_from_store()
+
   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 self._empty_reservation()
-    updated = stored.apply_partial(partial)
-    await self.store.async_set(target, updated)
+    """Apply a thin update (service call) to current or next.
 
+    During an override, updates to targets covered by the override record are applied
+    in-memory only. Other targets (e.g. next when only current is overridden) still
+    write to the store.
+    """
     now = dt_util.utcnow()
+
+    if self._override is not None and (
+      target == STORE_CURRENT
+      or (target == STORE_NEXT and self._override.next is not None)
+    ):
+      if target == STORE_CURRENT:
+        self._override = OverrideData(
+          current=self._override.current.apply_partial(partial),
+          next=self._override.next,
+        )
+        self._current_last_refresh = now
+      else:
+        self._override = OverrideData(
+          current=self._override.current,
+          next=self._override.next.apply_partial(partial),  # type: ignore[union-attr]
+        )
+        self._next_last_refresh = now
+      displayed_next = (
+        self._override.next if self._override.next is not None
+        else self.store.get(STORE_NEXT) or self._empty_reservation()
+      )
+      self.async_set_updated_data(CoordinatorData(
+        current=self._override.current,
+        next=displayed_next,
+        current_last_refresh=self._current_last_refresh,
+        next_last_refresh=self._next_last_refresh,
+        api_status=self.data.api_status if self.data else None,
+        override_active=True,
+      ))
+      return
+
+    stored = self.store.get(target) or self._empty_reservation()
+    await self.store.async_set(target, stored.apply_partial(partial))
     if target == STORE_CURRENT:
       self._current_last_refresh = now
     else:
       self._next_last_refresh = now
 
-    self.async_set_updated_data(CoordinatorData(
-      current=self.store.get(STORE_CURRENT) or self._empty_reservation(),
-      next=self.store.get(STORE_NEXT) or self._empty_reservation(),
-      current_last_refresh=self._current_last_refresh,
-      next_last_refresh=self._next_last_refresh,
-    ))
+    if self._override is not None:
+      # Store was updated (e.g. next while only current is overridden); keep showing override.
+      displayed_next = (
+        self._override.next if self._override.next is not None
+        else self.store.get(STORE_NEXT) or self._empty_reservation()
+      )
+      self.async_set_updated_data(CoordinatorData(
+        current=self._override.current,
+        next=displayed_next,
+        current_last_refresh=self._current_last_refresh,
+        next_last_refresh=self._next_last_refresh,
+        api_status=self.data.api_status if self.data else None,
+        override_active=True,
+      ))
+    else:
+      self.async_set_updated_data(CoordinatorData(
+        current=self.store.get(STORE_CURRENT) or self._empty_reservation(),
+        next=self.store.get(STORE_NEXT) or self._empty_reservation(),
+        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:

+ 6 - 0
custom_components/room_cal_ha/models.py

@@ -5,6 +5,12 @@ from datetime import date, datetime, time
 from typing import Any
 
 
+@dataclass
+class OverrideData:
+  current: "ReservationData"
+  next: "ReservationData | None" = None
+
+
 @dataclass
 class ApiStatusData:
   status: str = "unknown"

+ 27 - 1
custom_components/room_cal_ha/switch.py

@@ -6,6 +6,7 @@ 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 homeassistant.helpers.update_coordinator import CoordinatorEntity
 
 from .const import CONF_ROOM_NAME, DOMAIN
 from .coordinator import RoomCalCoordinator
@@ -21,10 +22,11 @@ async def async_setup_entry(
 
   guest_mode = GuestModeSwitch(coordinator, entry, device_name)
   mode_sync = ModeSyncSwitch(coordinator, entry, device_name)
+  override = OverrideReservationSwitch(coordinator, entry, device_name)
 
   coordinator.inject_guest_mode_switch(guest_mode)
 
-  switches = [guest_mode, mode_sync]
+  switches = [guest_mode, mode_sync, override]
   hass.data[DOMAIN][entry.entry_id]["switches"] = switches
   async_add_entities(switches)
 
@@ -94,3 +96,27 @@ class ModeSyncSwitch(_RoomStateSwitch):
   async def async_turn_off(self, **kwargs: object) -> None:
     await super().async_turn_off(**kwargs)
     self._coordinator.cancel_mode_sync()
+
+
+class OverrideReservationSwitch(CoordinatorEntity[RoomCalCoordinator], SwitchEntity):
+  _attr_has_entity_name = True
+  _attr_name = "Override Reservation"
+  _attr_icon = "mdi:test-tube"
+
+  def __init__(self, coordinator: RoomCalCoordinator, entry: ConfigEntry, device_name: str) -> None:
+    super().__init__(coordinator)
+    self._attr_unique_id = f"{entry.entry_id}_override_reservation"
+    self._attr_device_info = DeviceInfo(
+      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
+      name=device_name,
+    )
+
+  @property
+  def is_on(self) -> bool:
+    return bool(self.coordinator.data and self.coordinator.data.override_active)
+
+  async def async_turn_on(self, **kwargs: object) -> None:
+    pass  # override is started via TestNextGuestButton or other explicit paths
+
+  async def async_turn_off(self, **kwargs: object) -> None:
+    await self.coordinator.async_end_override()

+ 91 - 3
tests/test_buttons.py

@@ -1,13 +1,20 @@
-"""Unit tests for ForceRefreshButton and PromoteFromNextButton.
+"""Unit tests for ForceRefreshButton, PromoteFromNextButton, and TestNextGuestButton.
 
 Uses object.__new__ to bypass HA entity setup — valid because async_press
 only calls coordinator methods, not HA lifecycle hooks.
 """
-from unittest.mock import AsyncMock, MagicMock
+from datetime import date, time, datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
-from custom_components.room_cal_ha.button import ForceRefreshButton, PromoteFromNextButton
+from custom_components.room_cal_ha.button import (
+  ForceRefreshButton,
+  PromoteFromNextButton,
+  TestNextGuestButton,
+)
+from custom_components.room_cal_ha.const import STORE_NEXT
+from custom_components.room_cal_ha.models import OverrideData, ReservationData
 
 
 def _button(cls):
@@ -40,3 +47,84 @@ async def test_promote_does_not_call_refresh():
   btn = _button(PromoteFromNextButton)
   await btn.async_press()
   btn._coordinator.async_refresh.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# TestNextGuestButton
+# ---------------------------------------------------------------------------
+
+_NEXT = ReservationData(
+  guest_name="Bob",
+  check_in_date=date(2026, 7, 10),
+  check_in_time=time(15, 0),
+  check_out_date=date(2026, 7, 14),
+  check_out_time=time(11, 0),
+)
+
+_FROZEN_NOW = datetime(2026, 6, 22, 14, 0, 0, tzinfo=timezone.utc)
+
+
+def _test_next_button(next_data=_NEXT):
+  btn = object.__new__(TestNextGuestButton)
+  btn.coordinator = MagicMock()
+  btn.coordinator.store = MagicMock()
+  btn.coordinator.store.get.return_value = next_data
+  btn.coordinator.async_start_override = AsyncMock()
+  return btn
+
+
+async def test_test_next_guest_calls_start_override():
+  btn = _test_next_button()
+  with patch("custom_components.room_cal_ha.button.dt_util.now", return_value=_FROZEN_NOW):
+    await btn.async_press()
+  btn.coordinator.async_start_override.assert_awaited_once()
+
+
+async def test_test_next_guest_passes_override_data():
+  btn = _test_next_button()
+  with patch("custom_components.room_cal_ha.button.dt_util.now", return_value=_FROZEN_NOW):
+    await btn.async_press()
+  arg = btn.coordinator.async_start_override.call_args[0][0]
+  assert isinstance(arg, OverrideData)
+
+
+async def test_test_next_guest_sets_check_in_to_one_hour_ago():
+  btn = _test_next_button()
+  with patch("custom_components.room_cal_ha.button.dt_util.now", return_value=_FROZEN_NOW):
+    await btn.async_press()
+  override: OverrideData = btn.coordinator.async_start_override.call_args[0][0]
+  # now() naive = 2026-06-22 14:00 UTC; minus 1h = 13:00
+  assert override.current.check_in_date == date(2026, 6, 22)
+  assert override.current.check_in_time == time(13, 0)
+
+
+async def test_test_next_guest_preserves_other_fields():
+  btn = _test_next_button()
+  with patch("custom_components.room_cal_ha.button.dt_util.now", return_value=_FROZEN_NOW):
+    await btn.async_press()
+  override: OverrideData = btn.coordinator.async_start_override.call_args[0][0]
+  assert override.current.guest_name == "Bob"
+  assert override.current.check_out_date == date(2026, 7, 14)
+  assert override.current.check_out_time == time(11, 0)
+
+
+async def test_test_next_guest_override_next_is_none():
+  btn = _test_next_button()
+  with patch("custom_components.room_cal_ha.button.dt_util.now", return_value=_FROZEN_NOW):
+    await btn.async_press()
+  override: OverrideData = btn.coordinator.async_start_override.call_args[0][0]
+  assert override.next is None
+
+
+async def test_test_next_guest_reads_from_store():
+  btn = _test_next_button()
+  with patch("custom_components.room_cal_ha.button.dt_util.now", return_value=_FROZEN_NOW):
+    await btn.async_press()
+  btn.coordinator.store.get.assert_called_with(STORE_NEXT)
+
+
+async def test_test_next_guest_no_next_data_does_nothing():
+  btn = _test_next_button(next_data=None)
+  with patch("custom_components.room_cal_ha.button.dt_util.now", return_value=_FROZEN_NOW):
+    await btn.async_press()
+  btn.coordinator.async_start_override.assert_not_awaited()

+ 238 - 1
tests/test_coordinator.py

@@ -21,7 +21,7 @@ from custom_components.room_cal_ha.const import (
   SYNC_MODE_MANUAL,
 )
 from custom_components.room_cal_ha.coordinator import RoomCalCoordinator
-from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
+from custom_components.room_cal_ha.models import ApiStatusData, OverrideData, ReservationData
 
 
 SAMPLE = ReservationData(
@@ -341,3 +341,240 @@ async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomC
     await coordinator._async_do_check_in()
 
   mock_switch.async_turn_on.assert_awaited_once()
+
+
+# ---------------------------------------------------------------------------
+# async_start_override / async_end_override
+# ---------------------------------------------------------------------------
+
+_OVERRIDE_RES = ReservationData(guest_name="Override Guest", door_code="1111")
+_WIFI_OVERRIDE = ReservationData(guest_name="WiFi Guest", wifi_ssid="GuestNet", wifi_key="secret")
+
+
+async def test_start_override_sets_current_in_data(coordinator: RoomCalCoordinator):
+  coordinator._guest_mode_switch = None
+  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  assert coordinator.data.current.guest_name == "Override Guest"
+
+
+async def test_start_override_sets_override_active_flag(coordinator: RoomCalCoordinator):
+  coordinator._guest_mode_switch = None
+  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  assert coordinator.data.override_active is True
+
+
+async def test_start_override_cancels_mode_sync(coordinator: RoomCalCoordinator):
+  cancel1, cancel2 = MagicMock(), MagicMock()
+  coordinator._mode_sync_handles = [cancel1, cancel2]
+  coordinator._guest_mode_switch = None
+  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  cancel1.assert_called_once()
+  cancel2.assert_called_once()
+  assert coordinator._mode_sync_handles == []
+
+
+async def test_start_override_turns_on_guest_mode(coordinator: RoomCalCoordinator):
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  coordinator._guest_mode_switch = mock_switch
+  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  mock_switch.async_turn_on.assert_awaited_once()
+
+
+async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator):
+  coordinator._guest_mode_switch = MagicMock()
+  coordinator._guest_mode_switch.async_turn_on = AsyncMock()
+  with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
+    await coordinator.async_start_override(OverrideData(current=_WIFI_OVERRIDE))
+  mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
+
+
+async def test_start_override_skips_wifi_when_ssid_missing(coordinator: RoomCalCoordinator):
+  coordinator._guest_mode_switch = None
+  no_ssid = ReservationData(wifi_ssid=None, wifi_key="secret")
+  with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
+    await coordinator.async_start_override(OverrideData(current=no_ssid))
+  mock_wifi.assert_not_awaited()
+
+
+async def test_start_override_uses_store_next_when_override_next_is_none(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_NEXT, SAMPLE)
+  coordinator._guest_mode_switch = None
+  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  assert coordinator.data.next.guest_name == "Alice"
+
+
+async def test_start_override_uses_override_next_when_provided(coordinator: RoomCalCoordinator):
+  override_next = ReservationData(guest_name="Override Next")
+  coordinator._guest_mode_switch = None
+  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES, next=override_next))
+  assert coordinator.data.next.guest_name == "Override Next"
+
+
+async def test_end_override_clears_override_active(coordinator: RoomCalCoordinator):
+  coordinator._guest_mode_switch = None
+  coordinator._override = OverrideData(current=_OVERRIDE_RES)
+  await coordinator.async_end_override()
+  assert coordinator.data.override_active is False
+
+
+async def test_end_override_restores_store_current(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
+  coordinator._guest_mode_switch = None
+  coordinator._override = OverrideData(current=_OVERRIDE_RES)
+  await coordinator.async_end_override()
+  assert coordinator.data.current.guest_name == "Alice"
+
+
+# ---------------------------------------------------------------------------
+# Thin updates during override
+# ---------------------------------------------------------------------------
+
+async def test_thin_update_current_during_override_updates_override_not_store(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
+  await coordinator.async_refresh()
+  coordinator._override = OverrideData(current=_OVERRIDE_RES)
+
+  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
+
+  assert coordinator.data.current.door_code == "9999"
+  # Store is unchanged
+  assert coordinator.store.get(STORE_CURRENT).door_code == "0472"
+
+
+async def test_thin_update_current_during_override_data_still_shows_override_active(coordinator: RoomCalCoordinator):
+  await coordinator.async_refresh()
+  coordinator._override = OverrideData(current=_OVERRIDE_RES)
+  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
+  assert coordinator.data.override_active is True
+
+
+async def test_thin_update_next_during_override_falls_through_to_store(coordinator: RoomCalCoordinator):
+  coordinator._override = OverrideData(current=_OVERRIDE_RES)  # override.next is None
+  await coordinator.async_refresh()
+
+  await coordinator.async_apply_update(STORE_NEXT, {"guest_name": "Next Guest"})
+
+  assert coordinator.store.get(STORE_NEXT).guest_name == "Next Guest"
+  assert coordinator.data.override_active is True
+  assert coordinator.data.current.guest_name == "Override Guest"
+
+
+# ---------------------------------------------------------------------------
+# Backend fetch during override preserves override display
+# ---------------------------------------------------------------------------
+
+async def test_backend_fetch_during_override_updates_store_but_preserves_override(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, CONF_CALDAV_URL: "https://cal.example.com", CONF_CALENDAR_NAME: "Room"})
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+  coord._override = OverrideData(current=_OVERRIDE_RES)
+
+  backend_data = ReservationData(guest_name="Backend Alice")
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
+    MockBackend.return_value.async_fetch = AsyncMock(return_value=(backend_data, None))
+    await coord.async_refresh()
+
+  # Store was written by the backend
+  assert coord.store.get(STORE_CURRENT).guest_name == "Backend Alice"
+  # But displayed data still shows the override
+  assert coord.data.current.guest_name == "Override Guest"
+  assert coord.data.override_active is True
+
+
+# ---------------------------------------------------------------------------
+# async_reconcile_from_store (shared helper)
+# ---------------------------------------------------------------------------
+
+_ACTIVE = ReservationData(
+  guest_name="Active Guest",
+  check_in_date=date(2026, 6, 20),
+  check_in_time=time(14, 0),
+  check_out_date=date(2026, 6, 25),
+  check_out_time=time(12, 0),
+)
+
+_ELAPSED = ReservationData(
+  guest_name="Past Guest",
+  check_in_date=date(2026, 6, 1),
+  check_in_time=time(14, 0),
+  check_out_date=date(2026, 6, 5),
+  check_out_time=time(12, 0),
+)
+
+_FUTURE = ReservationData(
+  guest_name="Future Guest",
+  check_in_date=date(2026, 7, 1),
+  check_in_time=time(14, 0),
+  check_out_date=date(2026, 7, 5),
+  check_out_time=time(12, 0),
+)
+
+# Freeze time at 2026-06-22 12:00 local (naive)
+_RECONCILE_NOW = MagicMock()
+_RECONCILE_NOW.replace.return_value = datetime(2026, 6, 22, 12, 0, 0)
+
+
+async def test_reconcile_active_current_turns_on_guest_mode(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, _ACTIVE)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  mock_switch.async_turn_off = AsyncMock()
+  coordinator._guest_mode_switch = mock_switch
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
+  ):
+    await coordinator.async_reconcile_from_store()
+  coordinator.cancel_mode_sync()
+
+  mock_switch.async_turn_on.assert_awaited_once()
+  mock_switch.async_turn_off.assert_not_awaited()
+
+
+async def test_reconcile_elapsed_current_clears_store_and_turns_off_guest_mode(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, _ELAPSED)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  mock_switch.async_turn_off = AsyncMock()
+  coordinator._guest_mode_switch = mock_switch
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
+  ):
+    await coordinator.async_reconcile_from_store()
+
+  assert coordinator.store.get(STORE_CURRENT) is None
+  mock_switch.async_turn_off.assert_awaited_once()
+
+
+async def test_reconcile_promotes_next_when_check_in_elapsed(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_NEXT, _ACTIVE)
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
+  ):
+    await coordinator.async_reconcile_from_store()
+  coordinator.cancel_mode_sync()
+
+  assert coordinator.store.get(STORE_CURRENT).guest_name == "Active Guest"
+
+
+async def test_reconcile_does_not_touch_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, _ACTIVE)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  mock_switch.async_turn_off = AsyncMock()
+  coordinator._guest_mode_switch = mock_switch
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=False),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_RECONCILE_NOW),
+  ):
+    await coordinator.async_reconcile_from_store()
+
+  mock_switch.async_turn_on.assert_not_awaited()
+  mock_switch.async_turn_off.assert_not_awaited()

+ 25 - 1
tests/test_models.py

@@ -2,7 +2,7 @@ from datetime import date, datetime, time, timezone
 
 import pytest
 
-from custom_components.room_cal_ha.models import ApiStatusData, ReservationData
+from custom_components.room_cal_ha.models import ApiStatusData, OverrideData, ReservationData
 
 
 # ---------------------------------------------------------------------------
@@ -172,3 +172,27 @@ def test_api_status_with_data():
   assert s.status == "ok"
   assert s.checks["caldav_connection"]["ok"] is True
   assert s.checked_at == ts
+
+
+# ---------------------------------------------------------------------------
+# OverrideData
+# ---------------------------------------------------------------------------
+
+_RES_A = ReservationData(guest_name="Alice")
+_RES_B = ReservationData(guest_name="Bob")
+
+
+def test_override_data_requires_current():
+  o = OverrideData(current=_RES_A)
+  assert o.current.guest_name == "Alice"
+
+
+def test_override_data_next_defaults_to_none():
+  o = OverrideData(current=_RES_A)
+  assert o.next is None
+
+
+def test_override_data_with_next():
+  o = OverrideData(current=_RES_A, next=_RES_B)
+  assert o.current.guest_name == "Alice"
+  assert o.next.guest_name == "Bob"

+ 42 - 2
tests/test_switches.py

@@ -1,4 +1,4 @@
-"""Unit tests for GuestModeSwitch and ModeSyncSwitch.
+"""Unit tests for GuestModeSwitch, ModeSyncSwitch, and OverrideReservationSwitch.
 
 Uses object.__new__ to bypass HA entity setup. async_write_ha_state is
 replaced with a MagicMock to avoid needing a real HA instance.
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
-from custom_components.room_cal_ha.switch import GuestModeSwitch, ModeSyncSwitch
+from custom_components.room_cal_ha.switch import GuestModeSwitch, ModeSyncSwitch, OverrideReservationSwitch
 
 
 def _guest_mode():
@@ -104,3 +104,43 @@ async def test_mode_sync_turn_on_does_not_cancel_sync():
 async def test_mode_sync_initial_state_is_false():
   s = _mode_sync()
   assert s.is_on is False
+
+
+# ---------------------------------------------------------------------------
+# OverrideReservationSwitch
+# ---------------------------------------------------------------------------
+
+def _override_switch(override_active: bool = False):
+  s = object.__new__(OverrideReservationSwitch)
+  s.coordinator = MagicMock()
+  s.coordinator.async_end_override = AsyncMock()
+  data = MagicMock()
+  data.override_active = override_active
+  s.coordinator.data = data
+  return s
+
+
+def test_override_switch_is_off_when_not_active():
+  assert _override_switch(override_active=False).is_on is False
+
+
+def test_override_switch_is_on_when_active():
+  assert _override_switch(override_active=True).is_on is True
+
+
+def test_override_switch_none_data_is_off():
+  s = _override_switch()
+  s.coordinator.data = None
+  assert s.is_on is False
+
+
+async def test_override_switch_turn_off_ends_override():
+  s = _override_switch(override_active=True)
+  await s.async_turn_off()
+  s.coordinator.async_end_override.assert_awaited_once()
+
+
+async def test_override_switch_turn_on_is_noop():
+  s = _override_switch()
+  await s.async_turn_on()
+  s.coordinator.async_end_override.assert_not_awaited()