Răsfoiți Sursa

Refactor to a three-slot active/current/next model

Current and Next are now pure, never-overridable mirrors of the backend
feed; override only ever masks Active, the record that actually drives
WiFi/lock/guest-mode actions and mode-sync scheduling. This removes the
OverrideData.next field, whose None-means-passthrough default let a
still-unconsumed Store Next record get silently re-promoted over a
patched override (the bug behind Override to Next + a service-call
patch reverting on schedule).

Service-call patches and turning on the Override Reservation switch
now share one snapshot-and-mask entry point (async_ensure_override),
folding OverrideCurrentButton's behavior into the switch. Renamed
TestNextGuestButton to OverrideNextButton, which now self-ends via
end_at instead of relying on override free-running. Arrived/Departed
move to the Room State device (they're Active-only concepts) and a new
Active Last Updated sensor exposes live-state changes independent of
the Current/Next display feed.
ZiB 3 luni în urmă
părinte
comite
bf832348ad

+ 3 - 5
custom_components/room_cal_ha/__init__.py

@@ -7,13 +7,12 @@ 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 .const import DOMAIN, SENSOR_KEYS
 from .coordinator import RoomCalCoordinator
 
 PLATFORMS = [Platform.BINARY_SENSOR, 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),
@@ -40,12 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 
   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}
+      partial = {k: v for k, v in call.data.items() if 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)
+          await coord.async_apply_update(partial)
 
     hass.services.async_register(
       DOMAIN,

+ 6 - 11
custom_components/room_cal_ha/binary_sensor.py

@@ -17,7 +17,6 @@ from .const import (
   CONF_SYNC_MODE,
   DEFAULT_REFRESH_INTERVAL,
   DOMAIN,
-  STORE_CURRENT,
   SYNC_MODE_MANUAL,
 )
 from .coordinator import CoordinatorData, RoomCalCoordinator
@@ -34,14 +33,10 @@ async def async_setup_entry(
     identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
     name=f"{room_name} Room State",
   )
-  current_device = DeviceInfo(
-    identifiers={(DOMAIN, f"{entry.entry_id}_{STORE_CURRENT}")},
-    name=f"{room_name} Current",
-  )
   async_add_entities([
     StaleSensor(coordinator, entry, room_state_device),
-    ArrivedSensor(coordinator, entry, current_device),
-    DepartedSensor(coordinator, entry, current_device),
+    ArrivedSensor(coordinator, entry, room_state_device),
+    DepartedSensor(coordinator, entry, room_state_device),
   ])
 
 
@@ -91,12 +86,12 @@ class ArrivedSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEntity):
     device_info: DeviceInfo,
   ) -> None:
     super().__init__(coordinator)
-    self._attr_unique_id = f"{entry.entry_id}_current_arrived"
+    self._attr_unique_id = f"{entry.entry_id}_active_arrived"
     self._attr_device_info = device_info
 
   @property
   def is_on(self) -> bool:
-    return self.coordinator.data.current_arrived
+    return self.coordinator.data.active_arrived
 
 
 class DepartedSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEntity):
@@ -111,11 +106,11 @@ class DepartedSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEntity):
     device_info: DeviceInfo,
   ) -> None:
     super().__init__(coordinator)
-    self._attr_unique_id = f"{entry.entry_id}_current_departed"
+    self._attr_unique_id = f"{entry.entry_id}_active_departed"
     self._attr_device_info = device_info
 
   @property
   def is_on(self) -> bool:
-    return self.coordinator.data.current_departed
+    return self.coordinator.data.active_departed
 
 

+ 16 - 35
custom_components/room_cal_ha/button.py

@@ -25,8 +25,7 @@ async def async_setup_entry(
   device_name = f"{entry.data.get(CONF_ROOM_NAME, entry.title)} Room State"
   async_add_entities([
     ForceRefreshButton(coordinator, entry, device_name),
-    TestNextGuestButton(coordinator, entry, device_name),
-    OverrideCurrentButton(coordinator, entry, device_name),
+    OverrideNextButton(coordinator, entry, device_name),
     OverrideEmptyButton(coordinator, entry, device_name),
     GuestArrivedButton(coordinator, entry, device_name),
     GuestDepartedButton(coordinator, entry, device_name),
@@ -55,11 +54,15 @@ class ForceRefreshButton(ButtonEntity):
     await self._coordinator.async_refresh()
 
 
-class TestNextGuestButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
+class OverrideNextButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
+  """Simulates the next reservation already having checked in: starts an override
+  seeded from Next's data with check-in backdated to an hour ago, and auto-ends at
+  that record's own check-out — at which point `active` resumes mirroring the real
+  backend feed, which by then has already rolled forward normally."""
+
   _attr_has_entity_name = True
-  _attr_translation_key = "test_next_guest"
+  _attr_translation_key = "override_next"
   _attr_icon = "mdi:skip-next"
-  __test__ = False  # prevent pytest from collecting this as a test class
 
   def __init__(
     self,
@@ -68,7 +71,7 @@ class TestNextGuestButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
     device_name: str,
   ) -> None:
     super().__init__(coordinator)
-    self._attr_unique_id = f"{entry.entry_id}_test_next_guest"
+    self._attr_unique_id = f"{entry.entry_id}_override_next"
     self._attr_device_info = DeviceInfo(
       identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
       name=device_name,
@@ -79,36 +82,14 @@ class TestNextGuestButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
     if not next_data:
       return
     check_in_dt = dt_util.now().replace(tzinfo=None) - timedelta(hours=1)
-    override_current = next_data.apply_partial({
+    active = 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))
-
-
-class OverrideCurrentButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
-  _attr_has_entity_name = True
-  _attr_translation_key = "override_current"
-  _attr_icon = "mdi:calendar-edit"
-
-  def __init__(
-    self,
-    coordinator: RoomCalCoordinator,
-    entry: ConfigEntry,
-    device_name: str,
-  ) -> None:
-    super().__init__(coordinator)
-    self._attr_unique_id = f"{entry.entry_id}_override_current"
-    self._attr_device_info = DeviceInfo(
-      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
-      name=device_name,
-    )
-
-  async def async_press(self) -> None:
-    current = self.coordinator.data.current if self.coordinator.data else None
-    if current is None:
-      return
-    await self.coordinator.async_start_override(OverrideData(current=current))
+    end_at: datetime | None = None
+    if active.check_out_date is not None and active.check_out_time is not None:
+      end_at = datetime.combine(active.check_out_date, active.check_out_time)
+    await self.coordinator.async_start_override(OverrideData(active=active), end_at=end_at)
 
 
 class OverrideEmptyButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
@@ -130,11 +111,11 @@ class OverrideEmptyButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
     )
 
   async def async_press(self) -> None:
-    outgoing = self.coordinator.data.current if self.coordinator.data else None
+    outgoing = self.coordinator.data.active if self.coordinator.data else None
     end_at: datetime | None = None
     if outgoing is not None and outgoing.check_out_date is not None and outgoing.check_out_time is not None:
       end_at = datetime.combine(outgoing.check_out_date, outgoing.check_out_time)
-    await self.coordinator.async_start_override(OverrideData(current=ReservationData()), end_at=end_at)
+    await self.coordinator.async_start_override(OverrideData(active=ReservationData()), end_at=end_at)
 
 
 class GuestArrivedButton(ButtonEntity):

+ 2 - 2
custom_components/room_cal_ha/const.py

@@ -49,6 +49,6 @@ DEFAULT_REFRESH_INTERVAL = 15
 # Store keys
 STORE_CURRENT = "current"
 STORE_NEXT = "next"
-STORE_ARRIVED = "current_arrived"
-STORE_DEPARTED = "current_departed"
+STORE_ARRIVED = "active_arrived"
+STORE_DEPARTED = "active_departed"
 

+ 129 - 111
custom_components/room_cal_ha/coordinator.py

@@ -34,19 +34,25 @@ _UNSET = object()
 
 @dataclass
 class CoordinatorData:
+  active: ReservationData
   current: ReservationData
   next: ReservationData
   current_last_refresh: datetime | None
   next_last_refresh: datetime | None
   current_last_updated: datetime | None = None
   next_last_updated: datetime | None = None
+  active_last_refresh: datetime | None = None
+  active_last_updated: datetime | None = None
   override_active: bool = False
   api_status: ApiStatusData | None = None
-  current_arrived: bool = False
-  current_departed: bool = False
+  active_arrived: bool = False
+  active_departed: bool = False
 
 
 class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
+  """Presents three records: `active` (drives WiFi/locks/guest mode, maskable by
+  override), and `current`/`next` (pure, never-overridable mirrors of the backend
+  feed, used only for display). Override is a mask on `active` only."""
 
   def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
     interval = entry.options.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL)
@@ -64,68 +70,57 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     self._next_last_refresh: datetime | None = None
     self._current_last_updated: datetime | None = None
     self._next_last_updated: datetime | None = None
+    self._active_last_refresh: datetime | None = None
+    self._active_last_updated: datetime | 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_departed: bool = False
+    self._active_arrived: bool = False
+    self._active_departed: bool = False
 
   async def async_setup(self) -> None:
     await self.store.async_load()
     self._current_last_updated = self.store.get_last_updated(STORE_CURRENT)
     self._next_last_updated = self.store.get_last_updated(STORE_NEXT)
-    self._current_arrived = self.store.get_arrived()
-    self._current_departed = self.store.get_departed()
+    # Active isn't independently persisted (override is ephemeral); when no override
+    # is active it always mirrors current, so seed it from current's own timestamp.
+    self._active_last_updated = self._current_last_updated
+    self._active_arrived = self.store.get_arrived()
+    self._active_departed = self.store.get_departed()
 
   def inject_room_state_switch(self, switch: Any) -> None:
     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)
+  def _effective_active(self) -> ReservationData:
+    """Read `active` from the override if one is set, else mirror the store's current."""
+    raw = self._override.active if self._override is not None else self.store.get(STORE_CURRENT)
     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)
