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"