فهرست منبع

Add door sensor integration with combined lock+door state

Optionally associates a binary_sensor with each lock via CONF_DOOR_SENSOR_MAP
in options. The coordinator subscribes to door sensor state changes and stores
door_open on LockState; initial state arrives from the first HA publish.

New sensors per lock (when a door sensor is configured): Door (Open/Closed)
and Combined state (DoorLockState enum: Locked/Open/Jammed/Closed).
Group combined state aggregates all contributing locks (those with door sensors)
using priority Jammed > Open > Closed > Locked; locks without door sensors are
excluded from group combined state. The options flow gains an associate_sensors
step with an optional binary_sensor selector per lock.
ZiB 4 ماه پیش
والد
کامیت
99c735135d

+ 32 - 3
custom_components/zlock_slots/config_flow.py

@@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFl
 from homeassistant.core import callback
 from homeassistant.core import callback
 from homeassistant.helpers import selector
 from homeassistant.helpers import selector
 
 
-from .const import DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME
+from .const import DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME, CONF_DOOR_SENSOR_MAP
 
 
 
 
 class ZLockSlotsConfigFlow(ConfigFlow, domain=DOMAIN):
 class ZLockSlotsConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -65,11 +65,14 @@ class ZLockSlotsOptionsFlow(OptionsFlow):
     # Deep copy: pruning mutates group dicts, which must not alias entry.options.
     # Deep copy: pruning mutates group dicts, which must not alias entry.options.
     self._groups: list[dict] = deepcopy(config_entry.options.get(CONF_GROUPS, []))
     self._groups: list[dict] = deepcopy(config_entry.options.get(CONF_GROUPS, []))
     self._lock_ids: list[str] = list(config_entry.data.get(CONF_LOCK_ENTITY_IDS, []))
     self._lock_ids: list[str] = list(config_entry.data.get(CONF_LOCK_ENTITY_IDS, []))
+    self._door_sensor_map: dict[str, str] = dict(
+      config_entry.options.get(CONF_DOOR_SENSOR_MAP, {})
+    )
 
 
   async def async_step_init(self, user_input=None) -> ConfigFlowResult:
   async def async_step_init(self, user_input=None) -> ConfigFlowResult:
     return self.async_show_menu(
     return self.async_show_menu(
       step_id="init",
       step_id="init",
-      menu_options=["manage_locks", "add_group", "remove_group", "finish"],
+      menu_options=["manage_locks", "associate_sensors", "add_group", "remove_group", "finish"],
     )
     )
 
 
   async def async_step_manage_locks(self, user_input=None) -> ConfigFlowResult:
   async def async_step_manage_locks(self, user_input=None) -> ConfigFlowResult:
@@ -87,6 +90,8 @@ class ZLockSlotsOptionsFlow(OptionsFlow):
               lid for lid in g.get(CONF_LOCK_ENTITY_IDS, []) if lid not in removed
               lid for lid in g.get(CONF_LOCK_ENTITY_IDS, []) if lid not in removed
             ]
             ]
           self._groups = [g for g in self._groups if g.get(CONF_LOCK_ENTITY_IDS)]
           self._groups = [g for g in self._groups if g.get(CONF_LOCK_ENTITY_IDS)]
+          for lid in removed:
+            self._door_sensor_map.pop(lid, None)
         self._lock_ids = new_lock_ids
         self._lock_ids = new_lock_ids
         return await self.async_step_init()
         return await self.async_step_init()
 
 
@@ -101,6 +106,27 @@ class ZLockSlotsOptionsFlow(OptionsFlow):
     })
     })
     return self.async_show_form(step_id="manage_locks", data_schema=schema, errors=errors)
     return self.async_show_form(step_id="manage_locks", data_schema=schema, errors=errors)
 
 
+  async def async_step_associate_sensors(self, user_input=None) -> ConfigFlowResult:
+    if user_input is not None:
+      self._door_sensor_map = {
+        lid: sensor_id
+        for lid in self._lock_ids
+        if (sensor_id := user_input.get(lid))
+      }
+      return await self.async_step_init()
+
+    schema_fields: dict = {}
+    for lid in self._lock_ids:
+      current = self._door_sensor_map.get(lid)
+      key = vol.Optional(lid, default=current) if current else vol.Optional(lid)
+      schema_fields[key] = selector.EntitySelector(
+        selector.EntitySelectorConfig(domain="binary_sensor")
+      )
+    return self.async_show_form(
+      step_id="associate_sensors",
+      data_schema=vol.Schema(schema_fields),
+    )
+
   async def async_step_add_group(self, user_input=None) -> ConfigFlowResult:
   async def async_step_add_group(self, user_input=None) -> ConfigFlowResult:
     errors: dict[str, str] = {}
     errors: dict[str, str] = {}
     lock_ids: list[str] = self._lock_ids
     lock_ids: list[str] = self._lock_ids
@@ -160,4 +186,7 @@ class ZLockSlotsOptionsFlow(OptionsFlow):
       self.hass.config_entries.async_update_entry(
       self.hass.config_entries.async_update_entry(
         self._entry, data={CONF_LOCK_ENTITY_IDS: self._lock_ids}
         self._entry, data={CONF_LOCK_ENTITY_IDS: self._lock_ids}
       )
       )
-    return self.async_create_entry(data={CONF_GROUPS: self._groups})
+    return self.async_create_entry(data={
+      CONF_GROUPS: self._groups,
+      CONF_DOOR_SENSOR_MAP: self._door_sensor_map,
+    })

+ 8 - 0
custom_components/zlock_slots/const.py

@@ -11,6 +11,7 @@ STORAGE_VERSION = 1
 CONF_LOCK_ENTITY_IDS = "lock_entity_ids"
 CONF_LOCK_ENTITY_IDS = "lock_entity_ids"
 CONF_GROUPS = "groups"
 CONF_GROUPS = "groups"
 CONF_GROUP_NAME = "name"
 CONF_GROUP_NAME = "name"
+CONF_DOOR_SENSOR_MAP = "door_sensor_map"
 
 
 
 
 class UserSource(Enum):
 class UserSource(Enum):
@@ -24,6 +25,13 @@ class LockAction(Enum):
   UNLOCKED = "Unlocked"
   UNLOCKED = "Unlocked"
 
 
 
 
+class DoorLockState(Enum):
+  LOCKED = "Locked"   # all locked + all closed
+  OPEN   = "Open"     # any unlocked + open
+  JAMMED = "Jammed"   # any locked + open
+  CLOSED = "Closed"   # all closed, at least one unlocked
+
+
 @dataclass(frozen=True)
 @dataclass(frozen=True)
 class AlarmMapping:
 class AlarmMapping:
   action: LockAction
   action: LockAction

+ 37 - 1
custom_components/zlock_slots/coordinator.py

@@ -17,8 +17,10 @@ from .const import (
   CONF_LOCK_ENTITY_IDS,
   CONF_LOCK_ENTITY_IDS,
   CONF_GROUPS,
   CONF_GROUPS,
   CONF_GROUP_NAME,
   CONF_GROUP_NAME,
+  CONF_DOOR_SENSOR_MAP,
   ALARM_MAP,
   ALARM_MAP,
   AlarmMapping,
   AlarmMapping,
+  DoorLockState,
   LockAction,
   LockAction,
   UserSource,
   UserSource,
 )
 )
@@ -46,6 +48,22 @@ class LockState:
   slots: dict[int, SlotInfo] = field(default_factory=dict)
   slots: dict[int, SlotInfo] = field(default_factory=dict)
   last_action: LockAction | None = None
   last_action: LockAction | None = None
   last_user: str | None = None
   last_user: str | None = None