+  async def _async_write_active(self, value: ReservationData | None) -> None:
+    """Write `value` into whichever layer currently owns `active`."""
+    if self._override is not None:
+      self._override = OverrideData(active=value)
     else:
-      await self.store.async_set(target, value)
+      await self.store.async_set(STORE_CURRENT, value)
 
-  # -- Shared transition primitive ------------------------------------------
+  def _current_mirror(self) -> ReservationData:
+    """Pure backend mirror — never masked by an override."""
+    return self._with_defaults(self.store.get(STORE_CURRENT))
 
-  async def _async_transition(self, target: str, value: ReservationData | None) -> "GuestStateDiff | None":
-    """Write `value` into whichever layer owns `target`.
+  def _next_mirror(self) -> ReservationData:
+    """Pure backend mirror — never masked by an override."""
+    return self._with_defaults(self.store.get(STORE_NEXT))
 
-    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)
+  # -- Shared transition primitive ------------------------------------------
+
+  async def _async_transition_active(self, value: ReservationData | None) -> "GuestStateDiff":
+    """Write `value` into whichever layer owns `active` and return the resulting diff."""
+    old = self._effective_active()
+    await self._async_write_active(value)
+    self._active_last_refresh = dt_util.utcnow()
+    new = self._effective_active()
     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)
@@ -134,40 +129,46 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     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))
+      await self._async_run_check_in_actions(self._effective_active())
 
   def _build_coordinator_data(
     self,
+    active: ReservationData,
     current: ReservationData,
     next_: ReservationData,
-    api_status: ApiStatusData | None = _UNSET,  # type: ignore[assignment]
+    api_status: ApiStatusData | None | object = _UNSET,
   ) -> CoordinatorData:
     if api_status is _UNSET:
       api_status = self.data.api_status if self.data else None
     return CoordinatorData(
+      active=active,
       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,
+      active_last_refresh=self._active_last_refresh,
+      active_last_updated=self._active_last_updated,
       override_active=self._override is not None,
       api_status=api_status,
-      current_arrived=self._current_arrived,
-      current_departed=self._current_departed,
+      active_arrived=self._active_arrived,
+      active_departed=self._active_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)
+    new_active = self._effective_active()
+    new_current = self._current_mirror()
+    new_next = self._next_mirror()
+    await self._async_note_displayed_change(new_active, new_current, new_next, now)
+    self.async_set_updated_data(self._build_coordinator_data(new_active, new_current, new_next))
+    self._reschedule_mode_sync(new_active, new_next)
 
   async def _async_note_displayed_change(
     self,
+    new_active: ReservationData,
     new_current: ReservationData,
     new_next: ReservationData,
     now: datetime,
@@ -177,15 +178,21 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     On first call (self.data is None), uses _empty_reservation() as the baseline
     unless a persisted timestamp already exists — in that case the persisted value
     is trusted and no update is issued, avoiding a false 'changed now' on startup.
+    `active`'s last_updated is in-memory only (override state is never persisted).
     """
     empty = self._empty_reservation()
     if self.data is not None:
+      old_active: ReservationData = self.data.active
       old_current: ReservationData = self.data.current
       old_next: ReservationData = self.data.next
     else:
+      old_active = empty if self._active_last_updated is None else new_active
       old_current = empty if self._current_last_updated is None else new_current
       old_next = empty if self._next_last_updated is None else new_next
 
+    if old_active != new_active:
+      self._active_last_updated = now
+
     if old_current != new_current:
       self._current_last_updated = now
       await self.store.async_set_last_updated(STORE_CURRENT, now)
@@ -236,9 +243,9 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
     if backend_fetched:
       # Calendar is authoritative for the store: write its result including None
-      # (clears the slot), regardless of whether an override currently covers Current.
+      # (clears the slot), regardless of whether an override currently masks active.
       old_store_current = self.store.get(STORE_CURRENT)
-      old_effective_current = self._effective(STORE_CURRENT)
+      old_effective_active = self._effective_active()
       await self.store.async_set(STORE_CURRENT, backend_current)
       await self.store.async_set(STORE_NEXT, backend_next)
       await self._async_maybe_reset_arrival_flags(old_store_current, backend_current)
@@ -246,21 +253,22 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       self._current_last_refresh = now
       self._next_last_refresh = now
 
-      # 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)
+      # Diffing effective active (not the store write) naturally no-ops while an
+      # override masks active, since old == new == the override's own value in that case.
+      new_effective_active = self._effective_active()
       now_naive = dt_util.now().replace(tzinfo=None)
