coordinator.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  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. ALARM_MAP,
  18. UserSource,
  19. )
  20. if TYPE_CHECKING:
  21. from homeassistant.config_entries import ConfigEntry
  22. _LOGGER = logging.getLogger(__name__)
  23. @dataclass
  24. class SlotInfo:
  25. status: str # "enabled" or "disabled"
  26. name: str | None = None
  27. @dataclass
  28. class GroupState:
  29. last_action: str | None = None
  30. last_user: str | None = None
  31. @dataclass
  32. class LockState:
  33. slots: dict[int, SlotInfo] = field(default_factory=dict)
  34. last_action: str | None = None
  35. last_user: str | None = None
  36. def _alarm_entity(lock_entity_id: str, suffix: str) -> str:
  37. """Derive alarm sensor entity ID from lock entity ID.
  38. lock.living_room_front_lock -> sensor.living_room_front_lock_alarm_<suffix>
  39. """
  40. object_id = lock_entity_id.split(".")[-1]
  41. return f"sensor.{object_id}_alarm_{suffix}"
  42. class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
  43. """Manages slot data, name storage, and event listening for all configured locks."""
  44. def __init__(self, hass: HomeAssistant, entry: "ConfigEntry") -> None:
  45. super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=None)
  46. self._entry = entry
  47. self._lock_entity_ids: list[str] = entry.data[CONF_LOCK_ENTITY_IDS]
  48. self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
  49. self._names: dict[str, str] = {} # "{entity_id}:{slot_num}" → name
  50. self._alarm_type_to_lock: dict[str, str] = {
  51. _alarm_entity(eid, "type"): eid for eid in self._lock_entity_ids
  52. }
  53. self._alarm_level_to_lock: dict[str, str] = {
  54. _alarm_entity(eid, "level"): eid for eid in self._lock_entity_ids
  55. }
  56. # State machine for pairing alarm_type and alarm_level, which arrive separately.
  57. # _pending_slot_alarm: alarm_type fired with SLOT source; waiting for alarm_level.
  58. # _recent_level: alarm_level arrived first; alarm_type may follow within the window.
  59. self._pending_slot_alarm: dict[str, AlarmMapping] = {}
  60. self._recent_level: dict[str, tuple[int, float]] = {} # lock → (slot, monotonic_time)
  61. self._unsub_event: callback | None = None
  62. self.data: dict[str, LockState] = {eid: LockState() for eid in self._lock_entity_ids}
  63. groups: list[dict] = entry.options.get(CONF_GROUPS, [])
  64. self.group_data: dict[str, GroupState] = {
  65. g[CONF_GROUP_NAME]: GroupState() for g in groups
  66. }
  67. # lock_entity_id → list of group names it belongs to
  68. self._lock_to_groups: dict[str, list[str]] = {}
  69. for g in groups:
  70. for lid in g.get(CONF_LOCK_ENTITY_IDS, []):
  71. self._lock_to_groups.setdefault(lid, []).append(g[CONF_GROUP_NAME])
  72. async def async_setup(self) -> None:
  73. stored = await self._store.async_load()
  74. if stored:
  75. self._names = stored.get("names", {})
  76. last_events = stored.get("last_events", {})
  77. for eid, ev in last_events.get("locks", {}).items():
  78. if eid in self.data:
  79. self.data[eid].last_action = ev.get("last_action")
  80. self.data[eid].last_user = ev.get("last_user")
  81. for gname, ev in last_events.get("groups", {}).items():
  82. if gname in self.group_data:
  83. self.group_data[gname].last_action = ev.get("last_action")
  84. self.group_data[gname].last_user = ev.get("last_user")
  85. await self._async_fetch_all_slots()
  86. self._unsub_event = async_track_state_change_event(
  87. self.hass,
  88. list(self._alarm_type_to_lock) + list(self._alarm_level_to_lock),
  89. self._handle_alarm_state_change,
  90. )
  91. def async_teardown(self) -> None:
  92. if self._unsub_event:
  93. self._unsub_event()
  94. self._unsub_event = None
  95. async def _async_fetch_all_slots(self) -> None:
  96. for lock_entity_id in self._lock_entity_ids:
  97. slots = await self._fetch_slots(lock_entity_id)
  98. self.data[lock_entity_id].slots = slots
  99. self.async_update_listeners()
  100. async def _fetch_slots(self, lock_entity_id: str) -> dict[int, SlotInfo]:
  101. try:
  102. from homeassistant.components.zwave_js.helpers import async_get_node_from_entity_id
  103. node = async_get_node_from_entity_id(self.hass, lock_entity_id)
  104. users = await node.access_control.get_users_cached()
  105. except Exception:
  106. _LOGGER.exception("Failed to fetch code slots for %s", lock_entity_id)
  107. return {}
  108. slots: dict[int, SlotInfo] = {}
  109. for user in users:
  110. slot_num = user.user_id
  111. if slot_num == 0:
  112. continue
  113. slots[slot_num] = SlotInfo(
  114. status="enabled" if user.active else "disabled",
  115. name=self.get_name(lock_entity_id, slot_num),
  116. )
  117. return slots
  118. # alarm_type and alarm_level are separate Z-Wave parameters and can arrive in
  119. # either order. We subscribe to both and pair them with a short time window.
  120. _LEVEL_WINDOW_S: float = 5.0
  121. @callback
  122. def _handle_alarm_state_change(self, event: Event) -> None:
  123. entity_id: str = event.data["entity_id"]
  124. new_state = event.data.get("new_state")
  125. if new_state is None:
  126. return
  127. if lock_entity_id := self._alarm_type_to_lock.get(entity_id):
  128. self._on_alarm_type(lock_entity_id, new_state.state)
  129. elif lock_entity_id := self._alarm_level_to_lock.get(entity_id):
  130. self._on_alarm_level(lock_entity_id, new_state.state)
  131. @callback
  132. def _on_alarm_type(self, lock_entity_id: str, state: str) -> None:
  133. # Clear any pending SLOT alarm — a new alarm_type supersedes it.
  134. self._pending_slot_alarm.pop(lock_entity_id, None)
  135. try:
  136. alarm_type = int(state)
  137. except (ValueError, TypeError):
  138. return
  139. mapping = ALARM_MAP.get(alarm_type)
  140. if not mapping:
  141. return
  142. if mapping.user_source == UserSource.MANUAL:
  143. self._emit_alarm(lock_entity_id, mapping.action, "<Manual>")
  144. elif mapping.user_source == UserSource.RADIO:
  145. self._emit_alarm(lock_entity_id, mapping.action, "<Radio>")
  146. else:
  147. # SLOT: check if alarm_level arrived recently (level-first ordering).
  148. recent = self._recent_level.get(lock_entity_id)
  149. if recent and (time.monotonic() - recent[1]) < self._LEVEL_WINDOW_S:
  150. slot_num = recent[0]
  151. self._emit_alarm(lock_entity_id, mapping.action,
  152. self.get_name(lock_entity_id, slot_num) or str(slot_num))
  153. else:
  154. # Level not yet received; store pending and resolve when it arrives.
  155. self._pending_slot_alarm[lock_entity_id] = mapping
  156. @callback
  157. def _on_alarm_level(self, lock_entity_id: str, state: str) -> None:
  158. try:
  159. slot_num = int(state)
  160. except (ValueError, TypeError):
  161. return
  162. self._recent_level[lock_entity_id] = (slot_num, time.monotonic())
  163. # Resolve a pending SLOT alarm that arrived before the level.
  164. pending = self._pending_slot_alarm.pop(lock_entity_id, None)
  165. if pending:
  166. self._emit_alarm(lock_entity_id, pending.action,
  167. self.get_name(lock_entity_id, slot_num) or str(slot_num))
  168. @callback
  169. def _emit_alarm(self, lock_entity_id: str, action: str, user_str: str) -> None:
  170. self.data[lock_entity_id].last_action = action
  171. self.data[lock_entity_id].last_user = user_str
  172. for group_name in self._lock_to_groups.get(lock_entity_id, []):
  173. self.group_data[group_name].last_action = action
  174. self.group_data[group_name].last_user = user_str
  175. self.hass.async_create_task(self._async_save())
  176. self.async_update_listeners()
  177. def get_name(self, lock_entity_id: str, slot_num: int) -> str | None:
  178. return self._names.get(f"{lock_entity_id}:{slot_num}")
  179. async def _async_save(self) -> None:
  180. await self._store.async_save({
  181. "names": self._names,
  182. "last_events": {
  183. "locks": {
  184. eid: {"last_action": s.last_action, "last_user": s.last_user}
  185. for eid, s in self.data.items()
  186. if s.last_action is not None or s.last_user is not None
  187. },
  188. "groups": {
  189. gname: {"last_action": g.last_action, "last_user": g.last_user}
  190. for gname, g in self.group_data.items()
  191. if g.last_action is not None or g.last_user is not None
  192. },
  193. },
  194. })
  195. async def async_set_name(self, lock_entity_id: str, slot_num: int, name: str) -> None:
  196. key = f"{lock_entity_id}:{slot_num}"
  197. if name:
  198. self._names[key] = name
  199. else:
  200. self._names.pop(key, None)
  201. await self._async_save()
  202. state = self.data.get(lock_entity_id)
  203. if state and slot_num in state.slots:
  204. state.slots[slot_num].name = name or None
  205. self.async_update_listeners()
  206. async def async_refresh_lock(self, lock_entity_id: str) -> None:
  207. slots = await self._fetch_slots(lock_entity_id)
  208. self.data[lock_entity_id].slots = slots
  209. self.async_update_listeners()
  210. async def _async_update_data(self) -> dict[str, LockState]:
  211. await self._async_fetch_all_slots()
  212. return self.data