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 and Fingerprint identified events #275

Merged
merged 19 commits into from
Nov 17, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
477f8b8
feat: add processing for NFC scan events #265
RaHehl Nov 10, 2024
88e53df
chore(pre-commit.ci): auto fixes
pre-commit-ci[bot] Nov 10, 2024
58b3a20
refactor(models): convert NfcMetadata to a Pydantic BaseModel for val…
RaHehl Nov 15, 2024
51f78f8
chore(pre-commit.ci): auto fixes
pre-commit-ci[bot] Nov 15, 2024
32022d5
refactor(models): rename attributes in NfcMetadata to snake_case and …
RaHehl Nov 15, 2024
5494e48
Merge remote-tracking branch 'origin/nfc-support' into nfc-support
RaHehl Nov 15, 2024
9a9b9ce
chore(pre-commit.ci): auto fixes
pre-commit-ci[bot] Nov 15, 2024
960f34c
test(tests): add assertions for event end time and last NFC card scanned
RaHehl Nov 15, 2024
1eef55d
feat: add processing for Fingerprint Identified events
RaHehl Nov 16, 2024
f6b9d46
chore(pre-commit.ci): auto fixes
pre-commit-ci[bot] Nov 16, 2024
be9d0c9
chore: remove assignment to unused variable `expected_user_id` in fin…
RaHehl Nov 16, 2024
58bc388
Merge remote-tracking branch 'origin/nfc-and-fingerprint-support' int…
RaHehl Nov 16, 2024
7881b97
test: add test coverage for nfc and fingerprint fields
RaHehl Nov 17, 2024
a20977a
chore(pre-commit.ci): auto fixes
pre-commit-ci[bot] Nov 17, 2024
6601093
Merge branch 'main' into nfc-and-fingerprint-support
bdraco Nov 17, 2024
7ab7557
test: add test coverage for nfc and fingerprint fields
RaHehl Nov 17, 2024
93ab0b5
Merge remote-tracking branch 'origin/nfc-and-fingerprint-support' int…
RaHehl Nov 17, 2024
22f68f9
test: add test coverage for nfc and fingerprint fields in none case
RaHehl Nov 17, 2024
cb74b11
test: add test coverage for nfc and fingerprint fields in none case
RaHehl Nov 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/uiprotect/data/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
}


Expand Down
29 changes: 29 additions & 0 deletions src/uiprotect/data/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,10 @@

# 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
Expand All @@ -1018,6 +1022,10 @@
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",
Expand Down Expand Up @@ -1086,6 +1094,10 @@
pop_dict_tuple(
data,
(
"lastFingerprintIdentified",
"lastFingerprintIdentifiedEventId",
"lastNfcCardScanned",
"lastNfcCardScannedEventId",
"lastRingEventId",
"lastSmartDetect",
"lastSmartAudioDetect",
Expand Down Expand Up @@ -1148,6 +1160,23 @@
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

Check warning on line 1168 in src/uiprotect/data/devices.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/data/devices.py#L1168

Added line #L1168 was not covered by tests
RaHehl marked this conversation as resolved.
Show resolved Hide resolved
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

Check warning on line 1177 in src/uiprotect/data/devices.py

View check run for this annotation

Codecov / codecov/patch

src/uiprotect/data/devices.py#L1177

Added line #L1177 was not covered by tests
RaHehl marked this conversation as resolved.
Show resolved Hide resolved
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."""
Expand Down
29 changes: 29 additions & 0 deletions src/uiprotect/data/nvr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/uiprotect/data/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
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
2 changes: 1 addition & 1 deletion tests/sample_data/sample_constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
73 changes: 73 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,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
}
]
146 changes: 146 additions & 0 deletions tests/test_api_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
},
bdraco marked this conversation as resolved.
Show resolved Hide resolved
"thumbnail": f"e-{expected_event_id}",
"heatmap": f"e-{expected_event_id}",
}

camera = get_camera()

assert camera.last_nfc_card_scanned 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 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(
Expand Down
Loading