mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 22:38:02 -04:00
Add UniFi Protect events to the API key only mode (#183230)
This commit is contained in:
@@ -5,7 +5,13 @@ import re
|
||||
from typing import Any, override
|
||||
|
||||
from uiprotect import ProtectEvent
|
||||
from uiprotect.data import Fob, ModelType, PublicDeviceModel, SmartDetectObjectType
|
||||
from uiprotect.data import (
|
||||
Fob,
|
||||
ModelType,
|
||||
PublicCamera,
|
||||
PublicDeviceModel,
|
||||
SmartDetectObjectType,
|
||||
)
|
||||
from uiprotect.data.nvr import Event, EventDetectedThumbnail
|
||||
from uiprotect.data.types import EventButtonType
|
||||
|
||||
@@ -48,6 +54,7 @@ from .entity import (
|
||||
ProtectDeviceEntity,
|
||||
ProtectEventMixin,
|
||||
ProtectFobEntity,
|
||||
_async_capability_supported,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -671,7 +678,7 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
|
||||
ProtectEventEntityDescription(
|
||||
key="package",
|
||||
translation_key="package",
|
||||
ufp_required_field="can_detect_package",
|
||||
ufp_capability=SmartDetectObjectType.PACKAGE,
|
||||
ufp_obj_type=SmartDetectObjectType.PACKAGE,
|
||||
event_types=[EVENT_TYPE_PACKAGE_DETECTED],
|
||||
entity_class=ProtectDeviceSmartDetectEventEntity,
|
||||
@@ -687,7 +694,7 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
|
||||
ProtectDetectionEventEntityDescription(
|
||||
key="smart_detection",
|
||||
translation_key="smart_detection",
|
||||
ufp_required_field="feature_flags.has_smart_detect",
|
||||
ufp_required_field="feature_flags.smart_detect_types",
|
||||
event_types=_SMART_OBJECT_EVENT_TYPES,
|
||||
ufp_public_event_types=_SMART_DETECT_EVENT_TYPES,
|
||||
include_event_source=True,
|
||||
@@ -714,6 +721,22 @@ def _async_event_entities(
|
||||
for device in (data.get_cameras() if ufp_device is None else [ufp_device])
|
||||
for description in EVENT_DESCRIPTIONS
|
||||
if description.has_required(device)
|
||||
and _async_capability_supported(None, device, description)
|
||||
]
|
||||
|
||||
|
||||
@callback
|
||||
def _async_public_event_entities(
|
||||
data: ProtectData, camera: PublicCamera
|
||||
) -> list[ProtectDeviceEntity]:
|
||||
# NFC, fingerprint and vehicle read their events from the private bootstrap;
|
||||
# the doorbell ring drops out on the missing public is_doorbell flag.
|
||||
return [
|
||||
description.entity_class(data, camera, description)
|
||||
for description in EVENT_DESCRIPTIONS
|
||||
if issubclass(description.entity_class, ProtectDevicePublicEventEntity)
|
||||
and description.has_required_public(camera)
|
||||
and _async_capability_supported(camera, None, description)
|
||||
]
|
||||
|
||||
|
||||
@@ -729,6 +752,8 @@ async def async_setup_entry(
|
||||
def _add_new_public_device(device: PublicDeviceModel) -> None:
|
||||
if isinstance(device, Fob):
|
||||
async_add_entities([ProtectFobButtonEventEntity(data, device)])
|
||||
elif isinstance(device, PublicCamera):
|
||||
async_add_entities(_async_public_event_entities(data, device))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(hass, data.public_add_signal, _add_new_public_device)
|
||||
@@ -743,10 +768,17 @@ async def async_setup_entry(
|
||||
for fob in api.public_bootstrap.fobs.values()
|
||||
)
|
||||
|
||||
if api.is_public_only:
|
||||
async_add_entities(
|
||||
entity
|
||||
for public, _ in data.get_public_cameras()
|
||||
if public is not None
|
||||
for entity in _async_public_event_entities(data, public)
|
||||
)
|
||||
return
|
||||
|
||||
# Everything below is driven by the private bootstrap, which public-only
|
||||
# entries do not have.
|
||||
if api.is_public_only:
|
||||
return
|
||||
|
||||
@callback
|
||||
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Test the UniFi Protect event platform."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -16,6 +18,7 @@ from uiprotect.data import (
|
||||
ModelType,
|
||||
SmartDetectAudioType,
|
||||
SmartDetectObjectType,
|
||||
WSAction,
|
||||
)
|
||||
from uiprotect.websocket import WebsocketState
|
||||
|
||||
@@ -24,6 +27,7 @@ from homeassistant.components.unifiprotect.const import (
|
||||
ATTR_EVENT_SOURCE,
|
||||
ATTR_SMART_DETECT_TYPES,
|
||||
DEFAULT_ATTRIBUTION,
|
||||
DOMAIN,
|
||||
EVENT_TYPE_PACKAGE_DETECTED,
|
||||
)
|
||||
from homeassistant.components.unifiprotect.event import (
|
||||
@@ -41,6 +45,8 @@ from .utils import (
|
||||
assert_entity_counts,
|
||||
ids_from_device_description,
|
||||
init_entry,
|
||||
make_public_camera,
|
||||
public_device_ws_message,
|
||||
remove_entities,
|
||||
setup_public_camera,
|
||||
)
|
||||
@@ -2436,3 +2442,144 @@ async def test_event_entities_unavailable_on_events_ws_disconnect(
|
||||
|
||||
assert hass.states.get(ring_id).state != STATE_UNAVAILABLE
|
||||
assert hass.states.get(motion_id).state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
def _event_keys(entity_registry: EntityRegistry, mac: str) -> set[str]:
|
||||
"""Return the description keys of a device's event entities."""
|
||||
prefix = f"{mac}_"
|
||||
return {
|
||||
entry.unique_id.removeprefix(prefix)
|
||||
for entry in entity_registry.entities.values()
|
||||
if entry.domain == Platform.EVENT and entry.unique_id.startswith(prefix)
|
||||
}
|
||||
|
||||
|
||||
async def test_smart_detection_events_need_advertised_types(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: EntityRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
) -> None:
|
||||
"""A camera advertising no smart detection types gets no smart detection events."""
|
||||
doorbell.feature_flags.has_smart_detect = True
|
||||
doorbell.feature_flags.smart_detect_types = []
|
||||
|
||||
await init_entry(hass, ufp, [doorbell])
|
||||
|
||||
keys = _event_keys(entity_registry, doorbell.mac)
|
||||
assert "motion_detection" in keys
|
||||
assert not keys & {"smart_detection", "package"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("object_types", "audio_types", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
[SmartDetectObjectType.PERSON, SmartDetectObjectType.PACKAGE],
|
||||
[SmartDetectAudioType.SMOKE],
|
||||
{"motion_detection", "smart_detection", "sound_detection", "package"},
|
||||
id="all",
|
||||
),
|
||||
pytest.param([], [], {"motion_detection"}, id="motion_only"),
|
||||
],
|
||||
)
|
||||
async def test_public_only_event_entities(
|
||||
entity_registry: EntityRegistry,
|
||||
doorbell: Camera,
|
||||
ufp_public_only: MockUFPFixture,
|
||||
setup_public_only: Callable[[], Coroutine[Any, Any, None]],
|
||||
object_types: list[SmartDetectObjectType],
|
||||
audio_types: list[SmartDetectAudioType],
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
"""A public-only entry builds the event entities fed by the public events WS.
|
||||
|
||||
The ring event needs the private ``is_doorbell`` flag, which the public
|
||||
camera does not carry.
|
||||
"""
|
||||
doorbell.feature_flags.smart_detect_types = object_types
|
||||
doorbell.feature_flags.smart_detect_audio_types = audio_types
|
||||
public = make_public_camera(doorbell)
|
||||
public.rtsps_streams = None
|
||||
ufp_public_only.api.public_bootstrap.cameras[doorbell.id] = public
|
||||
|
||||
await setup_public_only()
|
||||
|
||||
assert _event_keys(entity_registry, doorbell.mac) == expected
|
||||
|
||||
|
||||
async def test_public_only_skips_private_event_classes(
|
||||
entity_registry: EntityRegistry,
|
||||
doorbell: Camera,
|
||||
ufp_public_only: MockUFPFixture,
|
||||
setup_public_only: Callable[[], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""Event classes reading the private bootstrap stay off even if the flag matches."""
|
||||
public = make_public_camera(doorbell)
|
||||
public.rtsps_streams = None
|
||||
public.feature_flags.has_smart_detect = True
|
||||
ufp_public_only.api.public_bootstrap.cameras[doorbell.id] = public
|
||||
|
||||
await setup_public_only()
|
||||
|
||||
assert "vehicle" not in _event_keys(entity_registry, doorbell.mac)
|
||||
|
||||
|
||||
async def test_public_only_event_fires(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: EntityRegistry,
|
||||
doorbell: Camera,
|
||||
ufp_public_only: MockUFPFixture,
|
||||
setup_public_only: Callable[[], Coroutine[Any, Any, None]],
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""A public detection event fires the public-only camera's event entity."""
|
||||
public = make_public_camera(doorbell)
|
||||
public.rtsps_streams = None
|
||||
ufp_public_only.api.public_bootstrap.cameras[doorbell.id] = public
|
||||
await setup_public_only()
|
||||
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
Platform.EVENT, DOMAIN, f"{doorbell.mac}_smart_detection"
|
||||
)
|
||||
assert entity_id
|
||||
ufp_public_only.events_msg(
|
||||
ProtectEvent(
|
||||
id="smart-1",
|
||||
type=EventType.SMART_DETECT,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
start=fixed_now,
|
||||
smart_detect_types=[SmartDetectObjectType.PERSON],
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.attributes["event_type"] == "person"
|
||||
assert state.attributes[ATTR_EVENT_ID] == "smart-1"
|
||||
|
||||
|
||||
async def test_public_only_event_camera_added_after_setup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: EntityRegistry,
|
||||
camera: Camera,
|
||||
ufp_public_only: MockUFPFixture,
|
||||
setup_public_only: Callable[[], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""A camera added later gets its event entities from its public add frame."""
|
||||
await setup_public_only()
|
||||
assert_entity_counts(hass, Platform.EVENT, 0, 0)
|
||||
|
||||
public = make_public_camera(camera)
|
||||
public.rtsps_streams = None
|
||||
ufp_public_only.api.public_bootstrap.cameras[camera.id] = public
|
||||
msg = public_device_ws_message(public)
|
||||
msg.action = WSAction.ADD
|
||||
ufp_public_only.devices_ws_subscription(msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert "motion_detection" in _event_keys(entity_registry, camera.mac)
|
||||
|
||||
Reference in New Issue
Block a user