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