Jelajahi Sumber

Unify override into the standard reservation-transition pipeline

Route backend fetch, thin updates, promote/clear, scheduled check-in/out,
and override start/end through one shared diff -> dispatch -> publish
pipeline instead of duplicated hand-rolled paths. Fixes overriding to an
empty record running checkout instead of check-in, and makes an active
override's own Current check in/out on schedule instead of waiting
indefinitely for the next poll. Adds an optional end_at auto-end timer
to async_start_override.
ZiB 3 bulan lalu
induk
melakukan
0c5197739f
3 mengubah file dengan 376 tambahan dan 284 penghapusan
  1. 221 280
      custom_components/room_cal_ha/coordinator.py
  2. 153 4
      tests/test_coordinator.py
  3. 2 0
      tests/test_locks.py

+ 221 - 280
custom_components/room_cal_ha/coordinator.py

@@ -29,6 +29,8 @@ from .store import ReservationStore
 
 
 _LOGGER = logging.getLogger(__name__)
 _LOGGER = logging.getLogger(__name__)
 
 
+_UNSET = object()
+
 
 
 @dataclass
 @dataclass
 class CoordinatorData:
 class CoordinatorData:
@@ -63,6 +65,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     self._current_last_updated: datetime | None = None
     self._current_last_updated: datetime | None = None
     self._next_last_updated: datetime | None = None
     self._next_last_updated: datetime | None = None
     self._override: OverrideData | None = None
     self._override: OverrideData | None = None
+    self._override_end_handle: Any = None
+    self._override_end_target: OverrideData | None = None
     self._current_arrived: bool = False
     self._current_arrived: bool = False
     self._current_departed: bool = False
     self._current_departed: bool = False
 
 
@@ -76,6 +80,92 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
   def inject_room_state_switch(self, switch: Any) -> None:
   def inject_room_state_switch(self, switch: Any) -> None:
     self._room_state_switch = switch
     self._room_state_switch = switch
 
 
+  # -- Effective-layer resolution (override vs. store) ---------------------
+
+  def _target_covered_by_override(self, target: str) -> bool:
+    """Whether `target` ("current"/"next") is currently owned by the override."""
+    if self._override is None:
+      return False
+    if target == STORE_CURRENT:
+      return True
+    return self._override.next is not None
+
+  def _effective(self, target: str) -> ReservationData:
+    """Read `target` from whichever layer currently owns it, with defaults applied."""
+    if self._target_covered_by_override(target):
+      raw = self._override.current if target == STORE_CURRENT else self._override.next
+    else:
+      raw = self.store.get(target)
+    return self._with_defaults(raw)
+
+  async def _async_write(self, target: str, value: ReservationData | None) -> None:
+    """Write `value` into whichever layer currently owns `target`."""
+    if self._target_covered_by_override(target):
+      if target == STORE_CURRENT:
+        self._override = OverrideData(current=value, next=self._override.next)
+      else:
+        self._override = OverrideData(current=self._override.current, next=value)
+    else:
+      await self.store.async_set(target, value)
+
+  # -- Shared transition primitive ------------------------------------------
+
+  async def _async_transition(self, target: str, value: ReservationData | None) -> "GuestStateDiff | None":
+    """Write `value` into whichever layer owns `target`.
+
+    Returns the diff (old effective vs. new effective) when target is Current; None
+    for Next, since Next never drives guest-mode/WiFi/lock actions directly.
+    """
+    old = self._effective(target)
+    await self._async_write(target, value)
+    now = dt_util.utcnow()
+    if target == STORE_CURRENT:
+      self._current_last_refresh = now
+    else:
+      self._next_last_refresh = now
+    if target != STORE_CURRENT:
+      return None
+    new = self._effective(target)
+    await self._async_maybe_reset_arrival_flags(old, new)
+    now_naive = dt_util.now().replace(tzinfo=None)
+    return self._diff_with_unifi_check(old, new, now_naive)
+
+  async def _async_dispatch_diff(self, diff: "GuestStateDiff") -> None:
+    if diff.checkout_needed:
+      await self._async_run_check_out_actions(diff.departing)
+    if diff.checkin_needed or diff.wifi_key_changed or diff.door_code_changed:
+      await self._async_run_check_in_actions(self._effective(STORE_CURRENT))
+
+  def _build_coordinator_data(
+    self,
+    current: ReservationData,
+    next_: ReservationData,
+    api_status: ApiStatusData | None = _UNSET,  # type: ignore[assignment]
+  ) -> CoordinatorData:
+    if api_status is _UNSET:
+      api_status = self.data.api_status if self.data else None
+    return CoordinatorData(
+      current=current,
+      next=next_,
+      current_last_refresh=self._current_last_refresh,
+      next_last_refresh=self._next_last_refresh,
+      current_last_updated=self._current_last_updated,
+      next_last_updated=self._next_last_updated,
+      override_active=self._override is not None,
+      api_status=api_status,
+      current_arrived=self._current_arrived,
+      current_departed=self._current_departed,
+    )
+
+  async def _async_publish(self) -> None:
+    """Note displayed-state changes, reschedule mode sync, push CoordinatorData."""
+    now = dt_util.utcnow()
+    new_current = self._effective(STORE_CURRENT)
+    new_next = self._effective(STORE_NEXT)
+    await self._async_note_displayed_change(new_current, new_next, now)
+    self.async_set_updated_data(self._build_coordinator_data(new_current, new_next))
+    self._reschedule_mode_sync(new_current, new_next)
+
   async def _async_note_displayed_change(
   async def _async_note_displayed_change(
     self,
     self,
     new_current: ReservationData,
     new_current: ReservationData,
@@ -138,60 +228,31 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       except Exception as exc:
       except Exception as exc:
         _LOGGER.warning("API status fetch failed: %s", exc)
         _LOGGER.warning("API status fetch failed: %s", exc)
 
 
-    now = dt_util.utcnow()
-
     if backend_fetched:
     if backend_fetched:
-      # Calendar is authoritative: write its result including None (clears the slot).
-      old_current = self.store.get(STORE_CURRENT)
+      # Calendar is authoritative for the store: write its result including None
+      # (clears the slot), regardless of whether an override currently covers Current.
+      old_store_current = self.store.get(STORE_CURRENT)
+      old_effective_current = self._effective(STORE_CURRENT)
       await self.store.async_set(STORE_CURRENT, backend_current)
       await self.store.async_set(STORE_CURRENT, backend_current)
       await self.store.async_set(STORE_NEXT, backend_next)
       await self.store.async_set(STORE_NEXT, backend_next)
-      await self._async_maybe_reset_arrival_flags(old_current, backend_current)
+      await self._async_maybe_reset_arrival_flags(old_store_current, backend_current)
+      now = dt_util.utcnow()
       self._current_last_refresh = now
       self._current_last_refresh = now
       self._next_last_refresh = now
       self._next_last_refresh = now
-      if self._override is None:
-        now_naive = dt_util.now().replace(tzinfo=None)
-        diff = self._diff_with_unifi_check(old_current, backend_current, now_naive)
-        if diff.checkout_needed:
-          await self._async_run_check_out_actions(diff.departing)
-        if diff.checkin_needed or diff.wifi_key_changed or diff.door_code_changed:
-          await self._async_run_check_in_actions(backend_current or self._empty_reservation())
-
-    store_current = self._with_defaults(self.store.get(STORE_CURRENT))
-    store_next = self._with_defaults(self.store.get(STORE_NEXT))
-
-    if self._override is not None:
-      new_current = self._with_defaults(self._override.current)
-      displayed_next = (
-        self._with_defaults(self._override.next) if self._override.next is not None else store_next
-      )
-      await self._async_note_displayed_change(new_current, displayed_next, now)
-      return CoordinatorData(
-        current=new_current,
-        next=displayed_next,
-        current_last_refresh=self._current_last_refresh,
-        next_last_refresh=self._next_last_refresh,
-        current_last_updated=self._current_last_updated,
-        next_last_updated=self._next_last_updated,
-        override_active=True,
-        api_status=api_status,
-        current_arrived=self._current_arrived,
-        current_departed=self._current_departed,
-      )
-
-    await self._async_note_displayed_change(store_current, store_next, now)
-    self._reschedule_mode_sync(store_current, store_next)
 
 
-    return CoordinatorData(
-      current=store_current,
-      next=store_next,
-      current_last_refresh=self._current_last_refresh,
-      next_last_refresh=self._next_last_refresh,
-      current_last_updated=self._current_last_updated,
-      next_last_updated=self._next_last_updated,
-      api_status=api_status,
-      current_arrived=self._current_arrived,
-      current_departed=self._current_departed,
-    )
+      # Diffing effective (not store) old vs. new naturally no-ops while an override
+      # covers Current, since old == new == the override's own value in that case.
+      new_effective_current = self._effective(STORE_CURRENT)
+      now_naive = dt_util.now().replace(tzinfo=None)
+      diff = self._diff_with_unifi_check(old_effective_current, new_effective_current, now_naive)
+      await self._async_dispatch_diff(diff)
+
+    now = dt_util.utcnow()
+    new_current = self._effective(STORE_CURRENT)
+    new_next = self._effective(STORE_NEXT)
+    await self._async_note_displayed_change(new_current, new_next, now)
+    self._reschedule_mode_sync(new_current, new_next)
+    return self._build_coordinator_data(new_current, new_next, api_status=api_status)
 
 
   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:
@@ -298,58 +359,53 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     await self._async_set_room_state(False)
     await self._async_set_room_state(False)
     _LOGGER.info("Check-out complete: guest=%s", res.guest_name)
     _LOGGER.info("Check-out complete: guest=%s", res.guest_name)
 
 
-  async def async_reconcile_from_store(self, *, _startup: bool = False) -> None:
-    """Load store state, apply elapsed transitions, set room state, reschedule mode sync.
+  async def _reconcile_store_elapsed(self, *, _startup: bool = False) -> None:
+    """Catch the store up to real time: promote/clear elapsed reservations, set room state.
 
 
-    Called at startup (via async_post_setup_reconcile), when leaving override mode, and
-    when mode sync is turned on (off→on toggle).
+    Operates purely on the store — no override awareness needed, since this is
+    specifically "catch the store up to real time when nothing else has been polling
+    it," used at HA startup, when Mode Sync is toggled on, and (to catch drift in the
+    store's own Current) when an override ends.
 
 
     WiFi and lock actions are intentionally NOT driven here. UniFi/lock state is hardware
     WiFi and lock actions are intentionally NOT driven here. UniFi/lock state is hardware
     that outlives HA restarts and mode-sync toggles, so re-pushing on every reconcile
     that outlives HA restarts and mode-sync toggles, so re-pushing on every reconcile
     would be noisy and redundant. Any real drift is detected and corrected by
     would be noisy and redundant. Any real drift is detected and corrected by
-    _diff_with_unifi_check on the next backend poll. Use Force Refresh to trigger that
-    poll immediately if prompt correction is needed.
+    _diff_with_unifi_check on the next backend poll (or, for the override-end case, by
+    the diff/dispatch that follows this call in async_end_override).
 
 
     _startup=True uses direct state mutation on the room state switch instead of
     _startup=True uses direct state mutation on the room state switch instead of
     async_turn_on/off, because HA state writes are deferred until commit_state().
     async_turn_on/off, because HA state writes are deferred until commit_state().
     """
     """
+    if not self._is_mode_sync_on():
+      return
+
     old_current = self.store.get(STORE_CURRENT)
     old_current = self.store.get(STORE_CURRENT)
     current = old_current or self._empty_reservation()
     current = old_current or self._empty_reservation()
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
 
 
-    if self._is_mode_sync_on():
-      if self._reservation_window_state(nxt) in ("active", "elapsed"):
-        await self.store.async_set(STORE_CURRENT, nxt)
-        await self._async_maybe_reset_arrival_flags(old_current, nxt)
-        self._current_last_refresh = dt_util.utcnow()
-        current = nxt
-        old_current = nxt
-
-      state = self._reservation_window_state(current)
-      if state == "elapsed":
-        await self.store.async_set(STORE_CURRENT, None)
-        await self._async_maybe_reset_arrival_flags(old_current, None)
-        self._current_last_refresh = dt_util.utcnow()
-        current = self._empty_reservation()
-
-      await self._async_set_room_state(state == "active", _startup=_startup)
-
-    real_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
-    real_next = self.store.get(STORE_NEXT) or self._empty_reservation()
-    now = dt_util.utcnow()
-    await self._async_note_displayed_change(real_current, real_next, now)
-    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,
-      current_last_updated=self._current_last_updated,
-      next_last_updated=self._next_last_updated,
-      api_status=self.data.api_status if self.data else None,
-      current_arrived=self._current_arrived,
-      current_departed=self._current_departed,
-    ))
+    if self._reservation_window_state(nxt) in ("active", "elapsed"):
+      await self.store.async_set(STORE_CURRENT, nxt)
+      await self._async_maybe_reset_arrival_flags(old_current, nxt)
+      self._current_last_refresh = dt_util.utcnow()
+      current = nxt
+      old_current = nxt
+
+    state = self._reservation_window_state(current)
+    if state == "elapsed":
+      await self.store.async_set(STORE_CURRENT, None)
+      await self._async_maybe_reset_arrival_flags(old_current, None)
+      self._current_last_refresh = dt_util.utcnow()
+
+    await self._async_set_room_state(state == "active", _startup=_startup)
+
+  async def async_reconcile_from_store(self, *, _startup: bool = False) -> None:
+    """Catch the store up to real time, then publish it as the coordinator's data.
+
+    Called at startup (via async_post_setup_reconcile) and when mode sync is turned on
+    (off→on toggle). See _reconcile_store_elapsed for what "catching up" means.
+    """
+    await self._reconcile_store_elapsed(_startup=_startup)
+    await self._async_publish()
 
 
   async def async_post_setup_reconcile(self) -> None:
   async def async_post_setup_reconcile(self) -> None:
     """Called from async_setup_entry after all platforms are set up and switch states
     """Called from async_setup_entry after all platforms are set up and switch states