+  door_open: bool | None = None
+
+
+def compute_combined_state(
+  action: LockAction | None, door_open: bool | None
+) -> DoorLockState | None:
+  """Synthesize a single state from lock action and door position."""
+  if action is None or door_open is None:
+    return None
+  if action == LockAction.LOCKED and not door_open:
+    return DoorLockState.LOCKED
+  if action == LockAction.UNLOCKED and door_open:
+    return DoorLockState.OPEN
+  if action == LockAction.LOCKED and door_open:
+    return DoorLockState.JAMMED
+  return DoorLockState.CLOSED  # UNLOCKED + closed
 
 
 
 
 def _restore_action(value: str | None) -> LockAction | None:
 def _restore_action(value: str | None) -> LockAction | None:
@@ -81,6 +99,11 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
     self._alarm_level_to_lock: dict[str, str] = {
     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
     }
     }
+    door_sensor_map: dict[str, str] = entry.options.get(CONF_DOOR_SENSOR_MAP, {})
+    self._door_sensor_to_lock: dict[str, str] = {
+      sensor_eid: lock_eid for lock_eid, sensor_eid in door_sensor_map.items()
+      if lock_eid in self.lock_entity_ids
+    }
     # State machine for pairing alarm_type and alarm_level, which arrive separately.
     # 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.
     # _pending_slot_alarm: alarm_type fired with SLOT source; waiting for alarm_level.
     # _recent_level: alarm_level arrived first; alarm_type may follow within the window.
     # _recent_level: alarm_level arrived first; alarm_type may follow within the window.
@@ -117,7 +140,11 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
 
 
     self._unsub_event = async_track_state_change_event(
     self._unsub_event = async_track_state_change_event(
       self.hass,
       self.hass,
-      list(self._alarm_type_to_lock) + list(self._alarm_level_to_lock),
+      (
+        list(self._alarm_type_to_lock)
+        + list(self._alarm_level_to_lock)
+        + list(self._door_sensor_to_lock)
+      ),
       self._handle_alarm_state_change,
       self._handle_alarm_state_change,
     )
     )
 
 
@@ -167,6 +194,8 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
       self._on_alarm_type(lock_entity_id, new_state.state)
       self._on_alarm_type(lock_entity_id, new_state.state)
     elif lock_entity_id := self._alarm_level_to_lock.get(entity_id):
     elif lock_entity_id := self._alarm_level_to_lock.get(entity_id):
       self._on_alarm_level(lock_entity_id, new_state.state)
       self._on_alarm_level(lock_entity_id, new_state.state)
+    elif lock_entity_id := self._door_sensor_to_lock.get(entity_id):
+      self._on_door_sensor(lock_entity_id, new_state.state)
 
 
   @callback
   @callback
   def _on_alarm_type(self, lock_entity_id: str, state: str) -> None:
   def _on_alarm_type(self, lock_entity_id: str, state: str) -> None:
@@ -212,6 +241,13 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
       self._emit_alarm(lock_entity_id, pending.action,
       self._emit_alarm(lock_entity_id, pending.action,
                        self.get_name(lock_entity_id, slot_num) or str(slot_num))
                        self.get_name(lock_entity_id, slot_num) or str(slot_num))
 
 
+  @callback
+  def _on_door_sensor(self, lock_entity_id: str, state: str) -> None:
+    if state not in ("on", "off"):
+      return
+    self.data[lock_entity_id].door_open = state == "on"
+    self.async_update_listeners()
+
   @callback
   @callback
   def _emit_alarm(self, lock_entity_id: str, action: str, user_str: str) -> None:
   def _emit_alarm(self, lock_entity_id: str, action: str, user_str: str) -> None:
     self.data[lock_entity_id].last_action = action
     self.data[lock_entity_id].last_action = action

+ 84 - 5
custom_components/zlock_slots/sensor.py

