Procházet zdrojové kódy

Reconcile guest_mode state on startup without bouncing

After all platforms load and switch states are restored, run a single
reconcile pass that checks current time vs reservation window. When
mode_sync is on, silently corrects guest_mode._state (promoting next
if its check-in elapsed, clearing current if checkout passed) before
committing one HA state write via commit_state(). GuestModeSwitch
defers all async_write_ha_state() calls until commit_state() clears
the flag, so external state never sees an intermediate bounce.

Also fixes async_promote_next_to_current to reschedule mode-sync
handles and immediately activate guest mode if the imported record's
window is already active.

Fix missing async_fetch_services mock in three coordinator tests, and
align ApiStatusSensor checks-attribute test with flat-key implementation.
ZiB před 4 měsíci
rodič
revize
5362c67258

+ 1 - 0
custom_components/room_cal_ha/__init__.py

@@ -34,6 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 
 
   await coordinator.async_config_entry_first_refresh()
   await coordinator.async_config_entry_first_refresh()
   await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
   await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+  await coordinator.async_post_setup_reconcile()
 
 
   entry.async_on_unload(entry.add_update_listener(_async_options_updated))
   entry.async_on_unload(entry.add_update_listener(_async_options_updated))
 
 

+ 75 - 1
custom_components/room_cal_ha/coordinator.py

@@ -150,6 +150,73 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
           async_call_later(self.hass, delay, self._async_handle_check_out)
           async_call_later(self.hass, delay, self._async_handle_check_out)
         )
         )
 
 
+  def _reservation_window_state(self, res: ReservationData) -> str | None:
+    """
+    Returns 'active' if now is within check_in..check_out, 'elapsed' if past check_out,
+    'pending' if before check_in, or None if the reservation has no datetime info.
+    """
+    now = dt_util.now()
+    check_in = check_out = None
+    if res.check_in_date and res.check_in_time:
+      check_in = dt_util.as_local(datetime.combine(res.check_in_date, res.check_in_time))
+    if res.check_out_date and res.check_out_time:
+      check_out = dt_util.as_local(datetime.combine(res.check_out_date, res.check_out_time))
+    if check_out and now >= check_out:
+      return "elapsed"
+    if check_in and now >= check_in:
+      return "active"
+    if check_in:
+      return "pending"
+    return None
+
+  async def async_post_setup_reconcile(self) -> None:
+    """
+    Called from async_setup_entry after all platforms are set up and switch states
+    are restored.  Silently corrects guest_mode._state if mode_sync is on and the
+    stored reservation data implies a different state than what was restored, then
+    commits a single HA state write via GuestModeSwitch.commit_state().
+    """
+    current = self.store.get(STORE_CURRENT) or ReservationData()
+    nxt = self.store.get(STORE_NEXT) or ReservationData()
+    data_changed = False
+
+    if self._is_mode_sync_on():
+      # If next check-in elapsed while HA was down, promote it now.
+      if self._reservation_window_state(nxt) in ("active", "elapsed"):
+        await self.store.async_set(STORE_CURRENT, nxt)
+        self._current_last_refresh = dt_util.utcnow()
+        current = nxt
+        data_changed = True
+
+      state = self._reservation_window_state(current)
+
+      if state == "elapsed":
+        await self.store.async_set(STORE_CURRENT, None)
+        self._current_last_refresh = dt_util.utcnow()
+        current = ReservationData()
+        if self._guest_mode_switch is not None:
+          self._guest_mode_switch._state = False
+        data_changed = True
+      elif state == "active":
+        if self._guest_mode_switch is not None:
+          self._guest_mode_switch._state = True
+        data_changed = True
+
+      new_current = self.store.get(STORE_CURRENT) or ReservationData()
+      new_next = self.store.get(STORE_NEXT) or ReservationData()
+      self._reschedule_mode_sync(new_current, new_next)
+
+      if data_changed:
+        self.async_set_updated_data(CoordinatorData(
+          current=new_current,
+          next=new_next,
+          current_last_refresh=self._current_last_refresh,
+          next_last_refresh=self._next_last_refresh,
+        ))
+
+    if self._guest_mode_switch is not None:
+      self._guest_mode_switch.commit_state()
+
   def _is_mode_sync_on(self) -> bool:
   def _is_mode_sync_on(self) -> bool:
     from .switch import ModeSyncSwitch  # avoid circular at module level
     from .switch import ModeSyncSwitch  # avoid circular at module level
     for entity in self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("switches", []):
     for entity in self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("switches", []):
@@ -187,12 +254,19 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     next_data = self.store.get(STORE_NEXT) or ReservationData()
     next_data = self.store.get(STORE_NEXT) or ReservationData()
     await self.store.async_set(STORE_CURRENT, next_data)
     await self.store.async_set(STORE_CURRENT, next_data)
     self._current_last_refresh = dt_util.utcnow()
     self._current_last_refresh = dt_util.utcnow()
+    new_next = self.store.get(STORE_NEXT) or ReservationData()
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
       current=next_data,
       current=next_data,
