Răsfoiți Sursa

Add Use UniFi switch and SSID enable/disable on check-in/out

Adds a Use UniFi toggle to the Room State device (defaults on, restores
across restarts). When enabled, check-in now enables the SSID via
WlanEnableRequest before setting the password; check-out disables it
after clearing the current slot. Both actions are no-ops when the switch
is off. Extracts _find_wlan helper in wifi.py to share lookup logic.
ZiB 4 luni în urmă
părinte
comite
064f133060

+ 38 - 9
custom_components/room_cal_ha/coordinator.py

@@ -195,17 +195,34 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       await self._guest_mode_switch.async_turn_off()
       await self._guest_mode_switch.async_turn_off()
 
 
   async def _async_run_check_in_actions(self, res: ReservationData) -> None:
   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."""
+    """Run WiFi updates and guest mode activation. No-op when mode_sync is off."""
     if not self._is_mode_sync_on():
     if not self._is_mode_sync_on():
       return
       return
-    if isinstance(res.wifi_ssid, str) and isinstance(res.wifi_key, str):
-      from .wifi import async_set_wifi_key
+    if self._is_use_unifi_on() and isinstance(res.wifi_ssid, str):
+      from .wifi import async_set_ssid_enabled, async_set_wifi_key
       try:
       try:
-        await async_set_wifi_key(self.hass, res.wifi_ssid, res.wifi_key)
+        await async_set_ssid_enabled(self.hass, res.wifi_ssid, True)
       except Exception as err:
       except Exception as err:
-        _LOGGER.warning("Failed to set WiFi key: %s", err)
+        _LOGGER.warning("Failed to enable SSID '%s': %s", res.wifi_ssid, err)
+      if isinstance(res.wifi_key, str):
+        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)
     await self._async_set_guest_mode(True)
 
 
+  async def _async_run_check_out_actions(self, res: ReservationData) -> None:
+    """Run WiFi SSID disable and guest mode deactivation. No-op when mode_sync is off."""
+    if not self._is_mode_sync_on():
+      return
+    if self._is_use_unifi_on() and isinstance(res.wifi_ssid, str):
+      from .wifi import async_set_ssid_enabled
+      try:
+        await async_set_ssid_enabled(self.hass, res.wifi_ssid, False)
+      except Exception as err:
+        _LOGGER.warning("Failed to disable SSID '%s': %s", res.wifi_ssid, err)
+    await self._async_set_guest_mode(False)
+
   async def async_reconcile_from_store(self, *, _startup: bool = False) -> None:
   async def async_reconcile_from_store(self, *, _startup: bool = False) -> None:
     """Load store state, apply elapsed transitions, set room state, reschedule mode sync.
     """Load store state, apply elapsed transitions, set room state, reschedule mode sync.
 
 
@@ -243,6 +260,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       next=real_next,
       next=real_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=self.data.api_status if self.data else None,
     ))
     ))
 
 
   async def async_post_setup_reconcile(self) -> None:
   async def async_post_setup_reconcile(self) -> None:
@@ -260,6 +278,13 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         return entity.is_on
         return entity.is_on
     return False
     return False
 
 
+  def _is_use_unifi_on(self) -> bool:
+    from .switch import UseUnifiSwitch  # avoid circular at module level
+    for entity in self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id, {}).get("switches", []):
+      if isinstance(entity, UseUnifiSwitch):
+        return entity.is_on
+    return True  # safe default: don't silently skip WiFi if switch isn't found yet
+
   @callback
   @callback
   def _async_handle_check_in(self, _now: datetime) -> None:
   def _async_handle_check_in(self, _now: datetime) -> None:
     self.hass.async_create_task(self._async_do_check_in())
     self.hass.async_create_task(self._async_do_check_in())
@@ -273,6 +298,7 @@ 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.store.async_set(STORE_CURRENT, None)
     self._current_last_refresh = dt_util.utcnow()
     self._current_last_refresh = dt_util.utcnow()
     self.async_set_updated_data(CoordinatorData(
     self.async_set_updated_data(CoordinatorData(
@@ -280,8 +306,9 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       next=self.store.get(STORE_NEXT) or self._empty_reservation(),
       next=self.store.get(STORE_NEXT) or self._empty_reservation(),
       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=self.data.api_status if self.data else None,
     ))
     ))
-    await self._async_set_guest_mode(False)
+    await self._async_run_check_out_actions(departing)
 
 
   async def async_clear_current(self) -> None:
   async def async_clear_current(self) -> None:
     """Clear the Current slot in the store and notify listeners."""
     """Clear the Current slot in the store and notify listeners."""
@@ -306,6 +333,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       next=new_next,
       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,
+      api_status=self.data.api_status if self.data else None,
     ))
     ))
     self._reschedule_mode_sync(next_data, new_next)
     self._reschedule_mode_sync(next_data, new_next)
     # If mode_sync is on and the newly imported record's check-in has already passed,
     # If mode_sync is on and the newly imported record's check-in has already passed,
@@ -326,8 +354,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
       next=displayed_next,
       next=displayed_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,
-
       override_active=True,
       override_active=True,
+      api_status=self.data.api_status if self.data else None,
     ))
     ))
     await self._async_run_check_in_actions(override.current)
     await self._async_run_check_in_actions(override.current)
 
 
@@ -370,8 +398,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         next=displayed_next,
         next=displayed_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,
-  
         override_active=True,
         override_active=True,
+        api_status=self.data.api_status if self.data else None,
       ))
       ))
       return
       return
 
 
@@ -393,8 +421,8 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         next=displayed_next,
         next=displayed_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,
-  
         override_active=True,
         override_active=True,
+        api_status=self.data.api_status if self.data else None,
       ))
       ))
     else:
     else:
       self.async_set_updated_data(CoordinatorData(
       self.async_set_updated_data(CoordinatorData(
@@ -402,6 +430,7 @@ class RoomCalCoordinator(DataUpdateCoordinator[CoordinatorData]):
         next=self.store.get(STORE_NEXT) or self._empty_reservation(),
         next=self.store.get(STORE_NEXT) or self._empty_reservation(),
         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=self.data.api_status if self.data else None,
       ))
       ))
 
 
   def cancel_mode_sync(self) -> None:
   def cancel_mode_sync(self) -> None:

+ 12 - 1
custom_components/room_cal_ha/switch.py

@@ -23,10 +23,11 @@ async def async_setup_entry(
   guest_mode = GuestModeSwitch(coordinator, entry, device_name)
   guest_mode = GuestModeSwitch(coordinator, entry, device_name)
   mode_sync = ModeSyncSwitch(coordinator, entry, device_name)
   mode_sync = ModeSyncSwitch(coordinator, entry, device_name)
   override = OverrideReservationSwitch(coordinator, entry, device_name)
   override = OverrideReservationSwitch(coordinator, entry, device_name)
+  use_unifi = UseUnifiSwitch(coordinator, entry, device_name)
 
 
   coordinator.inject_guest_mode_switch(guest_mode)
   coordinator.inject_guest_mode_switch(guest_mode)
 
 
-  switches = [guest_mode, mode_sync, override]
+  switches = [guest_mode, mode_sync, override, use_unifi]
   hass.data[DOMAIN][entry.entry_id]["switches"] = switches
   hass.data[DOMAIN][entry.entry_id]["switches"] = switches
   async_add_entities(switches)
   async_add_entities(switches)
 
 
@@ -98,6 +99,16 @@ class ModeSyncSwitch(_RoomStateSwitch):
     self._coordinator.cancel_mode_sync()
     self._coordinator.cancel_mode_sync()
 
 
 
 
+class UseUnifiSwitch(_RoomStateSwitch):
+  _attr_name = "Use UniFi"
+  _attr_icon = "mdi:wifi-settings"
+  _state: bool = True  # default on; base async_added_to_hass restores saved state
+
+  def __init__(self, coordinator: RoomCalCoordinator, entry: ConfigEntry, device_name: str) -> None:
+    super().__init__(coordinator, entry, device_name)
+    self._attr_unique_id = f"{entry.entry_id}_use_unifi"
+
+
 class OverrideReservationSwitch(CoordinatorEntity[RoomCalCoordinator], SwitchEntity):
 class OverrideReservationSwitch(CoordinatorEntity[RoomCalCoordinator], SwitchEntity):
   _attr_has_entity_name = True
   _attr_has_entity_name = True
   _attr_name = "Override Reservation"
   _attr_name = "Override Reservation"

+ 25 - 12
custom_components/room_cal_ha/wifi.py

@@ -1,8 +1,8 @@
-"""UniFi WLAN key helper — looks up a WLAN by SSID and sets its password."""
+"""UniFi WLAN helpers — set password and enable/disable SSID by name."""
 from __future__ import annotations
 from __future__ import annotations
 
 
 import aiounifi
 import aiounifi
-from aiounifi.models.wlan import WlanChangePasswordRequest
+from aiounifi.models.wlan import WlanChangePasswordRequest, WlanEnableRequest
 
 
 from homeassistant.core import HomeAssistant
 from homeassistant.core import HomeAssistant
 from homeassistant.exceptions import HomeAssistantError
 from homeassistant.exceptions import HomeAssistantError
@@ -10,19 +10,32 @@ from homeassistant.exceptions import HomeAssistantError
 UNIFI_DOMAIN = "unifi"
 UNIFI_DOMAIN = "unifi"
 
 
 
 
-async def async_set_wifi_key(hass: HomeAssistant, ssid: str, key: str) -> None:
-  """Set the WPA2 password for a UniFi WLAN identified by its SSID name."""
+def _find_wlan(hass: HomeAssistant, ssid: str):
+  """Return (hub, wlan_id) for the first matching SSID across loaded hubs, or raise."""
   for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN):
   for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN):
     hub = config_entry.runtime_data
     hub = config_entry.runtime_data
     if not hub.available:
     if not hub.available:
       continue
       continue
     for wlan_id, wlan in hub.api.wlans.items():
     for wlan_id, wlan in hub.api.wlans.items():
-      if wlan.name != ssid:
-        continue
-      try:
-        await hub.api.request(WlanChangePasswordRequest.create(wlan_id, key))
-      except aiounifi.AiounifiException as err:
-        raise HomeAssistantError(f"Failed to set WiFi key for '{ssid}': {err}") from err
-      return
-
+      if wlan.name == ssid:
+        return hub, wlan_id
   raise HomeAssistantError(f"UniFi WLAN '{ssid}' not found in any loaded hub")
   raise HomeAssistantError(f"UniFi WLAN '{ssid}' not found in any loaded hub")
+
+
+async def async_set_wifi_key(hass: HomeAssistant, ssid: str, key: str) -> None:
+  """Set the WPA2 password for a UniFi WLAN identified by its SSID name."""
+  hub, wlan_id = _find_wlan(hass, ssid)
+  try:
+    await hub.api.request(WlanChangePasswordRequest.create(wlan_id, key))
+  except aiounifi.AiounifiException as err:
+    raise HomeAssistantError(f"Failed to set WiFi key for '{ssid}': {err}") from err
+
+
+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)
+  try:
+    await hub.api.request(WlanEnableRequest.create(wlan_id, enabled))
+  except aiounifi.AiounifiException as err:
+    action = "enable" if enabled else "disable"
+    raise HomeAssistantError(f"Failed to {action} SSID '{ssid}': {err}") from err

+ 82 - 0
tests/test_coordinator.py

@@ -291,10 +291,12 @@ async def test_check_in_calls_wifi_key(hass: HomeAssistant, coordinator: RoomCal
   with (
   with (
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     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,
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", new_callable=AsyncMock) as mock_wifi,
+    patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
   ):
   ):
     await coordinator._async_do_check_in()
     await coordinator._async_do_check_in()
   coordinator.cancel_mode_sync()
   coordinator.cancel_mode_sync()
 
 
+  mock_ssid.assert_awaited_once_with(hass, "GuestNet", True)
   mock_wifi.assert_awaited_once_with(hass, "GuestNet", "securepass")
   mock_wifi.assert_awaited_once_with(hass, "GuestNet", "securepass")
   mock_switch.async_turn_on.assert_awaited_once()
   mock_switch.async_turn_on.assert_awaited_once()
 
 
@@ -319,6 +321,26 @@ async def test_check_in_skips_wifi_when_key_none(coordinator: RoomCalCoordinator
   mock_wifi.assert_not_awaited()
   mock_wifi.assert_not_awaited()
 
 
 
 
+async def test_check_in_skips_wifi_when_use_unifi_off(hass: HomeAssistant, coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
+  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=True),
+    patch.object(coordinator, "_is_use_unifi_on", return_value=False),
+    patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
+    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_ssid.assert_not_awaited()
+  mock_wifi.assert_not_awaited()
+  mock_switch.async_turn_on.assert_awaited_once()
+
+
 async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomCalCoordinator):
 async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomCalCoordinator):
   await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
   await coordinator.store.async_set(STORE_NEXT, _WIFI_NEXT)
   mock_switch = MagicMock()
   mock_switch = MagicMock()
@@ -327,6 +349,7 @@ async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomC
 
 
   with (
   with (
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
     patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock),
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")),
     patch("custom_components.room_cal_ha.wifi.async_set_wifi_key", side_effect=HomeAssistantError("hub down")),
   ):
   ):
     await coordinator._async_do_check_in()
     await coordinator._async_do_check_in()
@@ -335,6 +358,65 @@ async def test_check_in_wifi_error_still_activates_guest_mode(coordinator: RoomC
   mock_switch.async_turn_on.assert_awaited_once()
   mock_switch.async_turn_on.assert_awaited_once()
 
 
 
 
+# ---------------------------------------------------------------------------
+# Check-out SSID disable behavior
+# ---------------------------------------------------------------------------
+
+async def test_check_out_disables_ssid(hass: HomeAssistant, coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_off = AsyncMock()
+  coordinator._guest_mode_switch = mock_switch
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
+  ):
+    await coordinator._async_do_check_out()
+
+  mock_ssid.assert_awaited_once_with(hass, "GuestNet", False)
+  mock_switch.async_turn_off.assert_awaited_once()
+
+
+async def test_check_out_skips_ssid_when_ssid_none(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, ReservationData(wifi_ssid=None))
+  coordinator._guest_mode_switch = None
+
+  with patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid:
+    await coordinator._async_do_check_out()
+
+  mock_ssid.assert_not_awaited()
+
+
+async def test_check_out_skips_ssid_when_use_unifi_off(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
+  coordinator._guest_mode_switch = None
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch.object(coordinator, "_is_use_unifi_on", return_value=False),
+    patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", new_callable=AsyncMock) as mock_ssid,
+  ):
+    await coordinator._async_do_check_out()
+
+  mock_ssid.assert_not_awaited()
+
+
+async def test_check_out_ssid_error_still_deactivates_guest_mode(coordinator: RoomCalCoordinator):
+  await coordinator.store.async_set(STORE_CURRENT, _WIFI_NEXT)
+  mock_switch = MagicMock()
+  mock_switch.async_turn_off = AsyncMock()
+  coordinator._guest_mode_switch = mock_switch
+
+  with (
+    patch.object(coordinator, "_is_mode_sync_on", return_value=True),
+    patch("custom_components.room_cal_ha.wifi.async_set_ssid_enabled", side_effect=HomeAssistantError("hub down")),
+  ):
+    await coordinator._async_do_check_out()
+
+  mock_switch.async_turn_off.assert_awaited_once()
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # async_start_override / async_end_override
 # async_start_override / async_end_override
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------

+ 30 - 1
tests/test_switches.py

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import pytest
 import pytest
 
 
-from custom_components.room_cal_ha.switch import GuestModeSwitch, ModeSyncSwitch, OverrideReservationSwitch
+from custom_components.room_cal_ha.switch import GuestModeSwitch, ModeSyncSwitch, OverrideReservationSwitch, UseUnifiSwitch
 
 
 
 
 def _guest_mode():
 def _guest_mode():
@@ -144,3 +144,32 @@ async def test_override_switch_turn_on_is_noop():
   s = _override_switch()
   s = _override_switch()
   await s.async_turn_on()
   await s.async_turn_on()
   s.coordinator.async_end_override.assert_not_awaited()
   s.coordinator.async_end_override.assert_not_awaited()
+
+
+# ---------------------------------------------------------------------------
+# UseUnifiSwitch
+# ---------------------------------------------------------------------------
+
+def _use_unifi():
+  s = object.__new__(UseUnifiSwitch)
+  s._coordinator = MagicMock()
+  s.async_write_ha_state = MagicMock()
+  return s
+
+
+def test_use_unifi_default_state_is_true():
+  s = _use_unifi()
+  assert s.is_on is True
+
+
+async def test_use_unifi_turn_off_sets_false():
+  s = _use_unifi()
+  await s.async_turn_off()
+  assert s.is_on is False
+
+
+async def test_use_unifi_turn_on_restores_true():
+  s = _use_unifi()
+  s._state = False
+  await s.async_turn_on()
+  assert s.is_on is True

+ 43 - 2
tests/test_wifi.py

@@ -1,4 +1,4 @@
-"""Unit tests for async_set_wifi_key."""
+"""Unit tests for wifi helpers."""
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import aiounifi
 import aiounifi
@@ -6,7 +6,7 @@ import pytest
 
 
 from homeassistant.exceptions import HomeAssistantError
 from homeassistant.exceptions import HomeAssistantError
 
 
-from custom_components.room_cal_ha.wifi import async_set_wifi_key
+from custom_components.room_cal_ha.wifi import async_set_ssid_enabled, async_set_wifi_key
 
 
 
 
 def _wlan(name: str) -> MagicMock:
 def _wlan(name: str) -> MagicMock:
@@ -93,3 +93,44 @@ async def test_api_error_raises_ha_error():
   hub.api.request = AsyncMock(side_effect=aiounifi.AiounifiException("bad"))
   hub.api.request = AsyncMock(side_effect=aiounifi.AiounifiException("bad"))
   with pytest.raises(HomeAssistantError, match="Failed to set WiFi key"):
   with pytest.raises(HomeAssistantError, match="Failed to set WiFi key"):
     await async_set_wifi_key(_hass(hub), "GuestNet", "password123")
     await async_set_wifi_key(_hass(hub), "GuestNet", "password123")
+
+
+# ---------------------------------------------------------------------------
+# async_set_ssid_enabled
+# ---------------------------------------------------------------------------
+
+async def test_ssid_enable_calls_request():
+  hub = _hub(wlans={"w1": _wlan("GuestNet")})
+  with patch("custom_components.room_cal_ha.wifi.WlanEnableRequest.create") as mock_create:
+    mock_create.return_value = "enable_req"
+    await async_set_ssid_enabled(_hass(hub), "GuestNet", True)
+  mock_create.assert_called_once_with("w1", True)
+  hub.api.request.assert_awaited_once_with("enable_req")
+
+
+async def test_ssid_disable_calls_request():
+  hub = _hub(wlans={"w1": _wlan("GuestNet")})
+  with patch("custom_components.room_cal_ha.wifi.WlanEnableRequest.create") as mock_create:
+    mock_create.return_value = "disable_req"
+    await async_set_ssid_enabled(_hass(hub), "GuestNet", False)
+  mock_create.assert_called_once_with("w1", False)
+  hub.api.request.assert_awaited_once_with("disable_req")
+
+
+async def test_ssid_not_found_raises():
+  with pytest.raises(HomeAssistantError, match="not found"):
+    await async_set_ssid_enabled(_hass(), "GuestNet", True)
+
+
+async def test_ssid_enable_api_error_raises():
+  hub = _hub(wlans={"w1": _wlan("GuestNet")})
+  hub.api.request = AsyncMock(side_effect=aiounifi.AiounifiException("bad"))
+  with pytest.raises(HomeAssistantError, match="enable"):
+    await async_set_ssid_enabled(_hass(hub), "GuestNet", True)
+
+
+async def test_ssid_disable_api_error_raises():
+  hub = _hub(wlans={"w1": _wlan("GuestNet")})
+  hub.api.request = AsyncMock(side_effect=aiounifi.AiounifiException("bad"))
+  with pytest.raises(HomeAssistantError, match="disable"):
+    await async_set_ssid_enabled(_hass(hub), "GuestNet", False)