diff --git a/src/uiprotect/data/bootstrap.py b/src/uiprotect/data/bootstrap.py index 0c69bcd9..b405bed6 100644 --- a/src/uiprotect/data/bootstrap.py +++ b/src/uiprotect/data/bootstrap.py @@ -98,6 +98,14 @@ "last_smart_audio_detect_event_id", ), EventType.RING: ("last_ring", "last_ring_event_id"), + EventType.NFC_CARD_SCANNED: ( + "last_nfc_card_scanned", + "last_nfc_card_scanned_event_id", + ), + EventType.FINGERPRINT_IDENTIFIED: ( + "last_fingerprint_identified", + "last_fingerprint_identified_event_id", + ), } diff --git a/src/uiprotect/data/devices.py b/src/uiprotect/data/devices.py index 9ad6cad4..b6f21f93 100644 --- a/src/uiprotect/data/devices.py +++ b/src/uiprotect/data/devices.py @@ -998,6 +998,10 @@ class Camera(ProtectMotionDeviceModel): # not directly from UniFi last_ring_event_id: str | None = None + last_nfc_card_scanned_event_id: str | None = None + last_nfc_card_scanned: datetime | None = None + last_fingerprint_identified_event_id: str | None = None + last_fingerprint_identified: datetime | None = None last_smart_detect: datetime | None = None last_smart_audio_detect: datetime | None = None last_smart_detect_event_id: str | None = None @@ -1018,6 +1022,10 @@ def _get_unifi_remaps(cls) -> dict[str, str]: def _get_excluded_changed_fields(cls) -> set[str]: return super()._get_excluded_changed_fields() | { "last_ring_event_id", + "last_nfc_card_scanned", + "last_nfc_card_scanned_event_id", + "last_fingerprint_identified", + "last_fingerprint_identified_event_id", "last_smart_detect", "last_smart_audio_detect", "last_smart_detect_event_id", @@ -1086,6 +1094,10 @@ def unifi_dict( pop_dict_tuple( data, ( + "lastFingerprintIdentified", + "lastFingerprintIdentifiedEventId", + "lastNfcCardScanned", + "lastNfcCardScannedEventId", "lastRingEventId", "lastSmartDetect", "lastSmartAudioDetect", @@ -1148,6 +1160,23 @@ def last_smart_detect_event(self) -> Event | None: return None return self._api.bootstrap.events.get(last_smart_detect_event_id) + @property + def last_nfc_card_scanned_event(self) -> Event | None: + if ( + last_nfc_card_scanned_event_id := self.last_nfc_card_scanned_event_id + ) is None: + return None + return self._api.bootstrap.events.get(last_nfc_card_scanned_event_id) + + @property + def last_fingerprint_identified_event(self) -> Event | None: + if ( + last_fingerprint_identified_event_id + := self.last_fingerprint_identified_event_id + ) is None: + return None + return self._api.bootstrap.events.get(last_fingerprint_identified_event_id) + @property def hdr_mode_display(self) -> Literal["auto", "off", "always"]: """Get HDR mode similar to how Protect interface works.""" diff --git a/src/uiprotect/data/nvr.py b/src/uiprotect/data/nvr.py index 122fc9f2..0eae0642 100644 --- a/src/uiprotect/data/nvr.py +++ b/src/uiprotect/data/nvr.py @@ -136,6 +136,32 @@ class EventThumbnailAttribute(ProtectBaseObject): val: str +class NfcMetadata(ProtectBaseObject): + nfc_id: str + user_id: str + + @classmethod + @cache + def _get_unifi_remaps(cls) -> dict[str, str]: + return { + **super()._get_unifi_remaps(), + "nfcId": "nfc_id", + "userId": "user_id", + } + + +class FingerprintMetadata(ProtectBaseObject): + ulp_id: str | None = None + + @classmethod + @cache + def _get_unifi_remaps(cls) -> dict[str, str]: + return { + **super()._get_unifi_remaps(), + "ulpId": "ulp_id", + } + + class EventThumbnailAttributes(ProtectBaseObject): color: EventThumbnailAttribute | None = None vehicle_type: EventThumbnailAttribute | None = None @@ -195,6 +221,9 @@ class EventMetadata(ProtectBaseObject): license_plate: LicensePlateMetadata | None = None # requires 2.11.13+ detected_thumbnails: list[EventDetectedThumbnail] | None = None + # requires 5.1.34+ + nfc: NfcMetadata | None = None + fingerprint: FingerprintMetadata | None = None _collapse_keys: ClassVar[SetStr] = { "lightId", diff --git a/src/uiprotect/data/types.py b/src/uiprotect/data/types.py index 3acf076c..3d43d22c 100644 --- a/src/uiprotect/data/types.py +++ b/src/uiprotect/data/types.py @@ -201,6 +201,8 @@ class EventType(str, ValuesEnumMixin, enum.Enum): POOR_CONNECTION = "poorConnection" STREAM_RECOVERY = "streamRecovery" MOTION = "motion" + NFC_CARD_SCANNED = "nfcCardScanned" + FINGERPRINT_IDENTIFIED = "fingerprintIdentified" RECORDING_DELETED = "recordingDeleted" SMART_AUDIO_DETECT = "smartAudioDetect" SMART_DETECT = "smartDetectZone" @@ -274,6 +276,8 @@ class EventType(str, ValuesEnumMixin, enum.Enum): def device_events() -> list[str]: return [ EventType.MOTION.value, + EventType.NFC_CARD_SCANNED.value, + EventType.FINGERPRINT_IDENTIFIED.value, EventType.RING.value, EventType.SMART_DETECT.value, EventType.SMART_AUDIO_DETECT.value, diff --git a/tests/conftest.py b/tests/conftest.py index c8430d48..b4214ff1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -878,7 +878,6 @@ def compare_objs(obj_type, expected, actual): if "clockBestWall" not in exp_thumb: del act_thumbnails[index]["clockBestWall"] assert exp_thumbnails == act_thumbnails - expected_keys = (expected.get("metadata") or {}).keys() actual_keys = (actual.get("metadata") or {}).keys() # delete all extra metadata keys, many of which are not modeled diff --git a/tests/sample_data/sample_constants.json b/tests/sample_data/sample_constants.json index c0243d5c..fdd98b97 100644 --- a/tests/sample_data/sample_constants.json +++ b/tests/sample_data/sample_constants.json @@ -21,7 +21,7 @@ "schedule": 0 }, "time": "2022-01-24T20:23:32.433278+00:00", - "event_count": 1374, + "event_count": 1377, "camera_thumbnail": "e-90f051ebf085214d331644a5", "camera_heatmap": "e-90f051ebf085214d331644a5", "camera_video_length": 23, diff --git a/tests/sample_data/sample_raw_events.json b/tests/sample_data/sample_raw_events.json index cf86a0da..b63d2c4e 100644 --- a/tests/sample_data/sample_raw_events.json +++ b/tests/sample_data/sample_raw_events.json @@ -29985,5 +29985,78 @@ "heatmap": "e-df90543f94ff894427b79827", "modelKey": "event", "timestamp": null + }, + { + "id": "6730b5af01029603e4453bdb", + "modelKey": "event", + "type": "nfcCardScanned", + "start": 1731245487257, + "end": 1731245487257, + "score": 0, + "smartDetectTypes": [], + "smartDetectEvents": [], + "camera": "1c9a2db4df6efda47a3509be", + "partition": null, + "user": null, + "metadata": { + "nfc": { + "nfcId": "64B2A621", + "userId": "672b573764f79603e400031d" + }, + "ramDescription": "", + "ramClassifications": [] + }, + "thumbnail": "e-6730b5af01029603e4003bdb", + "heatmap": "e-6730b5af01029603e4003bdb", + "timestamp": 1731245487257, + "category": null + }, + { + "id": "05cb9efc794deb5625d63901", + "modelKey": "event", + "type": "fingerprintIdentified", + "start": 1731761536049, + "end": 1731761536049, + "score": 0, + "smartDetectTypes": [], + "smartDetectEvents": [], + "camera": "1c9a2db4df6efda47a3509be", + "partition": null, + "user": null, + "metadata": { + "fingerprint": { + "ulpId": "0ef32f28-f654-404d-ab34-30e373e66436" + }, + "ramDescription": "", + "ramClassifications": [] + }, + "thumbnail": "e-05cb9efc794deb5625d63901", + "heatmap": "e-05cb9efc794deb5625d63901", + "timestamp": 1731761536049, + "category": null + }, + { + "id": "ebbf14dc11a45c2c846f43fc", + "modelKey": "event", + "type": "fingerprintIdentified", + "start": 1731761533438, + "end": 1731761533438, + "score": 0, + "smartDetectTypes": [], + "smartDetectEvents": [], + "camera": "1c9a2db4df6efda47a3509be", + "partition": null, + "user": null, + "metadata": { + "fingerprint": { + "ulpId": null + }, + "ramDescription": "", + "ramClassifications": [] + }, + "thumbnail": "e-ebbf14dc11a45c2c846f43fc", + "heatmap": "e-ebbf14dc11a45c2c846f43fc", + "timestamp": 1731761533438, + "category": null } ] diff --git a/tests/test_api.py b/tests/test_api.py index fb84159a..fdd54cbc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -66,6 +66,9 @@ async def check_camera(camera: Camera): if camera.last_smart_detect_event is not None: await check_motion_event(camera.last_smart_detect_event) + assert camera.last_nfc_card_scanned is None + assert camera.last_fingerprint_identified is None + for channel in camera.channels: assert channel._api is not None diff --git a/tests/test_api_polling.py b/tests/test_api_polling.py index 5651ddd7..56f111ab 100644 --- a/tests/test_api_polling.py +++ b/tests/test_api_polling.py @@ -42,6 +42,10 @@ def _reset_events(camera: Camera) -> None: camera.last_motion = None camera.last_smart_detect = None camera.last_smart_detect_event_id = None + camera.last_fingerprint_identified_event_id = None + camera.last_fingerprint_identified = None + camera.last_nfc_card_scanned_event_id = None + camera.last_nfc_card_scanned = None camera.last_smart_detects = {} camera.last_smart_detect_event_ids = {} @@ -194,3 +198,19 @@ async def get_events(*args, **kwargs): assert smart_event.heatmap_id == f"e-{expected_event_id}" assert smart_event.start == (now - timedelta(seconds=30)) assert smart_event.end == now + + +@pytest.mark.asyncio() +@patch("uiprotect.api.datetime", MockDatetime) +async def test_event_return_none(protect_client: ProtectApiClient, now, camera): + def get_camera(): + return protect_client.bootstrap.cameras[camera["id"]] + + camera = get_camera() + + _reset_events(camera) + + assert camera.last_smart_detect_event is None + assert camera.last_nfc_card_scanned_event is None + assert camera.last_motion_event is None + assert camera.last_fingerprint_identified_event is None diff --git a/tests/test_api_ws.py b/tests/test_api_ws.py index 8ecdd6bd..82d5efc0 100644 --- a/tests/test_api_ws.py +++ b/tests/test_api_ws.py @@ -293,6 +293,152 @@ def get_camera(): assert channel._api is not None +@pytest.mark.asyncio() +@patch("uiprotect.api.datetime", MockDatetime) +async def test_ws_event_nfc_card_scanned( + protect_client_no_debug: ProtectApiClient, + now, + camera, + packet: WSPacket, +): + protect_client = protect_client_no_debug + + def get_camera(): + return protect_client.bootstrap.cameras[camera["id"]] + + camera_before = get_camera().copy() + + expected_updated_id = "0441ecc6-f0fa-4b03-b071-7987c143138a" + expected_event_id = "6730b5af01029603e4003bdb" + expected_nfc_id = "66B2A649" + expected_user_id = "672b570000f79603e400049d" + + action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] + action_frame.data["newUpdateId"] = expected_updated_id + + data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] + data_frame.data = { + "id": expected_event_id, + "modelKey": "event", + "type": "nfcCardScanned", + "start": to_js_time(now - timedelta(seconds=30)), + "end": to_js_time(now), + "score": 0, + "smartDetectTypes": [], + "smartDetectEvents": [], + "camera": camera["id"], + "metadata": { + "nfc": {"nfcId": expected_nfc_id, "userId": expected_user_id}, + "ramDescription": "", + "ramClassifications": [], + }, + "thumbnail": f"e-{expected_event_id}", + "heatmap": f"e-{expected_event_id}", + } + + camera = get_camera() + + assert camera.last_nfc_card_scanned_event is None + + msg = MagicMock() + msg.data = packet.pack_frames() + + protect_client._process_ws_message(msg) + + event = camera.last_nfc_card_scanned_event + camera_before.last_nfc_card_scanned_event_id = None + camera.last_nfc_card_scanned_event_id = None + + assert camera.last_nfc_card_scanned == event.start + camera_before.last_nfc_card_scanned = None + camera.last_nfc_card_scanned = None + + assert camera.dict() == camera_before.dict() + assert event.id == expected_event_id + assert event.type == EventType.NFC_CARD_SCANNED + assert event.metadata.nfc.nfc_id == expected_nfc_id + assert event.metadata.nfc.user_id == expected_user_id + assert event.thumbnail_id == f"e-{expected_event_id}" + assert event.heatmap_id == f"e-{expected_event_id}" + assert event.start == (now - timedelta(seconds=30)) + assert event.end == now + + for channel in camera.channels: + assert channel._api is not None + + +@pytest.mark.asyncio() +@patch("uiprotect.api.datetime", MockDatetime) +async def test_ws_event_fingerprint_identified( + protect_client_no_debug: ProtectApiClient, + now, + camera, + packet: WSPacket, +): + protect_client = protect_client_no_debug + + def get_camera(): + return protect_client.bootstrap.cameras[camera["id"]] + + camera_before = get_camera().copy() + + expected_updated_id = "0441ecc6-f0fa-4b03-b071-7987c143138a" + expected_event_id = "6730b5af01029603e4003bdb" + expected_ulp_id = "0ef32f12-f291-123d-ab12-30e373e12345" + + action_frame: WSJSONPacketFrame = packet.action_frame # type: ignore[assignment] + action_frame.data["newUpdateId"] = expected_updated_id + + data_frame: WSJSONPacketFrame = packet.data_frame # type: ignore[assignment] + data_frame.data = { + "id": expected_event_id, + "modelKey": "event", + "type": "fingerprintIdentified", + "start": to_js_time(now - timedelta(seconds=30)), + "end": to_js_time(now), + "score": 0, + "smartDetectTypes": [], + "smartDetectEvents": [], + "camera": camera["id"], + "metadata": { + "fingerprint": {"ulpId": expected_ulp_id}, + "ramDescription": "", + "ramClassifications": [], + }, + "thumbnail": f"e-{expected_event_id}", + "heatmap": f"e-{expected_event_id}", + } + + camera = get_camera() + + assert camera.last_fingerprint_identified_event is None + + msg = MagicMock() + msg.data = packet.pack_frames() + + protect_client._process_ws_message(msg) + + event = camera.last_fingerprint_identified_event + camera_before.last_fingerprint_identified_event_id = None + camera.last_fingerprint_identified_event_id = None + + assert camera.last_fingerprint_identified == event.start + camera_before.last_fingerprint_identified = None + camera.last_fingerprint_identified = None + + assert camera.dict() == camera_before.dict() + assert event.id == expected_event_id + assert event.type == EventType.FINGERPRINT_IDENTIFIED + assert event.metadata.fingerprint.ulp_id == expected_ulp_id + assert event.thumbnail_id == f"e-{expected_event_id}" + assert event.heatmap_id == f"e-{expected_event_id}" + assert event.start == (now - timedelta(seconds=30)) + assert event.end == now + + for channel in camera.channels: + assert channel._api is not None + + @pytest.mark.asyncio() @patch("uiprotect.api.datetime", MockDatetime) async def test_ws_event_smart(