Sfoglia il codice sorgente

Remove redundant Clear Current / Promote from Next buttons, rename override buttons, drop UniFi status sensor

Override system now covers what those buttons did. Override Reservation and
Test Next Guest get new icons; Test Next Guest / Override Empty entities are
renamed to "Override to Next" / "Override to Empty". The UniFi availability
sensor is replaced by clear warning logs when a configured UniFi or zLock
Slots action can't fire because the hub/lock isn't actually available.
ZiB 3 mesi fa
parent
commit
d3e647d713

+ 2 - 54
custom_components/room_cal_ha/binary_sensor.py

@@ -4,8 +4,8 @@ from datetime import timedelta
 
 from homeassistant.components.binary_sensor import BinarySensorEntity
 from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import EVENT_COMPONENT_LOADED, EntityCategory
-from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
 from homeassistant.helpers.device_registry import DeviceInfo
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -22,8 +22,6 @@ from .const import (
 )
 from .coordinator import CoordinatorData, RoomCalCoordinator
 
-UNIFI_DOMAIN = "unifi"
-
 
 async def async_setup_entry(
   hass: HomeAssistant,
@@ -42,7 +40,6 @@ async def async_setup_entry(
   )
   async_add_entities([
     StaleSensor(coordinator, entry, room_state_device),
-    UnifiAvailableSensor(coordinator, entry, room_state_device),
     ArrivedSensor(coordinator, entry, current_device),
     DepartedSensor(coordinator, entry, current_device),
   ])
@@ -82,55 +79,6 @@ class StaleSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEntity):
     return False
 
 
-class UnifiAvailableSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEntity):
-  """Reflects UniFi hub availability, independent of the room_cal_ha coordinator.
-
-  This entity's state comes entirely from the `unifi` integration's config entries,
-  not from `coordinator.data` — so its availability must not be tied to
-  `coordinator.last_update_success` (the CoordinatorEntity default). A failed room_cal
-  backend poll has no bearing on whether UniFi itself is reachable.
-  """
-
-  _attr_has_entity_name = True
-  _attr_translation_key = "unifi_available"
-  _attr_icon = "mdi:wifi-check"
-  _attr_entity_category = EntityCategory.DIAGNOSTIC
-
-  def __init__(
-    self,
-    coordinator: RoomCalCoordinator,
-    entry: ConfigEntry,
-    device_info: DeviceInfo,
-  ) -> None:
-    super().__init__(coordinator)
-    self._entry = entry
-    self._attr_unique_id = f"{entry.entry_id}_unifi_available"
-    self._attr_device_info = device_info
-
-  async def async_added_to_hass(self) -> None:
-    await super().async_added_to_hass()
-    self.async_on_remove(
-      self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, self._handle_component_loaded)
-    )
-
-  @callback
-  def _handle_component_loaded(self, event: Event) -> None:
-    if event.data.get("component") == UNIFI_DOMAIN:
-      self.async_write_ha_state()
-
-  @property
-  def available(self) -> bool:
-    return True
-
-  @property
-  def is_on(self) -> bool:
-    for config_entry in self.hass.config_entries.async_loaded_entries(UNIFI_DOMAIN):
-      hub = config_entry.runtime_data
-      if hub.available:
-        return True
-    return False
-
-
 class ArrivedSensor(CoordinatorEntity[RoomCalCoordinator], BinarySensorEntity):
   _attr_has_entity_name = True
   _attr_translation_key = "arrived"

+ 1 - 45
custom_components/room_cal_ha/button.py

