From 2149f836afb306ebfc9328bc8989253c76def30d Mon Sep 17 00:00:00 2001 From: Andrew Grimberg Date: Mon, 16 Mar 2026 05:50:13 -0700 Subject: [PATCH] feat: add Last Used event entity per code slot Add an EventEntity per code slot that records when the slot was last used to unlock the lock. EventEntity extends RestoreEntity, so the timestamp automatically survives Home Assistant restarts. The entity listens for the existing EVENT_KEYMASTER_LOCK_STATE_CHANGED bus event and triggers on matching unlock events. A new EVENT_KEYMASTER_CODE_SLOT_RESET bus event is fired from reset_code_slot() so the entity can clear its state when a slot is reset. The Last Used entity also appears on the generated Lovelace dashboard between Active and Sync Status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Andrew Grimberg --- custom_components/keymaster/const.py | 2 + custom_components/keymaster/coordinator.py | 10 + custom_components/keymaster/event.py | 162 +++++++ custom_components/keymaster/lovelace.py | 2 + tests/test_coordinator.py | 36 ++ tests/test_event.py | 496 +++++++++++++++++++++ tests/test_init.py | 20 +- tests/test_lovelace.py | 1 + tests/test_sensor.py | 4 +- 9 files changed, 727 insertions(+), 6 deletions(-) create mode 100644 custom_components/keymaster/event.py create mode 100644 tests/test_event.py diff --git a/custom_components/keymaster/const.py b/custom_components/keymaster/const.py index e45061c8..ffdeaba7 100644 --- a/custom_components/keymaster/const.py +++ b/custom_components/keymaster/const.py @@ -16,6 +16,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.DATETIME, + Platform.EVENT, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, @@ -37,6 +38,7 @@ # Events EVENT_KEYMASTER_LOCK_STATE_CHANGED = "keymaster_lock_state_changed" +EVENT_KEYMASTER_CODE_SLOT_RESET = "keymaster_code_slot_reset" # Event data constants ATTR_ACTION_CODE = "action_code" diff --git a/custom_components/keymaster/coordinator.py b/custom_components/keymaster/coordinator.py index b631caa5..faf4de3e 100644 --- a/custom_components/keymaster/coordinator.py +++ b/custom_components/keymaster/coordinator.py @@ -45,6 +45,7 @@ BACKOFF_MAX_SECONDS, DAY_NAMES, DOMAIN, + EVENT_KEYMASTER_CODE_SLOT_RESET, EVENT_KEYMASTER_LOCK_STATE_CHANGED, ISSUE_URL, QUICK_REFRESH_SECONDS, @@ -1382,6 +1383,15 @@ async def reset_code_slot(self, config_entry_id: str, code_slot_num: int) -> Non accesslimit_day_of_week=dow_slots, ) kmlock.code_slots[code_slot_num] = new_kmslot + + self.hass.bus.async_fire( + EVENT_KEYMASTER_CODE_SLOT_RESET, + event_data={ + ATTR_ENTITY_ID: kmlock.lock_entity_id, + ATTR_CODE_SLOT: code_slot_num, + }, + ) + await self.async_refresh() @staticmethod diff --git a/custom_components/keymaster/event.py b/custom_components/keymaster/event.py new file mode 100644 index 00000000..877a1a7a --- /dev/null +++ b/custom_components/keymaster/event.py @@ -0,0 +1,162 @@ +"""Event entities for keymaster.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.components.lock.const import LockState +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_CODE_SLOT, + ATTR_CODE_SLOT_NAME, + ATTR_NAME, + CONF_SLOTS, + CONF_START, + COORDINATOR, + DOMAIN, + EVENT_KEYMASTER_CODE_SLOT_RESET, + EVENT_KEYMASTER_LOCK_STATE_CHANGED, +) +from .coordinator import KeymasterCoordinator +from .entity import KeymasterEntity, KeymasterEntityDescription + +EVENT_TYPE_UNLOCKED = "unlocked" + + +@dataclass(frozen=True, kw_only=True) +class KeymasterEventEntityDescription(KeymasterEntityDescription, EventEntityDescription): + """Entity Description for keymaster Event entities.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up event entities for keymaster code slots.""" + coordinator: KeymasterCoordinator = hass.data[DOMAIN][COORDINATOR] + + entities: list[KeymasterCodeSlotEventEntity] = [ + KeymasterCodeSlotEventEntity( + entity_description=KeymasterEventEntityDescription( + key=f"event.code_slots:{x}.last_used", + name=f"Code Slot {x}: Last Used", + icon="mdi:clock-outline", + event_types=[EVENT_TYPE_UNLOCKED], + entity_registry_enabled_default=True, + hass=hass, + config_entry=config_entry, + coordinator=coordinator, + ), + ) + for x in range( + config_entry.data[CONF_START], + config_entry.data[CONF_START] + config_entry.data[CONF_SLOTS], + ) + ] + + async_add_entities(entities, True) + + +class KeymasterCodeSlotEventEntity(KeymasterEntity, EventEntity): + """Event entity tracking when a code slot is used to unlock.""" + + entity_description: KeymasterEventEntityDescription + + def __init__( + self, + entity_description: KeymasterEventEntityDescription, + ) -> None: + """Initialize code slot event entity.""" + KeymasterEntity.__init__(self, entity_description=entity_description) + + async def async_added_to_hass(self) -> None: + """Register event listeners when added to hass.""" + await KeymasterEntity.async_added_to_hass(self) + await EventEntity.async_added_to_hass(self) + + self.async_on_remove( + self.hass.bus.async_listen( + EVENT_KEYMASTER_LOCK_STATE_CHANGED, + self._handle_lock_event, + ) + ) + self.async_on_remove( + self.hass.bus.async_listen( + EVENT_KEYMASTER_CODE_SLOT_RESET, + self._handle_reset_event, + ) + ) + + @callback + def _handle_lock_event(self, event: Event) -> None: + """Handle lock state changed bus event.""" + if event.data.get(ATTR_STATE) != LockState.UNLOCKED: + return + + code_slot_num = event.data.get(ATTR_CODE_SLOT, 0) + if code_slot_num == 0 or code_slot_num != self._code_slot: + return + + lock_entity_id = event.data.get(ATTR_ENTITY_ID) + if not self._kmlock or lock_entity_id != self._kmlock.lock_entity_id: + return + + self._trigger_event( + EVENT_TYPE_UNLOCKED, + { + ATTR_CODE_SLOT: code_slot_num, + ATTR_CODE_SLOT_NAME: event.data.get(ATTR_CODE_SLOT_NAME, ""), + ATTR_NAME: event.data.get(ATTR_NAME, ""), + }, + ) + self.async_write_ha_state() + + @callback + def _handle_reset_event(self, event: Event) -> None: + """Handle code slot reset bus event by clearing event state.""" + if event.data.get(ATTR_CODE_SLOT) != self._code_slot: + return + + lock_entity_id = event.data.get(ATTR_ENTITY_ID) + if not self._kmlock or lock_entity_id != self._kmlock.lock_entity_id: + return + + self._clear_event_state() + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator data updates for availability.""" + if not self._kmlock or not self._kmlock.connected: + self._attr_available = False + self.async_write_ha_state() + return + + if ".code_slots" in self._property and ( + not self._kmlock.code_slots or self._code_slot not in self._kmlock.code_slots + ): + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + self.async_write_ha_state() + + def _clear_event_state(self) -> None: + """Clear the event entity state back to None.""" + # EventEntity marks state as @final and stores event data in + # name-mangled private attributes. There is no public API to clear + # an event, so we access the mangled names directly. Guard with + # try/except in case HA core renames these internals in the future. + try: + self._EventEntity__last_event_triggered = None + self._EventEntity__last_event_type = None + self._EventEntity__last_event_attributes = None + except AttributeError: + pass diff --git a/custom_components/keymaster/lovelace.py b/custom_components/keymaster/lovelace.py index 5a2a1022..bd0e0e9f 100644 --- a/custom_components/keymaster/lovelace.py +++ b/custom_components/keymaster/lovelace.py @@ -459,6 +459,7 @@ def _generate_code_slot_conditional_entities_card_ll_config( DIVIDER_CARD, _generate_entity_card_ll_config(code_slot_num, "switch", "enabled", "Enabled"), _generate_entity_card_ll_config(code_slot_num, "binary_sensor", "active", "Active"), + _generate_entity_card_ll_config(code_slot_num, "event", "last_used", "Last Used"), _generate_entity_card_ll_config(code_slot_num, "sensor", "synced", "Sync Status"), *( ( @@ -690,6 +691,7 @@ def _generate_parent_view_card_ll_config( code_slot_num, "switch", "enabled", "Enabled", parent=True, type_="simple-entity" ), _generate_entity_card_ll_config(code_slot_num, "binary_sensor", "active", "Active"), + _generate_entity_card_ll_config(code_slot_num, "event", "last_used", "Last Used"), _generate_entity_card_ll_config(code_slot_num, "sensor", "synced", "Sync Status"), _generate_entity_card_ll_config( code_slot_num, "switch", "override_parent", "Override Parent" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index b3453378..ab312939 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -88,6 +88,7 @@ def mock_hass(): hass.config.path = Mock(return_value="/test/path") hass.bus = Mock() hass.bus.fire = Mock() + hass.bus.async_fire = Mock() hass.states = Mock() hass.states.get = Mock(return_value=None) return hass @@ -2270,6 +2271,41 @@ async def test_reset_code_slot_lock_not_found(self, coordinator_with_lock): ) coordinator_with_lock.clear_pin_from_lock.assert_not_called() + async def test_reset_code_slot_fires_reset_event(self, hass: HomeAssistant): + """Test that reset_code_slot fires a bus event for event entity reset.""" + with patch.object(KeymasterCoordinator, "__init__", return_value=None): + coordinator = KeymasterCoordinator(hass) + coordinator.hass = hass + coordinator.kmlocks = {} + coordinator.clear_pin_from_lock = AsyncMock() + coordinator.async_refresh = AsyncMock() + + lock = KeymasterLock( + lock_name="Front Door", + lock_entity_id="lock.front_door", + keymaster_config_entry_id="entry_1", + ) + lock.code_slots = { + 1: KeymasterCodeSlot(number=1, enabled=True, pin="1234"), + } + coordinator.kmlocks["entry_1"] = lock + + fired_events: list = [] + hass.bus.async_listen( + "keymaster_code_slot_reset", + fired_events.append, + ) + + await coordinator.reset_code_slot( + config_entry_id="entry_1", + code_slot_num=1, + ) + await hass.async_block_till_done() + + assert len(fired_events) == 1 + assert fired_events[0].data["code_slot_num"] == 1 + assert fired_events[0].data["entity_id"] == "lock.front_door" + async def test_reset_code_slot_slot_not_found(self, coordinator_with_lock): """Test reset_code_slot with non-existent code slot.""" await coordinator_with_lock.reset_code_slot( diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 00000000..d9d660a6 --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,496 @@ +"""Tests for keymaster Event platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.keymaster.const import ( + ATTR_CODE_SLOT, + ATTR_CODE_SLOT_NAME, + ATTR_NAME, + CONF_ALARM_LEVEL_OR_USER_CODE_ENTITY_ID, + CONF_ALARM_TYPE_OR_ACCESS_CONTROL_ENTITY_ID, + CONF_LOCK_ENTITY_ID, + CONF_LOCK_NAME, + CONF_SLOTS, + CONF_START, + COORDINATOR, + DOMAIN, +) +from custom_components.keymaster.coordinator import KeymasterCoordinator +from custom_components.keymaster.event import ( + EVENT_TYPE_UNLOCKED, + KeymasterCodeSlotEventEntity, + KeymasterEventEntityDescription, + async_setup_entry, +) +from custom_components.keymaster.lock import KeymasterCodeSlot, KeymasterLock +from homeassistant.components.lock.const import LockState +from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE +from homeassistant.core import HomeAssistant + +CONFIG_DATA_EVENT = { + CONF_ALARM_LEVEL_OR_USER_CODE_ENTITY_ID: "sensor.fake", + CONF_ALARM_TYPE_OR_ACCESS_CONTROL_ENTITY_ID: "sensor.fake", + CONF_LOCK_ENTITY_ID: "lock.kwikset_touchpad_electronic_deadbolt_frontdoor", + CONF_LOCK_NAME: "frontdoor", + CONF_SLOTS: 2, + CONF_START: 1, +} + + +def _make_entity( + hass: HomeAssistant, + config_entry: MockConfigEntry, + coordinator: KeymasterCoordinator, + code_slot: int = 1, +) -> KeymasterCodeSlotEventEntity: + """Create a code slot event entity for testing.""" + return KeymasterCodeSlotEventEntity( + entity_description=KeymasterEventEntityDescription( + key=f"event.code_slots:{code_slot}.last_used", + name=f"Code Slot {code_slot}: Last Used", + icon="mdi:clock-outline", + event_types=[EVENT_TYPE_UNLOCKED], + entity_registry_enabled_default=True, + hass=hass, + config_entry=config_entry, + coordinator=coordinator, + ), + ) + + +async def test_event_entity_initialization(hass: HomeAssistant): + """Test event entity initializes with correct properties.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + assert entity.event_types == [EVENT_TYPE_UNLOCKED] + assert entity._code_slot == 1 + assert entity.state is None + + +async def test_event_entity_triggers_on_unlock(hass: HomeAssistant): + """Test event entity triggers on matching unlock bus event.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.code_slots = {1: KeymasterCodeSlot(number=1, name="Guest")} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + # Simulate a matching bus event by calling the handler directly + + mock_event = MagicMock() + mock_event.data = { + ATTR_STATE: LockState.UNLOCKED, + ATTR_ENTITY_ID: "lock.test", + ATTR_CODE_SLOT: 1, + ATTR_CODE_SLOT_NAME: "Guest", + ATTR_NAME: "frontdoor", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_lock_event(mock_event) + + assert entity.state is not None # Should have a timestamp now + + +async def test_event_entity_ignores_wrong_slot(hass: HomeAssistant): + """Test event entity ignores events for other code slots.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.code_slots = {1: KeymasterCodeSlot(number=1)} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + mock_event = MagicMock() + mock_event.data = { + ATTR_STATE: LockState.UNLOCKED, + ATTR_ENTITY_ID: "lock.test", + ATTR_CODE_SLOT: 2, # Different slot + ATTR_CODE_SLOT_NAME: "", + ATTR_NAME: "frontdoor", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_lock_event(mock_event) + + assert entity.state is None # No event triggered + + +async def test_event_entity_ignores_slot_zero(hass: HomeAssistant): + """Test event entity ignores events for code slot 0 (manual unlock).""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.code_slots = {1: KeymasterCodeSlot(number=1)} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + mock_event = MagicMock() + mock_event.data = { + ATTR_STATE: LockState.UNLOCKED, + ATTR_ENTITY_ID: "lock.test", + ATTR_CODE_SLOT: 0, + ATTR_NAME: "frontdoor", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_lock_event(mock_event) + + assert entity.state is None + + +async def test_event_entity_ignores_wrong_lock(hass: HomeAssistant): + """Test event entity ignores events for a different lock.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.code_slots = {1: KeymasterCodeSlot(number=1)} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + mock_event = MagicMock() + mock_event.data = { + ATTR_STATE: LockState.UNLOCKED, + ATTR_ENTITY_ID: "lock.other_lock", # Different lock + ATTR_CODE_SLOT: 1, + ATTR_NAME: "other", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_lock_event(mock_event) + + assert entity.state is None + + +async def test_event_entity_ignores_locked_state(hass: HomeAssistant): + """Test event entity ignores lock events (not unlock).""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.code_slots = {1: KeymasterCodeSlot(number=1)} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + mock_event = MagicMock() + mock_event.data = { + ATTR_STATE: LockState.LOCKED, # Not unlocked + ATTR_ENTITY_ID: "lock.test", + ATTR_CODE_SLOT: 1, + ATTR_NAME: "frontdoor", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_lock_event(mock_event) + + assert entity.state is None + + +async def test_event_entity_reset_clears_state(hass: HomeAssistant): + """Test event entity clears state when reset event fires.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.code_slots = {1: KeymasterCodeSlot(number=1, name="Guest")} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + # First trigger an unlock event + unlock_event = MagicMock() + unlock_event.data = { + ATTR_STATE: LockState.UNLOCKED, + ATTR_ENTITY_ID: "lock.test", + ATTR_CODE_SLOT: 1, + ATTR_CODE_SLOT_NAME: "Guest", + ATTR_NAME: "frontdoor", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_lock_event(unlock_event) + + assert entity.state is not None + + # Now fire a reset event + reset_event = MagicMock() + reset_event.data = { + ATTR_CODE_SLOT: 1, + ATTR_ENTITY_ID: "lock.test", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_reset_event(reset_event) + + assert entity.state is None + + +async def test_event_entity_reset_ignores_wrong_slot(hass: HomeAssistant): + """Test event entity ignores reset events for other slots.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.code_slots = {1: KeymasterCodeSlot(number=1, name="Guest")} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + # First trigger an unlock + unlock_event = MagicMock() + unlock_event.data = { + ATTR_STATE: LockState.UNLOCKED, + ATTR_ENTITY_ID: "lock.test", + ATTR_CODE_SLOT: 1, + ATTR_CODE_SLOT_NAME: "Guest", + ATTR_NAME: "frontdoor", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_lock_event(unlock_event) + + assert entity.state is not None + + # Reset event for a different slot + reset_event = MagicMock() + reset_event.data = { + ATTR_CODE_SLOT: 2, # Different slot + ATTR_ENTITY_ID: "lock.test", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_reset_event(reset_event) + + assert entity.state is not None # Still has timestamp + + +async def test_event_entity_reset_ignores_wrong_lock(hass: HomeAssistant): + """Test event entity ignores reset events for a different lock.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.code_slots = {1: KeymasterCodeSlot(number=1, name="Guest")} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + # First trigger an unlock + unlock_event = MagicMock() + unlock_event.data = { + ATTR_STATE: LockState.UNLOCKED, + ATTR_ENTITY_ID: "lock.test", + ATTR_CODE_SLOT: 1, + ATTR_CODE_SLOT_NAME: "Guest", + ATTR_NAME: "frontdoor", + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_lock_event(unlock_event) + + assert entity.state is not None + + # Reset event for the same slot but a different lock + reset_event = MagicMock() + reset_event.data = { + ATTR_CODE_SLOT: 1, + ATTR_ENTITY_ID: "lock.other_lock", # Different lock + } + + with patch.object(entity, "async_write_ha_state"): + entity._handle_reset_event(reset_event) + + assert entity.state is not None # Still has timestamp + + +async def test_event_entity_unavailable_when_disconnected(hass: HomeAssistant): + """Test event entity becomes unavailable when lock disconnects.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.connected = False + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + with patch.object(entity, "async_write_ha_state"): + entity._handle_coordinator_update() + + assert not entity._attr_available + + +async def test_event_entity_unavailable_when_slot_missing(hass: HomeAssistant): + """Test event entity becomes unavailable when code slot is missing.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.connected = True + kmlock.code_slots = {} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + with patch.object(entity, "async_write_ha_state"): + entity._handle_coordinator_update() + + assert not entity._attr_available + + +async def test_event_entity_available_when_connected_with_slot(hass: HomeAssistant): + """Test event entity becomes available when lock is connected and slot exists.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + kmlock.connected = True + kmlock.code_slots = {1: KeymasterCodeSlot(number=1)} + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + with patch.object(entity, "async_write_ha_state"): + entity._handle_coordinator_update() + + assert entity._attr_available + + +async def test_clear_event_state_handles_attribute_error(hass: HomeAssistant): + """Test _clear_event_state gracefully handles AttributeError.""" + config_entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA_EVENT) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + kmlock = KeymasterLock( + lock_name="frontdoor", + lock_entity_id="lock.test", + keymaster_config_entry_id=config_entry.entry_id, + ) + coordinator.kmlocks[config_entry.entry_id] = kmlock + + entity = _make_entity(hass, config_entry, coordinator) + + # Simulate HA core renaming the private attributes by deleting the class attribute + # so the name-mangled assignment raises AttributeError + with patch.object( + type(entity), + "_EventEntity__last_event_triggered", + new_callable=lambda: property( + fget=lambda self: None, + fset=lambda self, v: (_ for _ in ()).throw(AttributeError("renamed")), + ), + ): + # Should not raise — the except block catches it + entity._clear_event_state() + + +async def test_event_entity_created_in_setup(hass: HomeAssistant): + """Test that async_setup_entry creates event entities for each slot.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="frontdoor", + data=CONFIG_DATA_EVENT, + version=3, + ) + config_entry.add_to_hass(hass) + + coordinator = KeymasterCoordinator(hass) + setattr(coordinator, "get_lock_by_config_entry_id", AsyncMock(return_value=None)) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][COORDINATOR] = coordinator + + added_entities: list = [] + + def mock_add_entities(new_entities, update_before_add=False): + del update_before_add + added_entities.extend(new_entities) + + await async_setup_entry(hass, config_entry, mock_add_entities) + + assert len(added_entities) == 2 + assert all(isinstance(e, KeymasterCodeSlotEventEntity) for e in added_entities) + assert added_entities[0].entity_description.key == "event.code_slots:1.last_used" + assert added_entities[1].entity_description.key == "event.code_slots:2.last_used" + assert added_entities[0].event_types == [EVENT_TYPE_UNLOCKED] diff --git a/tests/test_init.py b/tests/test_init.py index 2cc86c6f..a295f2fb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -29,6 +29,11 @@ _LOGGER = logging.getLogger(__name__) +# Keymaster creates: lock_name + autolock_timer + synced * num_slots +# CONFIG_DATA has 6 slots → 2 + 6 = 8 keymaster sensors +# (last_used moved from sensor to event platform) +KEYMASTER_SENSOR_COUNT = 8 + async def test_setup_entry( hass, @@ -39,6 +44,7 @@ async def test_setup_entry( integration, ): """Test setting up entities.""" + baseline = len(hass.states.async_entity_ids(SENSOR_DOMAIN)) entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA, version=3) @@ -46,7 +52,7 @@ async def test_setup_entry( assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 11 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == baseline + KEYMASTER_SENSOR_COUNT entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -65,13 +71,15 @@ async def test_setup_entry_core_state( ): """Test setting up entities.""" with patch.object(hass, "state", return_value="STARTING"): + baseline = len(hass.states.async_entity_ids(SENSOR_DOMAIN)) + entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA, version=3) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 11 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == baseline + KEYMASTER_SENSOR_COUNT entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -83,24 +91,26 @@ async def test_unload_entry( integration, ): """Test unloading entities.""" + baseline = len(hass.states.async_entity_ids(SENSOR_DOMAIN)) + entry = MockConfigEntry(domain=DOMAIN, title="frontdoor", data=CONFIG_DATA, version=3) entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 11 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == baseline + KEYMASTER_SENSOR_COUNT assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 11 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == baseline + KEYMASTER_SENSOR_COUNT assert len(hass.states.async_entity_ids(DOMAIN)) == 0 assert await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == baseline async def test_notify_script_name_slugified(hass): diff --git a/tests/test_lovelace.py b/tests/test_lovelace.py index b1266ccb..8371aab5 100644 --- a/tests/test_lovelace.py +++ b/tests/test_lovelace.py @@ -189,6 +189,7 @@ async def test_generate_view_config_slot_entities(hass: HomeAssistant): "code_slots_1_pin", "code_slots_1_enabled", "code_slots_1_active", + "code_slots_1_last_used", "code_slots_1_synced", "code_slots_1_notifications", "accesslimit_count_enabled", diff --git a/tests/test_sensor.py b/tests/test_sensor.py index ee790d47..eb31459c 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -254,7 +254,8 @@ def mock_add_entities(new_entities, update_before_add=False): # Call setup await async_setup_entry(hass, config_entry, mock_add_entities) - # Should have created 5 entities: lock_name, parent_name, autolock_timer, and 2 code slot sync sensors + # Should have created 5 entities: lock_name, parent_name, autolock_timer, + # and 2 code slot sync sensors (last_used moved to event platform) assert len(added_entities) == 5 assert added_entities[0].entity_description.key == "sensor.lock_name" assert added_entities[1].entity_description.key == "sensor.parent_name" @@ -494,6 +495,7 @@ def mock_add_entities(new_entities, update_before_add=False): await async_setup_entry(hass, config_entry, mock_add_entities) # Should have: lock_name, autolock_timer, and 2 code slot sync sensors = 4 + # (last_used moved to event platform) assert len(added_entities) == 4 autolock_entities = [ e for e in added_entities if e.entity_description.key == "sensor.autolock_timer"