Forráskód Böngészése

Introduce LockAction enum; capitalize Locked/Unlocked action strings

Replaces bare "locked"/"unlocked" strings with a LockAction enum so
action values have strong typing and a single place to change display
strings. Enum values are "Locked"/"Unlocked" (capitalized). Sensors
expose .value at the HA boundary; storage serializes/deserializes the
same string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ZiB 4 hónapja
szülő
commit
eb4ece89a3

+ 12 - 7
custom_components/zlock_slots/const.py

@@ -19,19 +19,24 @@ class UserSource(Enum):
   SLOT   = "slot"    # last_user = slot name or number from alarm_level
 
 
+class LockAction(Enum):
+  LOCKED   = "Locked"
+  UNLOCKED = "Unlocked"
+
+
 @dataclass(frozen=True)
 class AlarmMapping:
-  action: str        # "locked" | "unlocked"
+  action: LockAction
   user_source: UserSource
 
 
 # Maps alarm_type value → lock action and how to derive last_user.
 # alarm_level meaning varies by alarm_type; only SLOT entries use it.
 ALARM_MAP: dict[int, AlarmMapping] = {
-  18: AlarmMapping("locked",   UserSource.SLOT),
-  19: AlarmMapping("unlocked", UserSource.SLOT),
-  21: AlarmMapping("locked",   UserSource.MANUAL),
-  22: AlarmMapping("unlocked", UserSource.MANUAL),
-  24: AlarmMapping("locked",   UserSource.RADIO),
-  25: AlarmMapping("unlocked", UserSource.RADIO),
+  18: AlarmMapping(LockAction.LOCKED,   UserSource.SLOT),
+  19: AlarmMapping(LockAction.UNLOCKED, UserSource.SLOT),
+  21: AlarmMapping(LockAction.LOCKED,   UserSource.MANUAL),
+  22: AlarmMapping(LockAction.UNLOCKED, UserSource.MANUAL),
+  24: AlarmMapping(LockAction.LOCKED,   UserSource.RADIO),
+  25: AlarmMapping(LockAction.UNLOCKED, UserSource.RADIO),
 }

+ 22 - 6
custom_components/zlock_slots/coordinator.py

@@ -19,6 +19,7 @@ from .const import (
   CONF_GROUP_NAME,
   ALARM_MAP,
   AlarmMapping,
+  LockAction,
   UserSource,
 )
 
@@ -36,17 +37,26 @@ class SlotInfo:
 
 @dataclass
 class GroupState:
-  last_action: str | None = None
+  last_action: LockAction | None = None
   last_user: str | None = None
 
 
 @dataclass
 class LockState:
   slots: dict[int, SlotInfo] = field(default_factory=dict)
-  last_action: str | None = None
+  last_action: LockAction | None = None
   last_user: str | None = None
 
 
