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/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
- self._rtsp_url = f"rtsp://{self._api.connection_host}:{self._api.bootstrap.nvr.ports.rtsp}/{self.rtsp_alias}"
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
- self._rtsps_url = f"rtsps://{self._api.connection_host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}?enableSrtp"
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
- self._rtsps_no_srtp_url = f"rtsps://{self._api.connection_host}:{self._api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}"
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 # in milliseconds betweeen blinks, 0 = solid
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: int | None = None
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 (reset_at := data.get("lcd_message", {}).get("reset_at")) is not None:
1144
- if utc_now() > from_js_time(reset_at):
1145
- # Important: Make a copy of the data before modifying it
1146
- # since unifi_dict_to_dict will otherwise report incorrect changes
1147
- data = data.copy()
1148
- data["lcd_message"] = None
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
- self.led_settings.blink_rate = 0
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 sensitivity level on camera. Requires camera to have speakers"""
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: SensorSettingsBase
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 RELEASE_CACHE, convert_to_datetime
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": "zoneIds",
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 LicensePlateMetadata(ProtectBaseObject):
130
- name: str
131
- confidence_level: int
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
- color: EventThumbnailAttribute | None = None
167
- vehicle_type: EventThumbnailAttribute | None = None
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
- pop_dict_set_if_none(data, DELETE_KEYS_THUMB)
176
- return data
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
- """Get if current version of Protect is a prerelease version."""
1217
- # only EA versions have `-beta` in versions
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
  """