فهرست منبع

Attach lock sensors to their zwave_js devices; expose lock_entity_ids

- Per-lock sensors join the lock's existing zwave_js device via an
  identifiers-only DeviceInfo link, so they show up on the lock's device
  page; names shorten to 'Last action'/'Last user'/'Slots' with the device
  name prefixed by HA. Device-less fallback keeps the old self-identifying
  names if the lock isn't in the registry
- Group sensors get a virtual SERVICE-type device named after the group
- Sensor setup prunes stale registry entities and device links left by
  removed locks/groups on every (re)load
- coordinator.lock_entity_ids is now public (services.py no longer touches
  a private attribute)
- Device-attachment tests; deps-bypass fixture moved to conftest
ZiB 4 ماه پیش
والد
کامیت
98ee3a6192

+ 5 - 5
custom_components/zlock_slots/coordinator.py

@@ -62,14 +62,14 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
   def __init__(self, hass: HomeAssistant, entry: "ConfigEntry") -> None:
     super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=None)
     self._entry = entry
-    self._lock_entity_ids: list[str] = entry.data[CONF_LOCK_ENTITY_IDS]
+    self.lock_entity_ids: list[str] = entry.data[CONF_LOCK_ENTITY_IDS]
     self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
     self._names: dict[str, str] = {}  # "{entity_id}:{slot_num}" → name
     self._alarm_type_to_lock: dict[str, str] = {
-      _alarm_entity(eid, "type"): eid for eid in self._lock_entity_ids
+      _alarm_entity(eid, "type"): eid for eid in self.lock_entity_ids
     }
     self._alarm_level_to_lock: dict[str, str] = {
-      _alarm_entity(eid, "level"): eid for eid in self._lock_entity_ids
+      _alarm_entity(eid, "level"): eid for eid in self.lock_entity_ids
     }
     # State machine for pairing alarm_type and alarm_level, which arrive separately.
     # _pending_slot_alarm: alarm_type fired with SLOT source; waiting for alarm_level.
@@ -77,7 +77,7 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
     self._pending_slot_alarm: dict[str, AlarmMapping] = {}
     self._recent_level: dict[str, tuple[int, float]] = {}  # lock → (slot, monotonic_time)
     self._unsub_event: callback | None = None
-    self.data: dict[str, LockState] = {eid: LockState() for eid in self._lock_entity_ids}
+    self.data: dict[str, LockState] = {eid: LockState() for eid in self.lock_entity_ids}
 
     groups: list[dict] = entry.options.get(CONF_GROUPS, [])
     self.group_data: dict[str, GroupState] = {
@@ -117,7 +117,7 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
       self._unsub_event = None
 
   async def _async_fetch_all_slots(self) -> None:
-    for lock_entity_id in self._lock_entity_ids:
+    for lock_entity_id in self.lock_entity_ids:
       slots = await self._fetch_slots(lock_entity_id)
       self.data[lock_entity_id].slots = slots
     self.async_update_listeners()

+ 87 - 22
custom_components/zlock_slots/sensor.py

@@ -3,6 +3,8 @@ from __future__ import annotations
 from homeassistant.components.sensor import SensorEntity
 from homeassistant.config_entries import ConfigEntry
 from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
@@ -17,17 +19,73 @@ async def async_setup_entry(
 ) -> None:
   coordinator: ZLockCoordinator = hass.data[DOMAIN][entry.entry_id]
   entities = []
+  lock_device_ids: set[str] = set()
+
   for lock_entity_id in entry.data[CONF_LOCK_ENTITY_IDS]:
-    entities.append(ZLockLastActionSensor(coordinator, entry, lock_entity_id))
-    entities.append(ZLockLastUserSensor(coordinator, entry, lock_entity_id))
-    entities.append(ZLockSlotsSensor(coordinator, entry, lock_entity_id))
-  for group in entry.options.get(CONF_GROUPS, []):
-    group_name = group[CONF_GROUP_NAME]
+    device = _lock_device(hass, lock_entity_id)
+    # Identifiers-only DeviceInfo is a "link": our sensors join the lock's
+    # existing zwave_js device instead of creating a parallel one.
+    device_info = DeviceInfo(identifiers=device.identifiers) if device else None
+    if device:
+      lock_device_ids.add(device.id)
+    entities.append(ZLockLastActionSensor(coordinator, entry, lock_entity_id, device_info))
+    entities.append(ZLockLastUserSensor(coordinator, entry, lock_entity_id, device_info))
+    entities.append(ZLockSlotsSensor(coordinator, entry, lock_entity_id, device_info))
+
+  group_names = [g[CONF_GROUP_NAME] for g in entry.options.get(CONF_GROUPS, [])]
+  for group_name in group_names:
     entities.append(ZLockGroupLastActionSensor(coordinator, entry, group_name))
     entities.append(ZLockGroupLastUserSensor(coordinator, entry, group_name))
+
+  _async_cleanup_stale_registry_entries(hass, entry, entities, lock_device_ids, group_names)
   async_add_entities(entities)
 
 
+def _lock_device(hass: HomeAssistant, lock_entity_id: str) -> dr.DeviceEntry | None:
+  """Find the zwave_js device a lock entity belongs to."""
+  reg_entry = er.async_get(hass).async_get(lock_entity_id)
+  if not reg_entry or not reg_entry.device_id:
+    return None
+  device = dr.async_get(hass).async_get(reg_entry.device_id)
+  if device and device.identifiers:
+    return device
+  return None
+
+
+def _group_device_info(group_name: str) -> DeviceInfo:
+  return DeviceInfo(
+    identifiers={(DOMAIN, f"group_{group_name}")},
+    name=group_name,
+    entry_type=DeviceEntryType.SERVICE,
+  )
+
+
+def _async_cleanup_stale_registry_entries(
+  hass: HomeAssistant,
+  entry: ConfigEntry,
+  entities: list[SensorEntity],
+  lock_device_ids: set[str],
+  group_names: list[str],
+) -> None:
+  """Drop registry entities and device links left behind by removed locks/groups."""
+  valid_unique_ids = {e.unique_id for e in entities}
+  ent_reg = er.async_get(hass)
+  for reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
+    if reg_entry.unique_id not in valid_unique_ids:
+      ent_reg.async_remove(reg_entry.entity_id)
+
+  dev_reg = dr.async_get(hass)
+  group_identifiers = {(DOMAIN, f"group_{name}") for name in group_names}
+  for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
+    own_identifiers = {i for i in device.identifiers if i[0] == DOMAIN}
+    keep = (
+      own_identifiers & group_identifiers
+      or (not own_identifiers and device.id in lock_device_ids)
+    )
+    if not keep:
+      dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id)
+
+
 def _lock_object_id(lock_entity_id: str) -> str:
   return lock_entity_id.split(".")[-1]
 