@@ -392,210 +448,92 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     self.hass.async_create_task(self._async_do_check_out())
     self.hass.async_create_task(self._async_do_check_out())
 
 
   async def _async_do_check_out(self) -> None:
   async def _async_do_check_out(self) -> None:
-    departing = self.store.get(STORE_CURRENT) or self._empty_reservation()
-    await self.store.async_set(STORE_CURRENT, None)
-    await self._async_maybe_reset_arrival_flags(departing, None)
-    now = dt_util.utcnow()
-    self._current_last_refresh = now
-    nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
-    empty = self._empty_reservation()
-    await self._async_note_displayed_change(empty, nxt, now)
-    self.async_set_updated_data(CoordinatorData(
-      current=empty,
-      next=nxt,
-      current_last_refresh=self._current_last_refresh,
-      next_last_refresh=self._next_last_refresh,
-      current_last_updated=self._current_last_updated,
-      next_last_updated=self._next_last_updated,
-      api_status=self.data.api_status if self.data else None,
-      current_arrived=self._current_arrived,
-      current_departed=self._current_departed,
-    ))
-    await self._async_run_check_out_actions(departing)
-    self._reschedule_mode_sync(empty, nxt)
+    await self.async_clear_current()
+
+  @callback
+  def _async_handle_override_timeout(self, _now: datetime) -> None:
+    self.hass.async_create_task(self._async_do_override_timeout())
+
+  async def _async_do_override_timeout(self) -> None:
+    target = self._override_end_target
+    self._override_end_handle = None
+    self._override_end_target = None
+    if self._override is target:
+      await self.async_end_override()
+
+  def _cancel_override_end_timer(self) -> None:
+    if self._override_end_handle is not None:
+      self._override_end_handle()
+      self._override_end_handle = None
+    self._override_end_target = None
 
 
   async def async_clear_current(self) -> None:
   async def async_clear_current(self) -> None:
