test_door_sensor.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. """Tests for door sensor integration: state tracking, combined state, group aggregation."""
  2. import pytest
  3. from unittest.mock import patch
  4. from pytest_homeassistant_custom_component.common import MockConfigEntry
  5. from custom_components.zlock_slots.coordinator import ZLockCoordinator, compute_combined_state
  6. from custom_components.zlock_slots.sensor import (
  7. ZLockDoorSensor,
  8. ZLockCombinedStateSensor,
  9. ZLockGroupCombinedStateSensor,
  10. )
  11. from custom_components.zlock_slots.const import (
  12. DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
  13. CONF_DOOR_SENSOR_MAP, LockAction, DoorLockState,
  14. )
  15. from tests.conftest import (
  16. LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY, DOOR_SENSOR_ENTITY,
  17. FakeNode,
  18. )
  19. LOCK_2 = "lock.back_door"
  20. DOOR_SENSOR_2 = "binary_sensor.back_door_contact"
  21. _ZWAVE_HELPER = "homeassistant.components.zwave_js.helpers.async_get_node_from_entity_id"
  22. def _door_config_entry(extra_lock_ids=None, door_sensor_map=None):
  23. lock_ids = [LOCK_ENTITY_ID] + (extra_lock_ids or [])
  24. return MockConfigEntry(
  25. domain=DOMAIN,
  26. data={CONF_LOCK_ENTITY_IDS: lock_ids},
  27. options={CONF_DOOR_SENSOR_MAP: door_sensor_map or {LOCK_ENTITY_ID: DOOR_SENSOR_ENTITY}},
  28. entry_id="test_door_entry",
  29. )
  30. @pytest.fixture
  31. async def door_coord(hass, mock_node):
  32. entry = _door_config_entry()
  33. entry.add_to_hass(hass)
  34. with patch(_ZWAVE_HELPER, return_value=mock_node):
  35. c = ZLockCoordinator(hass, entry)
  36. await c.async_setup()
  37. await hass.async_block_till_done()
  38. yield c
  39. c.async_teardown()
  40. # ---------------------------------------------------------------------------
  41. # compute_combined_state
  42. # ---------------------------------------------------------------------------
  43. @pytest.mark.parametrize("action,door_open,expected", [
  44. (LockAction.LOCKED, False, DoorLockState.LOCKED),
  45. (LockAction.UNLOCKED, True, DoorLockState.OPEN),
  46. (LockAction.LOCKED, True, DoorLockState.JAMMED),
  47. (LockAction.UNLOCKED, False, DoorLockState.CLOSED),
  48. (None, False, None),
  49. (LockAction.LOCKED, None, None),
  50. (None, None, None),
  51. ])
  52. def test_compute_combined_state(action, door_open, expected):
  53. assert compute_combined_state(action, door_open) == expected
  54. # ---------------------------------------------------------------------------
  55. # Coordinator door sensor event handling
  56. # ---------------------------------------------------------------------------
  57. async def test_door_sensor_off_sets_closed(hass, door_coord):
  58. hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
  59. await hass.async_block_till_done()
  60. assert door_coord.data[LOCK_ENTITY_ID].door_open is False
  61. async def test_door_sensor_on_sets_open(hass, door_coord):
  62. hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
  63. await hass.async_block_till_done()
  64. assert door_coord.data[LOCK_ENTITY_ID].door_open is True
  65. async def test_door_sensor_unavailable_ignored(hass, door_coord):
  66. hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
  67. await hass.async_block_till_done()
  68. hass.states.async_set(DOOR_SENSOR_ENTITY, "unavailable")
  69. await hass.async_block_till_done()
  70. assert door_coord.data[LOCK_ENTITY_ID].door_open is True # unchanged
  71. async def test_door_open_initially_none(hass, door_coord):
  72. assert door_coord.data[LOCK_ENTITY_ID].door_open is None
  73. async def test_door_sensor_not_tracked_without_config(hass, mock_node):
  74. entry = MockConfigEntry(
  75. domain=DOMAIN,
  76. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
  77. options={},
  78. entry_id="test_no_door",
  79. )
  80. entry.add_to_hass(hass)
  81. with patch(_ZWAVE_HELPER, return_value=mock_node):
  82. c = ZLockCoordinator(hass, entry)
  83. await c.async_setup()
  84. hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
  85. await hass.async_block_till_done()
  86. assert c.data[LOCK_ENTITY_ID].door_open is None
  87. c.async_teardown()
  88. # ---------------------------------------------------------------------------
  89. # ZLockDoorSensor
  90. # ---------------------------------------------------------------------------
  91. async def test_door_sensor_entity_none_initially(door_coord, hass):
  92. entry = door_coord._entry
  93. sensor = ZLockDoorSensor(door_coord, entry, LOCK_ENTITY_ID)
  94. assert sensor.native_value is None
  95. async def test_door_sensor_entity_closed(hass, door_coord):
  96. hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
  97. await hass.async_block_till_done()
  98. sensor = ZLockDoorSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  99. assert sensor.native_value == "Closed"
  100. async def test_door_sensor_entity_open(hass, door_coord):
  101. hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
  102. await hass.async_block_till_done()
  103. sensor = ZLockDoorSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  104. assert sensor.native_value == "Open"
  105. # ---------------------------------------------------------------------------
  106. # ZLockCombinedStateSensor
  107. # ---------------------------------------------------------------------------
  108. async def test_combined_none_when_no_data(door_coord):
  109. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  110. assert sensor.native_value is None
  111. async def test_combined_none_when_only_door_known(hass, door_coord):
  112. hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
  113. await hass.async_block_till_done()
  114. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  115. assert sensor.native_value is None
  116. async def test_combined_none_when_only_action_known(hass, door_coord):
  117. hass.states.async_set(ALARM_TYPE_ENTITY, "24") # radio lock
  118. await hass.async_block_till_done()
  119. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  120. assert sensor.native_value is None
  121. async def test_combined_locked(hass, door_coord):
  122. hass.states.async_set(ALARM_TYPE_ENTITY, "24") # locked
  123. hass.states.async_set(DOOR_SENSOR_ENTITY, "off") # closed
  124. await hass.async_block_till_done()
  125. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  126. assert sensor.native_value == "Locked"
  127. async def test_combined_open(hass, door_coord):
  128. hass.states.async_set(ALARM_TYPE_ENTITY, "25") # unlocked
  129. hass.states.async_set(DOOR_SENSOR_ENTITY, "on") # open
  130. await hass.async_block_till_done()
  131. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  132. assert sensor.native_value == "Open"
  133. async def test_combined_jammed(hass, door_coord):
  134. hass.states.async_set(ALARM_TYPE_ENTITY, "24") # locked
  135. hass.states.async_set(DOOR_SENSOR_ENTITY, "on") # open
  136. await hass.async_block_till_done()
  137. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  138. assert sensor.native_value == "Jammed"
  139. async def test_combined_closed(hass, door_coord):
  140. hass.states.async_set(ALARM_TYPE_ENTITY, "25") # unlocked
  141. hass.states.async_set(DOOR_SENSOR_ENTITY, "off") # closed
  142. await hass.async_block_till_done()
  143. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  144. assert sensor.native_value == "Closed"
  145. # ---------------------------------------------------------------------------
  146. # ZLockGroupCombinedStateSensor — aggregation rules
  147. # ---------------------------------------------------------------------------
  148. @pytest.fixture
  149. async def two_lock_coord(hass, mock_node):
  150. """Coordinator with two locks, both with door sensors."""
  151. entry = MockConfigEntry(
  152. domain=DOMAIN,
  153. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
  154. options={
  155. CONF_DOOR_SENSOR_MAP: {
  156. LOCK_ENTITY_ID: DOOR_SENSOR_ENTITY,
  157. LOCK_2: DOOR_SENSOR_2,
  158. },
  159. CONF_GROUPS: [{
  160. CONF_GROUP_NAME: "All Locks",
  161. CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
  162. }],
  163. },
  164. entry_id="test_two_lock",
  165. )
  166. entry.add_to_hass(hass)
  167. with patch(_ZWAVE_HELPER, return_value=mock_node):
  168. c = ZLockCoordinator(hass, entry)
  169. await c.async_setup()
  170. await hass.async_block_till_done()
  171. yield c
  172. c.async_teardown()
  173. def _group_sensor(coord) -> ZLockGroupCombinedStateSensor:
  174. return ZLockGroupCombinedStateSensor(
  175. coord, coord._entry, "All Locks", [LOCK_ENTITY_ID, LOCK_2]
  176. )
  177. async def _set_lock_state(hass, alarm_type_entity, door_entity, alarm_type, door_state):
  178. hass.states.async_set(alarm_type_entity, alarm_type)
  179. hass.states.async_set(door_entity, door_state)
  180. await hass.async_block_till_done()
  181. ALARM_TYPE_2 = "sensor.back_door_alarm_type"
  182. ALARM_LEVEL_2 = "sensor.back_door_alarm_level"
  183. async def test_group_combined_none_when_no_data(two_lock_coord):
  184. sensor = _group_sensor(two_lock_coord)
  185. assert sensor.native_value is None
  186. async def test_group_combined_all_locked(hass, two_lock_coord):
  187. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off")
  188. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "24", "off")
  189. assert _group_sensor(two_lock_coord).native_value == "Locked"
  190. async def test_group_combined_any_open_wins_over_locked(hass, two_lock_coord):
  191. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off") # Locked
  192. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "on") # Open
  193. assert _group_sensor(two_lock_coord).native_value == "Open"
  194. async def test_group_combined_jammed_beats_open(hass, two_lock_coord):
  195. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "on") # Jammed
  196. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "on") # Open
  197. assert _group_sensor(two_lock_coord).native_value == "Jammed"
  198. async def test_group_combined_jammed_beats_locked(hass, two_lock_coord):
  199. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "on") # Jammed
  200. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "24", "off") # Locked
  201. assert _group_sensor(two_lock_coord).native_value == "Jammed"
  202. async def test_group_combined_closed_when_mix_of_locked_and_closed(hass, two_lock_coord):
  203. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off") # Locked
  204. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "off") # Closed
  205. assert _group_sensor(two_lock_coord).native_value == "Closed"
  206. async def test_group_combined_excludes_lock_without_door_sensor(hass, mock_node):
  207. """A lock with no door sensor must not affect group combined state."""
  208. entry = MockConfigEntry(
  209. domain=DOMAIN,
  210. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
  211. options={
  212. CONF_DOOR_SENSOR_MAP: {LOCK_ENTITY_ID: DOOR_SENSOR_ENTITY}, # only lock 1
  213. CONF_GROUPS: [{
  214. CONF_GROUP_NAME: "All Locks",
  215. CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
  216. }],
  217. },
  218. entry_id="test_partial_door",
  219. )
  220. entry.add_to_hass(hass)
  221. with patch(_ZWAVE_HELPER, return_value=mock_node):
  222. c = ZLockCoordinator(hass, entry)
  223. await c.async_setup()
  224. # lock 2 has no door sensor; lock 1 is locked+closed
  225. hass.states.async_set(ALARM_TYPE_ENTITY, "24")
  226. hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
  227. hass.states.async_set(ALARM_TYPE_2, "22") # lock 2 unlocked — ignored since no door sensor
  228. await hass.async_block_till_done()
  229. sensor = ZLockGroupCombinedStateSensor(c, entry, "All Locks", [LOCK_ENTITY_ID, LOCK_2])
  230. assert sensor.native_value == "Locked"
  231. c.async_teardown()