3
0

test_door_sensor.py 12 KB

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