diff --git a/homeassistant/components/unifiprotect/media_source.py b/homeassistant/components/unifiprotect/media_source.py index 48aa7e0a6a2..32cac04797f 100644 --- a/homeassistant/components/unifiprotect/media_source.py +++ b/homeassistant/components/unifiprotect/media_source.py @@ -47,6 +47,7 @@ class SimpleEventType(str, Enum): RING = "ring" MOTION = "motion" SMART = "smart" + AUDIO = "audio" class IdentifierType(str, Enum): @@ -64,21 +65,29 @@ class IdentifierTimeType(str, Enum): RANGE = "range" -EVENT_MAP = { - SimpleEventType.ALL: None, - SimpleEventType.RING: EventType.RING, - SimpleEventType.MOTION: EventType.MOTION, - SimpleEventType.SMART: EventType.SMART_DETECT, +EVENT_MAP: dict[SimpleEventType, set[EventType]] = { + SimpleEventType.ALL: { + EventType.RING, + EventType.MOTION, + EventType.SMART_DETECT, + EventType.SMART_DETECT_LINE, + EventType.SMART_AUDIO_DETECT, + }, + SimpleEventType.RING: {EventType.RING}, + SimpleEventType.MOTION: {EventType.MOTION}, + SimpleEventType.SMART: {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE}, + SimpleEventType.AUDIO: {EventType.SMART_AUDIO_DETECT}, } EVENT_NAME_MAP = { SimpleEventType.ALL: "All Events", SimpleEventType.RING: "Ring Events", SimpleEventType.MOTION: "Motion Events", - SimpleEventType.SMART: "Smart Detections", + SimpleEventType.SMART: "Object Detections", + SimpleEventType.AUDIO: "Audio Detections", } -def get_ufp_event(event_type: SimpleEventType) -> EventType | None: +def get_ufp_event(event_type: SimpleEventType) -> set[EventType]: """Get UniFi Protect event type from SimpleEventType.""" return EVENT_MAP[event_type] @@ -132,6 +141,51 @@ def _format_duration(duration: timedelta) -> str: return formatted.strip() +@callback +def _get_object_name(event: Event | dict[str, Any]) -> str: + if isinstance(event, Event): + event = event.unifi_dict() + + names = [] + types = set(event["smartDetectTypes"]) + metadata = event.get("metadata") or {} + for thumb in metadata.get("detectedThumbnails", []): + thumb_type = thumb.get("type") + if thumb_type not in types: + continue + + types.remove(thumb_type) + if thumb_type == SmartDetectObjectType.VEHICLE.value: + attributes = thumb.get("attributes") or {} + color = attributes.get("color", {}).get("val", "") + vehicle_type = attributes.get("vehicleType", {}).get("val", "vehicle") + license_plate = metadata.get("licensePlate", {}).get("name") + + name = f"{color} {vehicle_type}".strip().title() + if license_plate: + types.remove(SmartDetectObjectType.LICENSE_PLATE.value) + name = f"{name}: {license_plate}" + names.append(name) + else: + smart_type = SmartDetectObjectType(thumb_type) + names.append(smart_type.name.title().replace("_", " ")) + + for raw in types: + smart_type = SmartDetectObjectType(raw) + names.append(smart_type.name.title().replace("_", " ")) + + return ", ".join(sorted(names)) + + +@callback +def _get_audio_name(event: Event | dict[str, Any]) -> str: + if isinstance(event, Event): + event = event.unifi_dict() + + smart_types = [SmartDetectObjectType(e) for e in event["smartDetectTypes"]] + return ", ".join([s.name.title().replace("_", " ") for s in smart_types]) + + class ProtectMediaSource(MediaSource): """Represents all UniFi Protect NVRs.""" @@ -384,7 +438,7 @@ class ProtectMediaSource(MediaSource): end = event.end else: event_id = event["id"] - event_type = event["type"] + event_type = EventType(event["type"]) start = from_js_time(event["start"]) end = from_js_time(event["end"]) @@ -393,19 +447,14 @@ class ProtectMediaSource(MediaSource): title = dt_util.as_local(start).strftime("%x %X") duration = end - start title += f" {_format_duration(duration)}" - if event_type == EventType.RING.value: + if event_type in EVENT_MAP[SimpleEventType.RING]: event_text = "Ring Event" - elif event_type == EventType.MOTION.value: + elif event_type in EVENT_MAP[SimpleEventType.MOTION]: event_text = "Motion Event" - elif event_type == EventType.SMART_DETECT.value: - if isinstance(event, Event): - smart_types = event.smart_detect_types - else: - smart_types = [ - SmartDetectObjectType(e) for e in event["smartDetectTypes"] - ] - smart_type_names = [s.name.title().replace("_", " ") for s in smart_types] - event_text = f"Smart Detection - {','.join(smart_type_names)}" + elif event_type in EVENT_MAP[SimpleEventType.SMART]: + event_text = f"Object Detection - {_get_object_name(event)}" + elif event_type in EVENT_MAP[SimpleEventType.AUDIO]: + event_text = f"Audio Detection - {_get_audio_name(event)}" title += f" {event_text}" nvr = data.api.bootstrap.nvr @@ -442,20 +491,13 @@ class ProtectMediaSource(MediaSource): start: datetime, end: datetime, camera_id: str | None = None, - event_type: EventType | None = None, + event_types: set[EventType] | None = None, reserve: bool = False, ) -> list[BrowseMediaSource]: """Build media source for a given range of time and event type.""" - if event_type is None: - types = [ - EventType.RING, - EventType.MOTION, - EventType.SMART_DETECT, - ] - else: - types = [event_type] - + event_types = event_types or get_ufp_event(SimpleEventType.ALL) + types = list(event_types) sources: list[BrowseMediaSource] = [] events = await data.api.get_events_raw( start=start, end=end, types=types, limit=data.max_events @@ -515,9 +557,8 @@ class ProtectMediaSource(MediaSource): "start": now - timedelta(days=days), "end": now, "reserve": True, + "event_types": get_ufp_event(event_type), } - if event_type != SimpleEventType.ALL: - args["event_type"] = get_ufp_event(event_type) camera: Camera | None = None if camera_id != "all": @@ -646,9 +687,8 @@ class ProtectMediaSource(MediaSource): "start": start_dt, "end": end_dt, "reserve": False, + "event_types": get_ufp_event(event_type), } - if event_type != SimpleEventType.ALL: - args["event_type"] = get_ufp_event(event_type) camera: Camera | None = None if camera_id != "all": @@ -798,6 +838,9 @@ class ProtectMediaSource(MediaSource): source.children.append( await self._build_events_type(data, camera_id, SimpleEventType.SMART) ) + source.children.append( + await self._build_events_type(data, camera_id, SimpleEventType.AUDIO) + ) if is_doorbell or has_smart: source.children.insert( diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 90d09ac36d6..c79a46daafd 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -421,15 +421,17 @@ async def test_browse_media_event_type( assert browse.title == "UnifiProtect > All Cameras" assert browse.identifier == "test_id:browse:all" - assert len(browse.children) == 4 + assert len(browse.children) == 5 assert browse.children[0].title == "All Events" assert browse.children[0].identifier == "test_id:browse:all:all" assert browse.children[1].title == "Ring Events" assert browse.children[1].identifier == "test_id:browse:all:ring" assert browse.children[2].title == "Motion Events" assert browse.children[2].identifier == "test_id:browse:all:motion" - assert browse.children[3].title == "Smart Detections" + assert browse.children[3].title == "Object Detections" assert browse.children[3].identifier == "test_id:browse:all:smart" + assert browse.children[4].title == "Audio Detections" + assert browse.children[4].identifier == "test_id:browse:all:audio" ONE_MONTH_SIMPLE = ( @@ -649,24 +651,232 @@ async def test_browse_media_recent_truncated( assert browse.children[0].identifier == "test_id:event:test_event_id" +@pytest.mark.parametrize( + ("event", "expected_title"), + [ + ( + Event( + id="test_event_id", + type=EventType.RING, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id="test", + ), + "Ring Event", + ), + ( + Event( + id="test_event_id", + type=EventType.MOTION, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id="test", + ), + "Motion Event", + ), + ( + Event( + id="test_event_id", + type=EventType.SMART_DETECT, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=["person"], + smart_detect_event_ids=[], + camera_id="test", + metadata={ + "detected_thumbnails": [ + { + "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "type": "person", + "cropped_id": "event_id", + } + ], + }, + ), + "Object Detection - Person", + ), + ( + Event( + id="test_event_id", + type=EventType.SMART_DETECT, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=["vehicle", "person"], + smart_detect_event_ids=[], + camera_id="test", + ), + "Object Detection - Person, Vehicle", + ), + ( + Event( + id="test_event_id", + type=EventType.SMART_DETECT, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=["vehicle", "licensePlate"], + smart_detect_event_ids=[], + camera_id="test", + ), + "Object Detection - License Plate, Vehicle", + ), + ( + Event( + id="test_event_id", + type=EventType.SMART_DETECT, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=["vehicle", "licensePlate"], + smart_detect_event_ids=[], + camera_id="test", + metadata={ + "license_plate": {"name": "ABC1234", "confidence_level": 95}, + "detected_thumbnails": [ + { + "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "type": "vehicle", + "cropped_id": "event_id", + } + ], + }, + ), + "Object Detection - Vehicle: ABC1234", + ), + ( + Event( + id="test_event_id", + type=EventType.SMART_DETECT, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=["vehicle", "licensePlate"], + smart_detect_event_ids=[], + camera_id="test", + metadata={ + "license_plate": {"name": "ABC1234", "confidence_level": 95}, + "detected_thumbnails": [ + { + "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "type": "vehicle", + "cropped_id": "event_id", + "attributes": { + "vehicle_type": { + "val": "car", + "confidence": 95, + } + }, + } + ], + }, + ), + "Object Detection - Car: ABC1234", + ), + ( + Event( + id="test_event_id", + type=EventType.SMART_DETECT, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=["vehicle", "licensePlate"], + smart_detect_event_ids=[], + camera_id="test", + metadata={ + "license_plate": {"name": "ABC1234", "confidence_level": 95}, + "detected_thumbnails": [ + { + "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "type": "vehicle", + "cropped_id": "event_id", + "attributes": { + "color": { + "val": "black", + "confidence": 95, + } + }, + }, + { + "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "type": "person", + "cropped_id": "event_id", + }, + ], + }, + ), + "Object Detection - Black Vehicle: ABC1234", + ), + ( + Event( + id="test_event_id", + type=EventType.SMART_DETECT, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=["vehicle"], + smart_detect_event_ids=[], + camera_id="test", + metadata={ + "detected_thumbnails": [ + { + "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "type": "vehicle", + "cropped_id": "event_id", + "attributes": { + "color": { + "val": "black", + "confidence": 95, + }, + "vehicle_type": { + "val": "car", + "confidence": 95, + }, + }, + } + ] + }, + ), + "Object Detection - Black Car", + ), + ( + Event( + id="test_event_id", + type=EventType.SMART_AUDIO_DETECT, + start=datetime(1000, 1, 1, 0, 0, 0), + end=None, + score=100, + smart_detect_types=["alrmSpeak"], + smart_detect_event_ids=[], + camera_id="test", + ), + "Audio Detection - Speak", + ), + ], +) async def test_browse_media_event( - hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + fixed_now: datetime, + event: Event, + expected_title: str, ) -> None: """Test browsing specific event.""" ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) await init_entry(hass, ufp, [doorbell], regenerate_ids=False) - event = Event( - id="test_event_id", - type=EventType.RING, - start=fixed_now - timedelta(seconds=20), - end=fixed_now, - score=100, - smart_detect_types=[], - smart_detect_event_ids=[], - camera_id=doorbell.id, - ) + event.start = fixed_now - timedelta(seconds=20) + event.end = fixed_now + event.camera_id = doorbell.id event._api = ufp.api ufp.api.get_event = AsyncMock(return_value=event) @@ -674,10 +884,13 @@ async def test_browse_media_event( media_item = MediaSourceItem(hass, DOMAIN, "test_id:event:test_event_id", None) browse = await source.async_browse_media(media_item) + # chop off the datetime/duration + title = " ".join(browse.title.split(" ")[3:]) assert browse.identifier == "test_id:event:test_event_id" assert browse.children is None assert browse.media_class == MediaClass.VIDEO + assert title == expected_title async def test_browse_media_eventthumb(