-      diff = self._diff_with_unifi_check(old_effective_current, new_effective_current, now_naive)
+      diff = self._diff_with_unifi_check(old_effective_active, new_effective_active, 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:
+    new_active = self._effective_active()
+    new_current = self._current_mirror()
+    new_next = self._next_mirror()
+    await self._async_note_displayed_change(new_active, new_current, new_next, now)
+    self._reschedule_mode_sync(new_active, new_next)
+    return self._build_coordinator_data(new_active, new_current, new_next, api_status=api_status)
+
+  def _reschedule_mode_sync(self, active: ReservationData, nxt: ReservationData) -> None:
     for cancel in self._mode_sync_handles:
       cancel()
     self._mode_sync_handles.clear()
@@ -270,7 +278,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
     now = dt_util.now().replace(tzinfo=None)
 
-    # At next reservation's check-in: promote next → current, then activate guest mode.
+    # At next reservation's check-in: promote next → active, then activate guest mode.
+    # `nxt` is always the pure backend mirror — never masked by an override.
     if nxt.check_in_date is not None and nxt.check_in_time is not None:
       check_in = datetime.combine(nxt.check_in_date, nxt.check_in_time)
       delay = (check_in - now).total_seconds()
@@ -279,9 +288,9 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
           async_call_later(self.hass, delay, self._async_handle_check_in)
         )
 
-    # At current reservation's check-out: clear current, then deactivate guest mode.
-    if current.check_out_date is not None and current.check_out_time is not None:
-      check_out = datetime.combine(current.check_out_date, current.check_out_time)
+    # At active reservation's check-out: clear active, then deactivate guest mode.
+    if active.check_out_date is not None and active.check_out_time is not None:
+      check_out = datetime.combine(active.check_out_date, active.check_out_time)
       delay = (check_out - now).total_seconds()
       if delay > 0:
         self._mode_sync_handles.append(
@@ -368,7 +377,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     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.
+    store's own current) when an override ends.
 
     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
@@ -446,14 +455,14 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     self.hass.async_create_task(self._async_do_check_in())
 
   async def _async_do_check_in(self) -> None:
-    await self.async_promote_next_to_current()
+    await self.async_promote_next_to_active()
 
   @callback
   def _async_handle_check_out(self, _now: datetime) -> None:
     self.hass.async_create_task(self._async_do_check_out())
 
   async def _async_do_check_out(self) -> None:
-    await self.async_clear_current()
+    await self.async_clear_active()
 
   @callback
   def _async_handle_override_timeout(self, _now: datetime) -> None:
@@ -472,19 +481,17 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       self._override_end_handle = None
     self._override_end_target = None
 
-  async def async_clear_current(self) -> None:
-    """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)
+  async def async_clear_active(self) -> None:
+    """Clear whichever layer (override or store) currently owns active."""
+    diff = await self._async_transition_active(None)
+    await self._async_dispatch_diff(diff)
     await self._async_publish()
 
-  async def async_promote_next_to_current(self) -> None:
-    """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)
+  async def async_promote_next_to_active(self) -> None:
+    """Copy the (pure, backend-mirror) Next into whichever layer owns active."""
+    next_value = self._next_mirror()
+    diff = await self._async_transition_active(next_value)
+    await self._async_dispatch_diff(diff)
     await self._async_publish()
 
   async def async_start_override(self, override: OverrideData, *, end_at: datetime | None = None) -> None:
@@ -496,17 +503,14 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     override replacing this one) cancels the timer.
     """
     self._cancel_override_end_timer()
-    old_current = self._effective(STORE_CURRENT)
+    old_active = self._effective_active()
     self._override = override
-    _LOGGER.info("Override started: guest=%s end_at=%s", override.current.guest_name, end_at)
-    now = dt_util.utcnow()
-    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)
+    _LOGGER.info("Override started: guest=%s end_at=%s", override.active.guest_name, end_at)
+    self._active_last_refresh = dt_util.utcnow()
+    new_active = self._effective_active()
+    await self._async_maybe_reset_arrival_flags(old_active, new_active)
     now_naive = dt_util.now().replace(tzinfo=None)
-    diff = self._diff_with_unifi_check(old_current, new_current, now_naive)
+    diff = self._diff_with_unifi_check(old_active, new_active, now_naive)
     await self._async_dispatch_diff(diff)
     await self._async_publish()
 
@@ -518,29 +522,43 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
           self.hass, delay, self._async_handle_override_timeout
         )
 
+  async def async_ensure_override(self) -> None:
+    """Start a snapshot override (freezing `active` at its current value) if none is
+    active yet. Idempotent — a no-op if an override is already running. This is what
+    lets "turn the Override switch on" and "patch a field via service call" both mean
+    "stop auto-updating active" without needing separate machinery."""
+    if self._override is not None:
+      return
+    await self.async_start_override(OverrideData(active=self._effective_active()))
+
   async def async_end_override(self) -> None:
     """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
-    _LOGGER.info("Override ended: guest=%s", old_current.guest_name)
+    old_active = self._override.active
+    _LOGGER.info("Override ended: guest=%s", old_active.guest_name)
     await self._reconcile_store_elapsed()
     self._override = None
-    new_current = self._effective(STORE_CURRENT)
-    await self._async_maybe_reset_arrival_flags(old_current, new_current)
+    new_active = self._effective_active()
+    await self._async_maybe_reset_arrival_flags(old_active, new_active)
     now_naive = dt_util.now().replace(tzinfo=None)