+def _restore_action(value: str | None) -> LockAction | None:
+  if value is None:
+    return None
+  try:
+    return LockAction(value)
+  except ValueError:
+    return None
+
+
 def _alarm_entity(lock_entity_id: str, suffix: str) -> str:
   """Derive alarm sensor entity ID from lock entity ID.
 
@@ -96,11 +106,11 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
       last_events = stored.get("last_events", {})
       for eid, ev in last_events.get("locks", {}).items():
         if eid in self.data:
-          self.data[eid].last_action = ev.get("last_action")
+          self.data[eid].last_action = _restore_action(ev.get("last_action"))
           self.data[eid].last_user = ev.get("last_user")
       for gname, ev in last_events.get("groups", {}).items():
         if gname in self.group_data:
-          self.group_data[gname].last_action = ev.get("last_action")
+          self.group_data[gname].last_action = _restore_action(ev.get("last_action"))
           self.group_data[gname].last_user = ev.get("last_user")
 
     await self._async_fetch_all_slots()
@@ -224,12 +234,18 @@ class ZLockCoordinator(DataUpdateCoordinator[dict[str, LockState]]):
       "names": self._names,
       "last_events": {
         "locks": {
-          eid: {"last_action": s.last_action, "last_user": s.last_user}
+          eid: {
+            "last_action": s.last_action.value if s.last_action else None,
+            "last_user": s.last_user,
+          }
           for eid, s in self.data.items()
           if s.last_action is not None or s.last_user is not None
         },
         "groups": {
-          gname: {"last_action": g.last_action, "last_user": g.last_user}
+          gname: {
+            "last_action": g.last_action.value if g.last_action else None,
+            "last_user": g.last_user,
+          }
           for gname, g in self.group_data.items()
           if g.last_action is not None or g.last_user is not None
         },

+ 2 - 2
custom_components/zlock_slots/sensor.py

@@ -131,7 +131,7 @@ class ZLockLastActionSensor(_ZLockBaseSensor):
   @property
   def native_value(self) -> str | None:
     state = self._lock_state
-    return state.last_action if state else None
+    return state.last_action.value if state and state.last_action else None
 
 
 class ZLockLastUserSensor(_ZLockBaseSensor):
@@ -213,7 +213,7 @@ class ZLockGroupLastActionSensor(_ZLockGroupBaseSensor):
   @property
   def native_value(self) -> str | None:
     state = self._group_state
-    return state.last_action if state else None
+    return state.last_action.value if state and state.last_action else None
 
 
 class ZLockGroupLastUserSensor(_ZLockGroupBaseSensor):

+ 16 - 16
tests/test_coordinator.py

@@ -6,7 +6,7 @@ from pytest_homeassistant_custom_component.common import flush_store
 
 from custom_components.zlock_slots.coordinator import ZLockCoordinator
 from custom_components.zlock_slots.const import (
-  DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME,
+  DOMAIN, CONF_LOCK_ENTITY_IDS, CONF_GROUPS, CONF_GROUP_NAME, LockAction,
 )
 from tests.conftest import (
   LOCK_ENTITY_ID, ALARM_TYPE_ENTITY, ALARM_LEVEL_ENTITY,
@@ -129,7 +129,7 @@ async def test_keypad_lock_sets_action_and_user(hass, coord):
   hass.states.async_set(ALARM_TYPE_ENTITY, "18")
   await hass.async_block_till_done()
 
-  assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
+  assert coord.data[LOCK_ENTITY_ID].last_action == LockAction.LOCKED
   assert coord.data[LOCK_ENTITY_ID].last_user == "1"
 
 
@@ -144,31 +144,31 @@ async def test_keypad_lock_uses_slot_name(hass, coord):
 
 async def test_keypad_unlock(hass, coord):
   await _fire_alarm(hass, "19", alarm_level="2")
-  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_action == LockAction.UNLOCKED
   assert coord.data[LOCK_ENTITY_ID].last_user == "2"
 
 
 async def test_manual_lock(hass, coord):
   await _fire_alarm(hass, "21")
-  assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
+  assert coord.data[LOCK_ENTITY_ID].last_action == LockAction.LOCKED
   assert coord.data[LOCK_ENTITY_ID].last_user == "<Manual>"
 
 
 async def test_manual_unlock(hass, coord):
   await _fire_alarm(hass, "22")
-  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_action == LockAction.UNLOCKED
   assert coord.data[LOCK_ENTITY_ID].last_user == "<Manual>"
 
 
 async def test_radio_lock(hass, coord):
   await _fire_alarm(hass, "24")
-  assert coord.data[LOCK_ENTITY_ID].last_action == "locked"
+  assert coord.data[LOCK_ENTITY_ID].last_action == LockAction.LOCKED
   assert coord.data[LOCK_ENTITY_ID].last_user == "<Radio>"
 
 
 async def test_radio_unlock(hass, coord):
   await _fire_alarm(hass, "25")
-  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_action == LockAction.UNLOCKED
   assert coord.data[LOCK_ENTITY_ID].last_user == "<Radio>"
 
 
@@ -198,7 +198,7 @@ async def test_alarm_type_before_level_resolves_when_level_arrives(hass, coord):
   # 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_action == LockAction.UNLOCKED
   assert coord.data[LOCK_ENTITY_ID].last_user == "87"
 
 
@@ -228,7 +228,7 @@ async def test_stale_alarm_level_not_used(hass, coord):
   # 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_action == LockAction.UNLOCKED
   assert coord.data[LOCK_ENTITY_ID].last_user == "87"
 
 
@@ -241,7 +241,7 @@ async def test_new_alarm_type_clears_pending_slot_alarm(hass, coord):
   hass.states.async_set(ALARM_LEVEL_ENTITY, "5")
   await hass.async_block_till_done()
 
-  assert coord.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert coord.data[LOCK_ENTITY_ID].last_action == LockAction.UNLOCKED
 
 
 # ---------------------------------------------------------------------------
@@ -261,7 +261,7 @@ async def test_last_events_persisted_after_alarm(hass, config_entry, mock_node):
 
     stored = await c._store.async_load()
 
-  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "locked"
+  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "Locked"
   assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_user"] == "1"
   c.async_teardown()
 
@@ -281,7 +281,7 @@ async def test_last_events_restored_on_startup(hass, config_entry, mock_node):
     c2 = ZLockCoordinator(hass, config_entry)
     await c2.async_setup()
 
-  assert c2.data[LOCK_ENTITY_ID].last_action == "unlocked"
+  assert c2.data[LOCK_ENTITY_ID].last_action == LockAction.UNLOCKED
   assert c2.data[LOCK_ENTITY_ID].last_user == "<Manual>"
   c2.async_teardown()
 
@@ -299,7 +299,7 @@ async def test_names_still_present_after_alarm_save(hass, config_entry, mock_nod
     stored = await c._store.async_load()
 
   assert stored["names"][f"{LOCK_ENTITY_ID}:1"] == "Alice"
-  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "locked"
+  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "Locked"
   c.async_teardown()
 
 
@@ -321,7 +321,7 @@ async def test_alarm_saves_are_debounced(hass, coord):
   assert len(calls) == 1  # single coalesced write
 
   stored = await coord._store.async_load()
-  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "unlocked"
+  assert stored["last_events"]["locks"][LOCK_ENTITY_ID]["last_action"] == "Unlocked"
 
 
 # ---------------------------------------------------------------------------
@@ -351,7 +351,7 @@ async def test_group_state_updated_on_alarm(hass, group_config_entry, mock_node)
     await c.async_setup()
     await _fire_alarm(hass, "19", "1")
 
-  assert c.group_data["All Locks"].last_action == "unlocked"
+  assert c.group_data["All Locks"].last_action == LockAction.UNLOCKED
   assert c.group_data["All Locks"].last_user == "1"
   c.async_teardown()
 
@@ -368,6 +368,6 @@ async def test_group_last_events_restored(hass, group_config_entry, mock_node):
     c2 = ZLockCoordinator(hass, group_config_entry)
     await c2.async_setup()
 
-  assert c2.group_data["All Locks"].last_action == "locked"
+  assert c2.group_data["All Locks"].last_action == LockAction.LOCKED
   assert c2.group_data["All Locks"].last_user == "<Radio>"
   c2.async_teardown()

+ 2 - 2
tests/test_sensor.py

@@ -47,7 +47,7 @@ async def test_last_action_after_event(hass, coord, config_entry):
   await hass.async_block_till_done()
 
   sensor = ZLockLastActionSensor(coord, config_entry, LOCK_ENTITY_ID)
-  assert sensor.native_value == "locked"
+  assert sensor.native_value == "Locked"
 
 
 # ---------------------------------------------------------------------------
@@ -194,7 +194,7 @@ async def test_group_last_action_after_alarm(hass, group_config_entry, mock_node
     await hass.async_block_till_done()
 
   sensor = ZLockGroupLastActionSensor(c, group_config_entry, "All Locks")
-  assert sensor.native_value == "locked"
+  assert sensor.native_value == "Locked"
   c.async_teardown()