mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 00:20:30 +01:00
Add event platform for UniFi Access integration (#165531)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.EVENT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool:
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
from unifi_access_api import (
|
||||
ApiAuthError,
|
||||
@@ -15,6 +17,8 @@ from unifi_access_api import (
|
||||
WsMessageHandler,
|
||||
)
|
||||
from unifi_access_api.models.websocket import (
|
||||
HwDoorbell,
|
||||
InsightsAdd,
|
||||
LocationUpdateState,
|
||||
LocationUpdateV2,
|
||||
V2LocationState,
|
||||
@@ -23,7 +27,7 @@ from unifi_access_api.models.websocket import (
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -33,6 +37,16 @@ _LOGGER = logging.getLogger(__name__)
|
||||
type UnifiAccessConfigEntry = ConfigEntry[UnifiAccessCoordinator]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DoorEvent:
|
||||
"""Represent a door event from WebSocket."""
|
||||
|
||||
door_id: str
|
||||
category: str
|
||||
event_type: str
|
||||
event_data: dict[str, Any]
|
||||
|
||||
|
||||
class UnifiAccessCoordinator(DataUpdateCoordinator[dict[str, Door]]):
|
||||
"""Coordinator for fetching UniFi Access door data."""
|
||||
|
||||
@@ -53,12 +67,28 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[dict[str, Door]]):
|
||||
update_interval=None,
|
||||
)
|
||||
self.client = client
|
||||
self._event_listeners: list[Callable[[DoorEvent], None]] = []
|
||||
|
||||
@callback
|
||||
def async_subscribe_door_events(
|
||||
self,
|
||||
event_callback: Callable[[DoorEvent], None],
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Subscribe to door events (doorbell, access)."""
|
||||
|
||||
def _unsubscribe() -> None:
|
||||
self._event_listeners.remove(event_callback)
|
||||
|
||||
self._event_listeners.append(event_callback)
|
||||
return _unsubscribe
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the WebSocket connection for push updates."""
|
||||
handlers: dict[str, WsMessageHandler] = {
|
||||
"access.data.device.location_update_v2": self._handle_location_update,
|
||||
"access.data.v2.location.update": self._handle_v2_location_update,
|
||||
"access.hw.door_bell": self._handle_doorbell,
|
||||
"access.logs.insights.add": self._handle_insights_add,
|
||||
}
|
||||
self.client.start_websocket(
|
||||
handlers,
|
||||
@@ -126,3 +156,44 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[dict[str, Door]]):
|
||||
updates["door_lock_relay_status"] = "unlock"
|
||||
updated_door = current_door.with_updates(**updates)
|
||||
self.async_set_updated_data({**self.data, door_id: updated_door})
|
||||
|
||||
async def _handle_doorbell(self, msg: WebsocketMessage) -> None:
|
||||
"""Handle doorbell press events."""
|
||||
doorbell = cast(HwDoorbell, msg)
|
||||
self._dispatch_door_event(
|
||||
doorbell.data.door_id,
|
||||
"doorbell",
|
||||
"ring",
|
||||
{},
|
||||
)
|
||||
|
||||
async def _handle_insights_add(self, msg: WebsocketMessage) -> None:
|
||||
"""Handle access insights events (entry/exit)."""
|
||||
insights = cast(InsightsAdd, msg)
|
||||
door = insights.data.metadata.door
|
||||
if not door.id:
|
||||
return
|
||||
event_type = (
|
||||
"access_granted" if insights.data.result == "ACCESS" else "access_denied"
|
||||
)
|
||||
attrs: dict[str, Any] = {}
|
||||
if insights.data.metadata.actor.display_name:
|
||||
attrs["actor"] = insights.data.metadata.actor.display_name
|
||||
if insights.data.metadata.authentication.display_name:
|
||||
attrs["authentication"] = insights.data.metadata.authentication.display_name
|
||||
if insights.data.result:
|
||||
attrs["result"] = insights.data.result
|
||||
self._dispatch_door_event(door.id, "access", event_type, attrs)
|
||||
|
||||
@callback
|
||||
def _dispatch_door_event(
|
||||
self,
|
||||
door_id: str,
|
||||
category: str,
|
||||
event_type: str,
|
||||
event_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Dispatch a door event to all subscribed listeners."""
|
||||
event = DoorEvent(door_id, category, event_type, event_data)
|
||||
for listener in self._event_listeners:
|
||||
listener(event)
|
||||
|
||||
96
homeassistant/components/unifi_access/event.py
Normal file
96
homeassistant/components/unifi_access/event.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Event platform for the UniFi Access integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.event import (
|
||||
EventDeviceClass,
|
||||
EventEntity,
|
||||
EventEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import DoorEvent, UnifiAccessConfigEntry, UnifiAccessCoordinator
|
||||
from .entity import UnifiAccessEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class UnifiAccessEventEntityDescription(EventEntityDescription):
|
||||
"""Describes a UniFi Access event entity."""
|
||||
|
||||
category: str
|
||||
|
||||
|
||||
DOORBELL_EVENT_DESCRIPTION = UnifiAccessEventEntityDescription(
|
||||
key="doorbell",
|
||||
translation_key="doorbell",
|
||||
device_class=EventDeviceClass.DOORBELL,
|
||||
event_types=["ring"],
|
||||
category="doorbell",
|
||||
)
|
||||
|
||||
ACCESS_EVENT_DESCRIPTION = UnifiAccessEventEntityDescription(
|
||||
key="access",
|
||||
translation_key="access",
|
||||
event_types=["access_granted", "access_denied"],
|
||||
category="access",
|
||||
)
|
||||
|
||||
EVENT_DESCRIPTIONS: list[UnifiAccessEventEntityDescription] = [
|
||||
DOORBELL_EVENT_DESCRIPTION,
|
||||
ACCESS_EVENT_DESCRIPTION,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: UnifiAccessConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up UniFi Access event entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
UnifiAccessEventEntity(coordinator, door_id, description)
|
||||
for door_id in coordinator.data
|
||||
for description in EVENT_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class UnifiAccessEventEntity(UnifiAccessEntity, EventEntity):
|
||||
"""Representation of a UniFi Access event entity."""
|
||||
|
||||
entity_description: UnifiAccessEventEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: UnifiAccessCoordinator,
|
||||
door_id: str,
|
||||
description: UnifiAccessEventEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the event entity."""
|
||||
door = coordinator.data[door_id]
|
||||
super().__init__(coordinator, door, description.key)
|
||||
self.entity_description = description
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to door events when added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_subscribe_door_events(self._async_handle_event)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self, event: DoorEvent) -> None:
|
||||
"""Handle incoming event from coordinator."""
|
||||
if (
|
||||
event.door_id != self._door_id
|
||||
or event.category != self.entity_description.category
|
||||
or event.event_type not in self.event_types
|
||||
):
|
||||
return
|
||||
self._trigger_event(event.event_type, event.event_data)
|
||||
self.async_write_ha_state()
|
||||
@@ -4,6 +4,11 @@
|
||||
"unlock": {
|
||||
"default": "mdi:lock-open"
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
"access": {
|
||||
"default": "mdi:door"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,29 @@
|
||||
"unlock": {
|
||||
"name": "Unlock"
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
"access": {
|
||||
"name": "Access",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"access_denied": "Access denied",
|
||||
"access_granted": "Access granted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"doorbell": {
|
||||
"name": "Doorbell",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"ring": "Ring"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
235
tests/components/unifi_access/snapshots/test_event.ambr
Normal file
235
tests/components/unifi_access/snapshots/test_event.ambr
Normal file
@@ -0,0 +1,235 @@
|
||||
# serializer version: 1
|
||||
# name: test_event_entities[event.back_door_access-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'event_types': list([
|
||||
'access_granted',
|
||||
'access_denied',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.back_door_access',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Access',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Access',
|
||||
'platform': 'unifi_access',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'access',
|
||||
'unique_id': 'door-002-access',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entities[event.back_door_access-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'event_type': None,
|
||||
'event_types': list([
|
||||
'access_granted',
|
||||
'access_denied',
|
||||
]),
|
||||
'friendly_name': 'Back Door Access',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.back_door_access',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entities[event.back_door_doorbell-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'event_types': list([
|
||||
'ring',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.back_door_doorbell',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Doorbell',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.DOORBELL: 'doorbell'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Doorbell',
|
||||
'platform': 'unifi_access',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'doorbell',
|
||||
'unique_id': 'door-002-doorbell',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entities[event.back_door_doorbell-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'doorbell',
|
||||
'event_type': None,
|
||||
'event_types': list([
|
||||
'ring',
|
||||
]),
|
||||
'friendly_name': 'Back Door Doorbell',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.back_door_doorbell',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entities[event.front_door_access-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'event_types': list([
|
||||
'access_granted',
|
||||
'access_denied',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.front_door_access',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Access',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Access',
|
||||
'platform': 'unifi_access',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'access',
|
||||
'unique_id': 'door-001-access',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entities[event.front_door_access-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'event_type': None,
|
||||
'event_types': list([
|
||||
'access_granted',
|
||||
'access_denied',
|
||||
]),
|
||||
'friendly_name': 'Front Door Access',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.front_door_access',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entities[event.front_door_doorbell-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'event_types': list([
|
||||
'ring',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.front_door_doorbell',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Doorbell',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.DOORBELL: 'doorbell'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Doorbell',
|
||||
'platform': 'unifi_access',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'doorbell',
|
||||
'unique_id': 'door-001-doorbell',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entities[event.front_door_doorbell-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'doorbell',
|
||||
'event_type': None,
|
||||
'event_types': list([
|
||||
'ring',
|
||||
]),
|
||||
'friendly_name': 'Front Door Doorbell',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.front_door_doorbell',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
282
tests/components/unifi_access/test_event.py
Normal file
282
tests/components/unifi_access/test_event.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Tests for the UniFi Access event platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from unifi_access_api.models.websocket import (
|
||||
HwDoorbell,
|
||||
HwDoorbellData,
|
||||
InsightsAdd,
|
||||
InsightsAddData,
|
||||
InsightsMetadata,
|
||||
InsightsMetadataEntry,
|
||||
WebsocketMessage,
|
||||
)
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
FRONT_DOOR_DOORBELL_ENTITY = "event.front_door_doorbell"
|
||||
FRONT_DOOR_ACCESS_ENTITY = "event.front_door_access"
|
||||
BACK_DOOR_DOORBELL_ENTITY = "event.back_door_doorbell"
|
||||
BACK_DOOR_ACCESS_ENTITY = "event.back_door_access"
|
||||
|
||||
|
||||
def _get_ws_handlers(
|
||||
mock_client: MagicMock,
|
||||
) -> dict[str, Callable[[WebsocketMessage], Awaitable[None]]]:
|
||||
"""Extract WebSocket handlers from mock client."""
|
||||
return mock_client.start_websocket.call_args[0][0]
|
||||
|
||||
|
||||
async def test_event_entities(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test event entities are created with expected state."""
|
||||
with patch("homeassistant.components.unifi_access.PLATFORMS", [Platform.EVENT]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
||||
async def test_doorbell_ring_event(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test doorbell ring event is fired when WebSocket message arrives."""
|
||||
handlers = _get_ws_handlers(mock_client)
|
||||
|
||||
doorbell_msg = HwDoorbell(
|
||||
event="access.hw.door_bell",
|
||||
data=HwDoorbellData(
|
||||
door_id="door-001",
|
||||
door_name="Front Door",
|
||||
request_id="req-123",
|
||||
),
|
||||
)
|
||||
|
||||
await handlers["access.hw.door_bell"](doorbell_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
||||
assert state is not None
|
||||
assert state.attributes["event_type"] == "ring"
|
||||
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
||||
|
||||
|
||||
async def test_doorbell_ring_event_wrong_door(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test doorbell ring event for unknown door is ignored."""
|
||||
handlers = _get_ws_handlers(mock_client)
|
||||
|
||||
doorbell_msg = HwDoorbell(
|
||||
event="access.hw.door_bell",
|
||||
data=HwDoorbellData(
|
||||
door_id="door-unknown",
|
||||
door_name="Unknown Door",
|
||||
request_id="req-999",
|
||||
),
|
||||
)
|
||||
|
||||
await handlers["access.hw.door_bell"](doorbell_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Front door entity should still have no event
|
||||
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
||||
assert state is not None
|
||||
assert state.state == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"result",
|
||||
"expected_event_type",
|
||||
"door_id",
|
||||
"entity_id",
|
||||
"actor",
|
||||
"authentication",
|
||||
),
|
||||
[
|
||||
(
|
||||
"ACCESS",
|
||||
"access_granted",
|
||||
"door-001",
|
||||
FRONT_DOOR_ACCESS_ENTITY,
|
||||
"John Doe",
|
||||
"NFC",
|
||||
),
|
||||
(
|
||||
"BLOCKED",
|
||||
"access_denied",
|
||||
"door-002",
|
||||
BACK_DOOR_ACCESS_ENTITY,
|
||||
"Unknown",
|
||||
"PIN_CODE",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
||||
async def test_access_event(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
result: str,
|
||||
expected_event_type: str,
|
||||
door_id: str,
|
||||
entity_id: str,
|
||||
actor: str,
|
||||
authentication: str,
|
||||
) -> None:
|
||||
"""Test access event is fired with correct mapping from API result."""
|
||||
handlers = _get_ws_handlers(mock_client)
|
||||
|
||||
insights_msg = InsightsAdd(
|
||||
event="access.logs.insights.add",
|
||||
data=InsightsAddData(
|
||||
event_type="access.door.unlock",
|
||||
result=result,
|
||||
metadata=InsightsMetadata(
|
||||
door=InsightsMetadataEntry(
|
||||
id=door_id,
|
||||
display_name="Door",
|
||||
),
|
||||
actor=InsightsMetadataEntry(
|
||||
display_name=actor,
|
||||
),
|
||||
authentication=InsightsMetadataEntry(
|
||||
display_name=authentication,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await handlers["access.logs.insights.add"](insights_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes["event_type"] == expected_event_type
|
||||
assert state.attributes["actor"] == actor
|
||||
assert state.attributes["authentication"] == authentication
|
||||
assert state.attributes["result"] == result
|
||||
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
||||
|
||||
|
||||
async def test_insights_no_door_id_ignored(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test insights event without door_id is ignored."""
|
||||
handlers = _get_ws_handlers(mock_client)
|
||||
|
||||
insights_msg = InsightsAdd(
|
||||
event="access.logs.insights.add",
|
||||
data=InsightsAddData(
|
||||
event_type="access.door.unlock",
|
||||
result="ACCESS",
|
||||
metadata=InsightsMetadata(
|
||||
door=InsightsMetadataEntry(id="", display_name=""),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await handlers["access.logs.insights.add"](insights_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
||||
assert state is not None
|
||||
assert state.state == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "expected_event_type", "expected_result_attr"),
|
||||
[
|
||||
("ACCESS", "access_granted", "ACCESS"),
|
||||
("BLOCKED", "access_denied", "BLOCKED"),
|
||||
("TIMEOUT", "access_denied", "TIMEOUT"),
|
||||
("", "access_denied", None),
|
||||
],
|
||||
)
|
||||
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
|
||||
async def test_access_event_result_mapping(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
result: str,
|
||||
expected_event_type: str,
|
||||
expected_result_attr: str | None,
|
||||
) -> None:
|
||||
"""Test result-to-event-type mapping with minimal attributes."""
|
||||
handlers = _get_ws_handlers(mock_client)
|
||||
|
||||
insights_msg = InsightsAdd(
|
||||
event="access.logs.insights.add",
|
||||
data=InsightsAddData(
|
||||
event_type="access.door.unlock",
|
||||
result=result,
|
||||
metadata=InsightsMetadata(
|
||||
door=InsightsMetadataEntry(
|
||||
id="door-001",
|
||||
display_name="Front Door",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
await handlers["access.logs.insights.add"](insights_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY)
|
||||
assert state is not None
|
||||
assert state.attributes["event_type"] == expected_event_type
|
||||
assert "actor" not in state.attributes
|
||||
assert "authentication" not in state.attributes
|
||||
assert state.attributes.get("result") == expected_result_attr
|
||||
assert state.state == "2025-01-01T00:00:00.000+00:00"
|
||||
|
||||
|
||||
async def test_unload_entry_removes_listeners(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that events are not processed after config entry is unloaded."""
|
||||
handlers = _get_ws_handlers(mock_client)
|
||||
|
||||
await hass.config_entries.async_unload(init_integration.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
doorbell_msg = HwDoorbell(
|
||||
event="access.hw.door_bell",
|
||||
data=HwDoorbellData(
|
||||
door_id="door-001",
|
||||
door_name="Front Door",
|
||||
request_id="req-after-unload",
|
||||
),
|
||||
)
|
||||
|
||||
await handlers["access.hw.door_bell"](doorbell_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
|
||||
assert state is not None
|
||||
assert state.state == "unavailable"
|
||||
Reference in New Issue
Block a user