mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Migrate UniFi Protect detection sensors to the public API and add category event entities (#174948)
This commit is contained in:
@@ -2,17 +2,15 @@
|
||||
|
||||
from collections.abc import Sequence
|
||||
import dataclasses
|
||||
import operator
|
||||
from typing import cast, override
|
||||
|
||||
from uiprotect.data import (
|
||||
NVR,
|
||||
Camera,
|
||||
Event,
|
||||
ModelType,
|
||||
MountType,
|
||||
ProtectAdoptableDeviceModel,
|
||||
Sensor,
|
||||
SmartDetectObjectType,
|
||||
)
|
||||
from uiprotect.data.nvr import UOSDisk
|
||||
from uiprotect.data.public_devices import (
|
||||
@@ -296,6 +294,127 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
|
||||
ufp_value="is_person_tracking_enabled",
|
||||
ufp_perm=PermRequired.NO_WRITE,
|
||||
),
|
||||
# Sustained state via the public devices WS (uiprotect pushes a camera
|
||||
# update on each detection transition).
|
||||
ProtectBinaryEntityDescription(
|
||||
key="motion",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
ufp_public_value="is_motion_detected",
|
||||
ufp_event_driven=True,
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_obj_any",
|
||||
translation_key="object_detected",
|
||||
ufp_required_field="feature_flags.has_smart_detect",
|
||||
ufp_public_value="is_smart_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_obj_person",
|
||||
translation_key="person_detected",
|
||||
ufp_required_field="can_detect_person",
|
||||
ufp_public_value="is_person_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_person_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_obj_vehicle",
|
||||
translation_key="vehicle_detected",
|
||||
ufp_required_field="can_detect_vehicle",
|
||||
ufp_public_value="is_vehicle_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_vehicle_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_obj_animal",
|
||||
translation_key="animal_detected",
|
||||
ufp_required_field="can_detect_animal",
|
||||
ufp_public_value="is_animal_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_animal_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_any",
|
||||
translation_key="audio_object_detected",
|
||||
ufp_required_field="feature_flags.smart_detect_audio_types",
|
||||
ufp_public_value="is_audio_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_smoke",
|
||||
translation_key="smoke_alarm_detected",
|
||||
ufp_required_field="can_detect_smoke",
|
||||
ufp_public_value="is_smoke_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_smoke_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_cmonx",
|
||||
translation_key="co_alarm_detected",
|
||||
device_class=BinarySensorDeviceClass.CO,
|
||||
ufp_required_field="can_detect_co",
|
||||
ufp_public_value="is_cmonx_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_co_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_siren",
|
||||
translation_key="siren_detected",
|
||||
ufp_required_field="can_detect_siren",
|
||||
ufp_public_value="is_siren_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_siren_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_baby_cry",
|
||||
translation_key="baby_cry_detected",
|
||||
ufp_required_field="can_detect_baby_cry",
|
||||
ufp_public_value="is_baby_cry_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_baby_cry_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_speak",
|
||||
translation_key="speaking_detected",
|
||||
ufp_required_field="can_detect_speaking",
|
||||
ufp_public_value="is_speaking_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_speaking_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_bark",
|
||||
translation_key="barking_detected",
|
||||
ufp_required_field="can_detect_bark",
|
||||
ufp_public_value="is_bark_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_bark_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_car_alarm",
|
||||
translation_key="car_alarm_detected",
|
||||
ufp_required_field="can_detect_car_alarm",
|
||||
ufp_public_value="is_car_alarm_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_car_alarm_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_car_horn",
|
||||
translation_key="car_horn_detected",
|
||||
ufp_required_field="can_detect_car_horn",
|
||||
ufp_public_value="is_car_horn_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_car_horn_detection_on"),
|
||||
),
|
||||
ProtectBinaryEntityDescription(
|
||||
key="smart_audio_glass_break",
|
||||
translation_key="glass_break_detected",
|
||||
ufp_required_field="can_detect_glass_break",
|
||||
ufp_public_value="is_glass_break_currently_detected",
|
||||
ufp_event_driven=True,
|
||||
ufp_public_enabled_fn=operator.attrgetter("is_glass_break_detection_on"),
|
||||
),
|
||||
)
|
||||
|
||||
LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
|
||||
@@ -418,6 +537,8 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
# Doorbell ring is momentary (no sustained public state), so it stays on the
|
||||
# private event path.
|
||||
EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="doorbell",
|
||||
@@ -426,125 +547,6 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
|
||||
ufp_required_field="feature_flags.is_doorbell",
|
||||
ufp_event_obj="last_ring_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="motion",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
ufp_enabled="is_motion_detection_on",
|
||||
ufp_event_obj="last_motion_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_obj_any",
|
||||
translation_key="object_detected",
|
||||
ufp_required_field="feature_flags.has_smart_detect",
|
||||
ufp_event_obj="last_smart_detect_event",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_obj_person",
|
||||
translation_key="person_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.PERSON,
|
||||
ufp_required_field="can_detect_person",
|
||||
ufp_enabled="is_person_detection_on",
|
||||
ufp_event_obj="last_person_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_obj_vehicle",
|
||||
translation_key="vehicle_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.VEHICLE,
|
||||
ufp_required_field="can_detect_vehicle",
|
||||
ufp_enabled="is_vehicle_detection_on",
|
||||
ufp_event_obj="last_vehicle_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_obj_animal",
|
||||
translation_key="animal_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.ANIMAL,
|
||||
ufp_required_field="can_detect_animal",
|
||||
ufp_enabled="is_animal_detection_on",
|
||||
ufp_event_obj="last_animal_detect_event",
|
||||
),
|
||||
# Package detection is a momentary smart-detect event, not a sustained state:
|
||||
# it is the package event entity (event.py), not a binary sensor.
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_any",
|
||||
translation_key="audio_object_detected",
|
||||
ufp_required_field="feature_flags.has_smart_detect",
|
||||
ufp_event_obj="last_smart_audio_detect_event",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_smoke",
|
||||
translation_key="smoke_alarm_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.SMOKE,
|
||||
ufp_required_field="can_detect_smoke",
|
||||
ufp_enabled="is_smoke_detection_on",
|
||||
ufp_event_obj="last_smoke_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_cmonx",
|
||||
translation_key="co_alarm_detected",
|
||||
device_class=BinarySensorDeviceClass.CO,
|
||||
ufp_required_field="can_detect_co",
|
||||
ufp_enabled="is_co_detection_on",
|
||||
ufp_event_obj="last_cmonx_detect_event",
|
||||
ufp_obj_type=SmartDetectObjectType.CMONX,
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_siren",
|
||||
translation_key="siren_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.SIREN,
|
||||
ufp_required_field="can_detect_siren",
|
||||
ufp_enabled="is_siren_detection_on",
|
||||
ufp_event_obj="last_siren_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_baby_cry",
|
||||
translation_key="baby_cry_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.BABY_CRY,
|
||||
ufp_required_field="can_detect_baby_cry",
|
||||
ufp_enabled="is_baby_cry_detection_on",
|
||||
ufp_event_obj="last_baby_cry_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_speak",
|
||||
translation_key="speaking_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.SPEAK,
|
||||
ufp_required_field="can_detect_speaking",
|
||||
ufp_enabled="is_speaking_detection_on",
|
||||
ufp_event_obj="last_speaking_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_bark",
|
||||
translation_key="barking_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.BARK,
|
||||
ufp_required_field="can_detect_bark",
|
||||
ufp_enabled="is_bark_detection_on",
|
||||
ufp_event_obj="last_bark_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_car_alarm",
|
||||
translation_key="car_alarm_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.BURGLAR,
|
||||
ufp_required_field="can_detect_car_alarm",
|
||||
ufp_enabled="is_car_alarm_detection_on",
|
||||
ufp_event_obj="last_car_alarm_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_car_horn",
|
||||
translation_key="car_horn_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.CAR_HORN,
|
||||
ufp_required_field="can_detect_car_horn",
|
||||
ufp_enabled="is_car_horn_detection_on",
|
||||
ufp_event_obj="last_car_horn_detect_event",
|
||||
),
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_glass_break",
|
||||
translation_key="glass_break_detected",
|
||||
ufp_obj_type=SmartDetectObjectType.GLASS_BREAK,
|
||||
ufp_required_field="can_detect_glass_break",
|
||||
ufp_enabled="is_glass_break_detection_on",
|
||||
ufp_event_obj="last_glass_break_detect_event",
|
||||
),
|
||||
)
|
||||
|
||||
VIEWER_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = (
|
||||
@@ -665,31 +667,6 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity):
|
||||
self._attr_is_on = False
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
@callback
|
||||
def _find_active_event_with_object_type(
|
||||
self, device: ProtectDeviceType
|
||||
) -> Event | None:
|
||||
"""Find an active event containing this sensor's object type.
|
||||
|
||||
Fallback for issue #152133: last_smart_detect_event_ids may not update
|
||||
immediately when a new detection type is added to an ongoing event.
|
||||
"""
|
||||
obj_type = self.entity_description.ufp_obj_type
|
||||
if obj_type is None or not isinstance(device, Camera):
|
||||
return None
|
||||
|
||||
# Check known active event IDs from camera first (fast path)
|
||||
for event_id in device.last_smart_detect_event_ids.values():
|
||||
if (
|
||||
event_id
|
||||
and (event := self.data.api.bootstrap.events.get(event_id))
|
||||
and event.end is None
|
||||
and obj_type in event.smart_detect_types
|
||||
):
|
||||
return event
|
||||
|
||||
return None
|
||||
|
||||
@callback
|
||||
@override
|
||||
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
|
||||
@@ -700,22 +677,11 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity):
|
||||
super()._async_update_device_from_protect(device)
|
||||
|
||||
event = description.get_event_obj(device)
|
||||
if event is None:
|
||||
# Fallback for #152133: check active events directly
|
||||
event = self._find_active_event_with_object_type(device)
|
||||
|
||||
if event:
|
||||
self._event = event
|
||||
self._event_end = event.end
|
||||
|
||||
if not (
|
||||
event
|
||||
and (
|
||||
description.ufp_obj_type is None
|
||||
or description.has_matching_smart(event)
|
||||
)
|
||||
and not self._event_already_ended(prev_event, prev_event_end)
|
||||
):
|
||||
if not (event and not self._event_already_ended(prev_event, prev_event_end)):
|
||||
self._set_event_done()
|
||||
return
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ AUTH_RETRIES = 2
|
||||
|
||||
ATTR_EVENT_SCORE = "event_score"
|
||||
ATTR_EVENT_ID = "event_id"
|
||||
ATTR_SMART_DETECT_TYPES = "smart_detect_types"
|
||||
ATTR_WIDTH = "width"
|
||||
ATTR_HEIGHT = "height"
|
||||
ATTR_FPS = "fps"
|
||||
|
||||
@@ -98,6 +98,7 @@ class ProtectData:
|
||||
self.auth_retries = 0
|
||||
self.last_update_success = False
|
||||
self.last_public_update_success = False
|
||||
self.last_events_update_success = False
|
||||
self.api = protect
|
||||
self.adopt_signal = _async_dispatch_id(entry, DISPATCH_ADOPT)
|
||||
self.add_signal = _async_dispatch_id(entry, DISPATCH_ADD)
|
||||
@@ -212,6 +213,7 @@ class ProtectData:
|
||||
"""Subscribe and do the refresh."""
|
||||
self.last_update_success = True
|
||||
self.last_public_update_success = True
|
||||
self.last_events_update_success = True
|
||||
self._async_update_change(True, force_update=True)
|
||||
api = self.api
|
||||
self._unsubs = [
|
||||
@@ -227,6 +229,7 @@ class ProtectData:
|
||||
self._async_process_public_devices_ws_message
|
||||
),
|
||||
api.subscribe_devices_websocket_state(self._async_public_ws_state_changed),
|
||||
api.subscribe_events_websocket_state(self._async_events_ws_state_changed),
|
||||
]
|
||||
|
||||
@callback
|
||||
@@ -310,14 +313,14 @@ class ProtectData:
|
||||
) -> None:
|
||||
"""Dispatch a public events websocket event to its subscribers.
|
||||
|
||||
Only the start of an event is dispatched, routed to the subscribers that
|
||||
registered for this device and event type; an entity that cares about a
|
||||
sub-type (e.g. a smart-detect object type) filters further itself.
|
||||
Subscriptions are keyed by ``device_id`` (the stable cross-API join key,
|
||||
shared by the private and public bootstraps), so the event routes
|
||||
directly without a bootstrap lookup.
|
||||
Each non-eviction change is dispatched — a detection type may surface at
|
||||
the event start, on a later update, or only as it ends — routed to the
|
||||
subscribers registered for this device and event type; entities fire each
|
||||
``(event, type)`` once. Subscriptions are keyed by ``device_id`` (the
|
||||
stable cross-API join key, shared by the private and public bootstraps),
|
||||
so the event routes directly without a bootstrap lookup.
|
||||
"""
|
||||
if change is not EventChange.STARTED:
|
||||
if change is EventChange.REMOVED:
|
||||
return
|
||||
if not (
|
||||
subscriptions := self._public_event_subscriptions.get(
|
||||
@@ -373,6 +376,21 @@ class ProtectData:
|
||||
for public in list(self.api.public_bootstrap.cameras.values()):
|
||||
async_dispatcher_send(self._hass, self.channels_signal, public)
|
||||
|
||||
@callback
|
||||
def _async_events_ws_state_changed(self, state: WebsocketState) -> None:
|
||||
"""Handle a change in the public events websocket state.
|
||||
|
||||
Entities whose values are derived from the events stream (the
|
||||
detection booleans and the public event entities) include this in
|
||||
their availability, since the devices websocket alone cannot tell
|
||||
whether detections still flow.
|
||||
"""
|
||||
success = state is WebsocketState.CONNECTED
|
||||
if success == self.last_events_update_success:
|
||||
return
|
||||
self.last_events_update_success = success
|
||||
self._async_process_public_updates()
|
||||
|
||||
@callback
|
||||
def _async_process_public_updates(self) -> None:
|
||||
"""Re-signal public-API entities after a public websocket state change."""
|
||||
|
||||
@@ -31,6 +31,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from .const import (
|
||||
ATTR_EVENT_ID,
|
||||
ATTR_EVENT_SCORE,
|
||||
ATTR_SMART_DETECT_TYPES,
|
||||
DEFAULT_ATTRIBUTION,
|
||||
DEFAULT_BRAND,
|
||||
DOMAIN,
|
||||
@@ -232,6 +233,9 @@ class BaseProtectEntity(Entity):
|
||||
# (set ``ufp_public_value``); ``None`` until primed/refreshed.
|
||||
_ufp_public_obj: PublicDeviceModel | None = None
|
||||
_ufp_uses_public: bool = False
|
||||
# Values derived from the public events websocket (detection booleans,
|
||||
# public event entities) additionally require that websocket to be healthy.
|
||||
_ufp_requires_events_ws: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -281,12 +285,19 @@ class BaseProtectEntity(Entity):
|
||||
# Migrated entities are fully public: availability tracks the public
|
||||
# websocket health and the public object's state (CONNECTED only;
|
||||
# CONNECTING/DISCONNECTED/UNKNOWN and a missing object read as
|
||||
# unavailable), independent of the private connection. An optional
|
||||
# ``ufp_public_enabled_fn`` gate then mirrors ``ufp_enabled`` against
|
||||
# the public object (e.g. a sensor feature toggled off).
|
||||
# unavailable), independent of the private connection. Values fed by
|
||||
# the events websocket also require it to be healthy — the devices
|
||||
# websocket keeps the device state fresh, but only the events stream
|
||||
# carries the detections. An optional ``ufp_public_enabled_fn`` gate
|
||||
# then mirrors ``ufp_enabled`` against the public object (e.g. a
|
||||
# sensor feature toggled off).
|
||||
public_obj = self._ufp_public_obj
|
||||
if (
|
||||
self.data.last_public_update_success
|
||||
and (
|
||||
not self._ufp_requires_events_ws
|
||||
or self.data.last_events_update_success
|
||||
)
|
||||
and public_obj is not None
|
||||
and public_obj.state is DeviceState.CONNECTED
|
||||
):
|
||||
@@ -355,11 +366,18 @@ class BaseProtectEntity(Entity):
|
||||
)
|
||||
# Not every entity carries an entity_description (e.g. cameras), so getattr.
|
||||
description = getattr(self, "entity_description", None)
|
||||
if isinstance(description, ProtectEntityDescription) and (
|
||||
description.ufp_public_value is not None
|
||||
or description.ufp_public_value_fn is not None
|
||||
):
|
||||
self._ufp_uses_public = True
|
||||
if isinstance(description, ProtectEntityDescription):
|
||||
if (
|
||||
description.ufp_public_value is not None
|
||||
or description.ufp_public_value_fn is not None
|
||||
):
|
||||
self._ufp_uses_public = True
|
||||
if description.ufp_event_driven:
|
||||
self._ufp_requires_events_ws = True
|
||||
# ``_ufp_uses_public`` may also be declared as a class attribute by
|
||||
# entities driven by the public API without a migrated value (the
|
||||
# public event entities).
|
||||
if self._ufp_uses_public:
|
||||
self._ufp_public_obj = self.data.async_get_public_device(self.device)
|
||||
self.async_on_remove(
|
||||
self.data.async_subscribe_public(
|
||||
@@ -429,7 +447,9 @@ class EventEntityMixin(ProtectDeviceEntity):
|
||||
"""Adds motion event attributes to sensor."""
|
||||
|
||||
entity_description: ProtectEventMixin
|
||||
_unrecorded_attributes = frozenset({ATTR_EVENT_ID, ATTR_EVENT_SCORE})
|
||||
_unrecorded_attributes = frozenset(
|
||||
{ATTR_EVENT_ID, ATTR_EVENT_SCORE, ATTR_SMART_DETECT_TYPES}
|
||||
)
|
||||
_event: Event | None = None
|
||||
_event_end: datetime | None = None
|
||||
|
||||
@@ -484,6 +504,9 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046
|
||||
ufp_public_value: str | None = None
|
||||
# Callable variant of ``ufp_public_value`` for public values needing a transform.
|
||||
ufp_public_value_fn: Callable[[PublicDeviceModel], Any] | None = None
|
||||
# True when the public value is derived from the events websocket (the
|
||||
# detection booleans); availability then also tracks that websocket.
|
||||
ufp_event_driven: bool = False
|
||||
ufp_enabled: str | None = None
|
||||
# Public counterpart of ``ufp_enabled``; a callable because public enablement
|
||||
# is often compound (e.g. mount type plus a settings flag).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Platform providing event entities for UniFi Protect."""
|
||||
|
||||
import dataclasses
|
||||
import re
|
||||
from typing import Any, override
|
||||
|
||||
from uiprotect import ProtectEvent
|
||||
@@ -20,6 +21,7 @@ from homeassistant.helpers.event import async_call_at
|
||||
from . import Bootstrap
|
||||
from .const import (
|
||||
ATTR_EVENT_ID,
|
||||
ATTR_SMART_DETECT_TYPES,
|
||||
EVENT_TYPE_FINGERPRINT_IDENTIFIED,
|
||||
EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
|
||||
EVENT_TYPE_NFC_SCANNED,
|
||||
@@ -42,6 +44,10 @@ from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# Per-entity cap on tracked event ids for fire dedup (far above realistic
|
||||
# concurrent/recent events per camera per category).
|
||||
_MAX_TRACKED_EVENTS = 16
|
||||
|
||||
|
||||
# Select best thumbnail
|
||||
# Prefer thumbnails with LPR data, sorted by confidence
|
||||
@@ -75,7 +81,69 @@ class ProtectEventEntityDescription(ProtectEventMixin, EventEntityDescription):
|
||||
entity_class: type[ProtectDeviceEntity]
|
||||
|
||||
|
||||
class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
|
||||
# Protect emits overlapping ``smartDetectZone``, ``smartDetectLine``, and
|
||||
# ``smartDetectLoiterZone`` frames for the same underlying detection, and a
|
||||
# line-crossing or loitering detection can arrive as a standalone event of its
|
||||
# own type — all three carry the same ``smartDetectTypes`` payload per the
|
||||
# public API schema, so smart-detect entities subscribe to all of them.
|
||||
_SMART_DETECT_EVENT_TYPES = (
|
||||
EventType.SMART_DETECT,
|
||||
EventType.SMART_DETECT_LINE,
|
||||
EventType.SMART_DETECT_LOITER,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, kw_only=True)
|
||||
class ProtectDetectionEventEntityDescription(ProtectEventEntityDescription):
|
||||
"""Describes a category detection event entity driven by the public events WS."""
|
||||
|
||||
ufp_public_event_types: tuple[EventType, ...]
|
||||
|
||||
|
||||
class ProtectDevicePublicEventEntity(
|
||||
EventEntityMixin, ProtectDeviceEntity, EventEntity
|
||||
):
|
||||
"""Base for entities driven by the public events WS.
|
||||
|
||||
A detection type can surface at the event start, on a later update, or only
|
||||
as the event ends, and every non-eviction change is dispatched — so firing is
|
||||
deduped per ``(event id, event type)``.
|
||||
|
||||
Availability follows the public API (device present and connected) plus the
|
||||
events websocket, which is the only channel these entities fire from.
|
||||
"""
|
||||
|
||||
_ufp_uses_public = True
|
||||
_ufp_requires_events_ws = True
|
||||
|
||||
entity_description: ProtectEventEntityDescription
|
||||
# A camera can run two overlapping events of the same category whose
|
||||
# dispatches interleave, so dedup tracks fired types per recent event id
|
||||
# (bounded), not just the current one.
|
||||
_fired: dict[str, frozenset[str]] | None = None
|
||||
|
||||
@callback
|
||||
def _fire_once(
|
||||
self, event: ProtectEvent, event_type: str, event_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Fire ``event_type`` once per event, ignoring repeat dispatches."""
|
||||
fired = self._fired
|
||||
if fired is None:
|
||||
fired = self._fired = {}
|
||||
# Pop-and-reinsert so any dispatch refreshes this event id's recency; a
|
||||
# long-running event that keeps updating is then not evicted below.
|
||||
types = fired.pop(event.id, frozenset())
|
||||
if event_type in types:
|
||||
fired[event.id] = types
|
||||
return
|
||||
fired[event.id] = types | {event_type}
|
||||
if len(fired) > _MAX_TRACKED_EVENTS:
|
||||
del fired[next(iter(fired))] # evict the least-recently-seen event id
|
||||
self._trigger_event(event_type, event_data)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class ProtectDeviceRingEventEntity(ProtectDevicePublicEventEntity):
|
||||
"""A UniFi Protect doorbell ring event entity driven by the public events WS."""
|
||||
|
||||
entity_description: ProtectEventEntityDescription
|
||||
@@ -92,8 +160,7 @@ class ProtectDeviceRingEventEntity(EventEntityMixin, ProtectDeviceEntity, EventE
|
||||
|
||||
@callback
|
||||
def _async_ring_event(self, event: ProtectEvent) -> None:
|
||||
self._trigger_event(DoorbellEventType.RING, {ATTR_EVENT_ID: event.id})
|
||||
self.async_write_ha_state()
|
||||
self._fire_once(event, DoorbellEventType.RING, {ATTR_EVENT_ID: event.id})
|
||||
|
||||
|
||||
class ProtectDeviceNFCEventEntity(EventEntityMixin, ProtectDeviceEntity, EventEntity):
|
||||
@@ -360,9 +427,7 @@ class ProtectDeviceVehicleEventEntity(
|
||||
self._async_set_thumbnail_timer()
|
||||
|
||||
|
||||
class ProtectDeviceSmartDetectEventEntity(
|
||||
EventEntityMixin, ProtectDeviceEntity, EventEntity
|
||||
):
|
||||
class ProtectDeviceSmartDetectEventEntity(ProtectDevicePublicEventEntity):
|
||||
"""A UniFi Protect smart-detect event entity driven by the public events WS.
|
||||
|
||||
Used for object types that Protect models as discrete, point-in-time
|
||||
@@ -380,19 +445,102 @@ class ProtectDeviceSmartDetectEventEntity(
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to public smart-detect events for this camera."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.data.async_subscribe_public_event(
|
||||
self.device.id, EventType.SMART_DETECT, self._async_smart_detect_event
|
||||
for event_type in _SMART_DETECT_EVENT_TYPES:
|
||||
self.async_on_remove(
|
||||
self.data.async_subscribe_public_event(
|
||||
self.device.id, event_type, self._async_smart_detect_event
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_smart_detect_event(self, event: ProtectEvent) -> None:
|
||||
description = self.entity_description
|
||||
event_types = description.event_types
|
||||
if event_types and description.ufp_obj_type in event.smart_detect_types:
|
||||
self._trigger_event(event_types[0], {ATTR_EVENT_ID: event.id})
|
||||
self.async_write_ha_state()
|
||||
self._fire_once(event, event_types[0], {ATTR_EVENT_ID: event.id})
|
||||
|
||||
|
||||
_CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
|
||||
|
||||
# Friendly event-type slugs where the raw enum value is unclear or prefixed;
|
||||
# unlisted types fall back to a snake_case slug so new ones still auto-surface.
|
||||
_EVENT_TYPE_OVERRIDES = {
|
||||
SmartDetectObjectType.SMOKE: "smoke",
|
||||
SmartDetectObjectType.CMONX: "co",
|
||||
SmartDetectObjectType.SIREN: "siren",
|
||||
SmartDetectObjectType.BABY_CRY: "baby_cry",
|
||||
SmartDetectObjectType.SPEAK: "speaking",
|
||||
SmartDetectObjectType.BARK: "bark",
|
||||
SmartDetectObjectType.BURGLAR: "car_alarm",
|
||||
SmartDetectObjectType.CAR_HORN: "car_horn",
|
||||
SmartDetectObjectType.GLASS_BREAK: "glass_break",
|
||||
}
|
||||
|
||||
|
||||
def _event_type(detected: SmartDetectObjectType) -> str:
|
||||
"""Stable snake_case event type for a detection (HA translation-key rules)."""
|
||||
return (
|
||||
_EVENT_TYPE_OVERRIDES.get(detected)
|
||||
or _CAMEL_BOUNDARY.sub("_", detected.value).lower()
|
||||
)
|
||||
|
||||
|
||||
_SMART_OBJECT_EVENT_TYPES = [
|
||||
_event_type(t) for t in SmartDetectObjectType if t.audio_type is None
|
||||
]
|
||||
_SMART_AUDIO_EVENT_TYPES = [
|
||||
_event_type(t) for t in SmartDetectObjectType if t.audio_type is not None
|
||||
]
|
||||
|
||||
|
||||
class ProtectDeviceDetectionEventEntity(ProtectDevicePublicEventEntity):
|
||||
"""A camera smart-detect category event entity (object or audio), public WS.
|
||||
|
||||
Fires a momentary event for each detected type the entity surfaces. The
|
||||
``event_types`` are derived from the uiprotect enum, so a new detection type
|
||||
is surfaced automatically without code changes (only a state label is added).
|
||||
The subscribed category comes from ``ufp_public_event_types``; the motion
|
||||
variant overrides the firing.
|
||||
"""
|
||||
|
||||
entity_description: ProtectDetectionEventEntityDescription
|
||||
|
||||
@override
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to the category's public detection events."""
|
||||
await super().async_added_to_hass()
|
||||
for event_type in self.entity_description.ufp_public_event_types:
|
||||
self.async_on_remove(
|
||||
self.data.async_subscribe_public_event(
|
||||
self.device.id,
|
||||
event_type,
|
||||
self._async_detection_event,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_detection_event(self, event: ProtectEvent) -> None:
|
||||
allowed = self.entity_description.event_types or ()
|
||||
# One fire per detected type so each stays independently automatable
|
||||
# (incl. types with no binary sensor); carries the co-detected set known
|
||||
# at fire time (types can still arrive on a later update).
|
||||
detected = [_event_type(t) for t in event.smart_detect_types]
|
||||
for event_type in detected:
|
||||
if event_type in allowed:
|
||||
self._fire_once(
|
||||
event,
|
||||
event_type,
|
||||
{ATTR_EVENT_ID: event.id, ATTR_SMART_DETECT_TYPES: detected},
|
||||
)
|
||||
|
||||
|
||||
class ProtectDeviceMotionEventEntity(ProtectDeviceDetectionEventEntity):
|
||||
"""A camera motion-detection event entity (public events WS)."""
|
||||
|
||||
@callback
|
||||
@override
|
||||
def _async_detection_event(self, event: ProtectEvent) -> None:
|
||||
self._fire_once(event, EventType.MOTION.value, {ATTR_EVENT_ID: event.id})
|
||||
|
||||
|
||||
EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
|
||||
@@ -439,6 +587,30 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
|
||||
event_types=[EVENT_TYPE_PACKAGE_DETECTED],
|
||||
entity_class=ProtectDeviceSmartDetectEventEntity,
|
||||
),
|
||||
ProtectDetectionEventEntityDescription(
|
||||
key="motion_detection",
|
||||
translation_key="motion_detection",
|
||||
device_class=EventDeviceClass.MOTION,
|
||||
event_types=[EventType.MOTION.value],
|
||||
ufp_public_event_types=(EventType.MOTION,),
|
||||
entity_class=ProtectDeviceMotionEventEntity,
|
||||
),
|
||||
ProtectDetectionEventEntityDescription(
|
||||
key="smart_detection",
|
||||
translation_key="smart_detection",
|
||||
ufp_required_field="feature_flags.has_smart_detect",
|
||||
event_types=_SMART_OBJECT_EVENT_TYPES,
|
||||
ufp_public_event_types=_SMART_DETECT_EVENT_TYPES,
|
||||
entity_class=ProtectDeviceDetectionEventEntity,
|
||||
),
|
||||
ProtectDetectionEventEntityDescription(
|
||||
key="sound_detection",
|
||||
translation_key="sound_detection",
|
||||
ufp_required_field="feature_flags.smart_detect_audio_types",
|
||||
event_types=_SMART_AUDIO_EVENT_TYPES,
|
||||
ufp_public_event_types=(EventType.SMART_AUDIO_DETECT,),
|
||||
entity_class=ProtectDeviceDetectionEventEntity,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -181,12 +181,21 @@
|
||||
"fingerprint": {
|
||||
"default": "mdi:fingerprint"
|
||||
},
|
||||
"motion_detection": {
|
||||
"default": "mdi:motion-sensor"
|
||||
},
|
||||
"nfc": {
|
||||
"default": "mdi:nfc"
|
||||
},
|
||||
"package": {
|
||||
"default": "mdi:package-variant-closed"
|
||||
},
|
||||
"smart_detection": {
|
||||
"default": "mdi:cctv"
|
||||
},
|
||||
"sound_detection": {
|
||||
"default": "mdi:waveform"
|
||||
},
|
||||
"vehicle": {
|
||||
"default": "mdi:car"
|
||||
}
|
||||
|
||||
@@ -61,18 +61,9 @@ class ProtectLight(ProtectDeviceEntity, LightEntity):
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
_state_attrs = ("_attr_available", "_attr_is_on", "_attr_brightness")
|
||||
|
||||
@override
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Read state from the public API (primed before the first update)."""
|
||||
self._ufp_uses_public = True
|
||||
self._ufp_public_obj = self.data.async_get_public_device(self.device)
|
||||
self.async_on_remove(
|
||||
self.data.async_subscribe_public(
|
||||
self.device.mac, self._async_public_updated
|
||||
)
|
||||
)
|
||||
await super().async_added_to_hass()
|
||||
# State comes from the public API; the base class primes the object and
|
||||
# subscribes to the public devices websocket on this flag.
|
||||
_ufp_uses_public = True
|
||||
|
||||
@callback
|
||||
@override
|
||||
|
||||
@@ -289,6 +289,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion_detection": {
|
||||
"name": "Motion detection",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"motion": "Motion"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"nfc": {
|
||||
"name": "NFC",
|
||||
"state_attributes": {
|
||||
@@ -309,6 +319,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"smart_detection": {
|
||||
"name": "Smart detection",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"animal": "Animal",
|
||||
"car": "Car",
|
||||
"face": "Face",
|
||||
"license_plate": "License plate",
|
||||
"package": "Package",
|
||||
"person": "Person",
|
||||
"pet": "Pet",
|
||||
"vehicle": "Vehicle"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sound_detection": {
|
||||
"name": "Sound detection",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"baby_cry": "Baby cry",
|
||||
"bark": "Barking",
|
||||
"car_alarm": "Car alarm",
|
||||
"car_horn": "Car horn",
|
||||
"co": "CO alarm",
|
||||
"glass_break": "Glass break",
|
||||
"siren": "Siren",
|
||||
"smoke": "Smoke alarm",
|
||||
"speaking": "Speaking"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"vehicle": {
|
||||
"name": "Vehicle",
|
||||
"state_attributes": {
|
||||
|
||||
@@ -254,11 +254,18 @@ def mock_entry(
|
||||
ufp.devices_ws_state_subscription = ws_state_subscription
|
||||
return Mock()
|
||||
|
||||
def subscribe_events_websocket_state(
|
||||
ws_state_subscription: Callable[[WebsocketState], None],
|
||||
) -> Any:
|
||||
ufp.events_ws_state_subscription = ws_state_subscription
|
||||
return Mock()
|
||||
|
||||
ufp_client.subscribe_websocket = subscribe
|
||||
ufp_client.subscribe_websocket_state = subscribe_websocket_state
|
||||
ufp_client.subscribe_devices_websocket = subscribe_devices_websocket
|
||||
ufp_client.subscribe_events = subscribe_events
|
||||
ufp_client.subscribe_devices_websocket_state = subscribe_devices_websocket_state
|
||||
ufp_client.subscribe_events_websocket_state = subscribe_events_websocket_state
|
||||
|
||||
async def update_public() -> Any:
|
||||
# Mirror the library prime: build each camera's public model from the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test the UniFi Protect binary_sensor platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -13,7 +14,7 @@ from uiprotect.data import (
|
||||
ModelType,
|
||||
MountType,
|
||||
Sensor,
|
||||
SmartDetectObjectType,
|
||||
SmartDetectAudioType,
|
||||
)
|
||||
from uiprotect.data.public_devices import SensorFeatureCapability
|
||||
from uiprotect.websocket import WebsocketState
|
||||
@@ -27,11 +28,8 @@ from homeassistant.components.unifiprotect.binary_sensor import (
|
||||
SENSE_SENSORS,
|
||||
ProtectBinaryEntityDescription,
|
||||
)
|
||||
from homeassistant.components.unifiprotect.const import (
|
||||
ATTR_EVENT_SCORE,
|
||||
DEFAULT_ATTRIBUTION,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN
|
||||
from homeassistant.components.unifiprotect.number import CAMERA_NUMBERS
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
ATTR_DEVICE_CLASS,
|
||||
@@ -41,7 +39,7 @@ from homeassistant.const import (
|
||||
STATE_UNAVAILABLE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event as HAEvent, EventStateChangedData, HomeAssistant
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .utils import (
|
||||
@@ -50,10 +48,12 @@ from .utils import (
|
||||
assert_entity_counts,
|
||||
ids_from_device_description,
|
||||
init_entry,
|
||||
make_public_camera,
|
||||
make_public_light,
|
||||
make_public_sensor,
|
||||
public_device_ws_message,
|
||||
remove_entities,
|
||||
setup_public_camera,
|
||||
setup_public_light,
|
||||
setup_public_sensor,
|
||||
)
|
||||
@@ -76,11 +76,11 @@ async def test_binary_sensor_camera_remove(
|
||||
|
||||
ufp.api.bootstrap.nvr.system_info.ustorage = None
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 6)
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 6)
|
||||
await remove_entities(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0)
|
||||
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 6)
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 6)
|
||||
|
||||
|
||||
async def test_binary_sensor_light_remove(
|
||||
@@ -148,8 +148,9 @@ async def test_binary_sensor_setup_camera_all(
|
||||
"""Test binary_sensor entity setup for camera devices (all features)."""
|
||||
|
||||
ufp.api.bootstrap.nvr.system_info.ustorage = None
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 6)
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 7, 6)
|
||||
|
||||
description = EVENT_SENSORS[0]
|
||||
unique_id, entity_id = await ids_from_device_description(
|
||||
@@ -180,8 +181,8 @@ async def test_binary_sensor_setup_camera_all(
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
||||
|
||||
# Motion
|
||||
description = EVENT_SENSORS[1]
|
||||
# Motion (migrated to the public path, available via setup_public_camera)
|
||||
description = next(d for d in CAMERA_SENSORS if d.key == "motion")
|
||||
unique_id, entity_id = await ids_from_device_description(
|
||||
hass, Platform.BINARY_SENSOR, doorbell, description
|
||||
)
|
||||
@@ -685,49 +686,73 @@ async def test_binary_sensor_update_motion(
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""Test binary_sensor motion entity."""
|
||||
"""Test the migrated motion binary sensor reads sustained state from the public API."""
|
||||
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 14, 12)
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 13, 12)
|
||||
|
||||
motion = next(d for d in CAMERA_SENSORS if d.key == "motion")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1]
|
||||
hass, Platform.BINARY_SENSOR, doorbell, motion
|
||||
)
|
||||
|
||||
event = Event(
|
||||
model=ModelType.EVENT,
|
||||
id="test_event_id",
|
||||
type=EventType.MOTION,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=None,
|
||||
score=100,
|
||||
smart_detect_types=[],
|
||||
smart_detect_event_ids=[],
|
||||
camera_id=doorbell.id,
|
||||
api=ufp.api,
|
||||
)
|
||||
|
||||
new_camera = doorbell.model_copy()
|
||||
new_camera.is_motion_detected = True
|
||||
new_camera.last_motion_event_id = event.id
|
||||
|
||||
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
||||
ufp.api.bootstrap.events = {event.id: event}
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = event
|
||||
ufp.ws_msg(mock_msg)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_ON
|
||||
assert state.state == STATE_OFF
|
||||
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
||||
assert state.attributes[ATTR_EVENT_SCORE] == 100
|
||||
|
||||
ufp.devices_ws_subscription(
|
||||
public_device_ws_message(make_public_camera(doorbell, is_motion_detected=True))
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ON
|
||||
|
||||
# Detection ends -> sustained state clears.
|
||||
ufp.devices_ws_subscription(public_device_ws_message(make_public_camera(doorbell)))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
||||
|
||||
|
||||
async def test_binary_sensor_detection_unavailable_on_events_ws_disconnect(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
) -> None:
|
||||
"""Detection sensors follow the events websocket their values derive from.
|
||||
|
||||
A migrated value fed by the devices websocket (microphone level) must not
|
||||
be affected by an events websocket outage.
|
||||
"""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
motion = next(d for d in CAMERA_SENSORS if d.key == "motion")
|
||||
_, motion_id = await ids_from_device_description(
|
||||
hass, Platform.BINARY_SENSOR, doorbell, motion
|
||||
)
|
||||
mic_level = next(d for d in CAMERA_NUMBERS if d.key == "mic_level")
|
||||
_, mic_id = await ids_from_device_description(
|
||||
hass, Platform.NUMBER, doorbell, mic_level
|
||||
)
|
||||
assert hass.states.get(motion_id).state == STATE_OFF
|
||||
assert hass.states.get(mic_id).state != STATE_UNAVAILABLE
|
||||
|
||||
assert ufp.events_ws_state_subscription is not None
|
||||
ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(motion_id).state == STATE_UNAVAILABLE
|
||||
assert hass.states.get(mic_id).state != STATE_UNAVAILABLE
|
||||
|
||||
ufp.events_ws_state_subscription(WebsocketState.CONNECTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(motion_id).state == STATE_OFF
|
||||
|
||||
|
||||
async def test_binary_sensor_update_light_motion(
|
||||
@@ -837,37 +862,108 @@ async def test_binary_sensor_person_detected(
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""Test binary_sensor person detected detection entity."""
|
||||
"""Test the migrated person-detection binary sensor over the public API."""
|
||||
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 14, 14)
|
||||
|
||||
doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON)
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 13, 13)
|
||||
|
||||
person = next(d for d in CAMERA_SENSORS if d.key == "smart_obj_person")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3]
|
||||
hass, Platform.BINARY_SENSOR, doorbell, person
|
||||
)
|
||||
|
||||
events = async_capture_events(hass, EVENT_STATE_CHANGED)
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
||||
|
||||
# Person detection starts (camera update pushed on the public devices WS).
|
||||
ufp.devices_ws_subscription(
|
||||
public_device_ws_message(
|
||||
make_public_camera(
|
||||
doorbell,
|
||||
is_smart_currently_detected=True,
|
||||
is_person_currently_detected=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ON
|
||||
|
||||
# Detection ends -> sustained state clears.
|
||||
ufp.devices_ws_subscription(public_device_ws_message(make_public_camera(doorbell)))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@pytest.mark.parametrize(
|
||||
("key", "make_disabled"),
|
||||
[
|
||||
("smart_obj_person", lambda c: make_public_camera(c, object_types=[])),
|
||||
("smart_audio_smoke", lambda c: make_public_camera(c, audio_types=[])),
|
||||
],
|
||||
)
|
||||
async def test_binary_sensor_detection_disabled_unavailable(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
key: str,
|
||||
make_disabled: Callable[[Camera], Mock],
|
||||
) -> None:
|
||||
"""A migrated detection binary is unavailable when its type is disabled in Protect."""
|
||||
|
||||
# Ensure the audio-alarm capability so the smoke binary is created.
|
||||
doorbell.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE]
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in CAMERA_SENSORS if d.key == key)
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.BINARY_SENSOR, doorbell, description
|
||||
)
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
||||
|
||||
# The detection type is turned off in Protect -> the enabled gate fails.
|
||||
ufp.devices_ws_subscription(public_device_ws_message(make_disabled(doorbell)))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_binary_sensor_doorbell_ring(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""The doorbell occupancy binary stays on the private ring event path."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_SENSORS if d.key == "doorbell")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.BINARY_SENSOR, doorbell, description
|
||||
)
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
||||
|
||||
state_changes = async_capture_events(hass, EVENT_STATE_CHANGED)
|
||||
event = Event(
|
||||
model=ModelType.EVENT,
|
||||
id="test_event_id",
|
||||
type=EventType.SMART_DETECT,
|
||||
id="ring-1",
|
||||
type=EventType.RING,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=None,
|
||||
score=50,
|
||||
end=fixed_now,
|
||||
smart_detect_types=[],
|
||||
smart_detect_event_ids=[],
|
||||
camera_id=doorbell.id,
|
||||
api=ufp.api,
|
||||
)
|
||||
|
||||
new_camera = doorbell.model_copy()
|
||||
new_camera.is_smart_detected = True
|
||||
|
||||
new_camera.last_ring_event_id = event.id
|
||||
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
||||
ufp.api.bootstrap.events = {event.id: event}
|
||||
|
||||
@@ -875,106 +971,12 @@ async def test_binary_sensor_person_detected(
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = event
|
||||
ufp.ws_msg(mock_msg)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
event = Event(
|
||||
model=ModelType.EVENT,
|
||||
id="test_event_id",
|
||||
type=EventType.SMART_DETECT,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now + timedelta(seconds=1),
|
||||
score=65,
|
||||
smart_detect_types=[SmartDetectObjectType.PERSON],
|
||||
smart_detect_event_ids=[],
|
||||
camera_id=doorbell.id,
|
||||
api=ufp.api,
|
||||
)
|
||||
|
||||
new_camera = doorbell.model_copy()
|
||||
new_camera.is_smart_detected = True
|
||||
new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id
|
||||
|
||||
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
||||
ufp.api.bootstrap.events = {event.id: event}
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = event
|
||||
ufp.ws_msg(mock_msg)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_events = [event for event in events if event.data["entity_id"] == entity_id]
|
||||
assert len(entity_events) == 3
|
||||
assert entity_events[0].data["new_state"].state == STATE_OFF
|
||||
assert entity_events[1].data["new_state"].state == STATE_ON
|
||||
assert entity_events[2].data["new_state"].state == STATE_OFF
|
||||
|
||||
# Event is already seen and has end, should now be off
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
# Now send an event that has an end right away
|
||||
event = Event(
|
||||
model=ModelType.EVENT,
|
||||
id="new_event_id",
|
||||
type=EventType.SMART_DETECT,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now + timedelta(seconds=1),
|
||||
score=80,
|
||||
smart_detect_types=[SmartDetectObjectType.PERSON],
|
||||
smart_detect_event_ids=[],
|
||||
camera_id=doorbell.id,
|
||||
api=ufp.api,
|
||||
)
|
||||
|
||||
new_camera = doorbell.model_copy()
|
||||
new_camera.is_smart_detected = True
|
||||
new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id
|
||||
|
||||
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
||||
ufp.api.bootstrap.events = {event.id: event}
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = event
|
||||
|
||||
state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events(
|
||||
hass, EVENT_STATE_CHANGED
|
||||
)
|
||||
ufp.ws_msg(mock_msg)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
assert len(state_changes) == 2
|
||||
|
||||
on_event = state_changes[0]
|
||||
state = on_event.data["new_state"]
|
||||
assert state
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
||||
assert state.attributes[ATTR_EVENT_SCORE] == 80
|
||||
|
||||
off_event = state_changes[1]
|
||||
state = off_event.data["new_state"]
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
assert ATTR_EVENT_SCORE not in state.attributes
|
||||
|
||||
# replay and ensure ignored
|
||||
ufp.ws_msg(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
assert len(state_changes) == 2
|
||||
# A momentary ring blips on, then immediately clears.
|
||||
ring_changes = [e for e in state_changes if e.data["entity_id"] == entity_id]
|
||||
assert any(c.data["new_state"].state == STATE_ON for c in ring_changes)
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
||||
|
||||
|
||||
async def test_aiport_no_binary_sensor_entities(
|
||||
@@ -1003,171 +1005,62 @@ async def test_binary_sensor_simultaneous_person_and_vehicle_detection(
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""Test that when an event is updated with additional detection types, both trigger.
|
||||
"""Person and vehicle detected at once both report ON.
|
||||
|
||||
This is a regression test for https://github.com/home-assistant/core/issues/152133
|
||||
where an event starting with vehicle detection gets updated to also include person
|
||||
detection (e.g., someone getting out of a car). Both sensors should be ON
|
||||
simultaneously, not queued.
|
||||
Regression for https://github.com/home-assistant/core/issues/152133 (a second
|
||||
type added to an ongoing detection): on the public path each type's sustained
|
||||
state is derived independently by the library, so adding person to an ongoing
|
||||
vehicle detection turns both ON without queueing.
|
||||
"""
|
||||
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 14, 14)
|
||||
assert_entity_counts(hass, Platform.BINARY_SENSOR, 13, 13)
|
||||
|
||||
doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON)
|
||||
doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.VEHICLE)
|
||||
|
||||
# Get entity IDs for both person and vehicle detection
|
||||
person = next(d for d in CAMERA_SENSORS if d.key == "smart_obj_person")
|
||||
vehicle = next(d for d in CAMERA_SENSORS if d.key == "smart_obj_vehicle")
|
||||
_, person_entity_id = await ids_from_device_description(
|
||||
hass,
|
||||
Platform.BINARY_SENSOR,
|
||||
doorbell,
|
||||
EVENT_SENSORS[3], # person detected
|
||||
hass, Platform.BINARY_SENSOR, doorbell, person
|
||||
)
|
||||
_, vehicle_entity_id = await ids_from_device_description(
|
||||
hass,
|
||||
Platform.BINARY_SENSOR,
|
||||
doorbell,
|
||||
EVENT_SENSORS[4], # vehicle detected
|
||||
hass, Platform.BINARY_SENSOR, doorbell, vehicle
|
||||
)
|
||||
|
||||
# Step 1: Initial event with only VEHICLE detection (car arriving)
|
||||
event = Event(
|
||||
model=ModelType.EVENT,
|
||||
id="combined_event_id",
|
||||
type=EventType.SMART_DETECT,
|
||||
start=fixed_now - timedelta(seconds=5),
|
||||
end=None, # Event is ongoing
|
||||
score=90,
|
||||
smart_detect_types=[SmartDetectObjectType.VEHICLE],
|
||||
smart_detect_event_ids=[],
|
||||
camera_id=doorbell.id,
|
||||
api=ufp.api,
|
||||
# Vehicle arrives.
|
||||
ufp.devices_ws_subscription(
|
||||
public_device_ws_message(
|
||||
make_public_camera(
|
||||
doorbell,
|
||||
is_smart_currently_detected=True,
|
||||
is_vehicle_currently_detected=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
new_camera = doorbell.model_copy()
|
||||
new_camera.is_smart_detected = True
|
||||
new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = event.id
|
||||
|
||||
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
||||
ufp.api.bootstrap.events = {event.id: event}
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = event
|
||||
ufp.ws_msg(mock_msg)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Vehicle sensor should be ON
|
||||
vehicle_state = hass.states.get(vehicle_entity_id)
|
||||
assert vehicle_state
|
||||
assert vehicle_state.state == STATE_ON, "Vehicle detection should be ON"
|
||||
assert hass.states.get(vehicle_entity_id).state == STATE_ON
|
||||
assert hass.states.get(person_entity_id).state == STATE_OFF
|
||||
|
||||
# Person sensor should still be OFF (no person detected yet)
|
||||
person_state = hass.states.get(person_entity_id)
|
||||
assert person_state
|
||||
assert person_state.state == STATE_OFF, "Person detection should be OFF initially"
|
||||
|
||||
# Step 2: Same event gets updated to include PERSON detection
|
||||
# (someone gets out of the car - Protect adds PERSON to the same event)
|
||||
#
|
||||
# BUG SCENARIO: UniFi Protect updates the event to include PERSON in
|
||||
# smart_detect_types, BUT does NOT update last_smart_detect_event_ids[PERSON]
|
||||
# until the event ends. This is the core issue reported in #152133.
|
||||
updated_event = Event(
|
||||
model=ModelType.EVENT,
|
||||
id="combined_event_id", # Same event ID!
|
||||
type=EventType.SMART_DETECT,
|
||||
start=fixed_now - timedelta(seconds=5),
|
||||
end=None, # Event still ongoing
|
||||
score=90,
|
||||
smart_detect_types=[
|
||||
SmartDetectObjectType.VEHICLE,
|
||||
SmartDetectObjectType.PERSON,
|
||||
],
|
||||
smart_detect_event_ids=[],
|
||||
camera_id=doorbell.id,
|
||||
api=ufp.api,
|
||||
# Person joins the same scene -> both ON simultaneously.
|
||||
ufp.devices_ws_subscription(
|
||||
public_device_ws_message(
|
||||
make_public_camera(
|
||||
doorbell,
|
||||
is_smart_currently_detected=True,
|
||||
is_vehicle_currently_detected=True,
|
||||
is_person_currently_detected=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# IMPORTANT: The camera's last_smart_detect_event_ids is NOT updated for PERSON!
|
||||
# This simulates the real bug where UniFi Protect doesn't immediately update
|
||||
# the camera's last_smart_detect_event_ids when a new detection type is added
|
||||
# to an ongoing event.
|
||||
new_camera = doorbell.model_copy()
|
||||
new_camera.is_smart_detected = True
|
||||
# Only VEHICLE has the event ID - PERSON does not (simulating the bug)
|
||||
new_camera.last_smart_detect_event_ids[SmartDetectObjectType.VEHICLE] = (
|
||||
updated_event.id
|
||||
)
|
||||
# NOTE: We're NOT setting last_smart_detect_event_ids[PERSON] to simulate the bug!
|
||||
|
||||
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
||||
ufp.api.bootstrap.events = {updated_event.id: updated_event}
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = updated_event
|
||||
ufp.ws_msg(mock_msg)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# CRITICAL: Both sensors should now be ON simultaneously
|
||||
vehicle_state = hass.states.get(vehicle_entity_id)
|
||||
assert vehicle_state
|
||||
assert vehicle_state.state == STATE_ON, (
|
||||
"Vehicle detection should still be ON after event update"
|
||||
)
|
||||
|
||||
person_state = hass.states.get(person_entity_id)
|
||||
assert person_state
|
||||
assert person_state.state == STATE_ON, (
|
||||
"Person detection should be ON immediately when added to event, "
|
||||
"not waiting for vehicle detection to end"
|
||||
)
|
||||
|
||||
# Verify both have correct attributes
|
||||
assert vehicle_state.attributes[ATTR_EVENT_SCORE] == 90
|
||||
assert person_state.attributes[ATTR_EVENT_SCORE] == 90
|
||||
|
||||
# Step 3: Event ends - both sensors should turn OFF
|
||||
ended_event = Event(
|
||||
model=ModelType.EVENT,
|
||||
id="combined_event_id",
|
||||
type=EventType.SMART_DETECT,
|
||||
start=fixed_now - timedelta(seconds=5),
|
||||
end=fixed_now, # Event ended now
|
||||
score=90,
|
||||
smart_detect_types=[
|
||||
SmartDetectObjectType.VEHICLE,
|
||||
SmartDetectObjectType.PERSON,
|
||||
],
|
||||
smart_detect_event_ids=[],
|
||||
camera_id=doorbell.id,
|
||||
api=ufp.api,
|
||||
)
|
||||
|
||||
ufp.api.bootstrap.events = {ended_event.id: ended_event}
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = ended_event
|
||||
ufp.ws_msg(mock_msg)
|
||||
assert hass.states.get(vehicle_entity_id).state == STATE_ON
|
||||
assert hass.states.get(person_entity_id).state == STATE_ON
|
||||
|
||||
# Scene clears -> both OFF.
|
||||
ufp.devices_ws_subscription(public_device_ws_message(make_public_camera(doorbell)))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Both should be OFF now
|
||||
vehicle_state = hass.states.get(vehicle_entity_id)
|
||||
assert vehicle_state
|
||||
assert vehicle_state.state == STATE_OFF, (
|
||||
"Vehicle detection should be OFF after event ends"
|
||||
)
|
||||
|
||||
person_state = hass.states.get(person_entity_id)
|
||||
assert person_state
|
||||
assert person_state.state == STATE_OFF, (
|
||||
"Person detection should be OFF after event ends"
|
||||
)
|
||||
assert hass.states.get(vehicle_entity_id).state == STATE_OFF
|
||||
assert hass.states.get(person_entity_id).state == STATE_OFF
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -12,18 +14,24 @@ from uiprotect.data import (
|
||||
Event,
|
||||
EventType,
|
||||
ModelType,
|
||||
PublicBootstrap,
|
||||
SmartDetectAudioType,
|
||||
SmartDetectObjectType,
|
||||
)
|
||||
from uiprotect.websocket import WebsocketState
|
||||
|
||||
from homeassistant.components.unifiprotect.const import (
|
||||
ATTR_EVENT_ID,
|
||||
ATTR_SMART_DETECT_TYPES,
|
||||
DEFAULT_ATTRIBUTION,
|
||||
EVENT_TYPE_PACKAGE_DETECTED,
|
||||
)
|
||||
from homeassistant.components.unifiprotect.event import EVENT_DESCRIPTIONS
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, Platform
|
||||
from homeassistant.components.unifiprotect.event import (
|
||||
_MAX_TRACKED_EVENTS,
|
||||
EVENT_DESCRIPTIONS,
|
||||
)
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import Event as HAEvent, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .utils import (
|
||||
@@ -33,6 +41,7 @@ from .utils import (
|
||||
ids_from_device_description,
|
||||
init_entry,
|
||||
remove_entities,
|
||||
setup_public_camera,
|
||||
)
|
||||
|
||||
# Short delay for testing
|
||||
@@ -56,11 +65,11 @@ async def test_camera_remove(
|
||||
|
||||
ufp.api.bootstrap.nvr.system_info.ustorage = None
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
await remove_entities(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 0, 0)
|
||||
await adopt_devices(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
|
||||
|
||||
async def test_doorbell_ring(
|
||||
@@ -73,18 +82,12 @@ async def test_doorbell_ring(
|
||||
"""Test a doorbell ring event fired from the public events websocket."""
|
||||
|
||||
# Ring is delivered over the public events websocket, which is only
|
||||
# subscribed once update_public() has primed the public bootstrap.
|
||||
ufp.api.has_public_bootstrap = True
|
||||
ufp.api.public_bootstrap = Mock(
|
||||
spec=PublicBootstrap,
|
||||
relays={},
|
||||
sirens={},
|
||||
arm_mode=None,
|
||||
arm_profiles={},
|
||||
)
|
||||
# subscribed once update_public() has primed the public bootstrap; the
|
||||
# entity's availability also requires the public camera to resolve.
|
||||
setup_public_camera(ufp)
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -133,7 +136,8 @@ async def test_doorbell_ring(
|
||||
await hass.async_block_till_done()
|
||||
assert len(events) == 1
|
||||
|
||||
# Only the start of an event is dispatched; an update must be ignored.
|
||||
# Updates are dispatched too, but the entity fires each event id only
|
||||
# once, so a repeat dispatch of the same ring event must be suppressed.
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="test_ring_event",
|
||||
@@ -151,28 +155,31 @@ async def test_doorbell_ring(
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_type",
|
||||
[
|
||||
pytest.param(EventType.SMART_DETECT, id="zone"),
|
||||
pytest.param(EventType.SMART_DETECT_LINE, id="line"),
|
||||
pytest.param(EventType.SMART_DETECT_LOITER, id="loiter"),
|
||||
],
|
||||
)
|
||||
async def test_package_detected(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
event_type: EventType,
|
||||
) -> None:
|
||||
"""Test a package detection event fired from the public events websocket."""
|
||||
|
||||
# Package detection is delivered over the public events websocket, which is
|
||||
# only subscribed once update_public() has primed the public bootstrap.
|
||||
ufp.api.has_public_bootstrap = True
|
||||
ufp.api.public_bootstrap = Mock(
|
||||
spec=PublicBootstrap,
|
||||
relays={},
|
||||
sirens={},
|
||||
arm_mode=None,
|
||||
arm_profiles={},
|
||||
)
|
||||
# only subscribed once update_public() has primed the public bootstrap; the
|
||||
# entity's availability also requires the public camera to resolve.
|
||||
setup_public_camera(ufp)
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -186,12 +193,13 @@ async def test_package_detected(
|
||||
unsub = async_track_state_change_event(hass, entity_id, _capture_event)
|
||||
|
||||
# Package detection arrives on the public events websocket as a
|
||||
# smartDetectZone detection event with the package object type. Protect
|
||||
# records it already-ended; the event entity fires on the detection start.
|
||||
# smartDetectZone, smartDetectLine, or smartDetectLoiterZone detection
|
||||
# event with the package object type. Protect records it already-ended;
|
||||
# the event entity fires on the detection start.
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="test_package_event",
|
||||
type=EventType.SMART_DETECT,
|
||||
type=event_type,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
@@ -227,7 +235,8 @@ async def test_package_detected(
|
||||
await hass.async_block_till_done()
|
||||
assert len(events) == 1
|
||||
|
||||
# Only the start of a detection is dispatched; an update must be ignored.
|
||||
# Updates are dispatched too, but the entity fires each (event id, type)
|
||||
# once, so a repeat dispatch of the same package event must be suppressed.
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="test_package_event",
|
||||
@@ -315,7 +324,7 @@ async def test_doorbell_nfc_scanned(
|
||||
"""Test a doorbell NFC scanned event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -390,7 +399,7 @@ async def test_doorbell_nfc_scanned_ulpusr_deactivated(
|
||||
"""Test a doorbell NFC scanned event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -466,7 +475,7 @@ async def test_doorbell_nfc_scanned_no_ulpusr(
|
||||
"""Test a doorbell NFC scanned event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -534,7 +543,7 @@ async def test_doorbell_nfc_scanned_no_keyring(
|
||||
"""Test a doorbell NFC scanned event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -595,7 +604,7 @@ async def test_doorbell_fingerprint_identified(
|
||||
"""Test a doorbell fingerprint identified event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -663,7 +672,7 @@ async def test_doorbell_fingerprint_identified_user_deactivated(
|
||||
"""Test a doorbell fingerprint identified event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -732,7 +741,7 @@ async def test_doorbell_fingerprint_identified_no_user(
|
||||
"""Test a doorbell fingerprint identified event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -793,7 +802,7 @@ async def test_doorbell_fingerprint_not_identified(
|
||||
"""Test a doorbell fingerprint identified event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -851,7 +860,7 @@ async def test_vehicle_detection_basic(
|
||||
"""Test basic vehicle detection event with thumbnails."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -925,7 +934,7 @@ async def test_vehicle_detection_with_lpr_ufp6(
|
||||
"""Test vehicle detection with license plate recognition (UFP 6.0+ format)."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -1009,7 +1018,7 @@ async def test_vehicle_detection_with_lpr_legacy(
|
||||
"""Test vehicle detection with license plate recognition (legacy format)."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -1082,7 +1091,7 @@ async def test_vehicle_detection_multiple_thumbnails(
|
||||
"""Test vehicle detection with multiple thumbnails - should pick best LPR."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -1183,7 +1192,7 @@ async def test_vehicle_detection_no_thumbnails(
|
||||
"""Test vehicle detection event without thumbnails - should not fire."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -1241,7 +1250,7 @@ async def test_vehicle_detection_timer_reset_on_new_thumbnail(
|
||||
"""Test that timer resets when new thumbnails arrive for same event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -1352,7 +1361,7 @@ async def test_vehicle_detection_new_event_cancels_timer(
|
||||
"""Test that new event cancels timer for previous event."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -1479,7 +1488,7 @@ async def test_vehicle_detection_timer_cleanup_on_remove(
|
||||
"""Test that pending timer is cancelled when entity is removed."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[3]
|
||||
@@ -1544,7 +1553,7 @@ async def test_vehicle_detection_refire_on_lpr_data(
|
||||
"""Test that event refires when LPR data arrives after initial detection."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -1655,7 +1664,7 @@ async def test_vehicle_detection_no_refire_same_data(
|
||||
"""Test that event does NOT refire when same data arrives again."""
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
assert_entity_counts(hass, Platform.EVENT, 5, 5)
|
||||
assert_entity_counts(hass, Platform.EVENT, 7, 7)
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
@@ -1740,3 +1749,520 @@ async def test_aiport_no_event_entities(
|
||||
# AI Port should not create any camera-specific event entities
|
||||
# (doorbell, motion, etc.)
|
||||
assert_entity_counts(hass, Platform.EVENT, 0, 0)
|
||||
|
||||
|
||||
async def test_motion_detection_event(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""The motion event entity fires from the public events websocket."""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="motion-1",
|
||||
type=EventType.MOTION,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now,
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.attributes["event_type"] == "motion"
|
||||
assert state.attributes[ATTR_EVENT_ID] == "motion-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_type",
|
||||
[
|
||||
pytest.param(EventType.SMART_DETECT, id="zone"),
|
||||
pytest.param(EventType.SMART_DETECT_LINE, id="line"),
|
||||
pytest.param(EventType.SMART_DETECT_LOITER, id="loiter"),
|
||||
],
|
||||
)
|
||||
async def test_smart_detection_event(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
event_type: EventType,
|
||||
) -> None:
|
||||
"""The smart-detection event entity fires per object type with the full type set.
|
||||
|
||||
smartDetectZone, smartDetectLine, and smartDetectLoiterZone events all carry
|
||||
smart detections, so a standalone line-crossing or loitering event must fire
|
||||
the entity too.
|
||||
"""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
def _capture(event: HAEvent) -> None:
|
||||
events.append(event)
|
||||
|
||||
unsub = async_track_state_change_event(hass, entity_id, _capture)
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="smart-1",
|
||||
type=event_type,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now,
|
||||
smart_detect_types=(
|
||||
SmartDetectObjectType.PERSON,
|
||||
SmartDetectObjectType.VEHICLE,
|
||||
),
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
unsub()
|
||||
|
||||
# One fire per surfaced type, each carrying the full co-detected set.
|
||||
fired = [event.data["new_state"].attributes["event_type"] for event in events]
|
||||
assert fired == ["person", "vehicle"]
|
||||
last = events[-1].data["new_state"]
|
||||
assert last.attributes[ATTR_EVENT_ID] == "smart-1"
|
||||
assert last.attributes[ATTR_SMART_DETECT_TYPES] == ["person", "vehicle"]
|
||||
|
||||
|
||||
async def test_sound_detection_event(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""The sound-detection event entity fires for audio types (slugged event type)."""
|
||||
doorbell.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE]
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="audio-1",
|
||||
type=EventType.SMART_AUDIO_DETECT,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now,
|
||||
smart_detect_types=(SmartDetectObjectType.SMOKE,),
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.attributes["event_type"] == "smoke"
|
||||
assert state.attributes[ATTR_EVENT_ID] == "audio-1"
|
||||
|
||||
|
||||
async def test_sound_detection_event_late_type(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""Audio types arrive on a later update, not at start; fire once when they appear."""
|
||||
doorbell.feature_flags.smart_detect_audio_types = [SmartDetectAudioType.SMOKE]
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
def _capture(event: HAEvent) -> None:
|
||||
events.append(event)
|
||||
|
||||
unsub = async_track_state_change_event(hass, entity_id, _capture)
|
||||
base = {
|
||||
"id": "audio-1",
|
||||
"type": EventType.SMART_AUDIO_DETECT,
|
||||
"channel": ProtectEventChannel.DETECTION,
|
||||
"device_id": doorbell.id,
|
||||
"device_mac": doorbell.mac,
|
||||
"start": fixed_now - timedelta(seconds=1),
|
||||
}
|
||||
# Start carries no type yet -> nothing fires.
|
||||
ufp.events_msg(
|
||||
ProtectEvent(**base, end=None, smart_detect_types=()), EventChange.STARTED
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert events == []
|
||||
|
||||
# The type appears on a later update -> fires once.
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
**base, end=None, smart_detect_types=(SmartDetectObjectType.SMOKE,)
|
||||
),
|
||||
EventChange.UPDATED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# A further update for the same type is deduped (no re-fire).
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
**base, end=fixed_now, smart_detect_types=(SmartDetectObjectType.SMOKE,)
|
||||
),
|
||||
EventChange.UPDATED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
unsub()
|
||||
|
||||
fired = [event.data["new_state"].attributes["event_type"] for event in events]
|
||||
assert fired == ["smoke"]
|
||||
|
||||
|
||||
async def test_sound_detection_absent_without_audio_types(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
) -> None:
|
||||
"""Object smart-detect without audio support creates no sound-detection entity."""
|
||||
doorbell.feature_flags.smart_detect_audio_types = []
|
||||
await init_entry(hass, ufp, [doorbell])
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
smart = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
|
||||
sound = next(d for d in EVENT_DESCRIPTIONS if d.key == "sound_detection")
|
||||
_, smart_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, smart
|
||||
)
|
||||
_, sound_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, sound
|
||||
)
|
||||
|
||||
# object detection stays, audio (sound) detection is gated out
|
||||
assert entity_registry.async_get(smart_id) is not None
|
||||
assert entity_registry.async_get(sound_id) is None
|
||||
|
||||
|
||||
async def test_detection_event_removed_change_ignored(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""A REMOVED (eviction) change does not fire a detection event."""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
def _capture(event: HAEvent) -> None:
|
||||
events.append(event)
|
||||
|
||||
unsub = async_track_state_change_event(hass, entity_id, _capture)
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="ev-removed",
|
||||
type=EventType.SMART_DETECT,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now,
|
||||
smart_detect_types=(SmartDetectObjectType.PERSON,),
|
||||
),
|
||||
EventChange.REMOVED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
unsub()
|
||||
|
||||
assert events == []
|
||||
|
||||
|
||||
async def test_doorbell_ring_dedup_across_dispatches(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""A ring fires once even though start, update and end are all dispatched."""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, EVENT_DESCRIPTIONS[0]
|
||||
)
|
||||
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
def _capture(event: HAEvent) -> None:
|
||||
events.append(event)
|
||||
|
||||
unsub = async_track_state_change_event(hass, entity_id, _capture)
|
||||
base = {
|
||||
"id": "ring-1",
|
||||
"type": EventType.RING,
|
||||
"channel": ProtectEventChannel.DETECTION,
|
||||
"device_id": doorbell.id,
|
||||
"device_mac": doorbell.mac,
|
||||
"start": fixed_now - timedelta(seconds=1),
|
||||
}
|
||||
for change, end in (
|
||||
(EventChange.STARTED, None),
|
||||
(EventChange.UPDATED, None),
|
||||
(EventChange.ENDED, fixed_now),
|
||||
):
|
||||
ufp.events_msg(ProtectEvent(**base, end=end), change)
|
||||
await hass.async_block_till_done()
|
||||
unsub()
|
||||
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
def test_detection_event_types_have_translations() -> None:
|
||||
"""Every category detection event type has a strings.json state label.
|
||||
|
||||
Guards against a uiprotect enum addition slugging into ``event_types`` (and
|
||||
firing) without a matching translation label.
|
||||
"""
|
||||
strings = json.loads(
|
||||
(
|
||||
Path(__file__).parents[3]
|
||||
/ "homeassistant/components/unifiprotect/strings.json"
|
||||
).read_text()
|
||||
)
|
||||
event_states = strings["entity"]["event"]
|
||||
for key in ("motion_detection", "smart_detection", "sound_detection"):
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == key)
|
||||
labels = event_states[key]["state_attributes"]["event_type"]["state"]
|
||||
missing = [t for t in description.event_types or () if t not in labels]
|
||||
assert not missing, f"{key} missing event_type labels: {missing}"
|
||||
|
||||
|
||||
async def test_smart_detection_event_interleaved_dedup(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""Two overlapping same-category events whose dispatches interleave don't re-fire."""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
def _capture(event: HAEvent) -> None:
|
||||
events.append(event)
|
||||
|
||||
unsub = async_track_state_change_event(hass, entity_id, _capture)
|
||||
common = {
|
||||
"type": EventType.SMART_DETECT,
|
||||
"channel": ProtectEventChannel.DETECTION,
|
||||
"device_id": doorbell.id,
|
||||
"device_mac": doorbell.mac,
|
||||
"start": fixed_now - timedelta(seconds=1),
|
||||
"end": None,
|
||||
}
|
||||
# Event A and B overlap; A re-dispatches after B started.
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="evt-a", smart_detect_types=(SmartDetectObjectType.PERSON,), **common
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="evt-b", smart_detect_types=(SmartDetectObjectType.VEHICLE,), **common
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="evt-a", smart_detect_types=(SmartDetectObjectType.PERSON,), **common
|
||||
),
|
||||
EventChange.UPDATED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
unsub()
|
||||
|
||||
# A's person is not re-fired when its update arrives after B's dispatch.
|
||||
fired = [event.data["new_state"].attributes["event_type"] for event in events]
|
||||
assert fired == ["person", "vehicle"]
|
||||
|
||||
|
||||
async def test_detection_event_dedup_is_bounded(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""The fire-dedup tracker is bounded; distinct events keep firing."""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
def _capture(event: HAEvent) -> None:
|
||||
events.append(event)
|
||||
|
||||
unsub = async_track_state_change_event(hass, entity_id, _capture)
|
||||
for index in range(20):
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id=f"motion-{index}",
|
||||
type=EventType.MOTION,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now,
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
unsub()
|
||||
|
||||
assert len(events) == 20
|
||||
|
||||
|
||||
async def test_detection_event_dedup_evicts_oldest(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""Exceeding the dedup cap evicts the oldest event id, letting it refire.
|
||||
|
||||
An unbounded tracker would also pass ``test_detection_event_dedup_is_bounded``
|
||||
(it only sends distinct ids), so this replays an id that should have aged out.
|
||||
"""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
events: list[HAEvent] = []
|
||||
|
||||
@callback
|
||||
def _capture(event: HAEvent) -> None:
|
||||
events.append(event)
|
||||
|
||||
unsub = async_track_state_change_event(hass, entity_id, _capture)
|
||||
|
||||
def _send(index: int) -> None:
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id=f"motion-{index}",
|
||||
type=EventType.MOTION,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now,
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
|
||||
# A (cap + 1)th distinct id evicts the oldest tracked id ("motion-0").
|
||||
for index in range(_MAX_TRACKED_EVENTS + 1):
|
||||
_send(index)
|
||||
await hass.async_block_till_done()
|
||||
assert len(events) == _MAX_TRACKED_EVENTS + 1
|
||||
|
||||
# Replaying the evicted id fires again; a bug that never evicts would dedup it.
|
||||
_send(0)
|
||||
await hass.async_block_till_done()
|
||||
unsub()
|
||||
|
||||
assert len(events) == _MAX_TRACKED_EVENTS + 2
|
||||
|
||||
|
||||
async def test_event_entities_unavailable_on_events_ws_disconnect(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
unadopted_camera: Camera,
|
||||
) -> None:
|
||||
"""Public event entities follow the events websocket they fire from."""
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
ring = next(d for d in EVENT_DESCRIPTIONS if d.key == "doorbell")
|
||||
_, ring_id = await ids_from_device_description(hass, Platform.EVENT, doorbell, ring)
|
||||
motion = next(d for d in EVENT_DESCRIPTIONS if d.key == "motion_detection")
|
||||
_, motion_id = await ids_from_device_description(
|
||||
hass, Platform.EVENT, doorbell, motion
|
||||
)
|
||||
assert hass.states.get(ring_id).state != STATE_UNAVAILABLE
|
||||
assert hass.states.get(motion_id).state != STATE_UNAVAILABLE
|
||||
|
||||
assert ufp.events_ws_state_subscription is not None
|
||||
ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(ring_id).state == STATE_UNAVAILABLE
|
||||
assert hass.states.get(motion_id).state == STATE_UNAVAILABLE
|
||||
|
||||
ufp.events_ws_state_subscription(WebsocketState.CONNECTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(ring_id).state != STATE_UNAVAILABLE
|
||||
assert hass.states.get(motion_id).state != STATE_UNAVAILABLE
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
"""The tests for unifiprotect recorder."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
from uiprotect.data import Camera, Event, EventType, ModelType
|
||||
from uiprotect import EventChange, ProtectEvent, ProtectEventChannel
|
||||
from uiprotect.data import Camera, EventType, SmartDetectObjectType
|
||||
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.components.recorder.history import get_significant_states
|
||||
from homeassistant.components.unifiprotect.binary_sensor import EVENT_SENSORS
|
||||
from homeassistant.components.unifiprotect.const import (
|
||||
ATTR_EVENT_ID,
|
||||
ATTR_EVENT_SCORE,
|
||||
DEFAULT_ATTRIBUTION,
|
||||
ATTR_SMART_DETECT_TYPES,
|
||||
)
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, STATE_ON, Platform
|
||||
from homeassistant.components.unifiprotect.event import EVENT_DESCRIPTIONS
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .utils import MockUFPFixture, ids_from_device_description, init_entry
|
||||
from .utils import (
|
||||
MockUFPFixture,
|
||||
ids_from_device_description,
|
||||
init_entry,
|
||||
setup_public_camera,
|
||||
)
|
||||
|
||||
from tests.components.recorder.common import async_wait_recording_done
|
||||
|
||||
@@ -29,44 +33,37 @@ async def test_exclude_attributes(
|
||||
unadopted_camera: Camera,
|
||||
fixed_now: datetime,
|
||||
) -> None:
|
||||
"""Test binary_sensor has event_id and event_score excluded from recording."""
|
||||
"""The smart-detect event entity excludes event_id/smart_detect_types from recording."""
|
||||
now = fixed_now
|
||||
# Smart-detect events arrive on the public events websocket; the entity's
|
||||
# availability also requires the public camera to resolve.
|
||||
setup_public_camera(ufp)
|
||||
await init_entry(hass, ufp, [doorbell, unadopted_camera])
|
||||
|
||||
description = next(d for d in EVENT_DESCRIPTIONS if d.key == "smart_detection")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[1]
|
||||
hass, Platform.EVENT, doorbell, description
|
||||
)
|
||||
|
||||
event = Event(
|
||||
model=ModelType.EVENT,
|
||||
id="test_event_id",
|
||||
type=EventType.MOTION,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=None,
|
||||
score=100,
|
||||
smart_detect_types=[],
|
||||
smart_detect_event_ids=[],
|
||||
camera_id=doorbell.id,
|
||||
ufp.events_msg(
|
||||
ProtectEvent(
|
||||
id="test_event_id",
|
||||
type=EventType.SMART_DETECT,
|
||||
channel=ProtectEventChannel.DETECTION,
|
||||
device_id=doorbell.id,
|
||||
device_mac=doorbell.mac,
|
||||
start=fixed_now - timedelta(seconds=1),
|
||||
end=fixed_now,
|
||||
smart_detect_types=(SmartDetectObjectType.PERSON,),
|
||||
),
|
||||
EventChange.STARTED,
|
||||
)
|
||||
|
||||
new_camera = doorbell.model_copy()
|
||||
new_camera.is_motion_detected = True
|
||||
new_camera.last_motion_event_id = event.id
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = new_camera
|
||||
|
||||
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
||||
ufp.api.bootstrap.events = {event.id: event}
|
||||
ufp.ws_msg(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
||||
assert state.attributes[ATTR_EVENT_SCORE] == 100
|
||||
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
|
||||
assert ATTR_SMART_DETECT_TYPES in state.attributes
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
states = await hass.async_add_executor_job(
|
||||
@@ -75,6 +72,6 @@ async def test_exclude_attributes(
|
||||
assert len(states) >= 1
|
||||
for entity_states in states.values():
|
||||
for state in entity_states:
|
||||
assert ATTR_EVENT_SCORE not in state.attributes
|
||||
assert ATTR_EVENT_ID not in state.attributes
|
||||
assert ATTR_SMART_DETECT_TYPES not in state.attributes
|
||||
assert ATTR_FRIENDLY_NAME in state.attributes
|
||||
|
||||
@@ -455,6 +455,27 @@ async def test_public_ws_state_change_without_public_bootstrap(
|
||||
assert data.last_public_update_success is False
|
||||
|
||||
|
||||
async def test_events_ws_state_change_without_public_bootstrap(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
) -> None:
|
||||
"""Events WS state changes flip the flag but no-op without a bootstrap."""
|
||||
await init_entry(hass, ufp, [])
|
||||
data = ufp.entry.runtime_data
|
||||
assert data.last_events_update_success is True
|
||||
assert ufp.events_ws_state_subscription is not None
|
||||
|
||||
# No public bootstrap -> re-signal step returns early.
|
||||
ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED)
|
||||
await hass.async_block_till_done()
|
||||
assert data.last_events_update_success is False
|
||||
|
||||
# Same state again -> handler early-returns.
|
||||
ufp.events_ws_state_subscription(WebsocketState.DISCONNECTED)
|
||||
await hass.async_block_till_done()
|
||||
assert data.last_events_update_success is False
|
||||
|
||||
|
||||
async def test_relay_public_ws_message_without_public_old_obj(
|
||||
hass: HomeAssistant,
|
||||
ufp_with_relay: tuple[MockUFPFixture, Mock],
|
||||
|
||||
@@ -23,6 +23,8 @@ from uiprotect.data import (
|
||||
ProtectModelWithId,
|
||||
PublicBootstrap,
|
||||
Sensor,
|
||||
SmartDetectAudioType,
|
||||
SmartDetectObjectType,
|
||||
WSSubscriptionMessage,
|
||||
)
|
||||
from uiprotect.data.bootstrap import ProtectDeviceRef
|
||||
@@ -35,6 +37,7 @@ from uiprotect.data.public_devices import (
|
||||
PublicSensor,
|
||||
PublicSensorLeakSettings,
|
||||
PublicSensorMotionSettingsRead,
|
||||
PublicSmartDetectSettings,
|
||||
PublicWirelessBatteryStatus,
|
||||
PublicWirelessConnectionState,
|
||||
SensorFeatureCapability,
|
||||
@@ -62,6 +65,7 @@ class MockUFPFixture:
|
||||
devices_ws_subscription: Callable[[WSSubscriptionMessage], None] | None = None
|
||||
events_subscription: Callable[[ProtectEvent, EventChange], None] | None = None
|
||||
devices_ws_state_subscription: Callable[[WebsocketState], None] | None = None
|
||||
events_ws_state_subscription: Callable[[WebsocketState], None] | None = None
|
||||
|
||||
def ws_msg(self, msg: WSSubscriptionMessage) -> None:
|
||||
"""Emit WS message for testing."""
|
||||
@@ -408,18 +412,41 @@ _HDR_DISPLAY_TO_PUBLIC = {
|
||||
}
|
||||
|
||||
|
||||
_ALL_OBJECT_TYPES = [t for t in SmartDetectObjectType if t.audio_type is None]
|
||||
_ALL_AUDIO_TYPES = list(SmartDetectAudioType)
|
||||
|
||||
|
||||
def make_public_camera(
|
||||
camera: Camera,
|
||||
*,
|
||||
state: DeviceState | None = None,
|
||||
is_motion_detected: bool = False,
|
||||
is_smart_currently_detected: bool = False,
|
||||
is_person_currently_detected: bool = False,
|
||||
is_vehicle_currently_detected: bool = False,
|
||||
is_animal_currently_detected: bool = False,
|
||||
is_audio_currently_detected: bool = False,
|
||||
is_smoke_currently_detected: bool = False,
|
||||
is_cmonx_currently_detected: bool = False,
|
||||
is_siren_currently_detected: bool = False,
|
||||
is_baby_cry_currently_detected: bool = False,
|
||||
is_speaking_currently_detected: bool = False,
|
||||
is_bark_currently_detected: bool = False,
|
||||
is_car_alarm_currently_detected: bool = False,
|
||||
is_car_horn_currently_detected: bool = False,
|
||||
is_glass_break_currently_detected: bool = False,
|
||||
object_types: list[SmartDetectObjectType] | None = None,
|
||||
audio_types: list[SmartDetectAudioType] | None = None,
|
||||
mic_volume: int | None = None,
|
||||
hdr_type: PublicHdrMode | None = None,
|
||||
) -> Mock:
|
||||
"""Build a public-API camera mirroring a private camera's migrated fields.
|
||||
|
||||
``mic_volume`` and ``hdr_type`` default to values derived from the private
|
||||
fixture so the public mirror matches it; pass an override to assert a value
|
||||
the private object would not produce.
|
||||
The stream tiers/mic/HDR back the migrated stream and select entities; the
|
||||
``is_*`` flags back the migrated ``ufp_public_value`` detection paths and the
|
||||
``smart_detect_settings`` types back the per-type ``ufp_public_enabled_fn``
|
||||
gates (default: all types enabled). ``mic_volume`` and ``hdr_type`` default to
|
||||
values derived from the private fixture so the public mirror matches it.
|
||||
"""
|
||||
public = Mock(spec=PublicCamera)
|
||||
public.id = camera.id
|
||||
@@ -430,6 +457,43 @@ def make_public_camera(
|
||||
public.model = ModelType.CAMERA
|
||||
public.state = DeviceState[camera.state.name] if state is None else state
|
||||
public.mic_volume = camera.mic_volume if mic_volume is None else mic_volume
|
||||
public.is_motion_detected = is_motion_detected
|
||||
public.is_smart_currently_detected = is_smart_currently_detected
|
||||
public.is_person_currently_detected = is_person_currently_detected
|
||||
public.is_vehicle_currently_detected = is_vehicle_currently_detected
|
||||
public.is_animal_currently_detected = is_animal_currently_detected
|
||||
public.is_audio_currently_detected = is_audio_currently_detected
|
||||
public.is_smoke_currently_detected = is_smoke_currently_detected
|
||||
public.is_cmonx_currently_detected = is_cmonx_currently_detected
|
||||
public.is_siren_currently_detected = is_siren_currently_detected
|
||||
public.is_baby_cry_currently_detected = is_baby_cry_currently_detected
|
||||
public.is_speaking_currently_detected = is_speaking_currently_detected
|
||||
public.is_bark_currently_detected = is_bark_currently_detected
|
||||
public.is_car_alarm_currently_detected = is_car_alarm_currently_detected
|
||||
public.is_car_horn_currently_detected = is_car_horn_currently_detected
|
||||
public.is_glass_break_currently_detected = is_glass_break_currently_detected
|
||||
public.smart_detect_settings = PublicSmartDetectSettings(
|
||||
object_types=_ALL_OBJECT_TYPES if object_types is None else object_types,
|
||||
audio_types=_ALL_AUDIO_TYPES if audio_types is None else audio_types,
|
||||
)
|
||||
# A Mock(spec) does not evaluate properties, so mirror the PublicCamera
|
||||
# parity properties the migrated detection sensors gate on using the
|
||||
# library's own logic.
|
||||
for name in (
|
||||
"is_person_detection_on",
|
||||
"is_vehicle_detection_on",
|
||||
"is_animal_detection_on",
|
||||
"is_smoke_detection_on",
|
||||
"is_co_detection_on",
|
||||
"is_siren_detection_on",
|
||||
"is_baby_cry_detection_on",
|
||||
"is_speaking_detection_on",
|
||||
"is_bark_detection_on",
|
||||
"is_car_alarm_detection_on",
|
||||
"is_car_horn_detection_on",
|
||||
"is_glass_break_detection_on",
|
||||
):
|
||||
setattr(public, name, getattr(PublicCamera, name).fget(public))
|
||||
public.hdr_type = (
|
||||
_HDR_DISPLAY_TO_PUBLIC[camera.hdr_mode_display]
|
||||
if hdr_type is None
|
||||
|
||||
Reference in New Issue
Block a user