Sfoglia il codice sorgente

Diff against actual UniFi state to detect and recover from drift

Added get_wlan_state() to wifi.py to read cached SSID enabled flag and
passphrase without a network call. _diff_with_unifi_check() wraps
reservation_diff(): when use_unifi is on and the window is active it
reads actual hub state and promotes checkin_needed/wifi_key_changed if
the SSID is disabled or the key doesn't match, so the set() path
recovers from drift on the next backend poll or thin update.

Also made is_window_active() public in models.py so the coordinator can
use the same `now` it already has rather than a second dt_util.now()
call.
ZiB 4 mesi fa
parent
commit
28cc641ad7

+ 49 - 4
custom_components/room_cal_ha/coordinator.py

@@ -23,7 +23,7 @@ from .const import (
   SYNC_MODE_API,
   SYNC_MODE_CALDAV,
 )
-from .models import ApiStatusData, OverrideData, ReservationData, reservation_diff
+from .models import ApiStatusData, OverrideData, ReservationData, is_window_active, reservation_diff
 from dataclasses import fields as dataclass_fields
 from .store import ReservationStore
 
@@ -110,7 +110,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       self._next_last_refresh = now
       if self._override is None:
         now_naive = dt_util.now().replace(tzinfo=None)
-        diff = reservation_diff(old_current, backend_current, now_naive)
+        diff = self._diff_with_unifi_check(old_current, backend_current, now_naive)
         if diff.checkout_needed:
           await self._async_run_check_out_actions(diff.departing)
         if diff.checkin_needed or diff.wifi_key_changed:
@@ -376,7 +376,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     """Clear the active override and reconcile room state from the store."""
     if self._override is not None:
       now_naive = dt_util.now().replace(tzinfo=None)
-      diff = reservation_diff(self._override.current, self.store.get(STORE_CURRENT), now_naive)
+      diff = self._diff_with_unifi_check(self._override.current, self.store.get(STORE_CURRENT), now_naive)
       if diff.checkout_needed:
         await self._async_run_check_out_actions(diff.departing)
       if diff.checkin_needed or diff.wifi_key_changed:
@@ -459,12 +459,57 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       self._reschedule_mode_sync(store_current, store_next)
       if target == STORE_CURRENT:
         now_naive = dt_util.now().replace(tzinfo=None)
-        diff = reservation_diff(stored, new_stored, now_naive)
+        diff = self._diff_with_unifi_check(stored, new_stored, now_naive)
         if diff.checkout_needed:
           await self._async_run_check_out_actions(diff.departing)
         if diff.checkin_needed or diff.wifi_key_changed:
           await self._async_run_check_in_actions(new_stored)
 
+  def _diff_with_unifi_check(
+    self,
+    old: ReservationData | None,
+    new: ReservationData | None,
+    now: datetime,
+  ) -> "GuestStateDiff":
+    """reservation_diff() augmented with actual UniFi state when we're in control.
+
+    When use_unifi is on and the desired window is active, reads the SSID's current
+    enabled flag and passphrase from the aiounifi cache and treats any mismatch as a
+    change, so the set() path recovers from drift without waiting for a record update.
+    """
+    from .models import GuestStateDiff
+    from .wifi import get_wlan_state
+
+    diff = reservation_diff(old, new, now)
+
+    if not self._is_use_unifi_on():
+      return diff
+
+    ssid = new.wifi_ssid if new else None
+    if not ssid or not is_window_active(new, now):
+      return diff
+
+    actual_enabled, actual_key = get_wlan_state(self.hass, ssid)
+    if actual_enabled is None:
+      return diff
+
+    ssid_wrong = not actual_enabled
+    key_wrong = (
+      actual_key is not None
+      and new.wifi_key is not None
+      and actual_key != new.wifi_key
+    )
+
+    if not ssid_wrong and not key_wrong:
+      return diff
+
+    return GuestStateDiff(
+      checkout_needed=diff.checkout_needed,
+      checkin_needed=diff.checkin_needed or ssid_wrong,
+      wifi_key_changed=diff.wifi_key_changed or key_wrong,
+      departing=diff.departing,
+    )
+
   def cancel_mode_sync(self) -> None:
     for cancel in self._mode_sync_handles:
       cancel()

+ 3 - 3
custom_components/room_cal_ha/models.py

