Просмотр исходного кода

Gate all guest mode and WiFi changes behind mode_sync

Introduce _async_set_guest_mode and _async_run_check_in_actions as the
single callsites for room state changes. All callers — check-in timer,
check-out timer, override start, reconcile, and promote — now flow
through these helpers, which return early when mode_sync is off.

Previously, async_start_override bypassed the mode_sync toggle entirely,
meaning override/test mode could flip guest mode and WiFi passwords even
when the user had disabled mode_sync to prevent automated state changes.
ZiB 4 месяцев назад
Родитель
Сommit
1e6911449f
2 измененных файлов с 153 добавлено и 33 удалено
  1. 32 29
      custom_components/room_cal_ha/coordinator.py
  2. 121 4
      tests/test_coordinator.py

+ 32 - 29
custom_components/room_cal_ha/coordinator.py

@@ -172,6 +172,31 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       return "pending"
     return None
 
+  async def _async_set_guest_mode(self, on: bool, *, _startup: bool = False) -> None:
+    """Set guest mode on/off. No-op when mode_sync is off."""
+    if not self._is_mode_sync_on():
+      return
+    if self._guest_mode_switch is None:
+      return
+    if _startup:
+      self._guest_mode_switch._state = on
+    elif on:
+      await self._guest_mode_switch.async_turn_on()
+    else:
+      await self._guest_mode_switch.async_turn_off()
+
+  async def _async_run_check_in_actions(self, res: ReservationData) -> None:
+    """Run WiFi key update and guest mode activation. No-op when mode_sync is off."""
+    if not self._is_mode_sync_on():
+      return
+    if isinstance(res.wifi_ssid, str) and isinstance(res.wifi_key, str):
+      from .wifi import async_set_wifi_key
+      try:
+        await async_set_wifi_key(self.hass, res.wifi_ssid, res.wifi_key)
+      except Exception as err:
+        _LOGGER.warning("Failed to set WiFi key: %s", err)
+    await self._async_set_guest_mode(True)
+
   async def async_reconcile_from_store(self, *, _startup: bool = False) -> None:
     """Load store state, apply elapsed transitions, set room state, reschedule mode sync.
 
@@ -198,13 +223,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       elif state == "active":
         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()
+      if guest_on is not None:
+        await self._async_set_guest_mode(guest_on, _startup=_startup)
 
     real_current = self.store.get(STORE_CURRENT) or self._empty_reservation()
     real_next = self.store.get(STORE_NEXT) or self._empty_reservation()
@@ -237,15 +257,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
   async def _async_do_check_in(self) -> None:
     await self.async_promote_next_to_current()
-    current = self.data.current
-    if isinstance(current.wifi_ssid, str) and isinstance(current.wifi_key, str):
-      from .wifi import async_set_wifi_key
-      try:
-        await async_set_wifi_key(self.hass, current.wifi_ssid, current.wifi_key)
-      except Exception as err:
-        _LOGGER.warning("Failed to set WiFi key on check-in: %s", err)
-    if self._guest_mode_switch is not None:
-      await self._guest_mode_switch.async_turn_on()
+    await self._async_run_check_in_actions(self.data.current)
 
   @callback
   def _async_handle_check_out(self, _now: datetime) -> None:
@@ -260,8 +272,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       current_last_refresh=self._current_last_refresh,
       next_last_refresh=self._next_last_refresh,
     ))
-    if self._guest_mode_switch is not None:
-      await self._guest_mode_switch.async_turn_off()
+    await self._async_set_guest_mode(False)
 
   async def async_promote_next_to_current(self) -> None:
     """Copy the Next slot into Current and notify listeners. Next is left unchanged."""
@@ -278,9 +289,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
     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()
+    if self._reservation_window_state(next_data) == "active":
+      await self._async_set_guest_mode(True)
 
   async def async_start_override(self, override: OverrideData) -> None:
     """Activate a temporary override. Runs the full check-in sequence (WiFi + guest mode)."""
@@ -298,14 +308,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
 
       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()
+    await self._async_run_check_in_actions(override.current)
 
   async def async_end_override(self) -> None:
     """Clear the active override and reconcile room state from the store."""

+ 121 - 4
tests/test_coordinator.py

@@ -11,6 +11,7 @@ from custom_components.room_cal_ha.const import (
   CONF_API_BASE_URL,
   CONF_CALDAV_URL,
   CONF_CALENDAR_NAME,
+  CONF_RESERVATION_DEFAULTS,
   CONF_SYNC_MODE,
   DOMAIN,
   STORE_CURRENT,
@@ -287,8 +288,12 @@ async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCal
   mock_switch.async_turn_on = AsyncMock()
   coordinator._guest_mode_switch = mock_switch
 
-  with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
+  ):
     await coordinator._async_do_check_in()
+  coordinator.cancel_mode_sync()
 
   mock_wifi.assert_awaited_once_with(hass, "GuestNet", "securepass")
   mock_switch.async_turn_on.assert_awaited_once()
@@ -320,8 +325,12 @@ async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomC
   mock_switch.async_turn_on = AsyncMock()
   coordinator._guest_mode_switch = mock_switch
 
-  with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")):
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")),
+  ):
     await coordinator._async_do_check_in()
+  coordinator.cancel_mode_sync()
 
   mock_switch.async_turn_on.assert_awaited_once()
 
@@ -360,18 +369,41 @@ async def test_start_override_turns_on_guest_mode(coordinator: RoomCalCoordinato
   mock_switch = MagicMock()
   mock_switch.async_turn_on = AsyncMock()
   coordinator._guest_mode_switch = mock_switch
-  await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  with patch.object(coordinator, "_is_mode_sync_on", return_value=True):
+    await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
   mock_switch.async_turn_on.assert_awaited_once()
 
 
+async def test_start_override_does_not_turn_on_guest_mode_when_mode_sync_off(coordinator: RoomCalCoordinator):
+  mock_switch = MagicMock()
+  mock_switch.async_turn_on = AsyncMock()
+  coordinator._guest_mode_switch = mock_switch
+  with patch.object(coordinator, "_is_mode_sync_on", return_value=False):
+    await coordinator.async_start_override(OverrideData(current=_OVERRIDE_RES))
+  mock_switch.async_turn_on.assert_not_awaited()
+
+
 async def test_start_override_calls_wifi_when_credentials_present(hass: HomeAssistant, coordinator: RoomCalCoordinator):
   coordinator._guest_mode_switch = MagicMock()
   coordinator._guest_mode_switch.async_turn_on = AsyncMock()
-  with patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi:
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
+  ):
     await coordinator.async_start_override(OverrideData(current=_WIFI_OVERRIDE))
   mock_wifi.assert_awaited_once_with(hass, "GuestNet", "secret")
 
 
+async def test_start_override_skips_wifi_when_mode_sync_off(coordinator: RoomCalCoordinator):
+  coordinator._guest_mode_switch = None
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=False),
+    patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
+  ):
+    await coordinator.async_start_override(OverrideData(current=_WIFI_OVERRIDE))
+  mock_wifi.assert_not_awaited()
+
+
 async def test_start_override_skips_wifi_when_ssid_missing(coordinator: RoomCalCoordinator):
   coordinator._guest_mode_switch = None
   no_ssid = ReservationData(wifi_ssid=None, wifi_key="secret")
@@ -394,6 +426,91 @@ async def test_start_override_uses_override_next_when_provided(coordinator: Room
   assert coordinator.data.next.guest_name == "Override Next"
 
 
+async def test_start_override_applies_defaults_to_current(hass: HomeAssistant):
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={},
+    options={
+      CONF_SYNC_MODE: SYNC_MODE_MANUAL,
+      CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID", "check_out_time": "11:00:00"},
+    },
+  )
+  entry.add_to_hass(hass)
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+  coord._guest_mode_switch = None
+
+  partial_override = ReservationData(guest_name="Override Guest")
+  await coord.async_start_override(OverrideData(current=partial_override))
+
+  assert coord.data.current.guest_name == "Override Guest"
+  assert coord.data.current.wifi_ssid == "DefaultSSID"
+  assert coord.data.current.check_out_time == time(11, 0)
+
+
+async def test_start_override_defaults_do_not_overwrite_explicit_fields(hass: HomeAssistant):
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={},
+    options={
+      CONF_SYNC_MODE: SYNC_MODE_MANUAL,
+      CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"},
+    },
+  )
+  entry.add_to_hass(hass)
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+  coord._guest_mode_switch = None
+
+  explicit_override = ReservationData(guest_name="Override Guest", wifi_ssid="ExplicitSSID")
+  await coord.async_start_override(OverrideData(current=explicit_override))
+
+  assert coord.data.current.wifi_ssid == "ExplicitSSID"
+
+
+async def test_refresh_during_override_applies_defaults_to_current(hass: HomeAssistant):
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={},
+    options={
+      CONF_SYNC_MODE: SYNC_MODE_MANUAL,
+      CONF_RESERVATION_DEFAULTS: {"door_code": "9999"},
+    },
+  )
+  entry.add_to_hass(hass)
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+  coord._guest_mode_switch = None
+
+  partial_override = ReservationData(guest_name="Override Guest")
+  await coord.async_start_override(OverrideData(current=partial_override))
+  await coord.async_refresh()
+
+  assert coord.data.current.guest_name == "Override Guest"
+  assert coord.data.current.door_code == "9999"
+
+
+async def test_start_override_applies_defaults_to_store_next(hass: HomeAssistant):
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={},
+    options={
+      CONF_SYNC_MODE: SYNC_MODE_MANUAL,
+      CONF_RESERVATION_DEFAULTS: {"wifi_ssid": "DefaultSSID"},
+    },
+  )
+  entry.add_to_hass(hass)
+  coord = RoomCalCoordinator(hass, entry)
+  await coord.async_setup()
+  coord._guest_mode_switch = None
+
+  await coord.store.async_set(STORE_NEXT, ReservationData(guest_name="Next Guest"))
+  await coord.async_start_override(OverrideData(current=_OVERRIDE_RES))
+
+  assert coord.data.next.guest_name == "Next Guest"
+  assert coord.data.next.wifi_ssid == "DefaultSSID"
+
+
 async def test_end_override_clears_override_active(coordinator: RoomCalCoordinator):
   coordinator._guest_mode_switch = None
   coordinator._override = OverrideData(current=_OVERRIDE_RES)