test_door_sensor.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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_open_seeded_from_existing_state_at_startup(hass, mock_node):
  74. """Sensor state already published before integration loads — no change event fires."""
  75. hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
  76. entry = _door_config_entry()
  77. entry.add_to_hass(hass)
  78. with patch(_ZWAVE_HELPER, return_value=mock_node):
  79. c = ZLockCoordinator(hass, entry)
  80. await c.async_setup()
  81. assert c.data[LOCK_ENTITY_ID].door_open is False
  82. c.async_teardown()
  83. async def test_door_open_seeded_open_at_startup(hass, mock_node):
  84. hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
  85. entry = _door_config_entry()
  86. entry.add_to_hass(hass)
  87. with patch(_ZWAVE_HELPER, return_value=mock_node):
  88. c = ZLockCoordinator(hass, entry)
  89. await c.async_setup()
  90. assert c.data[LOCK_ENTITY_ID].door_open is True
  91. c.async_teardown()
  92. async def test_door_unavailable_at_startup_not_seeded(hass, mock_node):
  93. hass.states.async_set(DOOR_SENSOR_ENTITY, "unavailable")
  94. entry = _door_config_entry()
  95. entry.add_to_hass(hass)
  96. with patch(_ZWAVE_HELPER, return_value=mock_node):
  97. c = ZLockCoordinator(hass, entry)
  98. await c.async_setup()
  99. assert c.data[LOCK_ENTITY_ID].door_open is None
  100. c.async_teardown()
  101. async def test_door_sensor_not_tracked_without_config(hass, mock_node):
  102. entry = MockConfigEntry(
  103. domain=DOMAIN,
  104. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID]},
  105. options={},
  106. entry_id="test_no_door",
  107. )
  108. entry.add_to_hass(hass)
  109. with patch(_ZWAVE_HELPER, return_value=mock_node):
  110. c = ZLockCoordinator(hass, entry)
  111. await c.async_setup()
  112. hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
  113. await hass.async_block_till_done()
  114. assert c.data[LOCK_ENTITY_ID].door_open is None
  115. c.async_teardown()
  116. # ---------------------------------------------------------------------------
  117. # ZLockDoorSensor
  118. # ---------------------------------------------------------------------------
  119. async def test_door_sensor_entity_none_initially(door_coord, hass):
  120. entry = door_coord._entry
  121. sensor = ZLockDoorSensor(door_coord, entry, LOCK_ENTITY_ID)
  122. assert sensor.native_value is None
  123. async def test_door_sensor_entity_closed(hass, door_coord):
  124. hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
  125. await hass.async_block_till_done()
  126. sensor = ZLockDoorSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  127. assert sensor.native_value == "Closed"
  128. async def test_door_sensor_entity_open(hass, door_coord):
  129. hass.states.async_set(DOOR_SENSOR_ENTITY, "on")
  130. await hass.async_block_till_done()
  131. sensor = ZLockDoorSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  132. assert sensor.native_value == "Open"
  133. # ---------------------------------------------------------------------------
  134. # ZLockCombinedStateSensor
  135. # ---------------------------------------------------------------------------
  136. async def test_combined_none_when_no_data(door_coord):
  137. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  138. assert sensor.native_value is None
  139. async def test_combined_none_when_only_door_known(hass, door_coord):
  140. hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
  141. await hass.async_block_till_done()
  142. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  143. assert sensor.native_value is None
  144. async def test_combined_none_when_only_action_known(hass, door_coord):
  145. hass.states.async_set(ALARM_TYPE_ENTITY, "24") # radio lock
  146. await hass.async_block_till_done()
  147. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  148. assert sensor.native_value is None
  149. async def test_combined_locked(hass, door_coord):
  150. hass.states.async_set(ALARM_TYPE_ENTITY, "24") # locked
  151. hass.states.async_set(DOOR_SENSOR_ENTITY, "off") # closed
  152. await hass.async_block_till_done()
  153. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  154. assert sensor.native_value == "Locked"
  155. async def test_combined_open(hass, door_coord):
  156. hass.states.async_set(ALARM_TYPE_ENTITY, "25") # unlocked
  157. hass.states.async_set(DOOR_SENSOR_ENTITY, "on") # open
  158. await hass.async_block_till_done()
  159. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  160. assert sensor.native_value == "Open"
  161. async def test_combined_jammed(hass, door_coord):
  162. hass.states.async_set(ALARM_TYPE_ENTITY, "24") # locked
  163. hass.states.async_set(DOOR_SENSOR_ENTITY, "on") # open
  164. await hass.async_block_till_done()
  165. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  166. assert sensor.native_value == "Jammed"
  167. async def test_combined_closed(hass, door_coord):
  168. hass.states.async_set(ALARM_TYPE_ENTITY, "25") # unlocked
  169. hass.states.async_set(DOOR_SENSOR_ENTITY, "off") # closed
  170. await hass.async_block_till_done()
  171. sensor = ZLockCombinedStateSensor(door_coord, door_coord._entry, LOCK_ENTITY_ID)
  172. assert sensor.native_value == "Closed"
  173. # ---------------------------------------------------------------------------
  174. # ZLockGroupCombinedStateSensor — aggregation rules
  175. # ---------------------------------------------------------------------------
  176. @pytest.fixture
  177. async def two_lock_coord(hass, mock_node):
  178. """Coordinator with two locks, both with door sensors."""
  179. entry = MockConfigEntry(
  180. domain=DOMAIN,
  181. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
  182. options={
  183. CONF_DOOR_SENSOR_MAP: {
  184. LOCK_ENTITY_ID: DOOR_SENSOR_ENTITY,
  185. LOCK_2: DOOR_SENSOR_2,
  186. },
  187. CONF_GROUPS: [{
  188. CONF_GROUP_NAME: "All Locks",
  189. CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
  190. }],
  191. },
  192. entry_id="test_two_lock",
  193. )
  194. entry.add_to_hass(hass)
  195. with patch(_ZWAVE_HELPER, return_value=mock_node):
  196. c = ZLockCoordinator(hass, entry)
  197. await c.async_setup()
  198. await hass.async_block_till_done()
  199. yield c
  200. c.async_teardown()
  201. def _group_sensor(coord) -> ZLockGroupCombinedStateSensor:
  202. return ZLockGroupCombinedStateSensor(
  203. coord, coord._entry, "All Locks", [LOCK_ENTITY_ID, LOCK_2]
  204. )
  205. async def _set_lock_state(hass, alarm_type_entity, door_entity, alarm_type, door_state):
  206. hass.states.async_set(alarm_type_entity, alarm_type)
  207. hass.states.async_set(door_entity, door_state)
  208. await hass.async_block_till_done()
  209. ALARM_TYPE_2 = "sensor.back_door_alarm_type"
  210. ALARM_LEVEL_2 = "sensor.back_door_alarm_level"
  211. async def test_group_combined_none_when_no_data(two_lock_coord):
  212. sensor = _group_sensor(two_lock_coord)
  213. assert sensor.native_value is None
  214. async def test_group_combined_all_locked(hass, two_lock_coord):
  215. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off")
  216. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "24", "off")
  217. assert _group_sensor(two_lock_coord).native_value == "Locked"
  218. async def test_group_combined_any_open_wins_over_locked(hass, two_lock_coord):
  219. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off") # Locked
  220. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "on") # Open
  221. assert _group_sensor(two_lock_coord).native_value == "Open"
  222. async def test_group_combined_jammed_beats_open(hass, two_lock_coord):
  223. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "on") # Jammed
  224. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "on") # Open
  225. assert _group_sensor(two_lock_coord).native_value == "Jammed"
  226. async def test_group_combined_jammed_beats_locked(hass, two_lock_coord):
  227. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "on") # Jammed
  228. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "24", "off") # Locked
  229. assert _group_sensor(two_lock_coord).native_value == "Jammed"
  230. async def test_group_combined_closed_when_mix_of_locked_and_closed(hass, two_lock_coord):
  231. await _set_lock_state(hass, ALARM_TYPE_ENTITY, DOOR_SENSOR_ENTITY, "24", "off") # Locked
  232. await _set_lock_state(hass, ALARM_TYPE_2, DOOR_SENSOR_2, "25", "off") # Closed
  233. assert _group_sensor(two_lock_coord).native_value == "Closed"
  234. async def test_group_combined_excludes_lock_without_door_sensor(hass, mock_node):
  235. """A lock with no door sensor must not affect group combined state."""
  236. entry = MockConfigEntry(
  237. domain=DOMAIN,
  238. data={CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2]},
  239. options={
  240. CONF_DOOR_SENSOR_MAP: {LOCK_ENTITY_ID: DOOR_SENSOR_ENTITY}, # only lock 1
  241. CONF_GROUPS: [{
  242. CONF_GROUP_NAME: "All Locks",
  243. CONF_LOCK_ENTITY_IDS: [LOCK_ENTITY_ID, LOCK_2],
  244. }],
  245. },
  246. entry_id="test_partial_door",
  247. )
  248. entry.add_to_hass(hass)
  249. with patch(_ZWAVE_HELPER, return_value=mock_node):
  250. c = ZLockCoordinator(hass, entry)
  251. await c.async_setup()
  252. # lock 2 has no door sensor; lock 1 is locked+closed
  253. hass.states.async_set(ALARM_TYPE_ENTITY, "24")
  254. hass.states.async_set(DOOR_SENSOR_ENTITY, "off")
  255. hass.states.async_set(ALARM_TYPE_2, "22") # lock 2 unlocked — ignored since no door sensor
  256. await hass.async_block_till_done()
  257. sensor = ZLockGroupCombinedStateSensor(c, entry, "All Locks", [LOCK_ENTITY_ID, LOCK_2])
  258. assert sensor.native_value == "Locked"
  259. c.async_teardown()