@@ -8,8 +8,11 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 from homeassistant.helpers.update_coordinator import CoordinatorEntity
 
 
-from .const import DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME
-from .coordinator import ZLockCoordinator
+from .const import (
+  DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
+  CONF_DOOR_SENSOR_MAP, DoorLockState,
+)
+from .coordinator import ZLockCoordinator, compute_combined_state
 
 
 
 
 async def async_setup_entry(
 async def async_setup_entry(
@@ -20,6 +23,7 @@ async def async_setup_entry(
   coordinator: ZLockCoordinator = hass.data[DOMAIN][entry.entry_id]
   coordinator: ZLockCoordinator = hass.data[DOMAIN][entry.entry_id]
   entities = []
   entities = []
   lock_device_ids: set[str] = set()
   lock_device_ids: set[str] = set()
+  door_sensor_map: dict[str, str] = entry.options.get(CONF_DOOR_SENSOR_MAP, {})
 
 
   for lock_entity_id in entry.data[CONF_LOCK_ENTITY_IDS]:
   for lock_entity_id in entry.data[CONF_LOCK_ENTITY_IDS]:
     device = _lock_device(hass, lock_entity_id)
     device = _lock_device(hass, lock_entity_id)
@@ -31,11 +35,21 @@ async def async_setup_entry(
     entities.append(ZLockLastActionSensor(coordinator, entry, lock_entity_id, device_info))
     entities.append(ZLockLastActionSensor(coordinator, entry, lock_entity_id, device_info))
     entities.append(ZLockLastUserSensor(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))
     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:
+    if lock_entity_id in door_sensor_map:
+      entities.append(ZLockDoorSensor(coordinator, entry, lock_entity_id, device_info))
+      entities.append(ZLockCombinedStateSensor(coordinator, entry, lock_entity_id, device_info))
+
+  groups: list[dict] = entry.options.get(CONF_GROUPS, [])
+  group_names = [g[CONF_GROUP_NAME] for g in groups]
+  for group in groups:
+    group_name = group[CONF_GROUP_NAME]
+    group_lock_ids: list[str] = group.get(CONF_LOCK_ENTITY_IDS, [])
     entities.append(ZLockGroupLastActionSensor(coordinator, entry, group_name))
     entities.append(ZLockGroupLastActionSensor(coordinator, entry, group_name))
     entities.append(ZLockGroupLastUserSensor(coordinator, entry, group_name))
     entities.append(ZLockGroupLastUserSensor(coordinator, entry, group_name))
+    if any(lid in door_sensor_map for lid in group_lock_ids):
+      entities.append(
+        ZLockGroupCombinedStateSensor(coordinator, entry, group_name, group_lock_ids)
+      )
 
 
   _async_cleanup_stale_registry_entries(hass, entry, entities, lock_device_ids, group_names)
   _async_cleanup_stale_registry_entries(hass, entry, entities, lock_device_ids, group_names)
   async_add_entities(entities)
   async_add_entities(entities)
@@ -203,6 +217,43 @@ class _ZLockGroupBaseSensor(CoordinatorEntity[ZLockCoordinator], SensorEntity):
     return self.coordinator.group_data.get(self._group_name)
     return self.coordinator.group_data.get(self._group_name)
 
 
 
 
+class ZLockDoorSensor(_ZLockBaseSensor):
+
+  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}_door"
+
+  @property
+  def name(self) -> str:
+    return self._name("Door")
+
+  @property
+  def native_value(self) -> str | None:
+    state = self._lock_state
+    if state is None or state.door_open is None:
+      return None
+    return "Open" if state.door_open else "Closed"
+
+
+class ZLockCombinedStateSensor(_ZLockBaseSensor):
+
+  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}_combined_state"
+
+  @property
+  def name(self) -> str:
+    return self._name("Combined state")
+
+  @property
+  def native_value(self) -> str | None:
+    state = self._lock_state
+    if state is None:
+      return None
+    combined = compute_combined_state(state.last_action, state.door_open)
+    return combined.value if combined else None
+
+
 class ZLockGroupLastActionSensor(_ZLockGroupBaseSensor):
 class ZLockGroupLastActionSensor(_ZLockGroupBaseSensor):
   _attr_name = "Last action"
   _attr_name = "Last action"
 
 
@@ -227,3 +278,31 @@ class ZLockGroupLastUserSensor(_ZLockGroupBaseSensor):
   def native_value(self) -> str | None:
   def native_value(self) -> str | None:
     state = self._group_state
     state = self._group_state
     return state.last_user if state else None
     return state.last_user if state else None
+
+
+class ZLockGroupCombinedStateSensor(_ZLockGroupBaseSensor):
+  _attr_name = "Combined state"
+
+  def __init__(self, coordinator, entry, group_name, group_lock_ids: list[str]):
+    super().__init__(coordinator, entry, group_name)
+    self._group_lock_ids = group_lock_ids
+    self._attr_unique_id = f"{entry.entry_id}_group_{group_name}_combined_state"
+
+  @property
+  def native_value(self) -> str | None:
+    contributing: list[DoorLockState] = []
+    for lid in self._group_lock_ids:
+      lock_state = self.coordinator.data.get(lid)
+      if lock_state and lock_state.last_action is not None and lock_state.door_open is not None:
+        cs = compute_combined_state(lock_state.last_action, lock_state.door_open)
+        if cs:
+          contributing.append(cs)
+    if not contributing:
+      return None
+    if DoorLockState.JAMMED in contributing:
+      return DoorLockState.JAMMED.value
+    if DoorLockState.OPEN in contributing:
+      return DoorLockState.OPEN.value
+    if all(s == DoorLockState.LOCKED for s in contributing):
+      return DoorLockState.LOCKED.value
+    return DoorLockState.CLOSED.value

+ 1 - 0
tests/conftest.py

@@ -9,6 +9,7 @@ pytest_plugins = ["pytest_homeassistant_custom_component"]
 LOCK_ENTITY_ID = "lock.front_door"
 LOCK_ENTITY_ID = "lock.front_door"
 ALARM_TYPE_ENTITY = "sensor.front_door_alarm_type"
 ALARM_TYPE_ENTITY = "sensor.front_door_alarm_type"
 ALARM_LEVEL_ENTITY = "sensor.front_door_alarm_level"
 ALARM_LEVEL_ENTITY = "sensor.front_door_alarm_level"
+DOOR_SENSOR_ENTITY = "binary_sensor.front_door_contact"
 
 
 
 
 class FakeUserData:
 class FakeUserData:

+ 300 - 0
tests/test_door_sensor.py

@@ -0,0 +1,300 @@
+"""Tests for door sensor integration: state tracking, combined state, group aggregation."""
+import pytest
+from unittest.mock import patch
+
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.zlock_slots.coordinator import ZLockCoordinator, compute_combined_state
+from custom_components.zlock_slots.sensor import (
+  ZLockDoorSensor,
+  ZLockCombinedStateSensor,
+  ZLockGroupCombinedStateSensor,
+)
+from custom_components.zlock_slots.const import (
+  DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
+  CONF_DOOR_SENSOR_MAP, LockAction, DoorLockState,
+)
+from tests.conftest import (
+  LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY, DOOR_SENSOR_ENTITY,
+  FakeNode,
+)
+
+LOCK_2 = "lock.back_door"
+DOOR_SENSOR_2 = "binary_sensor.back_door_contact"
+
+_ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
+
+
+def _door_config_entry(extra_lock_ids=None, door_sensor_map=None):
+  lock_ids = [LOCK_ENTITY_ID] + (extra_lock_ids or [])
+  return MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: lock_ids},
+    options={CONF_DOOR_SENSOR_MAP: door_sensor_map or {LOCK_ENTITY_ID: DOOR_SENSOR_ENTITY}},
+    entry_id="test_door_entry",
+  )
+
+
+@pytest.fixture
+async def door_coord(hass, mock_node):
+  entry = _door_config_entry()
+  entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, entry)
+    await c.async_setup()
+    await hass.async_block_till_done()
+    yield c
+  c.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# compute_combined_state
+# ---------------------------------------------------------------------------
+
+@pytest.mark.parametrize("action,door_open,expected", [
+  (LockAction.LOCKED,   False, DoorLockState.LOCKED),
+  (LockAction.UNLOCKED, True,  DoorLockState.OPEN),
+  (LockAction.LOCKED,   True,  DoorLockState.JAMMED),
+  (LockAction.UNLOCKED, False, DoorLockState.CLOSED),
+  (None,                False, None),
+  (LockAction.LOCKED,   None,  None),
+  (None,                None,  None),
+])
+def test_compute_combined_state(action, door_open, expected):
+  assert compute_combined_state(action, door_open) == expected
+
+
+# ---------------------------------------------------------------------------
+# Coordinator door sensor event handling
+# ---------------------------------------------------------------------------
+
+async def test_door_sensor_off_sets_closed(hass, door_coord):
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
+  await hass.async_block_till_done()
+  assert door_coord.data[LOCK_ENTITY_ID].door_open is False
+
+
+async def test_door_sensor_on_sets_open(hass, door_coord):
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
+  await hass.async_block_till_done()
+  assert door_coord.data[LOCK_ENTITY_ID].door_open is True
+
+
+async def test_door_sensor_unavailable_ignored(hass, door_coord):
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
+  await hass.async_block_till_done()
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "unavailable")
+  await hass.async_block_till_done()
+  assert door_coord.data[LOCK_ENTITY_ID].door_open is True  # unchanged
+
+
+async def test_door_open_initially_none(hass, door_coord):
+  assert door_coord.data[LOCK_ENTITY_ID].door_open is None
+
+
+async def test_door_sensor_not_tracked_without_config(hass, mock_node):
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
+    options={},
+    entry_id="test_no_door",
+  )
+  entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, entry)
+    await c.async_setup()
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
+  await hass.async_block_till_done()
+  assert c.data[LOCK_ENTITY_ID].door_open is None
+  c.async_teardown()
+
+
+# ---------------------------------------------------------------------------
+# ZLockDoorSensor
+# ---------------------------------------------------------------------------
+
+async def test_door_sensor_entity_none_initially(door_coord, hass):
+  entry = door_coord._entry
+  sensor = ZLockDoorSensor(door_coord, entry, LOCK_ENTITY_ID)
+  assert sensor.native_value is None
+
+
+async def test_door_sensor_entity_closed(hass, door_coord):
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
+  await hass.async_block_till_done()
+  sensor = ZLockDoorSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "Closed"
+
+
+async def test_door_sensor_entity_open(hass, door_coord):
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
+  await hass.async_block_till_done()
+  sensor = ZLockDoorSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "Open"
+
+
+# ---------------------------------------------------------------------------
+# ZLockCombinedStateSensor
+# ---------------------------------------------------------------------------
+
+async def test_combined_none_when_no_data(door_coord):
+  sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value is None
+
+
+async def test_combined_none_when_only_door_known(hass, door_coord):
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
+  await hass.async_block_till_done()
+  sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value is None
+
+
+async def test_combined_none_when_only_action_known(hass, door_coord):
+  hass.states.async_set(ALARM_TYPE_ENTITY, "24")  # radio lock
+  await hass.async_block_till_done()
+  sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value is None
+
+
+async def test_combined_locked(hass, door_coord):
+  hass.states.async_set(ALARM_TYPE_ENTITY, "24")   # locked
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "off")  # closed
+  await hass.async_block_till_done()
+  sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "Locked"
+
+
+async def test_combined_open(hass, door_coord):
+  hass.states.async_set(ALARM_TYPE_ENTITY, "25")  # unlocked
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "on")  # open
+  await hass.async_block_till_done()
+  sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "Open"
+
+
+async def test_combined_jammed(hass, door_coord):
+  hass.states.async_set(ALARM_TYPE_ENTITY, "24")  # locked
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "on")  # open
+  await hass.async_block_till_done()
+  sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "Jammed"
+
+
+async def test_combined_closed(hass, door_coord):
+  hass.states.async_set(ALARM_TYPE_ENTITY, "25")   # unlocked
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "off")  # closed
+  await hass.async_block_till_done()
+  sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
+  assert sensor.native_value == "Closed"
+
+
+# ---------------------------------------------------------------------------
+# ZLockGroupCombinedStateSensor — aggregation rules
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+async def two_lock_coord(hass, mock_node):
+  """Coordinator with two locks, both with door sensors."""
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
+    options={
+      CONF_DOOR_SENSOR_MAP: {
+        LOCK_ENTITY_ID: DOOR_SENSOR_ENTITY,
+        LOCK_2: DOOR_SENSOR_2,
+      },
+      CONF_GROUPS: [{
+        CONF_GROUP_NAME: "All Locks",
+        CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
+      }],
+    },
+    entry_id="test_two_lock",
+  )
+  entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, entry)
+    await c.async_setup()
+    await hass.async_block_till_done()
+    yield c
+  c.async_teardown()
+
+
+def _group_sensor(coord) -> ZLockGroupCombinedStateSensor:
+  return ZLockGroupCombinedStateSensor(
+    coord, coord._entry, "All Locks", [LOCK_ENTITY_ID, LOCK_2]
+  )
+
+
+async def _set_lock_state(hass, alarm_type_entity, door_entity, alarm_type, door_state):
+  hass.states.async_set(alarm_type_entity, alarm_type)
+  hass.states.async_set(door_entity, door_state)
+  await hass.async_block_till_done()
+
+
+ALARM_TYPE_2 = "sensor.back_door_alarm_type"
+ALARM_LEVEL_2 = "sensor.back_door_alarm_level"
+
+
+async def test_group_combined_none_when_no_data(two_lock_coord):
+  sensor = _group_sensor(two_lock_coord)
+  assert sensor.native_value is None
+
+
+async def test_group_combined_all_locked(hass, two_lock_coord):
+  await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off")
+  await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "24", "off")
+  assert _group_sensor(two_lock_coord).native_value == "Locked"
+
+
+async def test_group_combined_any_open_wins_over_locked(hass, two_lock_coord):
+  await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off")  # Locked
+  await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "on")             # Open
+  assert _group_sensor(two_lock_coord).native_value == "Open"
+
+
+async def test_group_combined_jammed_beats_open(hass, two_lock_coord):
+  await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "on")  # Jammed
+  await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "on")            # Open
+  assert _group_sensor(two_lock_coord).native_value == "Jammed"
+
+
+async def test_group_combined_jammed_beats_locked(hass, two_lock_coord):
+  await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "on")  # Jammed
+  await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "24", "off")           # Locked
+  assert _group_sensor(two_lock_coord).native_value == "Jammed"
+
+
+async def test_group_combined_closed_when_mix_of_locked_and_closed(hass, two_lock_coord):
+  await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off")  # Locked
+  await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "off")            # Closed
+  assert _group_sensor(two_lock_coord).native_value == "Closed"
+
+
+async def test_group_combined_excludes_lock_without_door_sensor(hass, mock_node):
+  """A lock with no door sensor must not affect group combined state."""
+  entry = MockConfigEntry(
+    domain=DOMAIN,
+    data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
+    options={
+      CONF_DOOR_SENSOR_MAP: {LOCK_ENTITY_ID: DOOR_SENSOR_ENTITY},  # only lock 1
+      CONF_GROUPS: [{
+        CONF_GROUP_NAME: "All Locks",
+        CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
+      }],
+    },
+    entry_id="test_partial_door",
+  )
+  entry.add_to_hass(hass)
+  with patch(_ZWAVE_HELPER, return_value=mock_node):
+    c = ZLockCoordinator(hass, entry)
+    await c.async_setup()
+
+  # lock 2 has no door sensor; lock 1 is locked+closed
+  hass.states.async_set(ALARM_TYPE_ENTITY, "24")
+  hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
+  hass.states.async_set(ALARM_TYPE_2, "22")  # lock 2 unlocked — ignored since no door sensor
+  await hass.async_block_till_done()
+
+  sensor = ZLockGroupCombinedStateSensor(c, entry, "All Locks", [LOCK_ENTITY_ID, LOCK_2])
+  assert sensor.native_value == "Locked"
+  c.async_teardown()