@@ -25,8 +25,6 @@ async def async_setup_entry(
   device_name = f"{entry.data.get(CONF_ROOM_NAME, entry.title)} Room State"
   async_add_entities([
     ForceRefreshButton(coordinator, entry, device_name),
-    PromoteFromNextButton(coordinator, entry, device_name),
-    ClearCurrentButton(coordinator, entry, device_name),
     TestNextGuestButton(coordinator, entry, device_name),
     OverrideEmptyButton(coordinator, entry, device_name),
     GuestArrivedButton(coordinator, entry, device_name),
@@ -56,52 +54,10 @@ class ForceRefreshButton(ButtonEntity):
     await self._coordinator.async_refresh()
 
 
-class PromoteFromNextButton(ButtonEntity):
-  _attr_has_entity_name = True
-  _attr_translation_key = "promote_from_next"
-
-  def __init__(
-    self,
-    coordinator: RoomCalCoordinator,
-    entry: ConfigEntry,
-    device_name: str,
-  ) -> None:
-    self._coordinator = coordinator
-    self._attr_unique_id = f"{entry.entry_id}_promote_from_next"
-    self._attr_device_info = DeviceInfo(
-      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
-      name=device_name,
-    )
-
-  async def async_press(self) -> None:
-    await self._coordinator.async_promote_next_to_current()
-
-
-class ClearCurrentButton(ButtonEntity):
-  _attr_has_entity_name = True
-  _attr_translation_key = "clear_current"
-
-  def __init__(
-    self,
-    coordinator: RoomCalCoordinator,
-    entry: ConfigEntry,
-    device_name: str,
-  ) -> None:
-    self._coordinator = coordinator
-    self._attr_unique_id = f"{entry.entry_id}_clear_current"
-    self._attr_device_info = DeviceInfo(
-      identifiers={(DOMAIN, f"{entry.entry_id}_room_state")},
-      name=device_name,
-    )
-
-  async def async_press(self) -> None:
-    await self._coordinator.async_clear_current()
-
-
 class TestNextGuestButton(CoordinatorEntity[RoomCalCoordinator], ButtonEntity):
   _attr_has_entity_name = True
   _attr_translation_key = "test_next_guest"
-  _attr_icon = "mdi:test-tube"
+  _attr_icon = "mdi:skip-next"
   __test__ = False  # prevent pytest from collecting this as a test class
 
   def __init__(

+ 20 - 2
custom_components/room_cal_ha/locks.py

@@ -22,8 +22,14 @@ def get_lock_entity_ids(hass: HomeAssistant, entry: ConfigEntry) -> list[str]:
   entity_ids: list[str] = []
   for zlock_entry_id in entry.options.get(CONF_ZLOCK_ENTRIES, []):
     zlock_entry = hass.config_entries.async_get_entry(zlock_entry_id)
-    if zlock_entry is not None:
-      entity_ids.extend(zlock_entry.data.get("lock_entity_ids", []))
+    if zlock_entry is None:
+      _LOGGER.warning(
+        "zLock Slots entry '%s' is configured for this room but no longer exists; "
+        "skipping its locks",
+        zlock_entry_id,
+      )
+      continue
+    entity_ids.extend(zlock_entry.data.get("lock_entity_ids", []))
   return entity_ids
 
 
@@ -33,6 +39,12 @@ async def async_push_lock_codes(hass: HomeAssistant, entry: ConfigEntry, code: s
   lock_entity_ids = get_lock_entity_ids(hass, entry)
   _LOGGER.info("Lock enable: slot=%s locks=%s", slot, lock_entity_ids)
   if not lock_entity_ids or slot is None:
+    if entry.options.get(CONF_ZLOCK_ENTRIES):
+      _LOGGER.warning(
+        "zLock Slots is configured but unavailable (locks=%s, slot=%s); "
+        "skipping door code push",
+        lock_entity_ids, slot,
+      )
     return
   slot = int(slot)
   for entity_id in lock_entity_ids:
@@ -56,6 +68,12 @@ async def async_clear_lock_codes(hass: HomeAssistant, entry: ConfigEntry) -> Non
   lock_entity_ids = get_lock_entity_ids(hass, entry)
   _LOGGER.info("Lock disable: slot=%s locks=%s", slot, lock_entity_ids)
   if not lock_entity_ids or slot is None:
+    if entry.options.get(CONF_ZLOCK_ENTRIES):
+      _LOGGER.warning(
+        "zLock Slots is configured but unavailable (locks=%s, slot=%s); "
+        "skipping door code slot disable",
+        lock_entity_ids, slot,
+      )
     return
   slot = int(slot)
   for entity_id in lock_entity_ids:

+ 2 - 5
custom_components/room_cal_ha/strings.json

@@ -108,7 +108,6 @@
     },
     "binary_sensor": {
       "stale": { "name": "Stale" },
-      "unifi_available": { "name": "UniFi" },
       "arrived": { "name": "Arrived" },
       "departed": { "name": "Departed" }
     },
@@ -120,10 +119,8 @@
     },
     "button": {
       "force_refresh": { "name": "Force Refresh" },
-      "promote_from_next": { "name": "Promote from Next" },
-      "clear_current": { "name": "Clear Current" },
-      "test_next_guest": { "name": "Test Next Guest" },
-      "override_empty": { "name": "Override Empty" },
+      "test_next_guest": { "name": "Override to Next" },
+      "override_empty": { "name": "Override to Empty" },
       "guest_arrived": { "name": "Guest Arrived" },
       "guest_departed": { "name": "Guest Departed" }
     }

+ 1 - 1
custom_components/room_cal_ha/switch.py

@@ -135,7 +135,7 @@ class UseZlockSlotsSwitch(_SwitchBase):
 class OverrideReservationSwitch(CoordinatorEntity[RoomCalCoordinator], SwitchEntity):
   _attr_has_entity_name = True
   _attr_translation_key = "override_reservation"
-  _attr_icon = "mdi:test-tube"
+  _attr_icon = "mdi:debug-step-over"
 
   def __init__(self, coordinator: RoomCalCoordinator, entry: ConfigEntry, device_name: str) -> None:
     super().__init__(coordinator)

+ 2 - 5
custom_components/room_cal_ha/translations/en.json

@@ -108,7 +108,6 @@
     },
     "binary_sensor": {
       "stale": { "name": "Stale" },
-      "unifi_available": { "name": "UniFi" },
       "arrived": { "name": "Arrived" },
       "departed": { "name": "Departed" }
     },
@@ -120,10 +119,8 @@
     },
     "button": {
       "force_refresh": { "name": "Force Refresh" },
-      "promote_from_next": { "name": "Promote from Next" },
-      "clear_current": { "name": "Clear Current" },
-      "test_next_guest": { "name": "Test Next Guest" },
-      "override_empty": { "name": "Override Empty" },
+      "test_next_guest": { "name": "Override to Next" },
+      "override_empty": { "name": "Override to Empty" },
       "guest_arrived": { "name": "Guest Arrived" },
       "guest_departed": { "name": "Guest Departed" }
     }

+ 15 - 3
custom_components/room_cal_ha/wifi.py

@@ -11,15 +11,27 @@ UNIFI_DOMAIN = "unifi"
 
 
 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):
