uiprotect 7.5.2__py3-none-any.whl → 7.32.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uiprotect/api.py +886 -59
- uiprotect/cli/__init__.py +109 -24
- uiprotect/cli/aiports.py +1 -2
- uiprotect/cli/backup.py +5 -4
- uiprotect/cli/base.py +4 -4
- uiprotect/cli/cameras.py +152 -13
- uiprotect/cli/chimes.py +5 -6
- uiprotect/cli/doorlocks.py +2 -3
- uiprotect/cli/events.py +7 -8
- uiprotect/cli/lights.py +11 -3
- uiprotect/cli/liveviews.py +1 -2
- uiprotect/cli/sensors.py +2 -3
- uiprotect/cli/viewers.py +2 -3
- uiprotect/data/base.py +32 -32
- uiprotect/data/bootstrap.py +20 -15
- uiprotect/data/devices.py +183 -16
- uiprotect/data/nvr.py +139 -38
- uiprotect/data/types.py +32 -19
- uiprotect/stream.py +13 -2
- uiprotect/test_util/__init__.py +30 -7
- uiprotect/test_util/anonymize.py +4 -5
- uiprotect/utils.py +56 -24
- uiprotect/websocket.py +3 -3
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/METADATA +70 -17
- uiprotect-7.32.0.dist-info/RECORD +39 -0
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/WHEEL +1 -1
- uiprotect-7.5.2.dist-info/RECORD +0 -39
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/entry_points.txt +0 -0
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info/licenses}/LICENSE +0 -0
uiprotect/data/devices.py
CHANGED
|
@@ -8,11 +8,12 @@ import warnings
|
|
|
8
8
|
from collections.abc import Callable
|
|
9
9
|
from datetime import datetime, timedelta
|
|
10
10
|
from functools import cache, lru_cache
|
|
11
|
-
from ipaddress import IPv4Address
|
|
11
|
+
from ipaddress import IPv4Address, IPv6Address
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
14
14
|
|
|
15
15
|
from convertertools import pop_dict_set_if_none, pop_dict_tuple
|
|
16
|
+
from pydantic import model_validator
|
|
16
17
|
from pydantic.fields import PrivateAttr
|
|
17
18
|
|
|
18
19
|
from ..exceptions import BadRequest, NotAuthorized, StreamError
|
|
@@ -23,6 +24,7 @@ from ..utils import (
|
|
|
23
24
|
convert_smart_types,
|
|
24
25
|
convert_to_datetime,
|
|
25
26
|
convert_video_modes,
|
|
27
|
+
format_host_for_url,
|
|
26
28
|
from_js_time,
|
|
27
29
|
serialize_point,
|
|
28
30
|
timedelta_total_seconds,
|
|
@@ -80,6 +82,7 @@ from .types import (
|
|
|
80
82
|
from .user import User
|
|
81
83
|
|
|
82
84
|
if TYPE_CHECKING:
|
|
85
|
+
from ..api import RTSPSStreams
|
|
83
86
|
from .nvr import Event, Liveview
|
|
84
87
|
|
|
85
88
|
PRIVACY_ZONE_NAME = "pyufp_privacy_zone"
|
|
@@ -93,9 +96,10 @@ class LightDeviceSettings(ProtectBaseObject):
|
|
|
93
96
|
is_indicator_enabled: bool
|
|
94
97
|
# Brightness
|
|
95
98
|
led_level: LEDLevel
|
|
96
|
-
lux_sensitivity: LowMedHigh
|
|
97
99
|
pir_duration: timedelta
|
|
98
100
|
pir_sensitivity: PercentInt
|
|
101
|
+
# lux_sensitivity exists in Private API but not in Public API - filtered when sending updates
|
|
102
|
+
lux_sensitivity: LowMedHigh | None = None
|
|
99
103
|
|
|
100
104
|
@classmethod
|
|
101
105
|
@cache
|
|
@@ -160,6 +164,14 @@ class Light(ProtectMotionDeviceModel):
|
|
|
160
164
|
self.camera_id = camera.id
|
|
161
165
|
await self.save_device(data_before_changes, force_emit=True)
|
|
162
166
|
|
|
167
|
+
async def set_flood_light(self, enabled: bool) -> None:
|
|
168
|
+
"""Sets the flood light (force on) for the light"""
|
|
169
|
+
|
|
170
|
+
def callback() -> None:
|
|
171
|
+
self.light_on_settings.is_led_force_on = enabled
|
|
172
|
+
|
|
173
|
+
await self.queue_update(callback)
|
|
174
|
+
|
|
163
175
|
async def set_status_light(self, enabled: bool) -> None:
|
|
164
176
|
"""Sets the status indicator light for the light"""
|
|
165
177
|
|
|
@@ -260,10 +272,17 @@ class CameraChannel(ProtectBaseObject):
|
|
|
260
272
|
auto_bitrate: bool | None = None
|
|
261
273
|
auto_fps: bool | None = None
|
|
262
274
|
|
|
275
|
+
_parent: Camera | None = PrivateAttr(None)
|
|
263
276
|
_rtsp_url: str | None = PrivateAttr(None)
|
|
264
277
|
_rtsps_url: str | None = PrivateAttr(None)
|
|
265
278
|
_rtsps_no_srtp_url: str | None = PrivateAttr(None)
|
|
266
279
|
|
|
280
|
+
def _get_connection_host(self) -> IPv4Address | IPv6Address | str:
|
|
281
|
+
"""Get connection host (camera's for stacked NVR, otherwise NVR's)."""
|
|
282
|
+
if self._parent is not None and self._parent.connection_host is not None:
|
|
283
|
+
return self._parent.connection_host
|
|
284
|
+
return self._api.connection_host
|
|
285
|
+
|
|
267
286
|
@property
|
|
268
287
|
def rtsp_url(self) -> str | None:
|
|
269
288
|
if not self.is_rtsp_enabled or self.rtsp_alias is None:
|
|
@@ -271,7 +290,11 @@ class CameraChannel(ProtectBaseObject):
|
|
|
271
290
|
|
|
272
291
|
if self._rtsp_url is not None:
|
|
273
292
|
return self._rtsp_url
|
|
274
|
-
|
|
293
|
+
|
|
294
|
+
host = format_host_for_url(self._get_connection_host())
|
|
295
|
+
self._rtsp_url = (
|
|
296
|
+
f"rtsp://{host}:{self._api.bootstrap.nvr.ports.rtsp}/{self.rtsp_alias}"
|
|
297
|
+
)
|
|
275
298
|
return self._rtsp_url
|
|
276
299
|
|
|
277
300
|
@property
|
|
@@ -281,7 +304,9 @@ class CameraChannel(ProtectBaseObject):
|
|
|
281
304
|
|
|
282
305
|
if self._rtsps_url is not None:
|
|
283
306
|
return self._rtsps_url
|
|
284
|
-
|
|
307
|
+
|
|
308
|
+
host = format_host_for_url(self._get_connection_host())
|
|
309
|
+
self._rtsps_url = f"rtsps://{host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}?enableSrtp"
|
|
285
310
|
return self._rtsps_url
|
|
286
311
|
|
|
287
312
|
@property
|
|
@@ -291,7 +316,11 @@ class CameraChannel(ProtectBaseObject):
|
|
|
291
316
|
|
|
292
317
|
if self._rtsps_no_srtp_url is not None:
|
|
293
318
|
return self._rtsps_no_srtp_url
|
|
294
|
-
|
|
319
|
+
|
|
320
|
+
host = format_host_for_url(self._get_connection_host())
|
|
321
|
+
self._rtsps_no_srtp_url = (
|
|
322
|
+
f"rtsps://{host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}"
|
|
323
|
+
)
|
|
295
324
|
return self._rtsps_no_srtp_url
|
|
296
325
|
|
|
297
326
|
@property
|
|
@@ -358,7 +387,21 @@ class OSDSettings(ProtectBaseObject):
|
|
|
358
387
|
class LEDSettings(ProtectBaseObject):
|
|
359
388
|
# Status Light
|
|
360
389
|
is_enabled: bool
|
|
361
|
-
blink_rate: int
|
|
390
|
+
blink_rate: int | None = (
|
|
391
|
+
None # in milliseconds between blinks, 0 = solid (removed in Protect 6.x)
|
|
392
|
+
)
|
|
393
|
+
# 6.2+
|
|
394
|
+
welcome_led: bool | None = None
|
|
395
|
+
flood_led: bool | None = None
|
|
396
|
+
|
|
397
|
+
def unifi_dict(
|
|
398
|
+
self,
|
|
399
|
+
data: dict[str, Any] | None = None,
|
|
400
|
+
exclude: set[str] | None = None,
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
data = super().unifi_dict(data=data, exclude=exclude)
|
|
403
|
+
pop_dict_set_if_none(data, {"blinkRate", "welcomeLed", "floodLed"})
|
|
404
|
+
return data
|
|
362
405
|
|
|
363
406
|
|
|
364
407
|
class SpeakerSettings(ProtectBaseObject):
|
|
@@ -366,6 +409,23 @@ class SpeakerSettings(ProtectBaseObject):
|
|
|
366
409
|
# Status Sounds
|
|
367
410
|
are_system_sounds_enabled: bool
|
|
368
411
|
volume: PercentInt
|
|
412
|
+
# Doorbell ring volume (for doorbells)
|
|
413
|
+
ring_volume: PercentInt | None = None
|
|
414
|
+
ringtone_id: str | None = None
|
|
415
|
+
repeat_times: int | None = None
|
|
416
|
+
# Actual speaker output volume (for cameras with speakers)
|
|
417
|
+
speaker_volume: PercentInt | None = None
|
|
418
|
+
|
|
419
|
+
def unifi_dict(
|
|
420
|
+
self,
|
|
421
|
+
data: dict[str, Any] | None = None,
|
|
422
|
+
exclude: set[str] | None = None,
|
|
423
|
+
) -> dict[str, Any]:
|
|
424
|
+
data = super().unifi_dict(data=data, exclude=exclude)
|
|
425
|
+
pop_dict_set_if_none(
|
|
426
|
+
data, {"ringVolume", "ringtoneId", "repeatTimes", "speakerVolume"}
|
|
427
|
+
)
|
|
428
|
+
return data
|
|
369
429
|
|
|
370
430
|
|
|
371
431
|
class RecordingSettings(ProtectBaseObject):
|
|
@@ -685,6 +745,10 @@ class CameraZone(ProtectBaseObject):
|
|
|
685
745
|
if "points" in data:
|
|
686
746
|
data["points"] = [serialize_point(p) for p in data["points"]]
|
|
687
747
|
|
|
748
|
+
if "color" in data and isinstance(data["color"], dict):
|
|
749
|
+
# Serialize Color object to hex string to avoid Pydantic serialization warnings
|
|
750
|
+
data["color"] = self.color.as_hex().upper()
|
|
751
|
+
|
|
688
752
|
return data
|
|
689
753
|
|
|
690
754
|
@staticmethod
|
|
@@ -856,6 +920,8 @@ class CameraFeatureFlags(ProtectBaseObject):
|
|
|
856
920
|
# 4.73.71+
|
|
857
921
|
support_nfc: bool | None = None
|
|
858
922
|
has_fingerprint_sensor: bool | None = None
|
|
923
|
+
# 6.0.0+
|
|
924
|
+
support_full_hd_snapshot: bool | None = None
|
|
859
925
|
|
|
860
926
|
focus: PTZRange
|
|
861
927
|
pan: PTZRange
|
|
@@ -926,7 +992,7 @@ class Camera(ProtectMotionDeviceModel):
|
|
|
926
992
|
is_recording: bool
|
|
927
993
|
is_motion_detected: bool
|
|
928
994
|
is_smart_detected: bool
|
|
929
|
-
phy_rate:
|
|
995
|
+
phy_rate: float | None = None
|
|
930
996
|
hdr_mode: bool
|
|
931
997
|
# Recording Quality -> High Frame
|
|
932
998
|
video_mode: VideoMode
|
|
@@ -1065,6 +1131,13 @@ class Camera(ProtectMotionDeviceModel):
|
|
|
1065
1131
|
"chimeDuration": lambda x: timedelta(milliseconds=x),
|
|
1066
1132
|
} | super().unifi_dict_conversions()
|
|
1067
1133
|
|
|
1134
|
+
@model_validator(mode="after")
|
|
1135
|
+
def _set_channel_parents(self) -> Camera:
|
|
1136
|
+
"""Set parent camera reference in channels after initialization."""
|
|
1137
|
+
for channel in self.channels:
|
|
1138
|
+
channel._parent = self
|
|
1139
|
+
return self
|
|
1140
|
+
|
|
1068
1141
|
@classmethod
|
|
1069
1142
|
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
1070
1143
|
# LCD messages comes back as empty dict {}
|
|
@@ -1140,12 +1213,13 @@ class Camera(ProtectMotionDeviceModel):
|
|
|
1140
1213
|
|
|
1141
1214
|
def update_from_dict(self, data: dict[str, Any]) -> Camera:
|
|
1142
1215
|
# a message in the past is actually a signal to wipe the message
|
|
1143
|
-
if (
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1216
|
+
if (
|
|
1217
|
+
reset_at := data.get("lcd_message", {}).get("reset_at")
|
|
1218
|
+
) is not None and utc_now() > from_js_time(reset_at):
|
|
1219
|
+
# Important: Make a copy of the data before modifying it
|
|
1220
|
+
# since unifi_dict_to_dict will otherwise report incorrect changes
|
|
1221
|
+
data = data.copy()
|
|
1222
|
+
data["lcd_message"] = None
|
|
1149
1223
|
|
|
1150
1224
|
return super().update_from_dict(data)
|
|
1151
1225
|
|
|
@@ -1442,6 +1516,40 @@ class Camera(ProtectMotionDeviceModel):
|
|
|
1442
1516
|
"""Toggles vehicle smart detection. Requires camera to have smart detection"""
|
|
1443
1517
|
return await self._set_object_detect(SmartDetectObjectType.VEHICLE, enabled)
|
|
1444
1518
|
|
|
1519
|
+
# endregion
|
|
1520
|
+
# region Face
|
|
1521
|
+
|
|
1522
|
+
@property
|
|
1523
|
+
def can_detect_face(self) -> bool:
|
|
1524
|
+
return SmartDetectObjectType.FACE in self.feature_flags.smart_detect_types
|
|
1525
|
+
|
|
1526
|
+
@property
|
|
1527
|
+
def is_face_detection_on(self) -> bool:
|
|
1528
|
+
"""Is Face Detection available and enabled?"""
|
|
1529
|
+
return self._is_smart_enabled(SmartDetectObjectType.FACE)
|
|
1530
|
+
|
|
1531
|
+
@property
|
|
1532
|
+
def last_face_detect_event(self) -> Event | None:
|
|
1533
|
+
"""Get the last face smart detection event."""
|
|
1534
|
+
return self.get_last_smart_detect_event(SmartDetectObjectType.FACE)
|
|
1535
|
+
|
|
1536
|
+
@property
|
|
1537
|
+
def last_face_detect(self) -> datetime | None:
|
|
1538
|
+
"""Get the last face smart detection event."""
|
|
1539
|
+
return self.last_smart_detects.get(SmartDetectObjectType.FACE)
|
|
1540
|
+
|
|
1541
|
+
@property
|
|
1542
|
+
def is_face_currently_detected(self) -> bool:
|
|
1543
|
+
"""Is face currently being detected"""
|
|
1544
|
+
return self._is_smart_detected(SmartDetectObjectType.FACE)
|
|
1545
|
+
|
|
1546
|
+
async def set_face_detection(self, enabled: bool) -> None:
|
|
1547
|
+
"""Toggles face smart detection. Requires camera to have smart detection"""
|
|
1548
|
+
return await self._set_object_detect(
|
|
1549
|
+
SmartDetectObjectType.FACE,
|
|
1550
|
+
enabled,
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1445
1553
|
# endregion
|
|
1446
1554
|
# region License Plate
|
|
1447
1555
|
|
|
@@ -2043,6 +2151,43 @@ class Camera(ProtectMotionDeviceModel):
|
|
|
2043
2151
|
|
|
2044
2152
|
return await self._api.get_camera_snapshot(self.id, width, height, dt=dt)
|
|
2045
2153
|
|
|
2154
|
+
async def get_public_api_snapshot(
|
|
2155
|
+
self, high_quality: bool | None = None
|
|
2156
|
+
) -> bytes | None:
|
|
2157
|
+
"""Gets snapshot for camera using public API."""
|
|
2158
|
+
if self._api._api_key is None:
|
|
2159
|
+
raise NotAuthorized("Cannot get public API snapshot without an API key.")
|
|
2160
|
+
|
|
2161
|
+
if high_quality is None:
|
|
2162
|
+
high_quality = self.feature_flags.support_full_hd_snapshot or False
|
|
2163
|
+
|
|
2164
|
+
return await self._api.get_public_api_camera_snapshot(
|
|
2165
|
+
camera_id=self.id, high_quality=high_quality
|
|
2166
|
+
)
|
|
2167
|
+
|
|
2168
|
+
async def create_rtsps_streams(
|
|
2169
|
+
self, qualities: list[str] | str
|
|
2170
|
+
) -> RTSPSStreams | None:
|
|
2171
|
+
"""Creates RTSPS streams for camera using public API."""
|
|
2172
|
+
if self._api._api_key is None:
|
|
2173
|
+
raise NotAuthorized("Cannot create RTSPS streams without an API key.")
|
|
2174
|
+
|
|
2175
|
+
return await self._api.create_camera_rtsps_streams(self.id, qualities)
|
|
2176
|
+
|
|
2177
|
+
async def get_rtsps_streams(self) -> RTSPSStreams | None:
|
|
2178
|
+
"""Gets existing RTSPS streams for camera using public API."""
|
|
2179
|
+
if self._api._api_key is None:
|
|
2180
|
+
raise NotAuthorized("Cannot get RTSPS streams without an API key.")
|
|
2181
|
+
|
|
2182
|
+
return await self._api.get_camera_rtsps_streams(self.id)
|
|
2183
|
+
|
|
2184
|
+
async def delete_rtsps_streams(self, qualities: list[str] | str) -> bool:
|
|
2185
|
+
"""Deletes RTSPS streams for camera using public API."""
|
|
2186
|
+
if self._api._api_key is None:
|
|
2187
|
+
raise NotAuthorized("Cannot delete RTSPS streams without an API key.")
|
|
2188
|
+
|
|
2189
|
+
return await self._api.delete_camera_rtsps_streams(self.id, qualities)
|
|
2190
|
+
|
|
2046
2191
|
async def get_package_snapshot(
|
|
2047
2192
|
self,
|
|
2048
2193
|
width: int | None = None,
|
|
@@ -2175,7 +2320,9 @@ class Camera(ProtectMotionDeviceModel):
|
|
|
2175
2320
|
|
|
2176
2321
|
def callback() -> None:
|
|
2177
2322
|
self.led_settings.is_enabled = enabled
|
|
2178
|
-
|
|
2323
|
+
# blink_rate was removed in Protect 6.x
|
|
2324
|
+
if self.led_settings.blink_rate is not None:
|
|
2325
|
+
self.led_settings.blink_rate = 0
|
|
2179
2326
|
|
|
2180
2327
|
await self.queue_update(callback)
|
|
2181
2328
|
|
|
@@ -2265,7 +2412,17 @@ class Camera(ProtectMotionDeviceModel):
|
|
|
2265
2412
|
await self.queue_update(callback)
|
|
2266
2413
|
|
|
2267
2414
|
async def set_speaker_volume(self, level: int) -> None:
|
|
2268
|
-
"""Sets the speaker
|
|
2415
|
+
"""Sets the speaker output volume on camera. Requires camera to have speakers"""
|
|
2416
|
+
if not self.feature_flags.has_speaker:
|
|
2417
|
+
raise BadRequest("Camera does not have speaker")
|
|
2418
|
+
|
|
2419
|
+
def callback() -> None:
|
|
2420
|
+
self.speaker_settings.speaker_volume = PercentInt(level)
|
|
2421
|
+
|
|
2422
|
+
await self.queue_update(callback)
|
|
2423
|
+
|
|
2424
|
+
async def set_volume(self, level: int) -> None:
|
|
2425
|
+
"""Sets the general volume level on camera. Requires camera to have speakers"""
|
|
2269
2426
|
if not self.feature_flags.has_speaker:
|
|
2270
2427
|
raise BadRequest("Camera does not have speaker")
|
|
2271
2428
|
|
|
@@ -2274,6 +2431,16 @@ class Camera(ProtectMotionDeviceModel):
|
|
|
2274
2431
|
|
|
2275
2432
|
await self.queue_update(callback)
|
|
2276
2433
|
|
|
2434
|
+
async def set_ring_volume(self, level: int) -> None:
|
|
2435
|
+
"""Sets the doorbell ring volume. Requires camera to be a doorbell"""
|
|
2436
|
+
if not self.feature_flags.is_doorbell:
|
|
2437
|
+
raise BadRequest("Camera is not a doorbell")
|
|
2438
|
+
|
|
2439
|
+
def callback() -> None:
|
|
2440
|
+
self.speaker_settings.ring_volume = PercentInt(level)
|
|
2441
|
+
|
|
2442
|
+
await self.queue_update(callback)
|
|
2443
|
+
|
|
2277
2444
|
async def set_chime_type(self, chime_type: ChimeType) -> None:
|
|
2278
2445
|
"""Sets chime type for doorbell. Requires camera to be a doorbell"""
|
|
2279
2446
|
await self.set_chime_duration(timedelta(milliseconds=chime_type.value))
|
|
@@ -2813,7 +2980,7 @@ class Sensor(ProtectAdoptableDeviceModel):
|
|
|
2813
2980
|
is_motion_detected: bool
|
|
2814
2981
|
is_opened: bool
|
|
2815
2982
|
leak_detected_at: datetime | None = None
|
|
2816
|
-
led_settings:
|
|
2983
|
+
led_settings: LEDSettings
|
|
2817
2984
|
light_settings: SensorThresholdSettings
|
|
2818
2985
|
motion_detected_at: datetime | None = None
|
|
2819
2986
|
motion_settings: SensorSensitivitySettings
|
uiprotect/data/nvr.py
CHANGED
|
@@ -15,12 +15,12 @@ from uuid import UUID
|
|
|
15
15
|
|
|
16
16
|
import aiofiles
|
|
17
17
|
import orjson
|
|
18
|
-
from aiofiles import os as aos
|
|
19
18
|
from convertertools import pop_dict_set_if_none, pop_dict_tuple
|
|
19
|
+
from pydantic import ConfigDict
|
|
20
20
|
from pydantic.fields import PrivateAttr
|
|
21
21
|
|
|
22
22
|
from ..exceptions import BadRequest, NotAuthorized
|
|
23
|
-
from ..utils import
|
|
23
|
+
from ..utils import convert_to_datetime
|
|
24
24
|
from .base import (
|
|
25
25
|
ProtectBaseObject,
|
|
26
26
|
ProtectDeviceModel,
|
|
@@ -46,7 +46,6 @@ from .types import (
|
|
|
46
46
|
ModelType,
|
|
47
47
|
MountType,
|
|
48
48
|
PercentFloat,
|
|
49
|
-
PercentInt,
|
|
50
49
|
PermissionNode,
|
|
51
50
|
ProgressCallback,
|
|
52
51
|
RecordingMode,
|
|
@@ -71,27 +70,46 @@ DELETE_KEYS_THUMB = {"color", "vehicleType"}
|
|
|
71
70
|
DELETE_KEYS_EVENT = {"deletedAt", "category", "subCategory"}
|
|
72
71
|
|
|
73
72
|
|
|
73
|
+
class MetaInfo(ProtectBaseObject):
|
|
74
|
+
applicationVersion: str
|
|
75
|
+
|
|
76
|
+
|
|
74
77
|
class NVRLocation(UserLocation):
|
|
75
78
|
is_geofencing_enabled: bool
|
|
76
79
|
radius: int
|
|
77
80
|
model: ModelType | None = None
|
|
78
81
|
|
|
79
82
|
|
|
83
|
+
class SmartDetectItemAttribute(ProtectBaseObject):
|
|
84
|
+
"""Attribute value with confidence for smart detect items (e.g., color, vehicle type)."""
|
|
85
|
+
|
|
86
|
+
val: str
|
|
87
|
+
confidence: int
|
|
88
|
+
|
|
89
|
+
|
|
80
90
|
class SmartDetectItem(ProtectBaseObject):
|
|
81
91
|
id: str
|
|
82
92
|
timestamp: datetime
|
|
83
|
-
level: PercentInt
|
|
84
93
|
coord: tuple[int, int, int, int]
|
|
85
94
|
object_type: SmartDetectObjectType
|
|
86
95
|
zone_ids: list[int]
|
|
87
96
|
duration: timedelta
|
|
97
|
+
confidence: int
|
|
98
|
+
first_shown_time_ms: int
|
|
99
|
+
idle_since_time_ms: int
|
|
100
|
+
stationary: bool
|
|
101
|
+
license_plate: str | None = None # only populated for vehicle object_type
|
|
102
|
+
depth: float | None = None
|
|
103
|
+
speed: float | None = None
|
|
104
|
+
attributes: dict[str, SmartDetectItemAttribute] | None = None
|
|
105
|
+
lines: list[int] | None = None
|
|
88
106
|
|
|
89
107
|
@classmethod
|
|
90
108
|
@cache
|
|
91
109
|
def _get_unifi_remaps(cls) -> dict[str, str]:
|
|
92
110
|
return {
|
|
93
111
|
**super()._get_unifi_remaps(),
|
|
94
|
-
"zones": "
|
|
112
|
+
"zones": "zone_ids",
|
|
95
113
|
}
|
|
96
114
|
|
|
97
115
|
@classmethod
|
|
@@ -126,9 +144,22 @@ class SmartDetectTrack(ProtectBaseObject):
|
|
|
126
144
|
return self._api.bootstrap.events.get(self.event_id)
|
|
127
145
|
|
|
128
146
|
|
|
129
|
-
class
|
|
130
|
-
|
|
131
|
-
|
|
147
|
+
class EventThumbnailGroup(ProtectBaseObject):
|
|
148
|
+
"""Group information for detected thumbnails (e.g., license plate recognition)."""
|
|
149
|
+
|
|
150
|
+
id: str
|
|
151
|
+
name: str | None = None
|
|
152
|
+
matched_name: str | None = None
|
|
153
|
+
confidence: int
|
|
154
|
+
|
|
155
|
+
def unifi_dict(
|
|
156
|
+
self,
|
|
157
|
+
data: dict[str, Any] | None = None,
|
|
158
|
+
exclude: set[str] | None = None,
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
data = super().unifi_dict(data=data, exclude=exclude)
|
|
161
|
+
pop_dict_set_if_none(data, {"matchedName", "name"})
|
|
162
|
+
return data
|
|
132
163
|
|
|
133
164
|
|
|
134
165
|
class EventThumbnailAttribute(ProtectBaseObject):
|
|
@@ -163,8 +194,48 @@ class FingerprintMetadata(ProtectBaseObject):
|
|
|
163
194
|
|
|
164
195
|
|
|
165
196
|
class EventThumbnailAttributes(ProtectBaseObject):
|
|
166
|
-
|
|
167
|
-
|
|
197
|
+
"""
|
|
198
|
+
Dynamic attributes for detected thumbnails.
|
|
199
|
+
|
|
200
|
+
All attributes are stored as extra fields for full flexibility.
|
|
201
|
+
|
|
202
|
+
Common attribute types:
|
|
203
|
+
- color, vehicleType, faceMask: EventThumbnailAttribute (with val and confidence)
|
|
204
|
+
- zone: list[int] - Zone IDs where detection occurred
|
|
205
|
+
- trackerId: int - Unique tracker ID for this detection
|
|
206
|
+
|
|
207
|
+
Allows any attributes for forward compatibility with new UFP features.
|
|
208
|
+
|
|
209
|
+
Example usage:
|
|
210
|
+
>>> attrs = thumbnail.attributes
|
|
211
|
+
>>> if attrs:
|
|
212
|
+
... # Access EventThumbnailAttribute objects
|
|
213
|
+
... color = attrs.color # EventThumbnailAttribute
|
|
214
|
+
... if color:
|
|
215
|
+
... print(f"Color: {color.val} (confidence: {color.confidence})")
|
|
216
|
+
... # Access primitive types
|
|
217
|
+
... zones = attrs.zone # list[int] | None
|
|
218
|
+
... tracker = attrs.trackerId # int | None
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
model_config = ConfigDict(extra="allow")
|
|
222
|
+
|
|
223
|
+
def get_value(self, key: str) -> str | None:
|
|
224
|
+
"""Get the string value from an EventThumbnailAttribute field, if it exists."""
|
|
225
|
+
attr = getattr(self, key, None)
|
|
226
|
+
if isinstance(attr, EventThumbnailAttribute):
|
|
227
|
+
return attr.val
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
232
|
+
# Convert nested attribute objects to EventThumbnailAttribute instances
|
|
233
|
+
return {
|
|
234
|
+
key: EventThumbnailAttribute.from_unifi_dict(**value)
|
|
235
|
+
if isinstance(value, dict) and "val" in value and "confidence" in value
|
|
236
|
+
else value
|
|
237
|
+
for key, value in data.items()
|
|
238
|
+
}
|
|
168
239
|
|
|
169
240
|
def unifi_dict(
|
|
170
241
|
self,
|
|
@@ -172,8 +243,9 @@ class EventThumbnailAttributes(ProtectBaseObject):
|
|
|
172
243
|
exclude: set[str] | None = None,
|
|
173
244
|
) -> dict[str, Any]:
|
|
174
245
|
data = super().unifi_dict(data=data, exclude=exclude)
|
|
175
|
-
|
|
176
|
-
|
|
246
|
+
|
|
247
|
+
# Remove None values from extra fields
|
|
248
|
+
return {k: v for k, v in data.items() if v is not None}
|
|
177
249
|
|
|
178
250
|
|
|
179
251
|
class EventDetectedThumbnail(ProtectBaseObject):
|
|
@@ -182,6 +254,11 @@ class EventDetectedThumbnail(ProtectBaseObject):
|
|
|
182
254
|
cropped_id: str
|
|
183
255
|
attributes: EventThumbnailAttributes | None = None
|
|
184
256
|
name: str | None = None
|
|
257
|
+
coord: list[int] | None = None
|
|
258
|
+
confidence: int | None = None
|
|
259
|
+
# requires 6.0.0+
|
|
260
|
+
group: EventThumbnailGroup | None = None
|
|
261
|
+
object_id: str | None = None
|
|
185
262
|
|
|
186
263
|
@classmethod
|
|
187
264
|
@cache
|
|
@@ -194,7 +271,7 @@ class EventDetectedThumbnail(ProtectBaseObject):
|
|
|
194
271
|
exclude: set[str] | None = None,
|
|
195
272
|
) -> dict[str, Any]:
|
|
196
273
|
data = super().unifi_dict(data=data, exclude=exclude)
|
|
197
|
-
pop_dict_set_if_none(data, {"name"})
|
|
274
|
+
pop_dict_set_if_none(data, {"name", "group", "objectId", "coord", "confidence"})
|
|
198
275
|
return data
|
|
199
276
|
|
|
200
277
|
|
|
@@ -217,8 +294,6 @@ class EventMetadata(ProtectBaseObject):
|
|
|
217
294
|
alarm_type: str | None = None
|
|
218
295
|
device_id: str | None = None
|
|
219
296
|
mac: str | None = None
|
|
220
|
-
# require 2.7.5+
|
|
221
|
-
license_plate: LicensePlateMetadata | None = None
|
|
222
297
|
# requires 2.11.13+
|
|
223
298
|
detected_thumbnails: list[EventDetectedThumbnail] | None = None
|
|
224
299
|
# requires 5.1.34+
|
|
@@ -297,6 +372,9 @@ class Event(ProtectModelWithId):
|
|
|
297
372
|
# only appears if `get_events` is called with category
|
|
298
373
|
category: EventCategories | None = None
|
|
299
374
|
sub_category: str | None = None
|
|
375
|
+
# requires 6.0.0+
|
|
376
|
+
is_favorite: bool | None = None
|
|
377
|
+
favorite_object_ids: list[str] | None = None
|
|
300
378
|
|
|
301
379
|
# TODO:
|
|
302
380
|
# partition
|
|
@@ -377,6 +455,45 @@ class Event(ProtectModelWithId):
|
|
|
377
455
|
]
|
|
378
456
|
return self._smart_detect_events
|
|
379
457
|
|
|
458
|
+
def get_detected_thumbnail(self) -> EventDetectedThumbnail | None:
|
|
459
|
+
"""
|
|
460
|
+
Gets best detected thumbnail for event (UFP 6.x+).
|
|
461
|
+
|
|
462
|
+
Returns the thumbnail marked with clockBestWall, which indicates
|
|
463
|
+
the optimal frame for this detection (highest confidence, best angle, etc.).
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
EventDetectedThumbnail with the best detection frame, or None if:
|
|
467
|
+
- Event has no metadata
|
|
468
|
+
- No detected thumbnails available
|
|
469
|
+
- No thumbnail has clockBestWall set
|
|
470
|
+
|
|
471
|
+
Example usage:
|
|
472
|
+
>>> # License Plate Recognition
|
|
473
|
+
>>> thumbnail = event.get_detected_thumbnail()
|
|
474
|
+
>>> if thumbnail and thumbnail.group:
|
|
475
|
+
... plate = thumbnail.group.matched_name # "ABC123"
|
|
476
|
+
... confidence = thumbnail.group.confidence # 95
|
|
477
|
+
... if thumbnail.attributes:
|
|
478
|
+
... color = thumbnail.attributes.get_value("color") # "white"
|
|
479
|
+
... vehicle = thumbnail.attributes.get_value("vehicleType") # "sedan"
|
|
480
|
+
|
|
481
|
+
>>> # Face Detection
|
|
482
|
+
>>> thumbnail = event.get_detected_thumbnail()
|
|
483
|
+
>>> if thumbnail and thumbnail.group:
|
|
484
|
+
... face_name = thumbnail.group.matched_name # "John Doe"
|
|
485
|
+
... confidence = thumbnail.group.confidence # 87
|
|
486
|
+
|
|
487
|
+
"""
|
|
488
|
+
if not self.metadata or not self.metadata.detected_thumbnails:
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
for thumbnail in self.metadata.detected_thumbnails:
|
|
492
|
+
if thumbnail.clock_best_wall:
|
|
493
|
+
return thumbnail
|
|
494
|
+
|
|
495
|
+
return None
|
|
496
|
+
|
|
380
497
|
async def get_thumbnail(
|
|
381
498
|
self,
|
|
382
499
|
width: int | None = None,
|
|
@@ -1213,29 +1330,8 @@ class NVR(ProtectDeviceModel):
|
|
|
1213
1330
|
return versions
|
|
1214
1331
|
|
|
1215
1332
|
async def get_is_prerelease(self) -> bool:
|
|
1216
|
-
"""
|
|
1217
|
-
|
|
1218
|
-
if self.version.is_prerelease:
|
|
1219
|
-
return True
|
|
1220
|
-
|
|
1221
|
-
# 2.6.14 is an EA version that looks like a release version
|
|
1222
|
-
cache_file_path = self._api.cache_dir / "release_cache.json"
|
|
1223
|
-
versions = await self._read_cache_file(
|
|
1224
|
-
cache_file_path,
|
|
1225
|
-
) or await self._read_cache_file(RELEASE_CACHE)
|
|
1226
|
-
if versions is None or self.version not in versions:
|
|
1227
|
-
versions = await self._api.get_release_versions()
|
|
1228
|
-
try:
|
|
1229
|
-
_LOGGER.debug("Fetching releases from APT repos...")
|
|
1230
|
-
tmp = self._api.cache_dir / "release_cache.tmp.json"
|
|
1231
|
-
await aos.makedirs(self._api.cache_dir, exist_ok=True)
|
|
1232
|
-
async with aiofiles.open(tmp, "wb") as cache_file:
|
|
1233
|
-
await cache_file.write(orjson.dumps([str(v) for v in versions]))
|
|
1234
|
-
await aos.rename(tmp, cache_file_path)
|
|
1235
|
-
except Exception:
|
|
1236
|
-
_LOGGER.warning("Failed write cache file.")
|
|
1237
|
-
|
|
1238
|
-
return self.version not in versions
|
|
1333
|
+
"""[DEPRECATED] Always returns False. Will be removed after HA 2025.8.0."""
|
|
1334
|
+
return False
|
|
1239
1335
|
|
|
1240
1336
|
async def set_smart_detections(self, value: bool) -> None:
|
|
1241
1337
|
"""Set if smart detections are enabled."""
|
|
@@ -1361,6 +1457,11 @@ class NVR(ProtectDeviceModel):
|
|
|
1361
1457
|
"""
|
|
1362
1458
|
return self._is_smart_enabled(SmartDetectObjectType.VEHICLE)
|
|
1363
1459
|
|
|
1460
|
+
@property
|
|
1461
|
+
def is_global_face_detection_on(self) -> bool:
|
|
1462
|
+
"""Is Face Detection available and enabled?"""
|
|
1463
|
+
return self._is_smart_enabled(SmartDetectObjectType.FACE)
|
|
1464
|
+
|
|
1364
1465
|
@property
|
|
1365
1466
|
def is_global_license_plate_detection_on(self) -> bool:
|
|
1366
1467
|
"""
|