diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 8e5d9357d52c..f8685a6f73f3 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -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" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index fa28ce2946b3..dda330cba92b 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -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 diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 338779649e58..6408865a8d04 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -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"(? 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( diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 48b4721cafef..d8776e546a14 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -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", diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index 7f97bd68927a..9433b857169f 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -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( diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 2d94743052a4..23d1fdf0d99f 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -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