-    """Clear the Current slot in the store and notify listeners."""
-    departing = self.store.get(STORE_CURRENT)
-    await self.store.async_set(STORE_CURRENT, None)
-    await self._async_maybe_reset_arrival_flags(departing, None)
-    now = dt_util.utcnow()
-    self._current_last_refresh = now
-    nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
-    empty = self._empty_reservation()
-    await self._async_note_displayed_change(empty, nxt, now)
-    self.async_set_updated_data(CoordinatorData(
-      current=empty,
-      next=nxt,
-      current_last_refresh=self._current_last_refresh,
-      next_last_refresh=self._next_last_refresh,
-      current_last_updated=self._current_last_updated,
-      next_last_updated=self._next_last_updated,
-      api_status=self.data.api_status if self.data else None,
-      current_arrived=self._current_arrived,
-      current_departed=self._current_departed,
-    ))
-    if departing is not None:
-      await self._async_run_check_out_actions(departing)
-    self._reschedule_mode_sync(empty, nxt)
+    """Clear whichever layer (override or store) currently owns Current."""
+    diff = await self._async_transition(STORE_CURRENT, None)
+    if diff:
+      await self._async_dispatch_diff(diff)
+    await self._async_publish()
 
 
   async def async_promote_next_to_current(self) -> None:
   async def async_promote_next_to_current(self) -> None:
-    """Copy the Next slot into Current and notify listeners. Next is left unchanged."""
-    old_current = self.store.get(STORE_CURRENT)
-    next_data = self.store.get(STORE_NEXT) or self._empty_reservation()
-    await self.store.async_set(STORE_CURRENT, next_data)
-    await self._async_maybe_reset_arrival_flags(old_current, next_data)
-    now = dt_util.utcnow()
-    self._current_last_refresh = now
-    new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
-    await self._async_note_displayed_change(next_data, new_next, now)
-    self.async_set_updated_data(CoordinatorData(
-      current=next_data,
-      next=new_next,
-      current_last_refresh=self._current_last_refresh,
-      next_last_refresh=self._next_last_refresh,
-      current_last_updated=self._current_last_updated,
-      next_last_updated=self._next_last_updated,
-      api_status=self.data.api_status if self.data else None,
-      current_arrived=self._current_arrived,
-      current_departed=self._current_departed,
-    ))
-    self._reschedule_mode_sync(next_data, new_next)
-    if self._reservation_window_state(next_data) == "active":
-      await self._async_run_check_in_actions(next_data)
-
-  async def async_start_override(self, override: OverrideData) -> None:
-    """Activate a temporary override. Runs the full check-in sequence (WiFi + guest mode)."""
+    """Copy Next into whichever layer owns Current. Next is left unchanged."""
+    next_value = self._effective(STORE_NEXT)
+    diff = await self._async_transition(STORE_CURRENT, next_value)
+    if diff:
+      await self._async_dispatch_diff(diff)
+    await self._async_publish()
+
+  async def async_start_override(self, override: OverrideData, *, end_at: datetime | None = None) -> None:
+    """Activate a temporary override, running check-in/check-out actions as needed.
+
+    `end_at`, if given, is a naive local datetime (matching reservation check-in/
+    check-out time semantics) at which the override is automatically ended, as if the
+    Override Reservation switch had been turned off. A later manual end (or a new
+    override replacing this one) cancels the timer.
+    """
+    self._cancel_override_end_timer()
+    old_current = self._effective(STORE_CURRENT)
     self._override = override
     self._override = override
-    self.cancel_mode_sync()
-    new_current = self._with_defaults(override.current)
-    displayed_next = (
-      self._with_defaults(override.next) if override.next is not None
-      else self._with_defaults(self.store.get(STORE_NEXT) or self._empty_reservation())
-    )
     now = dt_util.utcnow()
     now = dt_util.utcnow()
-    await self._async_note_displayed_change(new_current, displayed_next, now)
-    self.async_set_updated_data(CoordinatorData(
-      current=new_current,
-      next=displayed_next,
-      current_last_refresh=self._current_last_refresh,
-      next_last_refresh=self._next_last_refresh,
-      current_last_updated=self._current_last_updated,
-      next_last_updated=self._next_last_updated,
-      override_active=True,
-      api_status=self.data.api_status if self.data else None,
-      current_arrived=self._current_arrived,
-      current_departed=self._current_departed,
-    ))
-    await self._async_run_check_in_actions(override.current)
+    self._current_last_refresh = now
+    if override.next is not None:
+      self._next_last_refresh = now
+    new_current = self._effective(STORE_CURRENT)
+    await self._async_maybe_reset_arrival_flags(old_current, new_current)
+    now_naive = dt_util.now().replace(tzinfo=None)
+    diff = self._diff_with_unifi_check(old_current, new_current, now_naive)
+    await self._async_dispatch_diff(diff)
+    await self._async_publish()
+
+    if end_at is not None:
+      delay = (end_at - now_naive).total_seconds()
+      if delay > 0:
+        self._override_end_target = override
+        self._override_end_handle = async_call_later(
+          self.hass, delay, self._async_handle_override_timeout
+        )
 
 
   async def async_end_override(self) -> None:
   async def async_end_override(self) -> None:
-    """Clear the active override and reconcile room state from the store."""
-    if self._override is not None:
-      now_naive = dt_util.now().replace(tzinfo=None)
-      diff = self._diff_with_unifi_check(self._override.current, self.store.get(STORE_CURRENT), now_naive)
-      if diff.checkout_needed:
-        await self._async_run_check_out_actions(diff.departing)
-      if diff.checkin_needed or diff.wifi_key_changed or diff.door_code_changed:
-        store_current = self.store.get(STORE_CURRENT)
-        await self._async_run_check_in_actions(store_current or self._empty_reservation())
+    """End the active override: reconcile the store's own elapsed state, then diff back."""
+    self._cancel_override_end_timer()
+    if self._override is None:
+      return
+    old_current = self._override.current
+    await self._reconcile_store_elapsed()
     self._override = None
     self._override = None
-    await self.async_reconcile_from_store()
+    new_current = self._effective(STORE_CURRENT)
+    await self._async_maybe_reset_arrival_flags(old_current, new_current)
+    now_naive = dt_util.now().replace(tzinfo=None)
+    diff = self._diff_with_unifi_check(old_current, new_current, now_naive)
+    await self._async_dispatch_diff(diff)
+    await self._async_publish()
 
 
   async def async_apply_update(self, target: str, partial: dict[str, Any]) -> None:
   async def async_apply_update(self, target: str, partial: dict[str, Any]) -> None:
-    """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()
-      )
-      await self._async_note_displayed_change(self._override.current, displayed_next, now)
-      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,
-        current_last_updated=self._current_last_updated,
-        next_last_updated=self._next_last_updated,
-        override_active=True,
-        api_status=self.data.api_status if self.data else None,
-        current_arrived=self._current_arrived,
-        current_departed=self._current_departed,
-      ))
-      return
-
-    stored = self.store.get(target) or self._empty_reservation()
-    new_stored = stored.apply_partial(partial)
-    await self.store.async_set(target, new_stored)
-    if target == STORE_CURRENT:
-      self._current_last_refresh = now
-    else:
-      self._next_last_refresh = now
-
-    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()
-      )
-      await self._async_note_displayed_change(self._override.current, displayed_next, now)
-      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,
-        current_last_updated=self._current_last_updated,
-        next_last_updated=self._next_last_updated,
-        override_active=True,
-        api_status=self.data.api_status if self.data else None,
-        current_arrived=self._current_arrived,
-        current_departed=self._current_departed,
-      ))
-    else:
-      store_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
-      store_next = self.store.get(STORE_NEXT) or self._empty_reservation()
-      await self._async_note_displayed_change(store_current, store_next, now)
-      self.async_set_updated_data(CoordinatorData(
-        current=store_current,
-        next=store_next,
-        current_last_refresh=self._current_last_refresh,
-        next_last_refresh=self._next_last_refresh,
-        current_last_updated=self._current_last_updated,
-        next_last_updated=self._next_last_updated,
-        api_status=self.data.api_status if self.data else None,
-        current_arrived=self._current_arrived,
-        current_departed=self._current_departed,
-      ))
-      self._reschedule_mode_sync(store_current, store_next)
-      if target == STORE_CURRENT:
-        now_naive = dt_util.now().replace(tzinfo=None)
-        diff = self._diff_with_unifi_check(stored, new_stored, now_naive)
-        if diff.checkout_needed:
-          await self._async_run_check_out_actions(diff.departing)
-        if diff.checkin_needed or diff.wifi_key_changed or diff.door_code_changed:
-          await self._async_run_check_in_actions(new_stored)
+    """Apply a thin update (service call) to whichever layer owns `target`."""
+    new_value = self._effective(target).apply_partial(partial)
+    diff = await self._async_transition(target, new_value)
+    if diff:
+      await self._async_dispatch_diff(diff)
+    await self._async_publish()
 
 
   def _diff_with_unifi_check(
   def _diff_with_unifi_check(
     self,
     self,
@@ -676,6 +614,9 @@ 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()
+    # Also cancel any pending override auto-end timer — both are coordinator-owned
+    # scheduled transitions and should stop together on unload / mode-sync-off.
+    self._cancel_override_end_timer()
 
 
   def _empty_reservation(self) -> ReservationData:
   def _empty_reservation(self) -> ReservationData:
     """Return a ReservationData with configured defaults applied (for empty slots)."""
     """Return a ReservationData with configured defaults applied (for empty slots)."""

+ 153 - 4
tests/test_coordinator.py

@@ -1,4 +1,4 @@
-from datetime import date, datetime, time
+from datetime import date, datetime, time, timedelta
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import pytest
 import pytest
@@ -469,6 +469,7 @@ async def test_check_out_disables_ssid(hass: HomeAssistant, coordinator: RoomCal
 
 
   with (
   with (
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
   ):
   ):
     await coordinator._async_do_check_out()
     await coordinator._async_do_check_out()
@@ -509,6 +510,7 @@ async def test_check_out_ssid_error_still_deactivates_guest_mode(coordinator: Ro
 
 
   with (
   with (
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", side_effect=HomeAssistantError("hub down")),
     patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", side_effect=HomeAssistantError("hub down")),
   ):
   ):
     await coordinator._async_do_check_out()
     await coordinator._async_do_check_out()
@@ -550,8 +552,17 @@ async def test_start_override_turns_on_guest_mode(coordinator: RoomCalCoordinato
   mock_switch = MagicMock()
   mock_switch = MagicMock()
   mock_switch.async_turn_on = AsyncMock()
   mock_switch.async_turn_on = AsyncMock()
   coordinator._room_state_switch = mock_switch
   coordinator._room_state_switch = mock_switch
-  with patch.object(coordinator, "_is_mode_sync_on", return_value=True):
-    await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  active_override = ReservationData(
+    guest_name=_OVERRIDE_RES.guest_name, door_code=_OVERRIDE_RES.door_code,
+    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),
+  )
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+  ):
+    await coordinator.async_start_override(OverrideData(current=active_override))
+  coordinator.cancel_mode_sync()
   mock_switch.async_turn_on.assert_awaited_once()
   mock_switch.async_turn_on.assert_awaited_once()
 
 
 
 
@@ -567,11 +578,18 @@ async def test_start_override_does_not_turn_on_guest_mode_when_mode_sync_off(coo
 async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator):
 async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator):
   coordinator._room_state_switch = MagicMock()
   coordinator._room_state_switch = MagicMock()
   coordinator._room_state_switch.async_turn_on = AsyncMock()
   coordinator._room_state_switch.async_turn_on = AsyncMock()
+  active_wifi_override = ReservationData(
+    guest_name=_WIFI_OVERRIDE.guest_name, wifi_ssid=_WIFI_OVERRIDE.wifi_ssid, wifi_key=_WIFI_OVERRIDE.wifi_key,
+    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),
+  )
   with (
   with (
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
     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))
+    await coordinator.async_start_override(OverrideData(current=active_wifi_override))
+  coordinator.cancel_mode_sync()
   mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
   mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
 
 
 
 
@@ -707,6 +725,137 @@ async def test_end_override_restores_store_current(coordinator: RoomCalCoordinat
   assert coordinator.data.current.guest_name == "Alice"
   assert coordinator.data.current.guest_name == "Alice"
 
 
 
 
+async def test_start_override_with_end_at_schedules_and_ends_override(coordinator: RoomCalCoordinator):
+  coordinator._room_state_switch = None
+  override = OverrideData(current=ReservationData(guest_name="Timed Guest"))
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=False),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+  ):
+    await coordinator.async_start_override(override, end_at=datetime(2026, 7, 1, 14, 5, 0))
+  scheduled_handle = coordinator._override_end_handle
+  assert scheduled_handle is not None
+  assert coordinator.data.override_active is True
+
+  # Simulate the scheduled call_later firing, then cancel the real (never-fired) handle
+  # so it doesn't leak into the next test.
+  await coordinator._async_do_override_timeout()
+  scheduled_handle()
+
+  assert coordinator._override_end_handle is None
+  assert coordinator.data.override_active is False
+
+
+async def test_stale_override_timeout_does_not_end_a_replacing_override(coordinator: RoomCalCoordinator):
+  """A timer from an override that was already manually ended/replaced must not end
+  whatever override is active by the time it fires."""
+  coordinator._room_state_switch = None
+  override = OverrideData(current=ReservationData(guest_name="Timed Guest"))
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=False),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+  ):
+    await coordinator.async_start_override(override, end_at=datetime(2026, 7, 1, 14, 5, 0))
+  stale_target = coordinator._override_end_target
+
+  await coordinator.async_end_override()
+  assert coordinator._override is None
+
+  new_override = OverrideData(current=ReservationData(guest_name="New Guest"))
+  with patch.object(coordinator, "_is_mode_sync_on", return_value=False):
+    await coordinator.async_start_override(new_override)  # no end_at — no new timer
+  # Simulate the original (stale) timer's callback firing late, after the replacement started.
+  coordinator._override_end_target = stale_target
+
+  await coordinator._async_do_override_timeout()
+
+  assert coordinator._override is new_override
+  assert coordinator.data.override_active is True
+
+
+async def test_start_override_empty_current_triggers_checkout_not_checkin(
+  hass: HomeAssistant, coordinator: RoomCalCoordinator
+):
+  """Overriding Current to empty while a real guest is checked in must run checkout
+  actions (SSID disable, guest-mode off) — not try to check in an empty record."""
+  checked_in_guest = ReservationData(
+    guest_name="Real 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),
+    wifi_ssid="GuestNet", wifi_key="secret",
+  )
+  await coordinator.store.async_set(STORE_CURRENT, checked_in_guest)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_off = AsyncMock()
+  mock_switch.async_turn_on = AsyncMock()
+  coordinator._room_state_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=_AT_CHECK_IN),
+    patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
+  ):
+    await coordinator.async_start_override(OverrideData(current=ReservationData()))
+  coordinator.cancel_mode_sync()
+
+  mock_ssid.assert_awaited_once_with(hass, "GuestNet", False)
+  mock_switch.async_turn_off.assert_awaited_once()
+  mock_switch.async_turn_on.assert_not_awaited()
+  assert coordinator.data.override_active is True
+
+
+async def test_override_current_checks_out_on_schedule_while_override_stays_active(
+  hass: HomeAssistant, coordinator: RoomCalCoordinator
+):
+  """With Mode Sync on, an override's own Current schedules a real check-out handle;
+  firing it clears the override's Current (not the store's), runs checkout actions,
+  override_active stays True, and a subsequent Next check-in keeps writing into the
+  override rather than the store."""
+  override_guest = ReservationData(
+    guest_name="Override 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),
+  )
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  mock_switch.async_turn_off = AsyncMock()
+  coordinator._room_state_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=_AT_CHECK_IN),
+  ):
+    await coordinator.async_start_override(OverrideData(current=override_guest))
+
+  assert len(coordinator._mode_sync_handles) >= 1
+  assert coordinator.data.override_active is True
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+  ):
+    await coordinator._async_do_check_out()
+  coordinator.cancel_mode_sync()
+
+  mock_switch.async_turn_off.assert_awaited_once()
+  assert coordinator.data.override_active is True
+  assert coordinator.data.current.guest_name is None
+  assert coordinator.store.get(STORE_CURRENT) is None
+
+  await coordinator.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest"))
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
+  ):
+    await coordinator._async_do_check_in()
+  coordinator.cancel_mode_sync()
+
+  assert coordinator.data.override_active is True
+  assert coordinator.data.current.guest_name == "Next Guest"
+  # The real store's own Current is untouched — the promoted value went into the override.
+  assert coordinator.store.get(STORE_CURRENT) is None
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # Thin updates during override
 # Thin updates during override
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------

+ 2 - 0
tests/test_locks.py

@@ -205,6 +205,7 @@ async def test_check_out_disables_lock_slot(hass: HomeAssistant, coordinator_wit
   with (
   with (
     patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
     patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
     patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
     patch("custom_components.room_cal_ha.locks._async_call_service", new_callable=AsyncMock) as mock_call,
   ):
   ):
     await coordinator_with_locks._async_do_check_out()
     await coordinator_with_locks._async_do_check_out()
@@ -236,6 +237,7 @@ async def test_check_out_lock_error_still_deactivates_guest_mode(hass: HomeAssis
   with (
   with (
     patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator_with_locks, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
     patch.object(coordinator_with_locks, "_is_use_zwave_on", return_value=True),
+    patch("custom_components.room_cal_ha.coordinator.dt_util.now", return_value=_AT_CHECK_IN),
     patch("custom_components.room_cal_ha.locks._async_call_service", side_effect=HomeAssistantError("lock offline")),
     patch("custom_components.room_cal_ha.locks._async_call_service", side_effect=HomeAssistantError("lock offline")),
   ):
   ):
     await coordinator_with_locks._async_do_check_out()
     await coordinator_with_locks._async_do_check_out()