-      next=self.store.get(STORE_NEXT) or ReservationData(),
+      next=new_next,
       current_last_refresh=self._current_last_refresh,
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
       next_last_refresh=self._next_last_refresh,
     ))
     ))
+    self._reschedule_mode_sync(next_data, new_next)
+    # If mode_sync is on and the newly imported record's check-in has already passed,
+    # activate guest mode immediately (the scheduled handle already fired or was missed).
+    if self._is_mode_sync_on() and self._reservation_window_state(next_data) == "active":
+      if self._guest_mode_switch is not None:
+        await self._guest_mode_switch.async_turn_on()
 
 
   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."""
     """Apply a thin update (service call) to current or next."""

+ 11 - 0
custom_components/room_cal_ha/switch.py

@@ -67,11 +67,22 @@ class _RoomStateSwitch(SwitchEntity, RestoreEntity):
 
 
 class GuestModeSwitch(_RoomStateSwitch):
 class GuestModeSwitch(_RoomStateSwitch):
   _attr_name = "Guest Mode"
   _attr_name = "Guest Mode"
+  _deferred: bool = True  # suppresses writes until coordinator reconcile calls commit_state()
 
 
   def __init__(self, coordinator: RoomCalCoordinator, entry: ConfigEntry, device_name: str) -> None:
   def __init__(self, coordinator: RoomCalCoordinator, entry: ConfigEntry, device_name: str) -> None:
     super().__init__(coordinator, entry, device_name)
     super().__init__(coordinator, entry, device_name)
     self._attr_unique_id = f"{entry.entry_id}_guest_mode"
     self._attr_unique_id = f"{entry.entry_id}_guest_mode"
 
 
+  def async_write_ha_state(self) -> None:
+    if self._deferred:
+      return
+    super().async_write_ha_state()
+
+  def commit_state(self) -> None:
+    """Unblock state writes and publish the final reconciled state exactly once."""
+    self._deferred = False
+    super().async_write_ha_state()
+
 
 
 class ModeSyncSwitch(_RoomStateSwitch):
 class ModeSyncSwitch(_RoomStateSwitch):
   _attr_name = "Mode Sync"
   _attr_name = "Mode Sync"

+ 3 - 0
tests/test_coordinator.py

@@ -149,6 +149,7 @@ async def test_api_fetch_writes_store_and_status(hass: HomeAssistant):
     instance = MockBackend.return_value
     instance = MockBackend.return_value
     instance.async_fetch = AsyncMock(return_value=(SAMPLE, None))
     instance.async_fetch = AsyncMock(return_value=(SAMPLE, None))
     instance.async_fetch_status = AsyncMock(return_value=SAMPLE_STATUS)
     instance.async_fetch_status = AsyncMock(return_value=SAMPLE_STATUS)
+    instance.async_fetch_services = AsyncMock(return_value={})
     await coord.async_refresh()
     await coord.async_refresh()
 
 
   assert coord.data.current.guest_name == "Alice"
   assert coord.data.current.guest_name == "Alice"
@@ -167,6 +168,7 @@ async def test_api_connect_error_preserves_store_and_still_fetches_status(hass:
     instance = MockBackend.return_value
     instance = MockBackend.return_value
     instance.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
     instance.async_fetch = AsyncMock(side_effect=BackendConnectError("timeout"))
     instance.async_fetch_status = AsyncMock(return_value=degraded)
     instance.async_fetch_status = AsyncMock(return_value=degraded)
+    instance.async_fetch_services = AsyncMock(return_value={})
     await coord.async_refresh()
     await coord.async_refresh()
 
 
   assert coord.data.current.guest_name == "Alice"
   assert coord.data.current.guest_name == "Alice"
@@ -186,6 +188,7 @@ async def test_api_auth_error_triggers_reauth(hass: HomeAssistant):
     instance = MockBackend.return_value
     instance = MockBackend.return_value
     instance.async_fetch = AsyncMock(side_effect=BackendAuthError)
     instance.async_fetch = AsyncMock(side_effect=BackendAuthError)
     instance.async_fetch_status = AsyncMock(return_value=ApiStatusData(status="auth_error"))
     instance.async_fetch_status = AsyncMock(return_value=ApiStatusData(status="auth_error"))
+    instance.async_fetch_services = AsyncMock(return_value={})
     await coord.async_refresh()
     await coord.async_refresh()
 
 
   mock_reauth.assert_called_once_with(hass)
   mock_reauth.assert_called_once_with(hass)

+ 2 - 1
tests/test_sensors.py

@@ -231,7 +231,8 @@ def test_api_status_sensor_attrs_include_checks_and_stale():
   s.coordinator = coord
   s.coordinator = coord
 
 
   attrs = s.extra_state_attributes
   attrs = s.extra_state_attributes
-  assert attrs["checks"] == checks
+  assert attrs["caldav_connection"] == {"ok": True}
+  assert attrs["calendar"] == {"ok": False, "reason": "not found"}
   assert attrs["stale"] is True
   assert attrs["stale"] is True