@@ -40,10 +98,20 @@ class _ZLockBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
     coordinator: ZLockCoordinator,
     entry: ConfigEntry,
     lock_entity_id: str,
+    device_info: DeviceInfo | None = None,
   ) -> None:
     super().__init__(coordinator)
     self._lock_entity_id = lock_entity_id
     self._entry = entry
+    if device_info:
+      self._attr_device_info = device_info
+
+  def _name(self, short: str) -> str:
+    # Attached to the lock's device, HA prefixes the device name; without a
+    # device the name must identify the lock on its own.
+    if self.device_info:
+      return short
+    return f"{_lock_object_id(self._lock_entity_id)} {short.lower()}"
 
   @property
   def _lock_state(self):
@@ -52,13 +120,13 @@ class _ZLockBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
 
 class ZLockLastActionSensor(_ZLockBaseSensor):
 
-  def __init__(self, coordinator, entry, lock_entity_id):
-    super().__init__(coordinator, entry, lock_entity_id)
+  def __init__(self, coordinator, entry, lock_entity_id, device_info=None):
+    super().__init__(coordinator, entry, lock_entity_id, device_info)
     self._attr_unique_id = f"{entry.entry_id}_{lock_entity_id}_last_action"
 
   @property
   def name(self) -> str:
-    return f"{_lock_object_id(self._lock_entity_id)} last action"
+    return self._name("Last action")
 
   @property
   def native_value(self) -> str | None:
@@ -68,13 +136,13 @@ class ZLockLastActionSensor(_ZLockBaseSensor):
 
 class ZLockLastUserSensor(_ZLockBaseSensor):
 
-  def __init__(self, coordinator, entry, lock_entity_id):
-    super().__init__(coordinator, entry, lock_entity_id)
+  def __init__(self, coordinator, entry, lock_entity_id, device_info=None):
+    super().__init__(coordinator, entry, lock_entity_id, device_info)
     self._attr_unique_id = f"{entry.entry_id}_{lock_entity_id}_last_user"
 
   @property
   def name(self) -> str:
-    return f"{_lock_object_id(self._lock_entity_id)} last user"
+    return self._name("Last user")
 
   @property
   def native_value(self) -> str | None:
@@ -84,13 +152,13 @@ class ZLockLastUserSensor(_ZLockBaseSensor):
 
 class ZLockSlotsSensor(_ZLockBaseSensor):
 
