Expose UniFi Protect smart detection event source (#179508)

This commit is contained in:
wi-adam
2026-09-08 07:24:30 +02:00
committed by GitHub
parent 531189f92b
commit a22eb5f3e8
6 changed files with 254 additions and 47 deletions
@@ -13,6 +13,7 @@ AUTH_RETRIES = 2
ATTR_EVENT_SCORE = "event_score"
ATTR_EVENT_ID = "event_id"
ATTR_EVENT_SOURCE = "event_source"
ATTR_SMART_DETECT_TYPES = "smart_detect_types"
ATTR_WIDTH = "width"
ATTR_HEIGHT = "height"
@@ -415,10 +415,11 @@ class ProtectData:
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.
subscribers registered for this device and event type; entities dedupe
each surfaced type by event id and Protect event type. 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 EventChange.REMOVED:
return
+35 -12
View File
@@ -21,6 +21,7 @@ from homeassistant.helpers.event import async_call_at
from . import Bootstrap
from .const import (
ATTR_EVENT_ID,
ATTR_EVENT_SOURCE,
ATTR_SMART_DETECT_TYPES,
EVENT_TYPE_FINGERPRINT_IDENTIFIED,
EVENT_TYPE_FINGERPRINT_NOT_IDENTIFIED,
@@ -98,6 +99,7 @@ class ProtectDetectionEventEntityDescription(ProtectEventEntityDescription):
"""Describes a category detection event entity driven by the public events WS."""
ufp_public_event_types: tuple[EventType, ...]
include_event_source: bool = False
class ProtectDevicePublicEventEntity(
@@ -107,7 +109,7 @@ class ProtectDevicePublicEventEntity(
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)``.
deduped per ``(event id, object type, event source)``.
Availability follows the public API (device present and connected) plus the
events websocket, which is the only channel these entities fire from.
@@ -118,25 +120,28 @@ class ProtectDevicePublicEventEntity(
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
# dispatches interleave, so dedup tracks fired object/source pairs per
# recent event id (bounded), not just the current one.
_fired: dict[str, frozenset[tuple[str, EventType]]] | 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."""
"""Fire once per event id, surfaced type, and Protect event type."""
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:
# Protect can reuse an event id across overlapping smart-detect sources,
# so omitting event.type would swallow a later line or loiter event.
fired_type = (event_type, event.type)
if fired_type in types:
fired[event.id] = types
return
fired[event.id] = types | {event_type}
fired[event.id] = types | {fired_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)
@@ -433,8 +438,8 @@ class ProtectDeviceSmartDetectEventEntity(ProtectDevicePublicEventEntity):
Used for object types that Protect models as discrete, point-in-time
detections (e.g. package): the camera fires once with a cooldown and the
smart-detect event is recorded already-ended, so a sustained binary sensor
can never reflect it. The public events websocket delivers these as proper
``smartDetectZone`` events (the private API only exposes the unhandled
can never reflect it. The public events websocket delivers these as smart
detection events (the private API only exposes the unhandled
``smartDetectObject`` model), so we subscribe there and fire a momentary
event when the description's object type matches.
"""
@@ -457,7 +462,14 @@ class ProtectDeviceSmartDetectEventEntity(ProtectDevicePublicEventEntity):
description = self.entity_description
event_types = description.event_types
if event_types and description.ufp_obj_type in event.smart_detect_types:
self._fire_once(event, event_types[0], {ATTR_EVENT_ID: event.id})
self._fire_once(
event,
event_types[0],
{
ATTR_EVENT_ID: event.id,
ATTR_EVENT_SOURCE: event.type.value,
},
)
_CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
@@ -520,17 +532,27 @@ class ProtectDeviceDetectionEventEntity(ProtectDevicePublicEventEntity):
@callback
def _async_detection_event(self, event: ProtectEvent) -> None:
allowed = self.entity_description.event_types or ()
description = self.entity_description
allowed = 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:
event_data: dict[str, Any] = {
ATTR_EVENT_ID: event.id,
ATTR_SMART_DETECT_TYPES: detected,
}
if description.include_event_source:
# Keep this raw: hassfest state translation keys reject
# camelCase, so normalization belongs in uiprotect. It remains
# recordable to distinguish overlapping sources in history.
event_data[ATTR_EVENT_SOURCE] = event.type.value
self._fire_once(
event,
event_type,
{ATTR_EVENT_ID: event.id, ATTR_SMART_DETECT_TYPES: detected},
event_data,
)
@@ -601,6 +623,7 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
ufp_required_field="feature_flags.has_smart_detect",
event_types=_SMART_OBJECT_EVENT_TYPES,
ufp_public_event_types=_SMART_DETECT_EVENT_TYPES,
include_event_source=True,
entity_class=ProtectDeviceDetectionEventEntity,
),
ProtectDetectionEventEntityDescription(
@@ -388,6 +388,9 @@
"package": {
"name": "Package",
"state_attributes": {
"event_source": {
"name": "[%key:component::unifiprotect::entity::event::smart_detection::state_attributes::event_source::name%]"
},
"event_type": {
"state": {
"detected": "Detected"
@@ -398,6 +401,9 @@
"smart_detection": {
"name": "Smart detection",
"state_attributes": {
"event_source": {
"name": "Event source"
},
"event_type": {
"state": {
"animal": "Animal",
+200 -30
View File
@@ -21,6 +21,7 @@ from uiprotect.websocket import WebsocketState
from homeassistant.components.unifiprotect.const import (
ATTR_EVENT_ID,
ATTR_EVENT_SOURCE,
ATTR_SMART_DETECT_TYPES,
DEFAULT_ATTRIBUTION,
EVENT_TYPE_PACKAGE_DETECTED,
@@ -156,11 +157,13 @@ async def test_doorbell_ring(
@pytest.mark.parametrize(
"event_type",
("event_type", "overlapping_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"),
pytest.param(EventType.SMART_DETECT, EventType.SMART_DETECT_LINE, id="zone"),
pytest.param(EventType.SMART_DETECT_LINE, EventType.SMART_DETECT, id="line"),
pytest.param(
EventType.SMART_DETECT_LOITER, EventType.SMART_DETECT, id="loiter"
),
],
)
async def test_package_detected(
@@ -170,6 +173,7 @@ async def test_package_detected(
unadopted_camera: Camera,
fixed_now: datetime,
event_type: EventType,
overlapping_event_type: EventType,
) -> None:
"""Test a package detection event fired from the public events websocket."""
@@ -216,6 +220,7 @@ async def test_package_detected(
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert state.attributes[ATTR_EVENT_ID] == "test_package_event"
assert state.attributes[ATTR_EVENT_SOURCE] == event_type.value
assert state.attributes["event_type"] == EVENT_TYPE_PACKAGE_DETECTED
# A non-package detection must not fire the package entity.
@@ -235,12 +240,12 @@ async def test_package_detected(
await hass.async_block_till_done()
assert len(events) == 1
# 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.
# Updates are dispatched too, but the entity fires each (event id, object
# type, event source) once, so the same source must be suppressed.
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,
@@ -253,6 +258,27 @@ async def test_package_detected(
await hass.async_block_till_done()
assert len(events) == 1
# The same event and object can fire again for a distinct Protect source.
ufp.events_msg(
ProtectEvent(
id="test_package_event",
type=overlapping_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.PACKAGE,),
),
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 2
assert (
events[-1].data["new_state"].attributes[ATTR_EVENT_SOURCE]
== overlapping_event_type.value
)
# Subscriptions are keyed by device_id alone: an event still dispatches when
# it carries no device_mac and the device is absent from the private
# bootstrap, proving the public event path does not depend on it.
@@ -271,8 +297,8 @@ async def test_package_detected(
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 2
assert events[1].data["new_state"].attributes[ATTR_EVENT_ID] == (
assert len(events) == 3
assert events[2].data["new_state"].attributes[ATTR_EVENT_ID] == (
"test_package_event_no_private_device"
)
@@ -291,7 +317,7 @@ async def test_package_detected(
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 2
assert len(events) == 3
# A non-smart-detect event that happens to carry a matching object type is
# routed by type, so it must not reach the smart-detect entity.
@@ -309,7 +335,7 @@ async def test_package_detected(
EventChange.STARTED,
)
await hass.async_block_till_done()
assert len(events) == 2
assert len(events) == 3
unsub()
@@ -1767,24 +1793,36 @@ async def test_motion_detection_event(
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)
motion_event = 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,
)
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,
),
motion_event,
EventChange.STARTED,
)
ufp.events_msg(motion_event, EventChange.UPDATED)
await hass.async_block_till_done()
unsub()
assert len(events) == 1
state = hass.states.get(entity_id)
assert state
assert state.attributes["event_type"] == "motion"
assert state.attributes[ATTR_EVENT_ID] == "motion-1"
assert ATTR_EVENT_SOURCE not in state.attributes
@pytest.mark.parametrize(
@@ -1844,11 +1882,136 @@ async def test_smart_detection_event(
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"]
states = [event.data["new_state"] for event in events]
assert [state.attributes["event_type"] for state in states] == [
"person",
"vehicle",
]
assert [state.attributes[ATTR_EVENT_ID] for state in states] == ["smart-1"] * 2
assert [state.attributes[ATTR_EVENT_SOURCE] for state in states] == [
event_type.value
] * 2
assert [state.attributes[ATTR_SMART_DETECT_TYPES] for state in states] == [
["person", "vehicle"]
] * 2
async def test_smart_detection_event_dedup_by_source(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""The same object and event id fire once for each raw Protect event source."""
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 = {
"id": "smart-1",
"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,),
}
for event_type, change in (
(EventType.SMART_DETECT, EventChange.STARTED),
(EventType.SMART_DETECT_LINE, EventChange.STARTED),
(EventType.SMART_DETECT_LINE, EventChange.UPDATED),
(EventType.SMART_DETECT_LOITER, EventChange.STARTED),
):
ufp.events_msg(ProtectEvent(type=event_type, **common), change)
await hass.async_block_till_done()
unsub()
states = [event.data["new_state"] for event in events]
assert [state.attributes[ATTR_EVENT_SOURCE] for state in states] == [
EventType.SMART_DETECT.value,
EventType.SMART_DETECT_LINE.value,
EventType.SMART_DETECT_LOITER.value,
]
assert [state.attributes["event_type"] for state in states] == ["person"] * 3
assert [state.attributes[ATTR_EVENT_ID] for state in states] == ["smart-1"] * 3
async def test_smart_detection_event_late_type(
hass: HomeAssistant,
ufp: MockUFPFixture,
doorbell: Camera,
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""A late object type fires once without refiring an earlier type."""
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)
base = {
"id": "smart-late",
"type": EventType.SMART_DETECT_LINE,
"channel": ProtectEventChannel.DETECTION,
"device_id": doorbell.id,
"device_mac": doorbell.mac,
"start": fixed_now - timedelta(seconds=1),
}
ufp.events_msg(
ProtectEvent(
**base,
end=None,
smart_detect_types=(SmartDetectObjectType.PERSON,),
),
EventChange.STARTED,
)
for end in (None, fixed_now):
ufp.events_msg(
ProtectEvent(
**base,
end=end,
smart_detect_types=(
SmartDetectObjectType.PERSON,
SmartDetectObjectType.VEHICLE,
),
),
EventChange.UPDATED,
)
await hass.async_block_till_done()
unsub()
states = [event.data["new_state"] for event in events]
assert [state.attributes["event_type"] for state in states] == [
"person",
"vehicle",
]
assert [state.attributes[ATTR_EVENT_SOURCE] for state in states] == [
EventType.SMART_DETECT_LINE.value
] * 2
assert states[0].attributes[ATTR_SMART_DETECT_TYPES] == ["person"]
assert states[1].attributes[ATTR_SMART_DETECT_TYPES] == ["person", "vehicle"]
async def test_sound_detection_event(
@@ -1887,6 +2050,7 @@ async def test_sound_detection_event(
assert state
assert state.attributes["event_type"] == "smoke"
assert state.attributes[ATTR_EVENT_ID] == "audio-1"
assert ATTR_EVENT_SOURCE not in state.attributes
async def test_sound_detection_event_late_type(
@@ -2086,7 +2250,7 @@ async def test_smart_detection_event_interleaved_dedup(
unadopted_camera: Camera,
fixed_now: datetime,
) -> None:
"""Two overlapping same-category events whose dispatches interleave don't re-fire."""
"""Two overlapping same-source event ids remain independently deduplicated."""
setup_public_camera(ufp)
await init_entry(hass, ufp, [doorbell, unadopted_camera])
@@ -2119,7 +2283,7 @@ async def test_smart_detection_event_interleaved_dedup(
)
ufp.events_msg(
ProtectEvent(
id="evt-b", smart_detect_types=(SmartDetectObjectType.VEHICLE,), **common
id="evt-b", smart_detect_types=(SmartDetectObjectType.PERSON,), **common
),
EventChange.STARTED,
)
@@ -2132,9 +2296,15 @@ async def test_smart_detection_event_interleaved_dedup(
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"]
states = [event.data["new_state"] for event in events]
assert [state.attributes[ATTR_EVENT_ID] for state in states] == [
"evt-a",
"evt-b",
]
assert [state.attributes["event_type"] for state in states] == ["person"] * 2
assert [state.attributes[ATTR_EVENT_SOURCE] for state in states] == [
EventType.SMART_DETECT.value
] * 2
async def test_detection_event_dedup_is_bounded(
@@ -9,6 +9,7 @@ from homeassistant.components.recorder import Recorder
from homeassistant.components.recorder.history import get_significant_states
from homeassistant.components.unifiprotect.const import (
ATTR_EVENT_ID,
ATTR_EVENT_SOURCE,
ATTR_SMART_DETECT_TYPES,
)
from homeassistant.components.unifiprotect.event import EVENT_DESCRIPTIONS
@@ -63,13 +64,18 @@ async def test_exclude_attributes(
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_EVENT_ID] == "test_event_id"
assert state.attributes[ATTR_EVENT_SOURCE] == EventType.SMART_DETECT.value
assert ATTR_SMART_DETECT_TYPES in state.attributes
await async_wait_recording_done(hass)
states = await hass.async_add_executor_job(
get_significant_states, hass, now, None, hass.states.async_entity_ids()
)
assert len(states) >= 1
assert entity_id in states
assert (
states[entity_id][-1].attributes[ATTR_EVENT_SOURCE]
== EventType.SMART_DETECT.value
)
for entity_states in states.values():
for state in entity_states:
assert ATTR_EVENT_ID not in state.attributes