Ver código fonte

Guard mode sync as if off while current guest is departed

Once GuestDepartedButton fires, freeze automatic check-in/check-out
actions, room state changes, and scheduled transitions for the current
reservation so downstream changes made in response to the departure
aren't clobbered. The guard lifts automatically when the current
reservation's identity changes (Clear Current, or a backend fetch that
reports a different current reservation).
ZiB 3 meses atrás
pai
commit
f545ba24d4

+ 23 - 8
custom_components/room_cal_ha/coordinator.py

@@ -200,7 +200,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       cancel()
     self._mode_sync_handles.clear()
 
-    if not self._is_mode_sync_on():
+    if not self._is_mode_sync_effectively_on():
       return
 
     now = dt_util.now().replace(tzinfo=None)
@@ -243,8 +243,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     return None
 
   async def _async_set_room_state(self, on: bool, *, _startup: bool = False) -> None:
-    """Set room active state on/off. No-op when mode_sync is off."""
-    if not self._is_mode_sync_on():
+    """Set room active state on/off. No-op when mode_sync is off (or effectively off)."""
+    if not self._is_mode_sync_effectively_on():
       return
     if self._room_state_switch is None:
       return
@@ -256,8 +256,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       await self._room_state_switch.async_turn_off()
 
   async def _async_run_check_in_actions(self, res: ReservationData) -> None:
-    """Run WiFi + lock updates and guest mode activation. No-op when mode_sync is off."""
-    if not self._is_mode_sync_on():
+    """Run WiFi + lock updates and guest mode activation. No-op when mode_sync is off (or effectively off)."""
+    if not self._is_mode_sync_effectively_on():
       return
     _LOGGER.info("Check-in: guest=%s", res.guest_name)
     if self._is_use_unifi_on() and isinstance(res.wifi_ssid, str):
@@ -281,8 +281,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     _LOGGER.info("Check-in complete: guest=%s", res.guest_name)
 
   async def _async_run_check_out_actions(self, res: ReservationData) -> None:
-    """Run WiFi SSID disable, lock slot disable, and guest mode deactivation. No-op when mode_sync is off."""
-    if not self._is_mode_sync_on():
+    """Run WiFi SSID disable, lock slot disable, and guest mode deactivation. No-op when mode_sync is off (or effectively off)."""
+    if not self._is_mode_sync_effectively_on():
       return
     _LOGGER.info("Check-out: guest=%s", res.guest_name)
     if self._is_use_unifi_on() and isinstance(res.wifi_ssid, str):
@@ -367,7 +367,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     current = old_current or self._empty_reservation()
     nxt = self.store.get(STORE_NEXT) or self._empty_reservation()
 
-    if self._is_mode_sync_on():
+    if self._is_mode_sync_effectively_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)
@@ -416,6 +416,17 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         return entity.is_on
     return False
 
+  def _is_mode_sync_effectively_on(self) -> bool:
+    """Mode sync, but forced off while the current guest is marked departed.
+
+    Once GuestDepartedButton fires, automation freezes so that any changes made in
+    response to the departure (thin updates, manual actions) persist undisturbed until
+    the current identity actually changes — typically a backend fetch clearing Current
+    to null, or an explicit Clear Current. Both reset current_departed via
+    _async_maybe_reset_arrival_flags and thaw automation again.
+    """
+    return self._is_mode_sync_on() and not self._current_departed
+
   def _is_use_unifi_on(self) -> bool:
     from .switch import UseUnifiSwitch  # avoid circular at module level
     for entity in self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("switches", []):
@@ -719,6 +730,10 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
   async def async_set_departed(self, departed: bool) -> None:
     self._current_departed = departed
     await self.store.async_set_departed(departed)
+    if departed:
+      # Guard as if mode sync were off: cancel any pending scheduled transition so it
+      # can't fire and clobber changes made in response to the departure.
+      self.cancel_mode_sync()
     if self.data is not None:
       self.async_set_updated_data(dataclass_replace(self.data, current_departed=departed))
 

+ 62 - 0
tests/test_arrived_departed.py

@@ -253,3 +253,65 @@ async def test_flags_reset_on_checkout(coord: RoomCalCoordinator):
   assert coord.data.current_arrived is False
 
 
+# ---------------------------------------------------------------------------
+# Coordinator: mode sync guarded as-if-off while current is departed
+# ---------------------------------------------------------------------------
+
+async def test_departed_blocks_check_in_actions(coord: RoomCalCoordinator):
+  await coord.async_set_departed(True)
+  with patch.object(coord, "_is_mode_sync_on", return_value=True):
+    await coord._async_run_check_in_actions(_ALICE)
+  assert coord._room_state_switch is None or not coord._room_state_switch.async_turn_on.called
+
+
+async def test_departed_blocks_reschedule(coord: RoomCalCoordinator):
+  await coord.async_set_departed(True)
+  with patch.object(coord, "_is_mode_sync_on", return_value=True):
+    coord._reschedule_mode_sync(_ALICE, _BOB)
+  assert coord._mode_sync_handles == []
+
+
+async def test_departed_cancels_pending_handles_immediately(coord: RoomCalCoordinator):
+  cancel = MagicMock()
+  coord._mode_sync_handles.append(cancel)
+
+  await coord.async_set_departed(True)
+
+  cancel.assert_called_once()
+  assert coord._mode_sync_handles == []
+
+
+async def test_recover_via_clear_current(coord: RoomCalCoordinator):
+  await coord.store.async_set(STORE_CURRENT, _ALICE)
+  await coord.async_refresh()
+  await coord.async_set_departed(True)
+  assert coord._current_departed is True
+
+  await coord.async_clear_current()
+
+  assert coord._current_departed is False
+  assert coord.data.current_departed is False
+
+
+async def test_recover_via_force_refresh(hass: HomeAssistant):
+  entry = _make_entry(hass, {CONF_SYNC_MODE: SYNC_MODE_CALDAV, "caldav_url": "https://x", "calendar_name": "R"})
+  c = RoomCalCoordinator(hass, entry)
+  await c.async_setup()
+
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as M:
+    M.return_value.async_fetch = AsyncMock(return_value=(_ALICE, None))
+    await c.async_refresh()
+
+  await c.async_set_departed(True)
+  assert c._current_departed is True
+
+  # Backend no longer reports Alice as current (e.g. calendar window ended) — a
+  # force refresh should observe the identity change and thaw the guard.
+  with patch("custom_components.room_cal_ha.coordinator.CalDavBackend") as M:
+    M.return_value.async_fetch = AsyncMock(return_value=(None, None))
+    await c.async_refresh()
+
+  assert c._current_departed is False
+  assert c.data.current_departed is False
+
+