Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions custom_components/keymaster/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.DATETIME,
Platform.EVENT,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
Expand All @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions custom_components/keymaster/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions custom_components/keymaster/event.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions custom_components/keymaster/lovelace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
*(
(
Expand Down Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading