Explorar o código

Fix alarm_type/alarm_level race: subscribe to both, pair with 5s window

ZiB hai 4 meses
pai
achega
d4f5624b73
Modificáronse 2 ficheiros con 117 adicións e 23 borrados
  1. 57 23
      custom_components/zlock_slots/coordinator.py
  2. 60 0
      tests/test_coordinator.py

+ 57 - 23
custom_components/zlock_slots/coordinator.py

@@ -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()
 

+ 60 - 0
tests/test_coordinator.py

@@ -177,6 +177,66 @@ async def test_non_integer_alarm_state_ignored(hass, coord):
   assert coord.data[LOCK_ENTITY_ID].last_action is None
 
 
+# ---------------------------------------------------------------------------
+# alarm_type / alarm_level arrival ordering
+# ---------------------------------------------------------------------------
+
+async def test_alarm_type_before_level_resolves_when_level_arrives(hass, coord):
+  """Reproduces the reported bug: type fires first with stale level in state machine."""
+  # Fire alarm_type with NO prior alarm_level update — nothing should emit yet.
+  hass.states.async_set(ALARM_TYPE_ENTITY, "19")
+  await hass.async_block_till_done()
+  assert coord.data[LOCK_ENTITY_ID].last_action is None
+
+  # Now alarm_level arrives — should resolve the pending action.
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "87")
+  await hass.async_block_till_done()
+  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_user == "87"
+
+
+async def test_alarm_type_before_level_uses_name(hass, coord):
+  await coord.async_set_name(LOCK_ENTITY_ID, 87, "Grace")
+  hass.states.async_set(ALARM_TYPE_ENTITY, "18")
+  await hass.async_block_till_done()
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "87")
+  await hass.async_block_till_done()
+  assert coord.data[LOCK_ENTITY_ID].last_user == "Grace"
+
+
+async def test_stale_alarm_level_not_used(hass, coord):
+  """An alarm_level older than the window must not be consumed by a later alarm_type."""
+  with patch("custom_components.zlock_slots.coordinator.time") as mock_time:
+    mock_time.monotonic.return_value = 0.0
+    hass.states.async_set(ALARM_LEVEL_ENTITY, "3")   # level=3 at t=0
+    await hass.async_block_till_done()
+
+    mock_time.monotonic.return_value = 10.0           # 10 s later — outside window
+    hass.states.async_set(ALARM_TYPE_ENTITY, "19")
+    await hass.async_block_till_done()
+
+  # Stale level rejected; event goes pending — nothing emitted yet.
+  assert coord.data[LOCK_ENTITY_ID].last_action is None
+
+  # Fresh level arrives and resolves the pending action.
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "87")
+  await hass.async_block_till_done()
+  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_user == "87"
+
+
+async def test_new_alarm_type_clears_pending_slot_alarm(hass, coord):
+  """A second alarm_type before level arrives should replace the first pending."""
+  hass.states.async_set(ALARM_TYPE_ENTITY, "18")   # lock pending
+  await hass.async_block_till_done()
+  hass.states.async_set(ALARM_TYPE_ENTITY, "19")   # unlock replaces it
+  await hass.async_block_till_done()
+  hass.states.async_set(ALARM_LEVEL_ENTITY, "5")
+  await hass.async_block_till_done()
+
+  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+
+
 # ---------------------------------------------------------------------------
 # last_events persistence and restore
 # ---------------------------------------------------------------------------