unifi_access: add missing WebSocket handlers for remote_view and device_update events (#168850)

Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
Raphael Hehl
2026-04-23 08:50:09 +02:00
committed by GitHub
co-authored by RaHehl
parent d45941d648
commit 67baec27cf
3 changed files with 404 additions and 5 deletions
@@ -8,6 +8,7 @@ from dataclasses import dataclass, replace
import logging
import math
from typing import Any, cast
import unicodedata
from unifi_access_api import (
ApiAuthError,
@@ -24,13 +25,16 @@ from unifi_access_api import (
WsMessageHandler,
)
from unifi_access_api.models.websocket import (
DeviceUpdate,
HwDoorbell,
InsightsAdd,
LocationUpdateState,
LocationUpdateV2,
LogAdd,
RemoteView,
SettingUpdate,
ThumbnailInfo,
V2DeviceUpdate,
V2LocationState,
V2LocationUpdate,
WebsocketMessage,
@@ -172,7 +176,10 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
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.data.v2.device.update": self._handle_v2_device_update,
"access.data.device.update": self._handle_device_update,
"access.hw.door_bell": self._handle_doorbell,
"access.remote_view": self._handle_remote_view,
"access.logs.insights.add": self._handle_insights_add,
"access.logs.add": self._handle_logs_add,
"access.data.setting.update": self._handle_setting_update,
@@ -345,12 +352,13 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
updated_lock_rule = current_lock_rule
lock_rule_updated = False
if ws_state is not None:
if ws_state.dps is not None:
if "dps" in ws_state.model_fields_set and ws_state.dps is not None:
updates["door_position_status"] = ws_state.dps
if ws_state.lock == "locked":
updates["door_lock_relay_status"] = DoorLockRelayStatus.LOCK
elif ws_state.lock == "unlocked":
updates["door_lock_relay_status"] = DoorLockRelayStatus.UNLOCK
if "lock" in ws_state.model_fields_set:
if ws_state.lock == "locked":
updates["door_lock_relay_status"] = DoorLockRelayStatus.LOCK
elif ws_state.lock == "unlocked":
updates["door_lock_relay_status"] = DoorLockRelayStatus.UNLOCK
if "remain_lock" in ws_state.model_fields_set:
lock_rule_updated = True
@@ -428,6 +436,51 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
{},
)
async def _handle_remote_view(self, msg: WebsocketMessage) -> None:
"""Handle remote view (video intercom doorbell press) events."""
remote_view = cast(RemoteView, msg)
device_id = remote_view.data.device_id
if device_id and device_id in self._device_to_door:
self._dispatch_door_event(
self._device_to_door[device_id], "doorbell", "ring", {}
)
return
door_name = remote_view.data.door_name
if self.data and door_name:
normalized = unicodedata.normalize("NFC", door_name.strip())
for door in self.data.doors.values():
if unicodedata.normalize("NFC", door.name.strip()) == normalized:
self._dispatch_door_event(door.id, "doorbell", "ring", {})
return
_LOGGER.debug(
"Received access.remote_view for unknown device %s (door '%s')",
device_id,
door_name,
)
async def _handle_v2_device_update(self, msg: WebsocketMessage) -> None:
"""Handle V2 device update messages."""
update = cast(V2DeviceUpdate, msg)
device_id = update.data.id
if not device_id:
return
first_valid_door_id: str | None = None
for loc_state in update.data.location_states:
door_id = loc_state.location_id
if not door_id:
continue
if first_valid_door_id is None:
first_valid_door_id = door_id
self._process_door_update(door_id, loc_state)
if first_valid_door_id is not None:
self._device_to_door[device_id] = first_valid_door_id
async def _handle_device_update(self, msg: WebsocketMessage) -> None:
"""Handle device update messages."""
update = cast(DeviceUpdate, msg)
if update.data.unique_id and update.data.door and update.data.door.unique_id:
self._device_to_door[update.data.unique_id] = update.data.door.unique_id
async def _handle_insights_add(self, msg: WebsocketMessage) -> None:
"""Handle access insights events (entry/exit)."""
insights = cast(InsightsAdd, msg)
@@ -11,6 +11,9 @@ from unifi_access_api.models.websocket import (
LocationUpdateData,
LocationUpdateState,
LocationUpdateV2,
V2DeviceLocationState,
V2DeviceUpdate,
V2DeviceUpdateData,
WebsocketMessage,
)
@@ -123,3 +126,78 @@ async def test_ws_reconnect_restores_binary_sensor_states(
assert hass.states.get(FRONT_DOOR_ENTITY).state == "off"
assert hass.states.get(BACK_DOOR_ENTITY).state == "on"
async def test_binary_sensor_state_updates_via_v2_device_update(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.data.v2.device.update changes binary sensor state."""
handlers = _get_ws_handlers(mock_client)
update_msg = V2DeviceUpdate(
event="access.data.v2.device.update",
data=V2DeviceUpdateData(
id="hub-device-001",
location_states=[
V2DeviceLocationState(
location_id="door-001",
dps=DoorPositionStatus.OPEN,
)
],
),
)
await handlers["access.data.v2.device.update"](update_msg)
await hass.async_block_till_done()
assert hass.states.get(FRONT_DOOR_ENTITY).state == "on"
async def test_v2_device_update_empty_location_id_ignored(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.data.v2.device.update with empty location_id does not update state."""
handlers = _get_ws_handlers(mock_client)
update_msg = V2DeviceUpdate(
event="access.data.v2.device.update",
data=V2DeviceUpdateData(
id="hub-device-001",
location_states=[V2DeviceLocationState(location_id="")],
),
)
await handlers["access.data.v2.device.update"](update_msg)
await hass.async_block_till_done()
# State should be unchanged (front door starts closed/off)
assert hass.states.get(FRONT_DOOR_ENTITY).state == "off"
async def test_v2_device_update_no_explicit_state_does_not_overwrite(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test v2.device.update without explicit dps/lock does not overwrite known state.
A device association message (only location_id set, no explicit dps/lock)
must not reset a known-open/unlocked door back to closed/locked.
"""
handlers = _get_ws_handlers(mock_client)
# back door starts open (on). Send a device update with no explicit dps/lock.
update_msg = V2DeviceUpdate(
event="access.data.v2.device.update",
data=V2DeviceUpdateData(
id="hub-device-002",
location_states=[V2DeviceLocationState(location_id="door-002")],
),
)
await handlers["access.data.v2.device.update"](update_msg)
await hass.async_block_till_done()
# Back door must still be open/on not silently reset to closed/off
assert hass.states.get(BACK_DOOR_ENTITY).state == "on"
+268
View File
@@ -8,6 +8,9 @@ from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from unifi_access_api.models.websocket import (
DeviceUpdate,
DeviceUpdateData,
DeviceUpdateDoor,
HwDoorbell,
HwDoorbellData,
InsightsAdd,
@@ -21,6 +24,11 @@ from unifi_access_api.models.websocket import (
LogEvent,
LogSource,
LogTarget,
RemoteView,
RemoteViewData,
V2DeviceLocationState,
V2DeviceUpdate,
V2DeviceUpdateData,
V2LocationUpdate,
V2LocationUpdateData,
WebsocketMessage,
@@ -780,6 +788,266 @@ async def test_logs_add_device_mapping_pruned_on_refresh(
assert hass.states.get(FRONT_DOOR_ACCESS_ENTITY) is None
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
async def test_remote_view_doorbell_ring_by_device_id(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.remote_view fires doorbell ring when device_id is in the mapping."""
handlers = _get_ws_handlers(mock_client)
await _populate_device_mapping(handlers)
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id="hub-device-001"),
)
await handlers["access.remote_view"](remote_view_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"
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
async def test_remote_view_doorbell_ring_by_door_name_fallback(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.remote_view falls back to door_name lookup when device_id is unmapped."""
handlers = _get_ws_handlers(mock_client)
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id="unknown-device", door_name="Front Door"),
)
await handlers["access.remote_view"](remote_view_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_remote_view_unknown_device_and_door_ignored(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.remote_view is ignored when both device_id and door_name are unknown."""
handlers = _get_ws_handlers(mock_client)
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id="unknown-device", door_name="Unknown Door"),
)
await handlers["access.remote_view"](remote_view_msg)
await hass.async_block_till_done()
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
assert state is not None
assert state.state == "unknown"
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
async def test_remote_view_device_mapping_via_device_update(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.remote_view resolves device_id populated by access.data.device.update."""
handlers = _get_ws_handlers(mock_client)
device_update_msg = DeviceUpdate(
event="access.data.device.update",
data=DeviceUpdateData(
unique_id="intercom-device-001",
door=DeviceUpdateDoor(unique_id="door-001"),
),
)
await handlers["access.data.device.update"](device_update_msg)
await hass.async_block_till_done()
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id="intercom-device-001"),
)
await handlers["access.remote_view"](remote_view_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"
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
async def test_remote_view_device_mapping_via_v2_device_update(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.remote_view resolves device_id populated by access.data.v2.device.update."""
handlers = _get_ws_handlers(mock_client)
v2_device_update_msg = V2DeviceUpdate(
event="access.data.v2.device.update",
data=V2DeviceUpdateData(
id="intercom-v2-001",
location_states=[V2DeviceLocationState(location_id="door-001")],
),
)
await handlers["access.data.v2.device.update"](v2_device_update_msg)
await hass.async_block_till_done()
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id="intercom-v2-001"),
)
await handlers["access.remote_view"](remote_view_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"
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
async def test_v2_device_update_multiple_location_states_maps_to_first_door(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test device with multiple location_states maps to the first valid door only."""
handlers = _get_ws_handlers(mock_client)
# Device has two location_states; should be mapped to the first door (door-001).
v2_device_update_msg = V2DeviceUpdate(
event="access.data.v2.device.update",
data=V2DeviceUpdateData(
id="hub-multi-001",
location_states=[
V2DeviceLocationState(location_id="door-001"),
V2DeviceLocationState(location_id="door-002"),
],
),
)
await handlers["access.data.v2.device.update"](v2_device_update_msg)
await hass.async_block_till_done()
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id="hub-multi-001"),
)
await handlers["access.remote_view"](remote_view_msg)
await hass.async_block_till_done()
# Should ring front door (door-001), not back door (door-002)
front_state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
assert front_state is not None
assert front_state.attributes["event_type"] == "ring"
assert front_state.state == "2025-01-01T00:00:00.000+00:00"
back_state = hass.states.get(BACK_DOOR_DOORBELL_ENTITY)
assert back_state is not None
assert back_state.state == "unknown"
async def test_device_update_without_door_does_not_map(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.data.device.update without a door does not populate the mapping."""
handlers = _get_ws_handlers(mock_client)
device_update_msg = DeviceUpdate(
event="access.data.device.update",
data=DeviceUpdateData(unique_id="orphan-device"),
)
await handlers["access.data.device.update"](device_update_msg)
await hass.async_block_till_done()
# Sending a remote_view for that device should not fire a doorbell event
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id="orphan-device"),
)
await handlers["access.remote_view"](remote_view_msg)
await hass.async_block_till_done()
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
assert state is not None
assert state.state == "unknown"
async def test_device_update_empty_unique_id_does_not_pollute_mapping(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.data.device.update with empty unique_id does not create mapping."""
handlers = _get_ws_handlers(mock_client)
device_update_msg = DeviceUpdate(
event="access.data.device.update",
data=DeviceUpdateData(
unique_id="",
door=DeviceUpdateDoor(unique_id="door-001"),
),
)
await handlers["access.data.device.update"](device_update_msg)
await hass.async_block_till_done()
# An empty device_id must not accidentally resolve via the empty-string key
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id=""),
)
await handlers["access.remote_view"](remote_view_msg)
await hass.async_block_till_done()
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
assert state is not None
assert state.state == "unknown"
async def test_v2_device_update_empty_id_does_not_pollute_mapping(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_client: MagicMock,
) -> None:
"""Test access.data.v2.device.update with empty id does not create mapping."""
handlers = _get_ws_handlers(mock_client)
v2_device_update_msg = V2DeviceUpdate(
event="access.data.v2.device.update",
data=V2DeviceUpdateData(
id="",
location_states=[V2DeviceLocationState(location_id="door-001")],
),
)
await handlers["access.data.v2.device.update"](v2_device_update_msg)
await hass.async_block_till_done()
# The empty-string device id must not produce an entry in _device_to_door
remote_view_msg = RemoteView(
event="access.remote_view",
data=RemoteViewData(device_id=""),
)
await handlers["access.remote_view"](remote_view_msg)
await hass.async_block_till_done()
state = hass.states.get(FRONT_DOOR_DOORBELL_ENTITY)
assert state is not None
assert state.state == "unknown"
@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00")
async def test_logs_add_uah_door_via_enriched_door_id(
hass: HomeAssistant,