-    diff = self._diff_with_unifi_check(old_current, new_current, now_naive)
+    diff = self._diff_with_unifi_check(old_active, new_active, 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:
-    """Apply a thin update (service call) to whichever layer owns `target`."""
-    _LOGGER.info("Patch applied: target=%s fields=%s", target, sorted(partial))
-    new_value = self._effective(target).apply_partial(partial)
-    diff = await self._async_transition(target, new_value)
-    if diff:
-      await self._async_dispatch_diff(diff)
+  async def async_apply_update(self, partial: dict[str, Any]) -> None:
+    """Apply a thin update (service call) to `active`.
+
+    If no override is running yet, one is started first (snapshotting `active` as-is)
+    so the patch can't be silently overwritten by the next backend poll or scheduled
+    transition — the same protection an explicit override gives.
+    """
+    _LOGGER.info("Patch applied: fields=%s", sorted(partial))
+    await self.async_ensure_override()
+    new_value = self._effective_active().apply_partial(partial)
+    diff = await self._async_transition_active(new_value)
+    await self._async_dispatch_diff(diff)
     await self._async_publish()
 
   def _diff_with_unifi_check(
@@ -601,22 +619,22 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     new: ReservationData | None,
   ) -> None:
     if self._arrival_identity(old) != self._arrival_identity(new):
-      self._current_arrived = False
-      self._current_departed = False
+      self._active_arrived = False
+      self._active_departed = False
       await self.store.async_set_arrived(False)
       await self.store.async_set_departed(False)
 
   async def async_set_arrived(self, arrived: bool) -> None:
-    self._current_arrived = arrived
+    self._active_arrived = arrived
     await self.store.async_set_arrived(arrived)
     if self.data is not None:
-      self.async_set_updated_data(dataclass_replace(self.data, current_arrived=arrived))
+      self.async_set_updated_data(dataclass_replace(self.data, active_arrived=arrived))
 
   async def async_set_departed(self, departed: bool) -> None:
-    self._current_departed = departed
+    self._active_departed = departed
     await self.store.async_set_departed(departed)
     if self.data is not None:
-      self.async_set_updated_data(dataclass_replace(self.data, current_departed=departed))
+      self.async_set_updated_data(dataclass_replace(self.data, active_departed=departed))
 
   def cancel_mode_sync(self) -> None:
     for cancel in self._mode_sync_handles:

+ 2 - 2
custom_components/room_cal_ha/models.py

@@ -37,8 +37,8 @@ class ApiStatusData:
 
 @dataclass
 class OverrideData:
-  current: "ReservationData"
-  next: "ReservationData | None" = None
+  """Masks `active` only. `current`/`next` are always pure backend mirrors."""
+  active: "ReservationData"
 
 
 @dataclass

+ 31 - 0
custom_components/room_cal_ha/sensor.py

@@ -43,6 +43,7 @@ async def async_setup_entry(
 
   entities: list[SensorEntity] = [
     SyncSourceSensor(entry, room_name, room_state_device),
+    ActiveLastUpdatedSensor(coordinator, entry, room_state_device),
   ]
 
   if entry.options.get(CONF_SYNC_MODE) == SYNC_MODE_API:
@@ -280,6 +281,36 @@ class LastUpdatedSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
     return {"last_refresh": refresh}
 
 
+class ActiveLastUpdatedSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
+  """Last time the live/active record changed — distinct from the Current device's
+  LastUpdatedSensor, which only tracks the backend-mirror feed and never reflects
+  override/patch activity."""
+
+  _attr_has_entity_name = True
+  _attr_translation_key = "active_last_updated"
+  _attr_device_class = SensorDeviceClass.TIMESTAMP
+  _attr_icon = "mdi:clock-edit-outline"
+  _attr_entity_category = EntityCategory.DIAGNOSTIC
+
+  def __init__(
+    self,
+    coordinator: RoomCalCoordinator,
+    entry: ConfigEntry,
+    device_info: DeviceInfo,
+  ) -> None:
+    super().__init__(coordinator)
+    self._attr_unique_id = f"{entry.entry_id}_active_last_updated"
+    self._attr_device_info = device_info
+
+  @property
+  def native_value(self) -> datetime | None:
+    return self.coordinator.data.active_last_updated
+
+  @property
+  def extra_state_attributes(self) -> dict:
+    return {"last_refresh": self.coordinator.data.active_last_refresh}
+
+
 class ApiHealthSensor(CoordinatorEntity[RoomCalCoordinator], SensorEntity):
   _attr_has_entity_name = True
   _attr_translation_key = "api_health"

+ 1 - 10
custom_components/room_cal_ha/services.yaml

@@ -1,16 +1,7 @@
 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.
+  description: Apply a partial update to the live (active) reservation record. Only the supplied fields are changed; all others retain their existing values. If no override is currently running, one is started automatically so the patch survives the next backend poll.
   fields:
-    target:
-      name: Target
-      description: Which reservation to update.
-      required: true
-      selector:
-        select:
-          options:
-            - current
-            - next
     guest_name:
       name: Guest Name
       selector:

+ 3 - 7
custom_components/room_cal_ha/strings.json

@@ -104,6 +104,7 @@
       "wifi_key": { "name": "WiFi Key" },
       "door_code": { "name": "Door Code" },
       "last_updated": { "name": "Last Updated" },
+      "active_last_updated": { "name": "Active Last Updated" },
       "api_health": { "name": "API Health" }
     },
     "binary_sensor": {
@@ -119,8 +120,7 @@
     },
     "button": {
       "force_refresh": { "name": "Force Refresh" },
-      "test_next_guest": { "name": "Override to Next" },
-      "override_current": { "name": "Override to Current" },
+      "override_next": { "name": "Override to Next" },
       "override_empty": { "name": "Override to Empty" },
       "guest_arrived": { "name": "Guest Arrived" },
       "guest_departed": { "name": "Guest Departed" }
@@ -129,12 +129,8 @@
   "services": {
     "update_reservation": {
       "name": "Update Reservation",
-      "description": "Apply a partial update to the current or next reservation record.",
+      "description": "Apply a partial update to the live (active) reservation record. Starts an override automatically if none is running.",
       "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." },

+ 3 - 1
custom_components/room_cal_ha/switch.py

@@ -150,7 +150,9 @@ class OverrideReservationSwitch(CoordinatorEntity[RoomCalCoordinator], SwitchEnt
     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
+    # Snapshots `active` as-is if no override is running yet (idempotent otherwise) —
+    # the manual "just stop auto-updating" entry point, same helper patches use.
+    await self.coordinator.async_ensure_override()
 
   async def async_turn_off(self, **kwargs: object) -> None:
     await self.coordinator.async_end_override()

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

@@ -104,6 +104,7 @@
       "wifi_key": { "name": "WiFi Key" },
       "door_code": { "name": "Door Code" },
       "last_updated": { "name": "Last Updated" },
+      "active_last_updated": { "name": "Active Last Updated" },
       "api_health": { "name": "API Health" }
     },
     "binary_sensor": {
@@ -119,8 +120,7 @@
     },
     "button": {
       "force_refresh": { "name": "Force Refresh" },
-      "test_next_guest": { "name": "Override to Next" },
-      "override_current": { "name": "Override to Current" },
+      "override_next": { "name": "Override to Next" },
       "override_empty": { "name": "Override to Empty" },
       "guest_arrived": { "name": "Guest Arrived" },
       "guest_departed": { "name": "Guest Departed" }
@@ -129,12 +129,8 @@
   "services": {
     "update_reservation": {
       "name": "Update Reservation",
-      "description": "Apply a partial update to the current or next reservation record.",
+      "description": "Apply a partial update to the live (active) reservation record. Starts an override automatically if none is running.",
       "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." },

+ 22 - 23
tests/test_arrived_departed.py

@@ -55,12 +55,13 @@ def _make_entry(hass, options: dict) -> MockConfigEntry:
 
 def _data(arrived=False, departed=False) -> CoordinatorData:
   return CoordinatorData(
+    active=ReservationData(),
     current=ReservationData(),
     next=ReservationData(),
     current_last_refresh=None,
     next_last_refresh=None,
-    current_arrived=arrived,
-    current_departed=departed,
+    active_arrived=arrived,
+    active_departed=departed,
   )
 
 
@@ -139,8 +140,8 @@ async def coord(hass: HomeAssistant):
 
 async def test_set_arrived_sets_flag(coord: RoomCalCoordinator):
   await coord.async_set_arrived(True)
-  assert coord._current_arrived is True
-  assert coord.data.current_arrived is True
+  assert coord._active_arrived is True
+  assert coord.data.active_arrived is True
 
 
 async def test_set_arrived_persists_to_store(coord: RoomCalCoordinator):
@@ -150,8 +151,8 @@ async def test_set_arrived_persists_to_store(coord: RoomCalCoordinator):
 
 async def test_set_departed_sets_flag(coord: RoomCalCoordinator):
   await coord.async_set_departed(True)
-  assert coord._current_departed is True
-  assert coord.data.current_departed is True
+  assert coord._active_departed is True
+  assert coord.data.active_departed is True
 
 
 async def test_set_departed_persists_to_store(coord: RoomCalCoordinator):
@@ -168,8 +169,8 @@ async def test_flags_load_from_store_on_startup(hass: HomeAssistant):
 
   c2 = RoomCalCoordinator(hass, entry)
   await c2.async_setup()
-  assert c2._current_arrived is True
-  assert c2._current_departed is True
+  assert c2._active_arrived is True
+  assert c2._active_departed is True
 
 
 # ---------------------------------------------------------------------------
@@ -187,15 +188,15 @@ async def test_flags_reset_when_reservation_changes(hass: HomeAssistant):
     await c.async_refresh()
 
   await c.async_set_arrived(True)
-  assert c._current_arrived is True
+  assert c._active_arrived is True
 
   # Bob becomes current — different check-in identity
   with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as M:
     M.return_value.async_fetch = AsyncMock(return_value=(_BOB, None))
     await c.async_refresh()
 
-  assert c._current_arrived is False
-  assert c.data.current_arrived is False
+  assert c._active_arrived is False
+  assert c.data.active_arrived is False
   assert c.store.get_arrived() is False
 
 
@@ -215,8 +216,8 @@ async def test_flags_not_reset_when_same_reservation(hass: HomeAssistant):
     M.return_value.async_fetch = AsyncMock(return_value=(_ALICE, None))
     await c.async_refresh()
 
-  assert c._current_arrived is True
-  assert c.data.current_arrived is True
+  assert c._active_arrived is True
+  assert c.data.active_arrived is True
 
 
 async def test_flags_reset_on_promote_from_next(coord: RoomCalCoordinator):
@@ -225,10 +226,10 @@ async def test_flags_reset_on_promote_from_next(coord: RoomCalCoordinator):
   await coord.async_refresh()
   await coord.async_set_arrived(True)
 
-  await coord.async_promote_next_to_current()
+  await coord.async_promote_next_to_active()
 
-  assert coord._current_arrived is False
-  assert coord.data.current_arrived is False
+  assert coord._active_arrived is False
+  assert coord.data.active_arrived is False
 
 
 async def test_flags_reset_on_clear_current(coord: RoomCalCoordinator):
@@ -236,10 +237,10 @@ async def test_flags_reset_on_clear_current(coord: RoomCalCoordinator):
   await coord.async_refresh()
   await coord.async_set_arrived(True)
 
-  await coord.async_clear_current()
+  await coord.async_clear_active()
 
-  assert coord._current_arrived is False
-  assert coord.data.current_arrived is False
+  assert coord._active_arrived is False
+  assert coord.data.active_arrived is False
 
 
 async def test_flags_reset_on_checkout(coord: RoomCalCoordinator):
@@ -249,7 +250,5 @@ async def test_flags_reset_on_checkout(coord: RoomCalCoordinator):
 
   await coord._async_do_check_out()
 
-  assert coord._current_arrived is False
-  assert coord.data.current_arrived is False
-
-
+  assert coord._active_arrived is False
+  assert coord.data.active_arrived is False

+ 1 - 0
tests/test_binary_sensor.py

@@ -29,6 +29,7 @@ def _sensor(sync_mode=SYNC_MODE_CALDAV, interval=DEFAULT_REFRESH_INTERVAL, curre
   s._entry.options = {CONF_SYNC_MODE: sync_mode, CONF_REFRESH_INTERVAL: interval}
   coord = MagicMock()
   coord.data = CoordinatorData(
+    active=ReservationData(),
     current=ReservationData(),
     next=ReservationData(),
     current_last_refresh=current_ts,

+ 42 - 86
tests/test_buttons.py

@@ -1,4 +1,4 @@
-"""Unit tests for ForceRefreshButton and TestNextGuestButton.
+"""Unit tests for ForceRefreshButton, OverrideNextButton, and OverrideEmptyButton.
 
 Uses object.__new__ to bypass HA entity setup — valid because async_press
 only calls coordinator methods, not HA lifecycle hooks.
@@ -10,9 +10,8 @@ import pytest
 
 from custom_components.room_cal_ha.button import (
   ForceRefreshButton,
-  OverrideCurrentButton,
   OverrideEmptyButton,
-  TestNextGuestButton,
+  OverrideNextButton,
 )
 from custom_components.room_cal_ha.const import STORE_NEXT
 from custom_components.room_cal_ha.models import OverrideData, ReservationData
@@ -22,8 +21,8 @@ def _button(cls):
   btn = object.__new__(cls)
   btn._coordinator = MagicMock()
   btn._coordinator.async_refresh = AsyncMock()
-  btn._coordinator.async_promote_next_to_current = AsyncMock()
-  btn._coordinator.async_clear_current = AsyncMock()
+  btn._coordinator.async_promote_next_to_active = AsyncMock()
+  btn._coordinator.async_clear_active = AsyncMock()
   return btn
 
 
@@ -36,11 +35,11 @@ async def test_force_refresh_calls_coordinator_refresh():
 async def test_force_refresh_does_not_call_promote():
   btn = _button(ForceRefreshButton)
   await btn.async_press()
-  btn._coordinator.async_promote_next_to_current.assert_not_called()
+  btn._coordinator.async_promote_next_to_active.assert_not_called()
 
 
 # ---------------------------------------------------------------------------
-# TestNextGuestButton
+# OverrideNextButton
 # ---------------------------------------------------------------------------
 
 _NEXT = ReservationData(
@@ -54,8 +53,8 @@ _NEXT = ReservationData(
 _FROZEN_NOW = datetime(2026, 6, 22, 14, 0, 0, tzinfo=timezone.utc)
 
 
-def _test_next_button(next_data=_NEXT):
-  btn = object.__new__(TestNextGuestButton)
+def _override_next_button(next_data=_NEXT):
+  btn = object.__new__(OverrideNextButton)
   btn.coordinator = MagicMock()
   btn.coordinator.store = MagicMock()
   btn.coordinator.store.get.return_value = next_data
@@ -63,110 +62,68 @@ def _test_next_button(next_data=_NEXT):
   return btn
 
 
-async def test_test_next_guest_calls_start_override():
-  btn = _test_next_button()
+async def test_override_next_calls_start_override():
+  btn = _override_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()
+async def test_override_next_passes_override_data():
+  btn = _override_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()
+async def test_override_next_sets_check_in_to_one_hour_ago():
+  btn = _override_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)
+  assert override.active.check_in_date == date(2026, 6, 22)
+  assert override.active.check_in_time == time(13, 0)
 
 
-async def test_test_next_guest_preserves_other_fields():
-  btn = _test_next_button()
+async def test_override_next_preserves_other_fields():
+  btn = _override_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)
+  assert override.active.guest_name == "Bob"
+  assert override.active.check_out_date == date(2026, 7, 14)
+  assert override.active.check_out_time == time(11, 0)
 
 
-async def test_test_next_guest_override_next_is_none():
-  btn = _test_next_button()
+async def test_override_next_end_at_matches_its_own_checkout():
+  btn = _override_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
+  kwargs = btn.coordinator.async_start_override.call_args[1]
+  assert kwargs["end_at"] == datetime(2026, 7, 14, 11, 0, 0)
 
 
-async def test_test_next_guest_reads_from_store():
-  btn = _test_next_button()
+async def test_override_next_no_end_at_when_no_checkout():
+  btn = _override_next_button(next_data=ReservationData(guest_name="Bob", check_in_date=date(2026, 7, 10), check_in_time=time(15, 0)))
   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)
+  kwargs = btn.coordinator.async_start_override.call_args[1]
+  assert kwargs["end_at"] is None
 
 
-async def test_test_next_guest_no_next_data_does_nothing():
-  btn = _test_next_button(next_data=None)
+async def test_override_next_reads_from_store():
+  btn = _override_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_not_awaited()
-
-
-# ---------------------------------------------------------------------------
-# OverrideCurrentButton
-# ---------------------------------------------------------------------------
-
-_CURRENT = ReservationData(
-  guest_name="Carol",
-  check_in_date=date(2026, 7, 2),
-  check_in_time=time(15, 0),
-  check_out_date=date(2026, 7, 6),
-  check_out_time=time(11, 0),
-)
-
-
-def _override_current_button(current=_CURRENT):
-  btn = object.__new__(OverrideCurrentButton)
-  btn.coordinator = MagicMock()
-  btn.coordinator.data = MagicMock(current=current) if current is not None else None
-  btn.coordinator.async_start_override = AsyncMock()
-  return btn
-
-
-async def test_override_current_calls_start_override():
-  btn = _override_current_button()
-  await btn.async_press()
-  btn.coordinator.async_start_override.assert_awaited_once()
-
-
-async def test_override_current_passes_unchanged_current_data():
-  btn = _override_current_button()
-  await btn.async_press()
-  override: OverrideData = btn.coordinator.async_start_override.call_args[0][0]
-  assert isinstance(override, OverrideData)
-  assert override.current == _CURRENT
-  assert override.next is None
-
-
-async def test_override_current_no_end_at():
-  btn = _override_current_button()
-  await btn.async_press()
-  kwargs = btn.coordinator.async_start_override.call_args[1]
-  assert "end_at" not in kwargs
+  btn.coordinator.store.get.assert_called_with(STORE_NEXT)
 
 
-async def test_override_current_no_data_does_nothing():
-  btn = _override_current_button(current=None)
-  btn.coordinator.data = None
-  await btn.async_press()
+async def test_override_next_no_next_data_does_nothing():
+  btn = _override_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()
 
 
@@ -183,10 +140,10 @@ _OUTGOING = ReservationData(
 )
 
 
-def _override_empty_button(current=_OUTGOING):
+def _override_empty_button(active=_OUTGOING):
   btn = object.__new__(OverrideEmptyButton)
   btn.coordinator = MagicMock()
-  btn.coordinator.data = MagicMock(current=current) if current is not None else None
+  btn.coordinator.data = MagicMock(active=active) if active is not None else None
   btn.coordinator.async_start_override = AsyncMock()
   return btn
 
@@ -202,8 +159,7 @@ async def test_override_empty_passes_empty_reservation_data():
   await btn.async_press()
   override: OverrideData = btn.coordinator.async_start_override.call_args[0][0]
   assert isinstance(override, OverrideData)
-  assert override.current == ReservationData()
-  assert override.next is None
+  assert override.active == ReservationData()
 
 
 async def test_override_empty_end_at_matches_outgoing_checkout():
@@ -214,14 +170,14 @@ async def test_override_empty_end_at_matches_outgoing_checkout():
 
 
 async def test_override_empty_no_end_at_when_outgoing_has_no_checkout():
-  btn = _override_empty_button(current=ReservationData(guest_name="Alice"))
+  btn = _override_empty_button(active=ReservationData(guest_name="Alice"))
   await btn.async_press()
   kwargs = btn.coordinator.async_start_override.call_args[1]
   assert kwargs["end_at"] is None
 
 
-async def test_override_empty_no_end_at_when_no_current_data():
-  btn = _override_empty_button(current=None)
+async def test_override_empty_no_end_at_when_no_active_data():
+  btn = _override_empty_button(active=None)
   btn.coordinator.data = None
   await btn.async_press()
   kwargs = btn.coordinator.async_start_override.call_args[1]

+ 94 - 81
tests/test_coordinator.py

@@ -187,21 +187,32 @@ async def test_thin_update_overlays_fields(coordinator: RoomCalCoordinator):
   await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
   await coordinator.async_refresh()
 
-  await coordinator.async_apply_update(STORE_CURRENT, {"wifi_ssid": "GuestNet", "door_code": "9999"})
+  await coordinator.async_apply_update({"wifi_ssid": "GuestNet", "door_code": "9999"})
 
-  assert coordinator.data.current.wifi_ssid == "GuestNet"
-  assert coordinator.data.current.door_code == "9999"
-  assert coordinator.data.current.guest_name == "Alice"
-  assert coordinator.data.current.wifi_key == "stone-angel-market"
+  assert coordinator.data.active.wifi_ssid == "GuestNet"
+  assert coordinator.data.active.door_code == "9999"
+  assert coordinator.data.active.guest_name == "Alice"
+  assert coordinator.data.active.wifi_key == "stone-angel-market"
+  # Current (pure backend mirror) is unaffected by the patch.
+  assert coordinator.data.current.door_code == "0472"
 
 
-async def test_thin_update_updates_last_refresh(coordinator: RoomCalCoordinator):
+async def test_thin_update_starts_an_override(coordinator: RoomCalCoordinator):
   await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
   await coordinator.async_refresh()
-  assert coordinator.data.current_last_refresh is None
+  assert coordinator.data.override_active is False
+
+  await coordinator.async_apply_update({"door_code": "1234"})
+  assert coordinator.data.override_active is True
+
+
+async def test_thin_update_updates_active_last_refresh(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
+  await coordinator.async_refresh()
+  assert coordinator.data.active_last_refresh is None
 
-  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "1234"})
-  assert coordinator.data.current_last_refresh is not None
+  await coordinator.async_apply_update({"door_code": "1234"})
+  assert coordinator.data.active_last_refresh is not None
 
 
 # ---------------------------------------------------------------------------
@@ -243,8 +254,8 @@ async def test_last_updated_set_on_thin_update_with_change(coordinator: RoomCalC
   await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
   await coordinator.async_refresh()
 
-  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
-  assert coordinator.data.current_last_updated is not None
+  await coordinator.async_apply_update({"door_code": "9999"})
+  assert coordinator.data.active_last_updated is not None
 
 
 async def test_last_updated_not_set_on_thin_update_with_same_value(coordinator: RoomCalCoordinator):
@@ -252,33 +263,33 @@ async def test_last_updated_not_set_on_thin_update_with_same_value(coordinator:
   await coordinator.async_refresh()
 
   # First update sets last_updated
-  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
-  ts = coordinator.data.current_last_updated
+  await coordinator.async_apply_update({"door_code": "9999"})
+  ts = coordinator.data.active_last_updated
 
   # Second update with the same value must not advance last_updated
-  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
-  assert coordinator.data.current_last_updated == ts
+  await coordinator.async_apply_update({"door_code": "9999"})
+  assert coordinator.data.active_last_updated == ts
 
 
 async def test_last_updated_set_when_override_starts(coordinator: RoomCalCoordinator):
   await coordinator.async_refresh()
   coordinator._room_state_switch = None
-  assert coordinator.data.current_last_updated is None
+  assert coordinator.data.active_last_updated is None
 
-  await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override")))
-  assert coordinator.data.current_last_updated is not None
+  await coordinator.async_start_override(OverrideData(active=ReservationData(guest_name="Override")))
+  assert coordinator.data.active_last_updated is not None
 
 
 async def test_last_updated_set_when_override_ends(coordinator: RoomCalCoordinator):
   coordinator._room_state_switch = None
-  await coordinator.async_start_override(OverrideData(current=ReservationData(guest_name="Override")))
-  ts_start = coordinator.data.current_last_updated
+  await coordinator.async_start_override(OverrideData(active=ReservationData(guest_name="Override")))
+  ts_start = coordinator.data.active_last_updated
 
   await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
-  coordinator._override = OverrideData(current=ReservationData(guest_name="Override"))
+  coordinator._override = OverrideData(active=ReservationData(guest_name="Override"))
   await coordinator.async_end_override()
 
-  assert coordinator.data.current_last_updated != ts_start
+  assert coordinator.data.active_last_updated != ts_start
 
 
 async def test_last_updated_persists_across_reload(hass: HomeAssistant, mock_entry):
@@ -544,15 +555,17 @@ _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):
+async def test_start_override_sets_active_in_data(coordinator: RoomCalCoordinator):
   coordinator._room_state_switch = None
-  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
-  assert coordinator.data.current.guest_name == "Override Guest"
+  await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
+  assert coordinator.data.active.guest_name == "Override Guest"
+  # Current (pure backend mirror) is unaffected by the override.
+  assert coordinator.data.current.guest_name is None
 
 
 async def test_start_override_sets_override_active_flag(coordinator: RoomCalCoordinator):
   coordinator._room_state_switch = None
-  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
   assert coordinator.data.override_active is True
 
 
@@ -560,7 +573,7 @@ async def test_start_override_cancels_mode_sync(coordinator: RoomCalCoordinator)
   cancel1, cancel2 = MagicMock(), MagicMock()
   coordinator._mode_sync_handles = [cancel1, cancel2]
   coordinator._room_state_switch = None
-  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
   cancel1.assert_called_once()
   cancel2.assert_called_once()
   assert coordinator._mode_sync_handles == []
@@ -579,7 +592,7 @@ async def test_start_override_turns_on_guest_mode(coordinator: RoomCalCoordinato
     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))
+    await coordinator.async_start_override(OverrideData(active=active_override))
   coordinator.cancel_mode_sync()
   mock_switch.async_turn_on.assert_awaited_once()
 
@@ -589,7 +602,7 @@ async def test_start_override_does_not_turn_on_guest_mode_when_mode_sync_off(coo
   mock_switch.async_turn_on = AsyncMock()
   coordinator._room_state_switch = mock_switch
   with patch.object(coordinator, "_is_mode_sync_on", return_value=False):
-    await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+    await coordinator.async_start_override(OverrideData(active=_OVERRIDE_RES))
   mock_switch.async_turn_on.assert_not_awaited()
 
 
@@ -606,7 +619,7 @@ async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssi
     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,
   ):
-    await coordinator.async_start_override(OverrideData(current=active_wifi_override))
+    await coordinator.async_start_override(OverrideData(active=active_wifi_override))
   coordinator.cancel_mode_sync()
   mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
 
@@ -617,7 +630,7 @@ async def test_start_override_skips_wifi_when_mode_sync_off(coordinator: RoomCal
     patch.object(coordinator, "_is_mode_sync_on", return_value=False),
     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(active=_WIFI_OVERRIDE))
   mock_wifi.assert_not_awaited()
 
 
@@ -625,24 +638,19 @@ async def test_start_override_skips_wifi_when_ssid_missing(coordinator: RoomCalC
   coordinator._room_state_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))
+    await coordinator.async_start_override(OverrideData(active=no_ssid))
   mock_wifi.assert_not_awaited()
 
 
-async def test_start_override_uses_store_next_when_override_next_is_none(coordinator: RoomCalCoordinator):
+async def test_start_override_next_always_mirrors_store(coordinator: RoomCalCoordinator):
+  """There's no such thing as an override-owned Next — Next always mirrors the
+  store's own feed, regardless of any override on active."""
   await coordinator.store.async_set(STORE_NEXT, SAMPLE)
   coordinator._room_state_switch = None
-  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  await coordinator.async_start_override(OverrideData(active=_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._room_state_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_start_override_applies_defaults_to_current(hass: HomeAssistant):
   entry = MockConfigEntry(
     domain=DOMAIN,
@@ -658,11 +666,11 @@ async def test_start_override_applies_defaults_to_current(hass: HomeAssistant):
   coord._guest_mode_switch = None
 
   partial_override = ReservationData(guest_name="Override Guest")
-  await coord.async_start_override(OverrideData(current=partial_override))
+  await coord.async_start_override(OverrideData(active=partial_override))
 
-  assert coord.data.current.guest_name == "Override Guest"
-  assert coord.data.current.wifi_ssid == "DefaultSSID"
-  assert coord.data.current.check_out_time == time(11, 0)
+  assert coord.data.active.guest_name == "Override Guest"
+  assert coord.data.active.wifi_ssid == "DefaultSSID"
+  assert coord.data.active.check_out_time == time(11, 0)
 
 
 async def test_start_override_defaults_do_not_overwrite_explicit_fields(hass: HomeAssistant):
@@ -680,9 +688,9 @@ async def test_start_override_defaults_do_not_overwrite_explicit_fields(hass: Ho
   coord._guest_mode_switch = None
 
   explicit_override = ReservationData(guest_name="Override Guest", wifi_ssid="ExplicitSSID")
-  await coord.async_start_override(OverrideData(current=explicit_override))
+  await coord.async_start_override(OverrideData(active=explicit_override))
 
-  assert coord.data.current.wifi_ssid == "ExplicitSSID"
+  assert coord.data.active.wifi_ssid == "ExplicitSSID"
 
 
 async def test_refresh_during_override_applies_defaults_to_current(hass: HomeAssistant):
@@ -700,11 +708,11 @@ async def test_refresh_during_override_applies_defaults_to_current(hass: HomeAss
   coord._guest_mode_switch = None
 
   partial_override = ReservationData(guest_name="Override Guest")
-  await coord.async_start_override(OverrideData(current=partial_override))
+  await coord.async_start_override(OverrideData(active=partial_override))
   await coord.async_refresh()
 
-  assert coord.data.current.guest_name == "Override Guest"
-  assert coord.data.current.door_code == "9999"
+  assert coord.data.active.guest_name == "Override Guest"
+  assert coord.data.active.door_code == "9999"
 
 
 async def test_start_override_applies_defaults_to_store_next(hass: HomeAssistant):
@@ -722,7 +730,7 @@ async def test_start_override_applies_defaults_to_store_next(hass: HomeAssistant
   coord._guest_mode_switch = None
 
   await coord.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest"))
-  await coord.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  await coord.async_start_override(OverrideData(active=_OVERRIDE_RES))
 
   assert coord.data.next.guest_name == "Next Guest"
   assert coord.data.next.wifi_ssid == "DefaultSSID"
@@ -730,7 +738,7 @@ async def test_start_override_applies_defaults_to_store_next(hass: HomeAssistant
 
 async def test_end_override_clears_override_active(coordinator: RoomCalCoordinator):
   coordinator._room_state_switch = None
-  coordinator._override = OverrideData(current=_OVERRIDE_RES)
+  coordinator._override = OverrideData(active=_OVERRIDE_RES)
   await coordinator.async_end_override()
   assert coordinator.data.override_active is False
 
@@ -738,14 +746,14 @@ async def test_end_override_clears_override_active(coordinator: RoomCalCoordinat
 async def test_end_override_restores_store_current(coordinator: RoomCalCoordinator):
   await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
   coordinator._room_state_switch = None
-  coordinator._override = OverrideData(current=_OVERRIDE_RES)
+  coordinator._override = OverrideData(active=_OVERRIDE_RES)
   await coordinator.async_end_override()
-  assert coordinator.data.current.guest_name == "Alice"
+  assert coordinator.data.active.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"))
+  override = OverrideData(active=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),
@@ -768,7 +776,7 @@ async def test_stale_override_timeout_does_not_end_a_replacing_override(coordina
   """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"))
+  override = OverrideData(active=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),
@@ -779,7 +787,7 @@ async def test_stale_override_timeout_does_not_end_a_replacing_override(coordina
   await coordinator.async_end_override()
   assert coordinator._override is None
 
-  new_override = OverrideData(current=ReservationData(guest_name="New Guest"))
+  new_override = OverrideData(active=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.
@@ -813,7 +821,7 @@ async def test_start_override_empty_current_triggers_checkout_not_checkin(
     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()))
+    await coordinator.async_start_override(OverrideData(active=ReservationData()))
   coordinator.cancel_mode_sync()
 
   mock_ssid.assert_awaited_once_with(hass, "GuestNet", False)
@@ -822,13 +830,13 @@ async def test_start_override_empty_current_triggers_checkout_not_checkin(
   assert coordinator.data.override_active is True
 
 
-async def test_override_current_checks_out_on_schedule_while_override_stays_active(
+async def test_override_active_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."""
+  """With Mode Sync on, an override's own active record schedules a real check-out
+  handle; firing it clears the override's active (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),
@@ -843,7 +851,7 @@ async def test_override_current_checks_out_on_schedule_while_override_stays_acti
     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))
+    await coordinator.async_start_override(OverrideData(active=override_guest))
 
   assert len(coordinator._mode_sync_handles) >= 1
   assert coordinator.data.override_active is True
@@ -857,7 +865,7 @@ async def test_override_current_checks_out_on_schedule_while_override_stays_acti
 
   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.data.active.guest_name is None
   assert coordinator.store.get(STORE_CURRENT) is None
 
   await coordinator.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest"))
@@ -869,7 +877,7 @@ async def test_override_current_checks_out_on_schedule_while_override_stays_acti
   coordinator.cancel_mode_sync()
 
   assert coordinator.data.override_active is True
-  assert coordinator.data.current.guest_name == "Next Guest"
+  assert coordinator.data.active.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
 
@@ -878,34 +886,38 @@ async def test_override_current_checks_out_on_schedule_while_override_stays_acti
 # Thin updates during override
 # ---------------------------------------------------------------------------
 
-async def test_thin_update_current_during_override_updates_override_not_store(coordinator: RoomCalCoordinator):
+async def test_thin_update_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)
+  coordinator._override = OverrideData(active=_OVERRIDE_RES)
 
-  await coordinator.async_apply_update(STORE_CURRENT, {"door_code": "9999"})
+  await coordinator.async_apply_update({"door_code": "9999"})
 
-  assert coordinator.data.current.door_code == "9999"
+  assert coordinator.data.active.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):
+async def test_thin_update_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"})
+  coordinator._override = OverrideData(active=_OVERRIDE_RES)
+  await coordinator.async_apply_update({"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
+async def test_thin_update_with_no_override_running_starts_one(coordinator: RoomCalCoordinator):
+  """Patching with no override active must snapshot `active` as an override first,
+  so the patch survives the next backend poll instead of being silently overwritten."""
+  await coordinator.store.async_set(STORE_CURRENT, SAMPLE)
   await coordinator.async_refresh()
+  assert coordinator.data.override_active is False
 
-  await coordinator.async_apply_update(STORE_NEXT, {"guest_name": "Next Guest"})
+  await coordinator.async_apply_update({"guest_name": "Patched 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"
+  assert coordinator.data.active.guest_name == "Patched Guest"
+  # Store's own current is untouched by the patch.
+  assert coordinator.store.get(STORE_CURRENT).guest_name == "Alice"
 
 
 # ---------------------------------------------------------------------------
@@ -916,7 +928,7 @@ async def test_backend_fetch_during_override_updates_store_but_preserves_overrid
   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)
+  coord._override = OverrideData(active=_OVERRIDE_RES)
 
   backend_data = ReservationData(guest_name="Backend Alice")
   with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as MockBackend:
@@ -925,8 +937,9 @@ async def test_backend_fetch_during_override_updates_store_but_preserves_overrid
 
   # 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"
+  # But displayed active still shows the override; current mirrors the fresh backend write.
+  assert coord.data.active.guest_name == "Override Guest"
+  assert coord.data.current.guest_name == "Backend Alice"
   assert coord.data.override_active is True
 
 

+ 3 - 15
tests/test_models.py

@@ -154,23 +154,11 @@ def test_default_values():
 # ---------------------------------------------------------------------------
 
 _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"
+def test_override_data_requires_active():
+  o = OverrideData(active=_RES_A)
+  assert o.active.guest_name == "Alice"
 
 
 # ---------------------------------------------------------------------------

+ 1 - 0
tests/test_sensors.py

@@ -37,6 +37,7 @@ def _coordinator(
 ):
   coord = MagicMock()
   coord.data = CoordinatorData(
+    active=current or ReservationData(),
     current=current or ReservationData(),
     next=nxt or ReservationData(),
     current_last_refresh=current_ts,

+ 3 - 1
tests/test_switches.py

@@ -123,6 +123,7 @@ def _override_switch(override_active: bool = False):
   s = object.__new__(OverrideReservationSwitch)
   s.coordinator = MagicMock()
   s.coordinator.async_end_override = AsyncMock()
+  s.coordinator.async_ensure_override = AsyncMock()
   data = MagicMock()
   data.override_active = override_active
   s.coordinator.data = data
@@ -149,9 +150,10 @@ async def test_override_switch_turn_off_ends_override():
   s.coordinator.async_end_override.assert_awaited_once()
 
 
-async def test_override_switch_turn_on_is_noop():
+async def test_override_switch_turn_on_snapshots_active():
   s = _override_switch()
   await s.async_turn_on()
+  s.coordinator.async_ensure_override.assert_awaited_once()
   s.coordinator.async_end_override.assert_not_awaited()