+  """Return (hub, wlan_id) for the first matching SSID across loaded hubs, or raise.
+
+  Distinguishes "no UniFi hub loaded/available" from "SSID not found on an
+  available hub" in the raised message, since this is the only diagnostic
+  signal we have for a configured-but-unreachable UniFi hub.
+  """
+  entries = hass.config_entries.async_loaded_entries(UNIFI_DOMAIN)
+  if not entries:
+    raise HomeAssistantError("No UniFi integration is loaded; cannot manage WLAN")
+  any_available = False
+  for config_entry in entries:
     hub = config_entry.runtime_data
     if not hub.available:
       continue
+    any_available = True
     for wlan_id, wlan in hub.api.wlans.items():
       if wlan.name == ssid:
         return hub, wlan_id
-  raise HomeAssistantError(f"UniFi WLAN '{ssid}' not found in any loaded hub")
+  if not any_available:
+    raise HomeAssistantError(f"UniFi hub unavailable; cannot manage WLAN '{ssid}'")
+  raise HomeAssistantError(f"UniFi WLAN '{ssid}' not found on any available hub")
 
 
 async def async_set_wifi_key(hass: HomeAssistant, ssid: str, key: str) -> None:

+ 1 - 40
tests/test_binary_sensor.py

@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
 
 import pytest
 
