|
@@ -24,7 +24,7 @@ from .const import (
|
|
|
SYNC_MODE_API,
|
|
SYNC_MODE_API,
|
|
|
SYNC_MODE_CALDAV,
|
|
SYNC_MODE_CALDAV,
|
|
|
)
|
|
)
|
|
|
-from .models import ApiStatusData, ReservationData
|
|
|
|
|
|
|
+from .models import ApiStatusData, OverrideData, ReservationData
|
|
|
from dataclasses import fields as dataclass_fields
|
|
from dataclasses import fields as dataclass_fields
|
|
|
from .store import ReservationStore
|
|
from .store import ReservationStore
|
|
|
|
|
|
|
@@ -38,6 +38,7 @@ class CoordinatorData:
|
|
|
current_last_refresh: datetime | None
|
|
current_last_refresh: datetime | None
|
|
|
next_last_refresh: datetime | None
|
|
next_last_refresh: datetime | None
|
|
|
api_status: ApiStatusData | None = None
|
|
api_status: ApiStatusData | None = None
|
|
|
|
|
+ override_active: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
@@ -56,6 +57,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
self._guest_mode_switch: Any = None # injected by switch.py after platform setup
|
|
self._guest_mode_switch: Any = None # injected by switch.py after platform setup
|
|
|
self._current_last_refresh: datetime | None = None
|
|
self._current_last_refresh: datetime | None = None
|
|
|
self._next_last_refresh: datetime | None = None
|
|
self._next_last_refresh: datetime | None = None
|
|
|
|
|
+ self._override: OverrideData | None = None
|
|
|
|
|
|
|
|
async def async_setup(self) -> None:
|
|
async def async_setup(self) -> None:
|
|
|
await self.store.async_load()
|
|
await self.store.async_load()
|
|
@@ -107,14 +109,25 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
self._current_last_refresh = now
|
|
self._current_last_refresh = now
|
|
|
self._next_last_refresh = now
|
|
self._next_last_refresh = now
|
|
|
|
|
|
|
|
- new_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
|
|
|
|
|
- new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
|
|
|
|
|
|
|
+ store_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
|
|
|
|
|
+ store_next = self.store.get(STORE_NEXT) or self._empty_reservation()
|
|
|
|
|
+
|
|
|
|
|
+ if self._override is not None:
|
|
|
|
|
+ displayed_next = self._override.next if self._override.next is not None else store_next
|
|
|
|
|
+ return CoordinatorData(
|
|
|
|
|
+ current=self._override.current,
|
|
|
|
|
+ next=displayed_next,
|
|
|
|
|
+ current_last_refresh=self._current_last_refresh,
|
|
|
|
|
+ next_last_refresh=self._next_last_refresh,
|
|
|
|
|
+ api_status=api_status,
|
|
|
|
|
+ override_active=True,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- self._reschedule_mode_sync(new_current, new_next)
|
|
|
|
|
|
|
+ self._reschedule_mode_sync(store_current, store_next)
|
|
|
|
|
|
|
|
return CoordinatorData(
|
|
return CoordinatorData(
|
|
|
- current=new_current,
|
|
|
|
|
- next=new_next,
|
|
|
|
|
|
|
+ current=store_current,
|
|
|
|
|
+ next=store_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,
|
|
|
api_status=api_status,
|
|
api_status=api_status,
|
|
@@ -167,51 +180,55 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
return "pending"
|
|
return "pending"
|
|
|
return None
|
|
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().
|
|
|
|
|
|
|
+ async def async_reconcile_from_store(self, *, _startup: bool = False) -> None:
|
|
|
|
|
+ """Load store state, apply elapsed transitions, set room state, reschedule mode sync.
|
|
|
|
|
+
|
|
|
|
|
+ Called at startup (via async_post_setup_reconcile) and when leaving override mode.
|
|
|
|
|
+ _startup=True uses direct state mutation on the guest mode switch instead of
|
|
|
|
|
+ async_turn_on/off, because HA state writes are deferred until commit_state().
|
|
|
"""
|
|
"""
|
|
|
current = self.store.get(STORE_CURRENT) or self._empty_reservation()
|
|
current = self.store.get(STORE_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()
|
|
|
- data_changed = False
|
|
|
|
|
|
|
|
|
|
if self._is_mode_sync_on():
|
|
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"):
|
|
if self._reservation_window_state(nxt) in ("active", "elapsed"):
|
|
|
await self.store.async_set(STORE_CURRENT, nxt)
|
|
await self.store.async_set(STORE_CURRENT, nxt)
|
|
|
self._current_last_refresh = dt_util.utcnow()
|
|
self._current_last_refresh = dt_util.utcnow()
|
|
|
current = nxt
|
|
current = nxt
|
|
|
- data_changed = True
|
|
|
|
|
|
|
|
|
|
state = self._reservation_window_state(current)
|
|
state = self._reservation_window_state(current)
|
|
|
-
|
|
|
|
|
|
|
+ guest_on: bool | None = None
|
|
|
if state == "elapsed":
|
|
if state == "elapsed":
|
|
|
await self.store.async_set(STORE_CURRENT, None)
|
|
await self.store.async_set(STORE_CURRENT, None)
|
|
|
self._current_last_refresh = dt_util.utcnow()
|
|
self._current_last_refresh = dt_util.utcnow()
|
|
|
current = self._empty_reservation()
|
|
current = self._empty_reservation()
|
|
|
- if self._guest_mode_switch is not None:
|
|
|
|
|
- self._guest_mode_switch._state = False
|
|
|
|
|
- data_changed = True
|
|
|
|
|
|
|
+ guest_on = False
|
|
|
elif state == "active":
|
|
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 self._empty_reservation()
|
|
|
|
|
- new_next = self.store.get(STORE_NEXT) or self._empty_reservation()
|
|
|
|
|
- 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,
|
|
|
|
|
- ))
|
|
|
|
|
|
|
+ guest_on = True
|
|
|
|
|
+
|
|
|
|
|
+ if self._guest_mode_switch is not None and guest_on is not None:
|
|
|
|
|
+ if _startup:
|
|
|
|
|
+ self._guest_mode_switch._state = guest_on
|
|
|
|
|
+ elif guest_on:
|
|
|
|
|
+ await self._guest_mode_switch.async_turn_on()
|
|
|
|
|
+ else:
|
|
|
|
|
+ await self._guest_mode_switch.async_turn_off()
|
|
|
|
|
+
|
|
|
|
|
+ real_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
|
|
|
|
|
+ real_next = self.store.get(STORE_NEXT) or self._empty_reservation()
|
|
|
|
|
+ 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,
|
|
|
|
|
+ ))
|
|
|
|
|
|
|
|
|
|
+ async def async_post_setup_reconcile(self) -> None:
|
|
|
|
|
+ """Called from async_setup_entry after all platforms are set up and switch states
|
|
|
|
|
+ are restored. Reconciles room state from the store, then commits the guest mode
|
|
|
|
|
+ switch state in a single HA write."""
|
|
|
|
|
+ await self.async_reconcile_from_store(_startup=True)
|
|
|
if self._guest_mode_switch is not None:
|
|
if self._guest_mode_switch is not None:
|
|
|
self._guest_mode_switch.commit_state()
|
|
self._guest_mode_switch.commit_state()
|
|
|
|
|
|
|
@@ -273,24 +290,103 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
|
|
|
if self._guest_mode_switch is not None:
|
|
if self._guest_mode_switch is not None:
|
|
|
await self._guest_mode_switch.async_turn_on()
|
|
await self._guest_mode_switch.async_turn_on()
|
|
|
|
|
|
|
|
|
|
+ async def async_start_override(self, override: OverrideData) -> None:
|
|
|
|
|
+ """Activate a temporary override. Runs the full check-in sequence (WiFi + guest mode)."""
|
|
|
|
|
+ self._override = override
|
|
|
|
|
+ self.cancel_mode_sync()
|
|
|
|
|
+ displayed_next = (
|
|
|
|
|
+ override.next if override.next is not None
|
|
|
|
|
+ else self.store.get(STORE_NEXT) or self._empty_reservation()
|
|
|
|
|
+ )
|
|
|
|
|
+ self.async_set_updated_data(CoordinatorData(
|
|
|
|
|
+ current=override.current,
|
|
|
|
|
+ next=displayed_next,
|
|
|
|
|
+ current_last_refresh=self._current_last_refresh,
|
|
|
|
|
+ next_last_refresh=self._next_last_refresh,
|
|
|
|
|
+ api_status=self.data.api_status if self.data else None,
|
|
|
|
|
+ override_active=True,
|
|
|
|
|
+ ))
|
|
|
|
|
+ if isinstance(override.current.wifi_ssid, str) and isinstance(override.current.wifi_key, str):
|
|
|
|
|
+ from .wifi import async_set_wifi_key
|
|
|
|
|
+ try:
|
|
|
|
|
+ await async_set_wifi_key(self.hass, override.current.wifi_ssid, override.current.wifi_key)
|
|
|
|
|
+ except Exception as err:
|
|
|
|
|
+ _LOGGER.warning("Failed to set WiFi key during override: %s", err)
|
|
|
|
|
+ if self._guest_mode_switch is not None:
|
|
|
|
|
+ await self._guest_mode_switch.async_turn_on()
|
|
|
|
|
+
|
|
|
|
|
+ async def async_end_override(self) -> None:
|
|
|
|
|
+ """Clear the active override and reconcile room state from the store."""
|
|
|
|
|
+ self._override = None
|
|
|
|
|
+ await self.async_reconcile_from_store()
|
|
|
|
|
+
|
|
|
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."""
|
|
|
|
|
- stored = self.store.get(target) or self._empty_reservation()
|
|
|
|
|
- updated = stored.apply_partial(partial)
|
|
|
|
|
- await self.store.async_set(target, updated)
|
|
|
|
|
|
|
+ """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()
|
|
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()
|
|
|
|
|
+ )
|
|
|
|
|
+ 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,
|
|
|
|
|
+ api_status=self.data.api_status if self.data else None,
|
|
|
|
|
+ override_active=True,
|
|
|
|
|
+ ))
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ stored = self.store.get(target) or self._empty_reservation()
|
|
|
|
|
+ await self.store.async_set(target, stored.apply_partial(partial))
|
|
|
if target == STORE_CURRENT:
|
|
if target == STORE_CURRENT:
|
|
|
self._current_last_refresh = now
|
|
self._current_last_refresh = now
|
|
|
else:
|
|
else:
|
|
|
self._next_last_refresh = now
|
|
self._next_last_refresh = now
|
|
|
|
|
|
|
|
- self.async_set_updated_data(CoordinatorData(
|
|
|
|
|
- current=self.store.get(STORE_CURRENT) or self._empty_reservation(),
|
|
|
|
|
- next=self.store.get(STORE_NEXT) or self._empty_reservation(),
|
|
|
|
|
- current_last_refresh=self._current_last_refresh,
|
|
|
|
|
- next_last_refresh=self._next_last_refresh,
|
|
|
|
|
- ))
|
|
|
|
|
|
|
+ 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()
|
|
|
|
|
+ )
|
|
|
|
|
+ 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,
|
|
|
|
|
+ api_status=self.data.api_status if self.data else None,
|
|
|
|
|
+ override_active=True,
|
|
|
|
|
+ ))
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.async_set_updated_data(CoordinatorData(
|
|
|
|
|
+ current=self.store.get(STORE_CURRENT) or self._empty_reservation(),
|
|
|
|
|
+ next=self.store.get(STORE_NEXT) or self._empty_reservation(),
|
|
|
|
|
+ current_last_refresh=self._current_last_refresh,
|
|
|
|
|
+ next_last_refresh=self._next_last_refresh,
|
|
|
|
|
+ ))
|
|
|
|
|
|
|
|
def cancel_mode_sync(self) -> None:
|
|
def cancel_mode_sync(self) -> None:
|
|
|
for cancel in self._mode_sync_handles:
|
|
for cancel in self._mode_sync_handles:
|