coordinator.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. from __future__ import annotations
  2. import logging
  3. import time
  4. from dataclasses import dataclass, field
  5. from typing import TYPE_CHECKING
  6. from homeassistant.core import HomeAssistant, Event, callback
  7. from homeassistant.helpers.event import async_track_state_change_event
  8. from homeassistant.helpers.storage import Store
  9. from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
  10. from .const import (
  11. DOMAIN,
  12. STORAGE_KEY,
  13. STORAGE_VERSION,
  14. CONF_LOCK_ENTITY_IDS,
  15. CONF_GROUPS,
  16. CONF_GROUP_NAME,
  17. CONF_DOOR_SENSOR_MAP,
  18. ALARM_MAP,
  19. AlarmMapping,
  20. DoorLockState,
  21. LockAction,
  22. UserSource,
  23. )
  24. if TYPE_CHECKING:
  25. from homeassistant.config_entries import ConfigEntry
  26. _LOGGER = logging.getLogger(__name__)
  27. @dataclass
  28. class SlotInfo:
  29. status: str # "enabled" or "disabled"
  30. name: str | None = None
  31. @dataclass
  32. class GroupState:
  33. last_action: LockAction | None = None
  34. last_user: str | None = None
  35. @dataclass
  36. class LockState:
  37. slots: dict[int, SlotInfo] = field(default_factory=dict)
  38. last_action: LockAction | None = None
  39. last_user: str | None = None
  40. door_open: bool | None = None
  41. def compute_combined_state(
  42. action: LockAction | None, door_open: bool | None
  43. ) -> DoorLockState | None:
  44. """Synthesize a single state from lock action and door position."""
  45. if action is None or door_open is None:
  46. return None
  47. if action == LockAction.LOCKED and not door_open:
  48. return DoorLockState.LOCKED
  49. if action == LockAction.UNLOCKED and door_open:
  50. return DoorLockState.OPEN
  51. if action == LockAction.LOCKED and door_open:
  52. return DoorLockState.JAMMED
  53. return DoorLockState.CLOSED # UNLOCKED + closed
  54. def _restore_action(value: str | None) -> LockAction | None:
  55. if value is None:
  56. return None
  57. try:
  58. return LockAction(value)
  59. except ValueError:
  60. return None
  61. def _alarm_entity(lock_entity_id: str, suffix: str) -> str:
  62. """Derive alarm sensor entity ID from lock entity ID.
  63. lock.living_room_front_lock -> sensor.living_room_front_lock_alarm_<suffix>
  64. """
  65. object_id = lock_entity_id.split(".")[-1]
  66. return f"sensor.{object_id}_alarm_{suffix}"
  67. class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
  68. """Manages slot data, name storage, and event listening for all configured locks."""
  69. def __init__(self, hass: HomeAssistant, entry: "ConfigEntry") -> None:
  70. super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=None)
  71. self._entry = entry
  72. self.lock_entity_ids: list[str] = entry.data[CONF_LOCK_ENTITY_IDS]
  73. self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
  74. self._names: dict[str, str] = {} # "{entity_id}:{slot_num}" → name
  75. self._alarm_type_to_lock: dict[str, str] = {
  76. _alarm_entity(eid, "type"): eid for eid in self.lock_entity_ids
  77. }
  78. self._alarm_level_to_lock: dict[str, str] = {
  79. _alarm_entity(eid, "level"): eid for eid in self.lock_entity_ids
  80. }
  81. door_sensor_map: dict[str, str] = entry.options.get(CONF_DOOR_SENSOR_MAP, {})
  82. self._door_sensor_to_lock: dict[str, str] = {
  83. sensor_eid: lock_eid for lock_eid, sensor_eid in door_sensor_map.items()
  84. if lock_eid in self.lock_entity_ids
  85. }
  86. # State machine for pairing alarm_type and alarm_level, which arrive separately.
  87. # _pending_slot_alarm: alarm_type fired with SLOT source; waiting for alarm_level.
  88. # _recent_level: alarm_level arrived first; alarm_type may follow within the window.
  89. self._pending_slot_alarm: dict[str, AlarmMapping] = {}
  90. self._recent_level: dict[str, tuple[int, float]] = {} # lock → (slot, monotonic_time)
  91. self._unsub_event: callback | None = None
  92. self.data: dict[str, LockState] = {eid: LockState() for eid in self.lock_entity_ids}
  93. groups: list[dict] = entry.options.get(CONF_GROUPS, [])
  94. self.group_data: dict[str, GroupState] = {
  95. g[CONF_GROUP_NAME]: GroupState() for g in groups
  96. }
  97. # group name → list of member lock entity_ids
  98. self._group_to_locks: dict[str, list[str]] = {
  99. g[CONF_GROUP_NAME]: list(g.get(CONF_LOCK_ENTITY_IDS, [])) for g in groups
  100. }
  101. # lock_entity_id → list of group names it belongs to
  102. self._lock_to_groups: dict[str, list[str]] = {}
  103. for g in groups:
  104. for lid in g.get(CONF_LOCK_ENTITY_IDS, []):
  105. self._lock_to_groups.setdefault(lid, []).append(g[CONF_GROUP_NAME])
  106. def group_lock_ids(self, group_name: str) -> list[str]:
  107. return self._group_to_locks.get(group_name, [])
  108. async def async_setup(self) -> None:
  109. stored = await self._store.async_load()
  110. if stored:
  111. self._names = stored.get("names", {})
  112. last_events = stored.get("last_events", {})
  113. for eid, ev in last_events.get("locks", {}).items():
  114. if eid in self.data:
  115. self.data[eid].last_action = _restore_action(ev.get("last_action"))
  116. self.data[eid].last_user = ev.get("last_user")
  117. for gname, ev in last_events.get("groups", {}).items():
  118. if gname in self.group_data:
  119. self.group_data[gname].last_action = _restore_action(ev.get("last_action"))
  120. self.group_data[gname].last_user = ev.get("last_user")
  121. await self._async_fetch_all_slots()
  122. self._unsub_event = async_track_state_change_event(
  123. self.hass,
  124. (
  125. list(self._alarm_type_to_lock)
  126. + list(self._alarm_level_to_lock)
  127. + list(self._door_sensor_to_lock)
  128. ),
  129. self._handle_alarm_state_change,
  130. )
  131. # async_track_state_change_event only fires on changes; seed door_open from
  132. # whatever state the sensor already has if it published before we loaded.
  133. for sensor_eid, lock_eid in self._door_sensor_to_lock.items():
  134. state = self.hass.states.get(sensor_eid)
  135. if state and state.state in ("on", "off"):
  136. self.data[lock_eid].door_open = state.state == "on"
  137. if self._door_sensor_to_lock:
  138. self.async_update_listeners()
  139. def async_teardown(self) -> None:
  140. if self._unsub_event:
  141. self._unsub_event()
  142. self._unsub_event = None
  143. async def _async_fetch_all_slots(self) -> None:
  144. for lock_entity_id in self.lock_entity_ids:
  145. slots = await self._fetch_slots(lock_entity_id)
  146. self.data[lock_entity_id].slots = slots
  147. self.async_update_listeners()
  148. async def _fetch_slots(self, lock_entity_id: str) -> dict[int, SlotInfo]:
  149. try:
  150. from homeassistant.components.zwave_js.helpers import async_get_node_from_entity_id
  151. node = async_get_node_from_entity_id(self.hass, lock_entity_id)
  152. users = await node.access_control.get_users_cached()
  153. except Exception:
  154. _LOGGER.exception("Failed to fetch code slots for %s", lock_entity_id)
  155. return {}
  156. slots: dict[int, SlotInfo] = {}
  157. for user in users:
  158. slot_num = user.user_id
  159. if slot_num == 0:
  160. continue
  161. slots[slot_num] = SlotInfo(
  162. status="enabled" if user.active else "disabled",
  163. name=self.get_name(lock_entity_id, slot_num),
  164. )
  165. return slots
  166. # alarm_type and alarm_level are separate Z-Wave parameters and can arrive in
  167. # either order. We subscribe to both and pair them with a short time window.
  168. _LEVEL_WINDOW_S: float = 5.0
  169. @callback
  170. def _handle_alarm_state_change(self, event: Event) -> None:
  171. entity_id: str = event.data["entity_id"]
  172. new_state = event.data.get("new_state")
  173. if new_state is None:
  174. return
  175. if lock_entity_id := self._alarm_type_to_lock.get(entity_id):
  176. self._on_alarm_type(lock_entity_id, new_state.state)
  177. elif lock_entity_id := self._alarm_level_to_lock.get(entity_id):
  178. self._on_alarm_level(lock_entity_id, new_state.state)
  179. elif lock_entity_id := self._door_sensor_to_lock.get(entity_id):
  180. self._on_door_sensor(lock_entity_id, new_state.state)
  181. @callback
  182. def _on_alarm_type(self, lock_entity_id: str, state: str) -> None:
  183. # Clear any pending SLOT alarm — a new alarm_type supersedes it.
  184. self._pending_slot_alarm.pop(lock_entity_id, None)
  185. try:
  186. alarm_type = int(state)
  187. except (ValueError, TypeError):
  188. return
  189. mapping = ALARM_MAP.get(alarm_type)
  190. if not mapping:
  191. return
  192. if mapping.user_source == UserSource.MANUAL:
  193. self._emit_alarm(lock_entity_id, mapping.action, "<Manual>")
  194. elif mapping.user_source == UserSource.RADIO:
  195. self._emit_alarm(lock_entity_id, mapping.action, "<Radio>")
  196. else:
  197. # SLOT: check if alarm_level arrived recently (level-first ordering).
  198. recent = self._recent_level.get(lock_entity_id)
  199. if recent and (time.monotonic() - recent[1]) < self._LEVEL_WINDOW_S:
  200. slot_num = recent[0]
  201. self._emit_alarm(lock_entity_id, mapping.action,
  202. self.get_name(lock_entity_id, slot_num) or str(slot_num))
  203. else:
  204. # Level not yet received; store pending and resolve when it arrives.
  205. self._pending_slot_alarm[lock_entity_id] = mapping
  206. @callback
  207. def _on_alarm_level(self, lock_entity_id: str, state: str) -> None:
  208. try:
  209. slot_num = int(state)
  210. except (ValueError, TypeError):
  211. return
  212. self._recent_level[lock_entity_id] = (slot_num, time.monotonic())
  213. # Resolve a pending SLOT alarm that arrived before the level.
  214. pending = self._pending_slot_alarm.pop(lock_entity_id, None)
  215. if pending:
  216. self._emit_alarm(lock_entity_id, pending.action,
  217. self.get_name(lock_entity_id, slot_num) or str(slot_num))
  218. @callback
  219. def _on_door_sensor(self, lock_entity_id: str, state: str) -> None:
  220. if state not in ("on", "off"):
  221. return
  222. self.data[lock_entity_id].door_open = state == "on"
  223. self.async_update_listeners()
  224. @callback
  225. def _emit_alarm(self, lock_entity_id: str, action: str, user_str: str) -> None:
  226. self.data[lock_entity_id].last_action = action
  227. self.data[lock_entity_id].last_user = user_str
  228. for group_name in self._lock_to_groups.get(lock_entity_id, []):
  229. self.group_data[group_name].last_action = action
  230. self.group_data[group_name].last_user = user_str
  231. # Debounced: bursts of lock events coalesce into one Store write.
  232. self._store.async_delay_save(self._data_to_save, self._SAVE_DELAY_S)
  233. self.async_update_listeners()
  234. def get_name(self, lock_entity_id: str, slot_num: int) -> str | None:
  235. return self._names.get(f"{lock_entity_id}:{slot_num}")
  236. _SAVE_DELAY_S: float = 1.0
  237. @callback
  238. def _data_to_save(self) -> dict:
  239. return {
  240. "names": self._names,
  241. "last_events": {
  242. "locks": {
  243. eid: {
  244. "last_action": s.last_action.value if s.last_action else None,
  245. "last_user": s.last_user,
  246. }
  247. for eid, s in self.data.items()
  248. if s.last_action is not None or s.last_user is not None
  249. },
  250. "groups": {
  251. gname: {
  252. "last_action": g.last_action.value if g.last_action else None,
  253. "last_user": g.last_user,
  254. }
  255. for gname, g in self.group_data.items()
  256. if g.last_action is not None or g.last_user is not None
  257. },
  258. },
  259. }
  260. async def _async_save(self) -> None:
  261. await self._store.async_save(self._data_to_save())
  262. async def async_set_name(self, lock_entity_id: str, slot_num: int, name: str) -> None:
  263. key = f"{lock_entity_id}:{slot_num}"
  264. if name:
  265. self._names[key] = name
  266. else:
  267. self._names.pop(key, None)
  268. await self._async_save()
  269. state = self.data.get(lock_entity_id)
  270. if state and slot_num in state.slots:
  271. state.slots[slot_num].name = name or None
  272. self.async_update_listeners()
  273. async def async_refresh_lock(self, lock_entity_id: str) -> None:
  274. slots = await self._fetch_slots(lock_entity_id)
  275. self.data[lock_entity_id].slots = slots
  276. self.async_update_listeners()
  277. async def _async_update_data(self) -> dict[str, LockState]:
  278. await self._async_fetch_all_slots()
  279. return self.data