1
0
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:
Raphael Hehl
2026-03-14 14:12:50 +01:00
committed by GitHub
parent 5582d83f7b
commit 54f96bcc33
7 changed files with 715 additions and 3 deletions

View File

@@ -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:

View File

@@ -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)

View 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()

View File

@@ -4,6 +4,11 @@
"unlock": {
"default": "mdi:lock-open"
}
},
"event": {
"access": {
"default": "mdi:door"
}
}
}
}

View File

@@ -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": {

View 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',
})
# ---

View 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"