-from custom_components.room_cal_ha.binary_sensor import StaleSensor, UnifiAvailableSensor
+from custom_components.room_cal_ha.binary_sensor import StaleSensor
 from custom_components.room_cal_ha.coordinator import CoordinatorData
 from custom_components.room_cal_ha.const import (
   CONF_REFRESH_INTERVAL,
@@ -106,42 +106,3 @@ def test_stale_sensor_custom_interval():
   s = _sensor(current_ts=recent, next_ts=recent, interval=5)
   with patch("custom_components.room_cal_ha.binary_sensor.dt_util.utcnow", return_value=_NOW):
     assert s.is_on is False
-
-
-# ---------------------------------------------------------------------------
-# UnifiAvailableSensor
-# ---------------------------------------------------------------------------
-
-def _entry(available: bool) -> MagicMock:
-  hub = MagicMock()
-  hub.available = available
-  e = MagicMock()
-  e.runtime_data = hub
-  return e
-
-
-def _unifi_sensor(entries=None) -> UnifiAvailableSensor:
-  s = object.__new__(UnifiAvailableSensor)
-  s.hass = MagicMock()
-  s.hass.config_entries.async_loaded_entries.return_value = entries if entries is not None else []
-  return s
-
-
-def test_unifi_sensor_no_entries_is_false():
-  assert _unifi_sensor([]).is_on is False
-
-
-def test_unifi_sensor_hub_unavailable_is_false():
-  assert _unifi_sensor([_entry(False)]).is_on is False
-
-
-def test_unifi_sensor_hub_available_is_true():
-  assert _unifi_sensor([_entry(True)]).is_on is True
-
-
-def test_unifi_sensor_first_unavailable_second_available_is_true():
-  assert _unifi_sensor([_entry(False), _entry(True)]).is_on is True
-
-
-def test_unifi_sensor_all_unavailable_is_false():
-  assert _unifi_sensor([_entry(False), _entry(False)]).is_on is False

+ 1 - 28
tests/test_buttons.py

@@ -1,4 +1,4 @@
-"""Unit tests for ForceRefreshButton, PromoteFromNextButton, and TestNextGuestButton.
+"""Unit tests for ForceRefreshButton and TestNextGuestButton.
 
 Uses object.__new__ to bypass HA entity setup — valid because async_press
 only calls coordinator methods, not HA lifecycle hooks.
@@ -9,10 +9,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 from custom_components.room_cal_ha.button import (
-  ClearCurrentButton,
   ForceRefreshButton,
   OverrideEmptyButton,
-  PromoteFromNextButton,
   TestNextGuestButton,
 )
 from custom_components.room_cal_ha.const import STORE_NEXT
@@ -34,37 +32,12 @@ async def test_force_refresh_calls_coordinator_refresh():
   btn._coordinator.async_refresh.assert_awaited_once()
 
 
-async def test_promote_from_next_calls_coordinator_promote():
-  btn = _button(PromoteFromNextButton)
-  await btn.async_press()
-  btn._coordinator.async_promote_next_to_current.assert_awaited_once()
-
-
 async def test_force_refresh_does_not_call_promote():
   btn = _button(ForceRefreshButton)
   await btn.async_press()
   btn._coordinator.async_promote_next_to_current.assert_not_called()
 
 
-async def test_promote_does_not_call_refresh():
-  btn = _button(PromoteFromNextButton)
-  await btn.async_press()
-  btn._coordinator.async_refresh.assert_not_called()
-
-
-async def test_clear_current_calls_coordinator_clear():
-  btn = _button(ClearCurrentButton)
-  await btn.async_press()
-  btn._coordinator.async_clear_current.assert_awaited_once()
-
-
-async def test_clear_current_does_not_call_refresh_or_promote():
-  btn = _button(ClearCurrentButton)
-  await btn.async_press()
-  btn._coordinator.async_refresh.assert_not_called()
-  btn._coordinator.async_promote_next_to_current.assert_not_called()
-
-
 # ---------------------------------------------------------------------------
 # TestNextGuestButton
 # ---------------------------------------------------------------------------

+ 4 - 3
tests/test_wifi.py

@@ -35,13 +35,13 @@ def _hass(*hubs: MagicMock) -> MagicMock:
 # ---------------------------------------------------------------------------
 
 async def test_no_entries_raises():
-  with pytest.raises(HomeAssistantError, match="not found"):
+  with pytest.raises(HomeAssistantError, match="No UniFi integration"):
     await async_set_wifi_key(_hass(), "GuestNet", "password1")
 
 
 async def test_hub_unavailable_raises():
   hub = _hub(available=False, wlans={"w1": _wlan("GuestNet")})
-  with pytest.raises(HomeAssistantError, match="not found"):
+  with pytest.raises(HomeAssistantError, match="unavailable"):
     await async_set_wifi_key(_hass(hub), "GuestNet", "password1")
 
 
@@ -118,8 +118,9 @@ async def test_ssid_disable_calls_request():
 
 
 async def test_ssid_not_found_raises():
+  hub = _hub(wlans={"w1": _wlan("OtherNet")})
   with pytest.raises(HomeAssistantError, match="not found"):
-    await async_set_ssid_enabled(_hass(), "GuestNet", True)
+    await async_set_ssid_enabled(_hass(hub), "GuestNet", True)
 
 
 async def test_ssid_enable_api_error_raises():