-  def __init__(self, coordinator, entry, lock_entity_id):
-    super().__init__(coordinator, entry, lock_entity_id)
+  def __init__(self, coordinator, entry, lock_entity_id, device_info=None):
+    super().__init__(coordinator, entry, lock_entity_id, device_info)
     self._attr_unique_id = f"{entry.entry_id}_{lock_entity_id}_slots"
 
   @property
   def name(self) -> str:
-    return f"{_lock_object_id(self._lock_entity_id)} slots"
+    return self._name("Slots")
 
   @property
   def native_value(self) -> int:
@@ -126,6 +194,9 @@ class _ZLockGroupBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
     super().__init__(coordinator)
     self._group_name = group_name
     self._entry = entry
+    # Groups have no physical device; give each a small virtual one so its
+    # sensors have a home in the UI. The device name supplies the group name.
+    self._attr_device_info = _group_device_info(group_name)
 
   @property
   def _group_state(self):
@@ -133,15 +204,12 @@ class _ZLockGroupBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
 
 
 class ZLockGroupLastActionSensor(_ZLockGroupBaseSensor):
+  _attr_name = "Last action"
 
   def __init__(self, coordinator, entry, group_name):
     super().__init__(coordinator, entry, group_name)
     self._attr_unique_id = f"{entry.entry_id}_group_{group_name}_last_action"
 
-  @property
-  def name(self) -> str:
-    return f"{self._group_name} last action"
-
   @property
   def native_value(self) -> str | None:
     state = self._group_state
@@ -149,15 +217,12 @@ class ZLockGroupLastActionSensor(_ZLockGroupBaseSensor):
 
 
 class ZLockGroupLastUserSensor(_ZLockGroupBaseSensor):
+  _attr_name = "Last user"
 
   def __init__(self, coordinator, entry, group_name):
     super().__init__(coordinator, entry, group_name)
     self._attr_unique_id = f"{entry.entry_id}_group_{group_name}_last_user"
 
-  @property
-  def name(self) -> str:
-    return f"{self._group_name} last user"
-
   @property
   def native_value(self) -> str | None:
     state = self._group_state

+ 1 - 1
custom_components/zlock_slots/services.py

@@ -46,7 +46,7 @@ SET_SLOT_ENABLED_SCHEMA = vol.Schema({
 def _get_coordinator(hass: HomeAssistant, lock_entity_id: str) -> ZLockCoordinator:
   for entry_data in hass.data.get(DOMAIN, {}).values():
     coordinator: ZLockCoordinator = entry_data
-    if lock_entity_id in coordinator._lock_entity_ids:
+    if lock_entity_id in coordinator.lock_entity_ids:
       return coordinator
   raise ServiceValidationError(f"{lock_entity_id} is not managed by {DOMAIN}")
 

+ 10 - 1
tests/conftest.py

@@ -1,5 +1,5 @@
 import pytest
-from unittest.mock import AsyncMock
+from unittest.mock import AsyncMock, patch
 from pytest_homeassistant_custom_component.common import MockConfigEntry
 
 from custom_components.zlock_slots.const import DOMAIN, CONF_LOCK_ENTITY_IDS
@@ -36,6 +36,15 @@ def auto_enable_custom_integrations(enable_custom_integrations):
   yield
 
 
+@pytest.fixture(autouse=True)
+def bypass_manifest_dependencies():
+  """zwave_js/frontend/lovelace can't be set up in the test harness."""
+  with patch(
+    "homeassistant.setup._async_process_dependencies", return_value=[]
+  ):
+    yield
+
+
 @pytest.fixture
 def mock_node():
   return FakeNode(users=[

+ 0 - 9
tests/test_config_flow.py

@@ -21,15 +21,6 @@ def mock_zwave_node():
     yield
 
 
-@pytest.fixture(autouse=True)
-def bypass_manifest_dependencies():
-  """zwave_js/frontend/lovelace can't be set up in the test harness."""
-  with patch(
-    "homeassistant.setup._async_process_dependencies", return_value=[]
-  ):
-    yield
-
-
 # ---------------------------------------------------------------------------
 # Config flow
 # ---------------------------------------------------------------------------

+ 94 - 2
tests/test_sensor.py

@@ -1,7 +1,10 @@
-"""Tests for sensor entities: native_value and extra_state_attributes."""
+"""Tests for sensor entities: native_value, extra_state_attributes, device attachment."""
 import pytest
 from unittest.mock import patch
 
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
 from custom_components.zlock_slots.coordinator import ZLockCoordinator
 from custom_components.zlock_slots.sensor import (
   ZLockLastActionSensor,
@@ -10,7 +13,9 @@ from custom_components.zlock_slots.sensor import (
   ZLockGroupLastActionSensor,
   ZLockGroupLastUserSensor,
 )
-from custom_components.zlock_slots.const import CONF_GROUPS, CONF_GROUP_NAME, CONF_LOCK_ENTITY_IDS
+from custom_components.zlock_slots.const import (
+  DOMAIN, CONF_GROUPS, CONF_GROUP_NAME, CONF_LOCK_ENTITY_IDS,
+)
 from tests.conftest import LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY
 
 _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
@@ -204,3 +209,90 @@ async def test_group_last_user_after_alarm(hass, group_config_entry, mock_node):
   sensor = ZLockGroupLastUserSensor(c, group_config_entry, "All Locks")
   assert sensor.native_value == "<Manual>"
   c.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# Device attachment
+# ---------------------------------------------------------------------------
+
+def _register_lock_device(hass) -> dr.DeviceEntry:
+  """Create a fake zwave_js device with a registered lock.front_door entity."""
+  zwave_entry = MockConfigEntry(domain="zwave_js", entry_id="fake_zwave_entry")
+  zwave_entry.add_to_hass(hass)
+  device = dr.async_get(hass).async_get_or_create(
+    config_entry_id=zwave_entry.entry_id,
+    identifiers={("zwave_js", "3245-89")},
+    name="Front Door",
+  )
+  er.async_get(hass).async_get_or_create(
+    "lock", "zwave_js", "front_door_lock_unique",
+    device_id=device.id,
+    suggested_object_id="front_door",
+  )
+  return device
+
+
+async def _setup_integration(hass, entry, mock_node):
+  entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    assert await hass.config_entries.async_setup(entry.entry_id)
+    await hass.async_block_till_done()
+
+
+async def test_lock_sensors_attach_to_lock_device(hass, config_entry, mock_node):
+  device = _register_lock_device(hass)
+  await _setup_integration(hass, config_entry, mock_node)
+
+  ent_reg = er.async_get(hass)
+  for suffix in ("last_action", "last_user", "slots"):
+    entity_id = ent_reg.async_get_entity_id(
+      "sensor", DOMAIN, f"{config_entry.entry_id}_{LOCK_ENTITY_ID}_{suffix}"
+    )
+    assert entity_id is not None
+    assert ent_reg.async_get(entity_id).device_id == device.id
+
+
+async def test_lock_sensors_without_zwave_device(hass, config_entry, mock_node):
+  """Lock not in the entity registry — sensors still created, just device-less."""
+  await _setup_integration(hass, config_entry, mock_node)
+
+  ent_reg = er.async_get(hass)
+  entity_id = ent_reg.async_get_entity_id(
+    "sensor", DOMAIN, f"{config_entry.entry_id}_{LOCK_ENTITY_ID}_last_action"
+  )
+  assert entity_id is not None
+  assert ent_reg.async_get(entity_id).device_id is None
+
+
+async def test_group_sensors_get_service_device(hass, group_config_entry, mock_node):
+  await _setup_integration(hass, group_config_entry, mock_node)
+
+  device = dr.async_get(hass).async_get_device(identifiers={(DOMAIN, "group_All Locks")})
+  assert device is not None
+  assert device.entry_type is dr.DeviceEntryType.SERVICE
+  assert device.name == "All Locks"
+
+  ent_reg = er.async_get(hass)
+  entity_id = ent_reg.async_get_entity_id(
+    "sensor", DOMAIN, f"{group_config_entry.entry_id}_group_All Locks_last_action"
+  )
+  assert ent_reg.async_get(entity_id).device_id == device.id
+
+
+async def test_stale_group_device_and_entities_removed_on_reload(
+  hass, group_config_entry, mock_node
+):
+  await _setup_integration(hass, group_config_entry, mock_node)
+  dev_reg = dr.async_get(hass)
+  assert dev_reg.async_get_device(identifiers={(DOMAIN, "group_All Locks")})
+
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    # Update listener reloads the entry; cleanup runs during sensor setup.
+    hass.config_entries.async_update_entry(group_config_entry, options={CONF_GROUPS: []})
+    await hass.async_block_till_done()
+
+  assert dev_reg.async_get_device(identifiers={(DOMAIN, "group_All Locks")}) is None
+  ent_reg = er.async_get(hass)
+  assert ent_reg.async_get_entity_id(
+    "sensor", DOMAIN, f"{group_config_entry.entry_id}_group_All Locks_last_action"
+  ) is None