@@ -102,8 +102,8 @@ def reservation_diff(
   `now` must be a naive local datetime. `old` is the reservation that drove the current
   guest-mode state; `new` is what should be reflected after the state change.
   """
-  old_active = _is_window_active(old, now)
-  new_active = _is_window_active(new, now)
+  old_active = is_window_active(old, now)
+  new_active = is_window_active(new, now)
 
   if not old_active and not new_active:
     return GuestStateDiff()
@@ -125,7 +125,7 @@ def reservation_diff(
   )
 
 
-def _is_window_active(res: ReservationData | None, now: datetime) -> bool:
+def is_window_active(res: ReservationData | None, now: datetime) -> bool:
   """Return True if now falls within the reservation's check-in/check-out window.
 
   Matches _reservation_window_state semantics: active once check_in is reached,

+ 17 - 0
custom_components/room_cal_ha/wifi.py

@@ -31,6 +31,23 @@ async def async_set_wifi_key(hass: HomeAssistant, ssid: str, key: str) -> None:
     raise HomeAssistantError(f"Failed to set WiFi key for '{ssid}': {err}") from err
 
 
+def get_wlan_state(hass: HomeAssistant, ssid: str) -> tuple[bool | None, str | None]:
+  """Return (enabled, key) for the named SSID from the cached hub state.
+
+  Reads from the in-memory aiounifi cache — no network call. Returns (None, None)
+  when the hub is unavailable or the SSID is not found. key may be None even when
+  found if the controller doesn't expose the passphrase in its read model.
+  """
+  for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN):
+    hub = config_entry.runtime_data
+    if not hub.available:
+      continue
+    for wlan in hub.api.wlans.values():
+      if wlan.name == ssid:
+        return wlan.enabled, getattr(wlan, "x_passphrase", None)
+  return None, None
+
+
 async def async_set_ssid_enabled(hass: HomeAssistant, ssid: str, enabled: bool) -> None:
   """Enable or disable a UniFi WLAN identified by its SSID name."""
   hub, wlan_id = _find_wlan(hass, ssid)

+ 83 - 0
tests/test_coordinator.py

@@ -767,3 +767,86 @@ async def test_reconcile_does_not_touch_guest_mode_when_mode_sync_off(coordinato
 
   mock_switch.async_turn_on.assert_not_awaited()
   mock_switch.async_turn_off.assert_not_awaited()
+
+
+# ---------------------------------------------------------------------------
+# _diff_with_unifi_check
+# ---------------------------------------------------------------------------
+
+_ACTIVE_WIFI_RES = ReservationData(
+  check_in_date=date(2026, 7, 1),
+  check_in_time=time(14, 0),
+  check_out_date=date(2026, 7, 5),
+  check_out_time=time(12, 0),
+  wifi_ssid="GuestNet",
+  wifi_key="correct-key",
+)
+_DURING_WINDOW = datetime(2026, 7, 3, 12, 0, 0)
+
+
+def _unifi_diff(coordinator, old, new, now, *, enabled, key):
+  with (
+    patch.object(coordinator, "_is_use_unifi_on", return_value=True),
+    patch("custom_components.room_cal_ha.wifi.get_wlan_state", return_value=(enabled, key)),
+  ):
+    return coordinator._diff_with_unifi_check(old, new, now)
+
+
+async def test_unifi_diff_ssid_disabled_adds_checkin_needed(coordinator):
+  diff = _unifi_diff(coordinator, None, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="correct-key")
+  assert diff.checkin_needed
+
+
+async def test_unifi_diff_key_mismatch_adds_wifi_key_changed(coordinator):
+  diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="wrong-key")
+  assert diff.wifi_key_changed
+
+
+async def test_unifi_diff_ssid_disabled_and_key_wrong_sets_both(coordinator):
+  diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=False, key="wrong-key")
+  assert diff.checkin_needed
+  assert diff.wifi_key_changed
+
+
+async def test_unifi_diff_state_matches_no_extra_flags(coordinator):
+  diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
+  assert not diff.has_changes
+
+
+async def test_unifi_diff_skipped_when_use_unifi_off(coordinator):
+  with (
+    patch.object(coordinator, "_is_use_unifi_on", return_value=False),
+    patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get,
+  ):
+    diff = coordinator._diff_with_unifi_check(_ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW)
+  mock_get.assert_not_called()
+  assert not diff.has_changes  # base diff: same record, no changes; unifi check skipped
+
+
+async def test_unifi_diff_skipped_when_window_not_active(coordinator):
+  before_window = datetime(2026, 6, 30, 12, 0, 0)
+  with (
+    patch.object(coordinator, "_is_use_unifi_on", return_value=True),
+    patch("custom_components.room_cal_ha.wifi.get_wlan_state") as mock_get,
+  ):
+    diff = coordinator._diff_with_unifi_check(None, _ACTIVE_WIFI_RES, before_window)
+  mock_get.assert_not_called()
+  assert not diff.has_changes
+
+
+async def test_unifi_diff_skipped_when_hub_unavailable(coordinator):
+  diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=None, key=None)
+  assert not diff.has_changes
+
+
+async def test_unifi_diff_key_check_skipped_when_actual_key_none(coordinator):
+  # Controller doesn't expose passphrase — only enabled state checked.
+  diff = _unifi_diff(coordinator, _ACTIVE_WIFI_RES, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key=None)
+  assert not diff.has_changes
+
+
+async def test_unifi_diff_preserves_original_diff_flags(coordinator):
+  # Even when UniFi state is clean, original checkin_needed from the base diff is kept.
+  old_inactive = ReservationData(wifi_ssid="GuestNet", wifi_key="correct-key")  # no dates → not active
+  diff = _unifi_diff(coordinator, old_inactive, _ACTIVE_WIFI_RES, _DURING_WINDOW, enabled=True, key="correct-key")
+  assert diff.checkin_needed  # from reservation_diff: old not active, new is active