From 54f96bcc3365dac148b610339ad18c0d5d4e88b4 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:12:50 +0100 Subject: [PATCH] Add event platform for UniFi Access integration (#165531) Co-authored-by: RaHehl --- .../components/unifi_access/__init__.py | 2 +- .../components/unifi_access/coordinator.py | 75 ++++- .../components/unifi_access/event.py | 96 ++++++ .../components/unifi_access/icons.json | 5 + .../components/unifi_access/strings.json | 23 ++ .../unifi_access/snapshots/test_event.ambr | 235 +++++++++++++++ tests/components/unifi_access/test_event.py | 282 ++++++++++++++++++ 7 files changed, 715 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/unifi_access/event.py create mode 100644 tests/components/unifi_access/snapshots/test_event.ambr create mode 100644 tests/components/unifi_access/test_event.py diff --git a/homeassistant/components/unifi_access/__init__.py b/homeassistant/components/unifi_access/__init__.py index b3637055a3e..d932b511601 100644 --- a/homeassistant/components/unifi_access/__init__.py +++ b/homeassistant/components/unifi_access/__init__.py @@ -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: diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index d031d5c487d..ccc52c02f4b 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -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) diff --git a/homeassistant/components/unifi_access/event.py b/homeassistant/components/unifi_access/event.py new file mode 100644 index 00000000000..30d2bc88404 --- /dev/null +++ b/homeassistant/components/unifi_access/event.py @@ -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() diff --git a/homeassistant/components/unifi_access/icons.json b/homeassistant/components/unifi_access/icons.json index 4482c48bde1..edbb22b157a 100644 --- a/homeassistant/components/unifi_access/icons.json +++ b/homeassistant/components/unifi_access/icons.json @@ -4,6 +4,11 @@ "unlock": { "default": "mdi:lock-open" } + }, + "event": { + "access": { + "default": "mdi:door" + } } } } diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json index 4bd0c01256f..691410dcb07 100644 --- a/homeassistant/components/unifi_access/strings.json +++ b/homeassistant/components/unifi_access/strings.json @@ -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": { diff --git a/tests/components/unifi_access/snapshots/test_event.ambr b/tests/components/unifi_access/snapshots/test_event.ambr new file mode 100644 index 00000000000..1efd0d3b7d3 --- /dev/null +++ b/tests/components/unifi_access/snapshots/test_event.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.back_door_access', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'entity_id': 'event.back_door_access', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.back_door_doorbell', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell', + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'event.back_door_doorbell', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.front_door_access', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'entity_id': 'event.front_door_access', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.front_door_doorbell', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Doorbell', + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'event.front_door_doorbell', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/unifi_access/test_event.py b/tests/components/unifi_access/test_event.py new file mode 100644 index 00000000000..86869a9b40b --- /dev/null +++ b/tests/components/unifi_access/test_event.py @@ -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"