diff --git a/src/uiprotect/data/bootstrap.py b/src/uiprotect/data/bootstrap.py index 0c69bcd9..d43cfcdd 100644 --- a/src/uiprotect/data/bootstrap.py +++ b/src/uiprotect/data/bootstrap.py @@ -98,6 +98,10 @@ "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", + ), } diff --git a/src/uiprotect/data/devices.py b/src/uiprotect/data/devices.py index 9ad6cad4..98ba8a8f 100644 --- a/src/uiprotect/data/devices.py +++ b/src/uiprotect/data/devices.py @@ -998,6 +998,8 @@ 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_smart_detect: datetime | None = None last_smart_audio_detect: datetime | None = None last_smart_detect_event_id: str | None = None @@ -1018,6 +1020,8 @@ 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_smart_detect", "last_smart_audio_detect", "last_smart_detect_event_id", @@ -1087,6 +1091,8 @@ def unifi_dict( data, ( "lastRingEventId", + "lastNfcCardScanned", + "lastNfcCardScannedEventId", "lastSmartDetect", "lastSmartAudioDetect", "lastSmartDetectEventId", @@ -1148,6 +1154,14 @@ 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 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..13bfe2b0 100644 --- a/src/uiprotect/data/nvr.py +++ b/src/uiprotect/data/nvr.py @@ -136,6 +136,20 @@ 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 EventThumbnailAttributes(ProtectBaseObject): color: EventThumbnailAttribute | None = None vehicle_type: EventThumbnailAttribute | None = None @@ -195,6 +209,8 @@ 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 _collapse_keys: ClassVar[SetStr] = { "lightId", diff --git a/src/uiprotect/data/types.py b/src/uiprotect/data/types.py index 3acf076c..b31a57ec 100644 --- a/src/uiprotect/data/types.py +++ b/src/uiprotect/data/types.py @@ -201,6 +201,7 @@ class EventType(str, ValuesEnumMixin, enum.Enum): POOR_CONNECTION = "poorConnection" STREAM_RECOVERY = "streamRecovery" MOTION = "motion" + NFC_CARD_SCANNED = "nfcCardScanned" RECORDING_DELETED = "recordingDeleted" SMART_AUDIO_DETECT = "smartAudioDetect" SMART_DETECT = "smartDetectZone" @@ -274,6 +275,7 @@ class EventType(str, ValuesEnumMixin, enum.Enum): def device_events() -> list[str]: return [ EventType.MOTION.value, + EventType.NFC_CARD_SCANNED.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..844d0377 100644 --- a/tests/sample_data/sample_constants.json +++ b/tests/sample_data/sample_constants.json @@ -7,7 +7,7 @@ "last_update_id": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70", "user_id": "4c5f03a8c8bd48ad8e066285", "counts": { - "camera": 11, + "camera": 12, "user": 7, "group": 2, "liveview": 5, @@ -21,7 +21,7 @@ "schedule": 0 }, "time": "2022-01-24T20:23:32.433278+00:00", - "event_count": 1374, + "event_count": 1375, "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..d358c806 100644 --- a/tests/sample_data/sample_raw_events.json +++ b/tests/sample_data/sample_raw_events.json @@ -29985,5 +29985,30 @@ "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 } ] diff --git a/tests/test_api_ws.py b/tests/test_api_ws.py index 8ecdd6bd..e1a82a17 100644 --- a/tests/test_api_ws.py +++ b/tests/test_api_ws.py @@ -293,6 +293,75 @@ 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}", + } + + msg = MagicMock() + msg.data = packet.pack_frames() + + protect_client._process_ws_message(msg) + + camera = get_camera() + + 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.dict_with_excludes() == camera_before.dict_with_excludes() + 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 + assert camera.last_nfc_card_scanned == event.start + + 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(