Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add processing for NFC scan events #265 #266

Closed
wants to merge 8 commits into from
4 changes: 4 additions & 0 deletions src/uiprotect/data/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
}


Expand Down
14 changes: 14 additions & 0 deletions src/uiprotect/data/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -1087,6 +1091,8 @@ def unifi_dict(
data,
(
"lastRingEventId",
"lastNfcCardScanned",
"lastNfcCardScannedEventId",
"lastSmartDetect",
"lastSmartAudioDetect",
"lastSmartDetectEventId",
Expand Down Expand Up @@ -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."""
Expand Down
7 changes: 7 additions & 0 deletions src/uiprotect/data/nvr.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ class EventThumbnailAttribute(ProtectBaseObject):
val: str


class NfcMetadata:
nfcId: str
userId: str

RaHehl marked this conversation as resolved.
Show resolved Hide resolved

class EventThumbnailAttributes(ProtectBaseObject):
color: EventThumbnailAttribute | None = None
vehicle_type: EventThumbnailAttribute | None = None
Expand Down Expand Up @@ -195,6 +200,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",
Expand Down
2 changes: 2 additions & 0 deletions src/uiprotect/data/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/sample_data/sample_constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions tests/sample_data/sample_raw_events.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
67 changes: 67 additions & 0 deletions tests/test_api_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,73 @@ 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.get("nfcId") == expected_nfc_id
assert event.metadata.nfc.get("userId") == 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))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Ensure consistent assertion methods across tests.

The test uses dict_with_excludes() while other similar tests use dict(). Consider:

  1. Using the same assertion method across all event tests for consistency
  2. Adding validation for the camera's last_nfc_card_scanned property
  3. Adding assertions for the event's end time, similar to the smart detect test

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(
Expand Down
Loading