|
|
@@ -1,6 +1,7 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import logging
|
|
|
+import time
|
|
|
from dataclasses import dataclass, field
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
|
@@ -63,10 +64,17 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
|
|
|
self._lock_entity_ids: list[str] = entry.data[CONF_LOCK_ENTITY_IDS]
|
|
|
self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
|
|
self._names: dict[str, str] = {} # "{entity_id}:{slot_num}" → name
|
|
|
- # alarm_type entity_id → lock_entity_id
|
|
|
- self._alarm_to_lock: dict[str, str] = {
|
|
|
+ self._alarm_type_to_lock: dict[str, str] = {
|
|
|
_alarm_entity(eid, "type"): eid for eid in self._lock_entity_ids
|
|
|
}
|
|
|
+ self._alarm_level_to_lock: dict[str, str] = {
|
|
|
+ _alarm_entity(eid, "level"): eid for eid in self._lock_entity_ids
|
|
|
+ }
|
|
|
+ # State machine for pairing alarm_type and alarm_level, which arrive separately.
|
|
|
+ # _pending_slot_alarm: alarm_type fired with SLOT source; waiting for alarm_level.
|
|
|
+ # _recent_level: alarm_level arrived first; alarm_type may follow within the window.
|
|
|
+ self._pending_slot_alarm: dict[str, AlarmMapping] = {}
|
|
|
+ self._recent_level: dict[str, tuple[int, float]] = {} # lock → (slot, monotonic_time)
|
|
|
self._unsub_event: callback | None = None
|
|
|
self.data: dict[str, LockState] = {eid: LockState() for eid in self._lock_entity_ids}
|
|
|
|
|
|
@@ -98,7 +106,7 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
|
|
|
|
|
|
self._unsub_event = async_track_state_change_event(
|
|
|
self.hass,
|
|
|
- list(self._alarm_to_lock),
|
|
|
+ list(self._alarm_type_to_lock) + list(self._alarm_level_to_lock),
|
|
|
self._handle_alarm_state_change,
|
|
|
)
|
|
|
|
|
|
@@ -133,19 +141,29 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
|
|
|
)
|
|
|
return slots
|
|
|
|
|
|
+ # alarm_type and alarm_level are separate Z-Wave parameters and can arrive in
|
|
|
+ # either order. We subscribe to both and pair them with a short time window.
|
|
|
+ _LEVEL_WINDOW_S: float = 5.0
|
|
|
+
|
|
|
@callback
|
|
|
def _handle_alarm_state_change(self, event: Event) -> None:
|
|
|
entity_id: str = event.data["entity_id"]
|
|
|
- lock_entity_id = self._alarm_to_lock.get(entity_id)
|
|
|
- if not lock_entity_id:
|
|
|
- return
|
|
|
-
|
|
|
new_state = event.data.get("new_state")
|
|
|
if new_state is None:
|
|
|
return
|
|
|
|
|
|
+ if lock_entity_id := self._alarm_type_to_lock.get(entity_id):
|
|
|
+ self._on_alarm_type(lock_entity_id, new_state.state)
|
|
|
+ elif lock_entity_id := self._alarm_level_to_lock.get(entity_id):
|
|
|
+ self._on_alarm_level(lock_entity_id, new_state.state)
|
|
|
+
|
|
|
+ @callback
|
|
|
+ def _on_alarm_type(self, lock_entity_id: str, state: str) -> None:
|
|
|
+ # Clear any pending SLOT alarm — a new alarm_type supersedes it.
|
|
|
+ self._pending_slot_alarm.pop(lock_entity_id, None)
|
|
|
+
|
|
|
try:
|
|
|
- alarm_type = int(new_state.state)
|
|
|
+ alarm_type = int(state)
|
|
|
except (ValueError, TypeError):
|
|
|
return
|
|
|
|
|
|
@@ -153,27 +171,43 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
|
|
|
if not mapping:
|
|
|
return
|
|
|
|
|
|
- if mapping.user_source == UserSource.SLOT:
|
|
|
- alarm_level_state = self.hass.states.get(_alarm_entity(lock_entity_id, "level"))
|
|
|
- try:
|
|
|
- slot_num = int(alarm_level_state.state)
|
|
|
- user_str = self.get_name(lock_entity_id, slot_num) or str(slot_num)
|
|
|
- except (AttributeError, ValueError, TypeError):
|
|
|
- user_str = "<Unknown>"
|
|
|
- elif mapping.user_source == UserSource.MANUAL:
|
|
|
- user_str = "<Manual>"
|
|
|
+ if mapping.user_source == UserSource.MANUAL:
|
|
|
+ self._emit_alarm(lock_entity_id, mapping.action, "<Manual>")
|
|
|
elif mapping.user_source == UserSource.RADIO:
|
|
|
- user_str = "<Radio>"
|
|
|
+ self._emit_alarm(lock_entity_id, mapping.action, "<Radio>")
|
|
|
else:
|
|
|
- user_str = "<Unknown>"
|
|
|
+ # SLOT: check if alarm_level arrived recently (level-first ordering).
|
|
|
+ recent = self._recent_level.get(lock_entity_id)
|
|
|
+ if recent and (time.monotonic() - recent[1]) < self._LEVEL_WINDOW_S:
|
|
|
+ slot_num = recent[0]
|
|
|
+ self._emit_alarm(lock_entity_id, mapping.action,
|
|
|
+ self.get_name(lock_entity_id, slot_num) or str(slot_num))
|
|
|
+ else:
|
|
|
+ # Level not yet received; store pending and resolve when it arrives.
|
|
|
+ self._pending_slot_alarm[lock_entity_id] = mapping
|
|
|
|
|
|
- self.data[lock_entity_id].last_action = mapping.action
|
|
|
- self.data[lock_entity_id].last_user = user_str
|
|
|
+ @callback
|
|
|
+ def _on_alarm_level(self, lock_entity_id: str, state: str) -> None:
|
|
|
+ try:
|
|
|
+ slot_num = int(state)
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ return
|
|
|
+
|
|
|
+ self._recent_level[lock_entity_id] = (slot_num, time.monotonic())
|
|
|
|
|
|
+ # Resolve a pending SLOT alarm that arrived before the level.
|
|
|
+ pending = self._pending_slot_alarm.pop(lock_entity_id, None)
|
|
|
+ if pending:
|
|
|
+ self._emit_alarm(lock_entity_id, pending.action,
|
|
|
+ self.get_name(lock_entity_id, slot_num) or str(slot_num))
|
|
|
+
|
|
|
+ @callback
|
|
|
+ def _emit_alarm(self, lock_entity_id: str, action: str, user_str: str) -> None:
|
|
|
+ self.data[lock_entity_id].last_action = action
|
|
|
+ self.data[lock_entity_id].last_user = user_str
|
|
|
for group_name in self._lock_to_groups.get(lock_entity_id, []):
|
|
|
- self.group_data[group_name].last_action = mapping.action
|
|
|
+ self.group_data[group_name].last_action = action
|
|
|
self.group_data[group_name].last_user = user_str
|
|
|
-
|
|
|
self.hass.async_create_task(self._async_save())
|
|
|
self.async_update_listeners()
|
|
|
|