uiprotect 3.8.0__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
@@ -7,12 +7,14 @@ import logging
7
7
  import warnings
8
8
  from collections.abc import Callable
9
9
  from datetime import datetime, timedelta
10
- from functools import cache
11
- from ipaddress import IPv4Address
10
+ from functools import cache, lru_cache
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
- from pydantic.v1.fields import PrivateAttr
15
+ from convertertools import pop_dict_set_if_none, pop_dict_tuple
16
+ from pydantic import model_validator
17
+ from pydantic.fields import PrivateAttr
16
18
 
17
19
  from ..exceptions import BadRequest, NotAuthorized, StreamError
18
20
  from ..stream import TalkbackStream
@@ -22,8 +24,10 @@ from ..utils import (
22
24
  convert_smart_types,
23
25
  convert_to_datetime,
24
26
  convert_video_modes,
27
+ format_host_for_url,
25
28
  from_js_time,
26
29
  serialize_point,
30
+ timedelta_total_seconds,
27
31
  to_js_time,
28
32
  utc_now,
29
33
  )
@@ -78,21 +82,11 @@ from .types import (
78
82
  from .user import User
79
83
 
80
84
  if TYPE_CHECKING:
85
+ from ..api import RTSPSStreams
81
86
  from .nvr import Event, Liveview
82
87
 
83
88
  PRIVACY_ZONE_NAME = "pyufp_privacy_zone"
84
- LUX_MAPPING_VALUES = [
85
- 30,
86
- 25,
87
- 20,
88
- 15,
89
- 12,
90
- 10,
91
- 7,
92
- 5,
93
- 3,
94
- 1,
95
- ]
89
+ LUX_MAPPING_VALUES = [30, 25, 20, 15, 12, 10, 7, 5, 3, 1, 0]
96
90
 
97
91
  _LOGGER = logging.getLogger(__name__)
98
92
 
@@ -102,9 +96,10 @@ class LightDeviceSettings(ProtectBaseObject):
102
96
  is_indicator_enabled: bool
103
97
  # Brightness
104
98
  led_level: LEDLevel
105
- lux_sensitivity: LowMedHigh
106
99
  pir_duration: timedelta
107
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
108
103
 
109
104
  @classmethod
110
105
  @cache
@@ -132,7 +127,7 @@ class Light(ProtectMotionDeviceModel):
132
127
  light_device_settings: LightDeviceSettings
133
128
  light_on_settings: LightOnSettings
134
129
  light_mode_settings: LightModeSettings
135
- camera_id: str | None
130
+ camera_id: str | None = None
136
131
  is_camera_paired: bool
137
132
 
138
133
  @classmethod
@@ -169,6 +164,14 @@ class Light(ProtectMotionDeviceModel):
169
164
  self.camera_id = camera.id
170
165
  await self.save_device(data_before_changes, force_emit=True)
171
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
+
172
175
  async def set_status_light(self, enabled: bool) -> None:
173
176
  """Sets the status indicator light for the light"""
174
177
 
@@ -254,23 +257,31 @@ class CameraChannel(ProtectBaseObject):
254
257
  name: str # read only
255
258
  enabled: bool # read only
256
259
  is_rtsp_enabled: bool
257
- rtsp_alias: str | None # read only
260
+ rtsp_alias: str | None = None # read only
258
261
  width: int
259
262
  height: int
260
- fps: int
263
+ fps: int | None = None # read only
261
264
  bitrate: int
262
- min_bitrate: int # read only
263
- max_bitrate: int # read only
264
- min_client_adaptive_bit_rate: int | None # read only
265
- min_motion_adaptive_bit_rate: int | None # read only
265
+ min_bitrate: int | None = None # read only
266
+ max_bitrate: int | None = None # read only
267
+ min_client_adaptive_bit_rate: int | None = None # read only
268
+ min_motion_adaptive_bit_rate: int | None = None # read only
266
269
  fps_values: list[int] # read only
267
270
  idr_interval: int
268
271
  # 3.0.22+
269
272
  auto_bitrate: bool | None = None
270
273
  auto_fps: bool | None = None
271
274
 
275
+ _parent: Camera | None = PrivateAttr(None)
272
276
  _rtsp_url: str | None = PrivateAttr(None)
273
277
  _rtsps_url: str | None = PrivateAttr(None)
278
+ _rtsps_no_srtp_url: str | None = PrivateAttr(None)
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
274
285
 
275
286
  @property
276
287
  def rtsp_url(self) -> str | None:
@@ -279,7 +290,11 @@ class CameraChannel(ProtectBaseObject):
279
290
 
280
291
  if self._rtsp_url is not None:
281
292
  return self._rtsp_url
282
- 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
+ )
283
298
  return self._rtsp_url
284
299
 
285
300
  @property
@@ -289,12 +304,28 @@ class CameraChannel(ProtectBaseObject):
289
304
 
290
305
  if self._rtsps_url is not None:
291
306
  return self._rtsps_url
292
- 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"
293
310
  return self._rtsps_url
294
311
 
312
+ @property
313
+ def rtsps_no_srtp_url(self) -> str | None:
314
+ if not self.is_rtsp_enabled or self.rtsp_alias is None:
315
+ return None
316
+
317
+ if self._rtsps_no_srtp_url is not None:
318
+ return self._rtsps_no_srtp_url
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
+ )
324
+ return self._rtsps_no_srtp_url
325
+
295
326
  @property
296
327
  def is_package(self) -> bool:
297
- return self.fps <= 2
328
+ return self.fps is not None and self.fps <= 2
298
329
 
299
330
 
300
331
  class ISPSettings(ProtectBaseObject):
@@ -323,8 +354,8 @@ class ISPSettings(ProtectBaseObject):
323
354
  d_zoom_stream_id: int
324
355
  focus_mode: FocusMode | None = None
325
356
  focus_position: int
326
- touch_focus_x: int | None
327
- touch_focus_y: int | None
357
+ touch_focus_x: int | None = None
358
+ touch_focus_y: int | None = None
328
359
  zoom_position: PercentInt
329
360
  mount_position: MountPosition | None = None
330
361
  # requires 2.8.14+
@@ -341,10 +372,7 @@ class ISPSettings(ProtectBaseObject):
341
372
  exclude: set[str] | None = None,
342
373
  ) -> dict[str, Any]:
343
374
  data = super().unifi_dict(data=data, exclude=exclude)
344
-
345
- if "focusMode" in data and data["focusMode"] is None:
346
- del data["focusMode"]
347
-
375
+ pop_dict_set_if_none(data, {"focusMode"})
348
376
  return data
349
377
 
350
378
 
@@ -359,7 +387,21 @@ class OSDSettings(ProtectBaseObject):
359
387
  class LEDSettings(ProtectBaseObject):
360
388
  # Status Light
361
389
  is_enabled: bool
362
- 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
363
405
 
364
406
 
365
407
  class SpeakerSettings(ProtectBaseObject):
@@ -367,6 +409,23 @@ class SpeakerSettings(ProtectBaseObject):
367
409
  # Status Sounds
368
410
  are_system_sounds_enabled: bool
369
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
370
429
 
371
430
 
372
431
  class RecordingSettings(ProtectBaseObject):
@@ -391,6 +450,8 @@ class RecordingSettings(ProtectBaseObject):
391
450
  retention_duration: datetime | None = None
392
451
  smart_detect_post_padding: timedelta | None = None
393
452
  smart_detect_pre_padding: timedelta | None = None
453
+ # requires 5.2.39+
454
+ create_access_event: bool | None = None
394
455
 
395
456
  @classmethod
396
457
  @cache
@@ -473,6 +534,20 @@ class SmartDetectSettings(ProtectBaseObject):
473
534
  "autoTrackingObjectTypes": convert_smart_types,
474
535
  } | super().unifi_dict_conversions()
475
536
 
537
+ def unifi_dict(
538
+ self,
539
+ data: dict[str, Any] | None = None,
540
+ exclude: set[str] | None = None,
541
+ ) -> dict[str, Any]:
542
+ data = super().unifi_dict(data=data, exclude=exclude)
543
+ if audio_types := data.get("audioTypes"):
544
+ # SMOKE_CMONX is not supported for audio types
545
+ # and should not be sent to the camera
546
+ data["audioTypes"] = [
547
+ t for t in audio_types if t != SmartDetectAudioType.SMOKE_CMONX.value
548
+ ]
549
+ return data
550
+
476
551
 
477
552
  class LCDMessage(ProtectBaseObject):
478
553
  type: DoorbellMessageType
@@ -530,10 +605,10 @@ class LCDMessage(ProtectBaseObject):
530
605
  class TalkbackSettings(ProtectBaseObject):
531
606
  type_fmt: AudioCodecs
532
607
  type_in: str
533
- bind_addr: IPv4Address
608
+ bind_addr: IPv4Address | None = None
534
609
  bind_port: int
535
- filter_addr: str | None # can be used to restrict sender address
536
- filter_port: int | None # can be used to restrict sender port
610
+ filter_addr: str | None = None # can be used to restrict sender address
611
+ filter_port: int | None = None # can be used to restrict sender port
537
612
  channels: int # 1 or 2
538
613
  sampling_rate: int # 8000, 11025, 22050, 44100, 48000
539
614
  bits_per_sample: int
@@ -541,22 +616,22 @@ class TalkbackSettings(ProtectBaseObject):
541
616
 
542
617
 
543
618
  class WifiStats(ProtectBaseObject):
544
- channel: int | None
545
- frequency: int | None
546
- link_speed_mbps: str | None
619
+ channel: int | None = None
620
+ frequency: int | None = None
621
+ link_speed_mbps: str | None = None
547
622
  signal_quality: PercentInt
548
623
  signal_strength: int
549
624
 
550
625
 
551
626
  class VideoStats(ProtectBaseObject):
552
- recording_start: datetime | None
553
- recording_end: datetime | None
554
- recording_start_lq: datetime | None
555
- recording_end_lq: datetime | None
556
- timelapse_start: datetime | None
557
- timelapse_end: datetime | None
558
- timelapse_start_lq: datetime | None
559
- timelapse_end_lq: datetime | None
627
+ recording_start: datetime | None = None
628
+ recording_end: datetime | None = None
629
+ recording_start_lq: datetime | None = None
630
+ recording_end_lq: datetime | None = None
631
+ timelapse_start: datetime | None = None
632
+ timelapse_end: datetime | None = None
633
+ timelapse_start_lq: datetime | None = None
634
+ timelapse_end_lq: datetime | None = None
560
635
 
561
636
  @classmethod
562
637
  @cache
@@ -572,24 +647,27 @@ class VideoStats(ProtectBaseObject):
572
647
  @classmethod
573
648
  @cache
574
649
  def unifi_dict_conversions(cls) -> dict[str, object | Callable[[Any], Any]]:
575
- return {
576
- key: convert_to_datetime
577
- for key in (
578
- "recordingStart",
579
- "recordingEnd",
580
- "recordingStartLQ",
581
- "recordingEndLQ",
582
- "timelapseStart",
583
- "timelapseEnd",
584
- "timelapseStartLQ",
585
- "timelapseEndLQ",
650
+ return (
651
+ dict.fromkeys(
652
+ (
653
+ "recordingStart",
654
+ "recordingEnd",
655
+ "recordingStartLQ",
656
+ "recordingEndLQ",
657
+ "timelapseStart",
658
+ "timelapseEnd",
659
+ "timelapseStartLQ",
660
+ "timelapseEndLQ",
661
+ ),
662
+ convert_to_datetime,
586
663
  )
587
- } | super().unifi_dict_conversions()
664
+ | super().unifi_dict_conversions()
665
+ )
588
666
 
589
667
 
590
668
  class StorageStats(ProtectBaseObject):
591
- used: int | None # bytes
592
- rate: float | None # bytes / millisecond
669
+ used: int | None = None # bytes
670
+ rate: float | None = None # bytes / millisecond
593
671
 
594
672
  @property
595
673
  def rate_per_second(self) -> float | None:
@@ -611,10 +689,7 @@ class StorageStats(ProtectBaseObject):
611
689
  exclude: set[str] | None = None,
612
690
  ) -> dict[str, Any]:
613
691
  data = super().unifi_dict(data=data, exclude=exclude)
614
-
615
- if "rate" in data and data["rate"] is None:
616
- del data["rate"]
617
-
692
+ pop_dict_set_if_none(data, {"rate"})
618
693
  return data
619
694
 
620
695
 
@@ -623,7 +698,7 @@ class CameraStats(ProtectBaseObject):
623
698
  tx_bytes: int
624
699
  wifi: WifiStats
625
700
  video: VideoStats
626
- storage: StorageStats | None
701
+ storage: StorageStats | None = None
627
702
  wifi_quality: PercentInt
628
703
  wifi_strength: int
629
704
 
@@ -670,6 +745,10 @@ class CameraZone(ProtectBaseObject):
670
745
  if "points" in data:
671
746
  data["points"] = [serialize_point(p) for p in data["points"]]
672
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
+
673
752
  return data
674
753
 
675
754
  @staticmethod
@@ -698,7 +777,7 @@ class SmartMotionZone(MotionZone):
698
777
 
699
778
 
700
779
  class PrivacyMaskCapability(ProtectBaseObject):
701
- max_masks: int | None
780
+ max_masks: int | None = None
702
781
  rectangle_only: bool
703
782
 
704
783
 
@@ -725,9 +804,9 @@ class Hotplug(ProtectBaseObject):
725
804
 
726
805
 
727
806
  class PTZRangeSingle(ProtectBaseObject):
728
- max: float | None
729
- min: float | None
730
- step: float | None
807
+ max: float | None = None
808
+ min: float | None = None
809
+ step: float | None = None
731
810
 
732
811
 
733
812
  class PTZRange(ProtectBaseObject):
@@ -761,7 +840,7 @@ class PTZRange(ProtectBaseObject):
761
840
 
762
841
 
763
842
  class PTZZoomRange(PTZRange):
764
- ratio: float
843
+ ratio: int
765
844
 
766
845
  def to_native_value(self, zoom_value: float, is_relative: bool = False) -> float:
767
846
  """Convert zoom values to step values."""
@@ -838,6 +917,11 @@ class CameraFeatureFlags(ProtectBaseObject):
838
917
  has_vertical_flip: bool | None = None
839
918
  # 3.0.22+
840
919
  flash_range: Any | None = None
920
+ # 4.73.71+
921
+ support_nfc: bool | None = None
922
+ has_fingerprint_sensor: bool | None = None
923
+ # 6.0.0+
924
+ support_full_hd_snapshot: bool | None = None
841
925
 
842
926
  focus: PTZRange
843
927
  pan: PTZRange
@@ -891,6 +975,15 @@ class CameraAudioSettings(ProtectBaseObject):
891
975
  style: list[AudioStyle]
892
976
 
893
977
 
978
+ @lru_cache
979
+ def _chime_type_from_total_seconds(total_seconds: float) -> ChimeType:
980
+ if total_seconds == 0.3:
981
+ return ChimeType.MECHANICAL
982
+ if total_seconds > 0.3:
983
+ return ChimeType.DIGITAL
984
+ return ChimeType.NONE
985
+
986
+
894
987
  class Camera(ProtectMotionDeviceModel):
895
988
  is_deleting: bool
896
989
  # Microphone Sensitivity
@@ -899,13 +992,13 @@ class Camera(ProtectMotionDeviceModel):
899
992
  is_recording: bool
900
993
  is_motion_detected: bool
901
994
  is_smart_detected: bool
902
- phy_rate: float | None
995
+ phy_rate: float | None = None
903
996
  hdr_mode: bool
904
997
  # Recording Quality -> High Frame
905
998
  video_mode: VideoMode
906
999
  is_probing_for_wifi: bool
907
1000
  chime_duration: timedelta
908
- last_ring: datetime | None
1001
+ last_ring: datetime | None = None
909
1002
  is_live_heatmap_enabled: bool
910
1003
  video_reconfiguration_in_progress: bool
911
1004
  channels: list[CameraChannel]
@@ -921,18 +1014,18 @@ class Camera(ProtectMotionDeviceModel):
921
1014
  smart_detect_zones: list[SmartMotionZone]
922
1015
  stats: CameraStats
923
1016
  feature_flags: CameraFeatureFlags
924
- lcd_message: LCDMessage | None
1017
+ lcd_message: LCDMessage | None = None
925
1018
  lenses: list[CameraLenses]
926
- platform: str
1019
+ platform: str | None = None
927
1020
  has_speaker: bool
928
1021
  has_wifi: bool
929
1022
  audio_bitrate: int
930
1023
  can_manage: bool
931
1024
  is_managed: bool
932
- voltage: float | None
1025
+ voltage: float | None = None
933
1026
  # requires 1.21+
934
- is_poor_network: bool | None
935
- is_wireless_uplink_enabled: bool | None
1027
+ is_poor_network: bool | None = None
1028
+ is_wireless_uplink_enabled: bool | None = None
936
1029
  # requires 2.6.13+
937
1030
  homekit_settings: CameraHomekitSettings | None = None
938
1031
  # requires 2.6.17+
@@ -952,7 +1045,12 @@ class Camera(ProtectMotionDeviceModel):
952
1045
  is_ptz: bool | None = None
953
1046
  # requires 2.11.13+
954
1047
  audio_settings: CameraAudioSettings | None = None
955
-
1048
+ # requires 5.0.33+
1049
+ is_third_party_camera: bool | None = None
1050
+ # requires 5.1.78+
1051
+ is_paired_with_ai_port: bool | None = None
1052
+ # requires 5.2.39+
1053
+ is_adopted_by_access_app: bool | None = None
956
1054
  # TODO: used for adopting
957
1055
  # apMac read only
958
1056
  # apRssi read only
@@ -968,6 +1066,10 @@ class Camera(ProtectMotionDeviceModel):
968
1066
 
969
1067
  # not directly from UniFi
970
1068
  last_ring_event_id: str | None = None
1069
+ last_nfc_card_scanned_event_id: str | None = None
1070
+ last_nfc_card_scanned: datetime | None = None
1071
+ last_fingerprint_identified_event_id: str | None = None
1072
+ last_fingerprint_identified: datetime | None = None
971
1073
  last_smart_detect: datetime | None = None
972
1074
  last_smart_audio_detect: datetime | None = None
973
1075
  last_smart_detect_event_id: str | None = None
@@ -977,7 +1079,6 @@ class Camera(ProtectMotionDeviceModel):
977
1079
  last_smart_detect_event_ids: dict[SmartDetectObjectType, str] = {}
978
1080
  last_smart_audio_detect_event_ids: dict[SmartDetectAudioType, str] = {}
979
1081
  talkback_stream: TalkbackStream | None = None
980
- _last_ring_timeout: datetime | None = PrivateAttr(None)
981
1082
 
982
1083
  @classmethod
983
1084
  @cache
@@ -989,6 +1090,10 @@ class Camera(ProtectMotionDeviceModel):
989
1090
  def _get_excluded_changed_fields(cls) -> set[str]:
990
1091
  return super()._get_excluded_changed_fields() | {
991
1092
  "last_ring_event_id",
1093
+ "last_nfc_card_scanned",
1094
+ "last_nfc_card_scanned_event_id",
1095
+ "last_fingerprint_identified",
1096
+ "last_fingerprint_identified_event_id",
992
1097
  "last_smart_detect",
993
1098
  "last_smart_audio_detect",
994
1099
  "last_smart_detect_event_id",
@@ -1026,6 +1131,13 @@ class Camera(ProtectMotionDeviceModel):
1026
1131
  "chimeDuration": lambda x: timedelta(milliseconds=x),
1027
1132
  } | super().unifi_dict_conversions()
1028
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
+
1029
1141
  @classmethod
1030
1142
  def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
1031
1143
  # LCD messages comes back as empty dict {}
@@ -1054,21 +1166,25 @@ class Camera(ProtectMotionDeviceModel):
1054
1166
  ]
1055
1167
 
1056
1168
  data = super().unifi_dict(data=data, exclude=exclude)
1057
- for key in (
1058
- "lastRingEventId",
1059
- "lastSmartDetect",
1060
- "lastSmartAudioDetect",
1061
- "lastSmartDetectEventId",
1062
- "lastSmartAudioDetectEventId",
1063
- "lastSmartDetects",
1064
- "lastSmartAudioDetects",
1065
- "lastSmartDetectEventIds",
1066
- "lastSmartAudioDetectEventIds",
1067
- "talkbackStream",
1068
- ):
1069
- if key in data:
1070
- del data[key]
1071
-
1169
+ pop_dict_tuple(
1170
+ data,
1171
+ (
1172
+ "lastFingerprintIdentified",
1173
+ "lastFingerprintIdentifiedEventId",
1174
+ "lastNfcCardScanned",
1175
+ "lastNfcCardScannedEventId",
1176
+ "lastRingEventId",
1177
+ "lastSmartDetect",
1178
+ "lastSmartAudioDetect",
1179
+ "lastSmartDetectEventId",
1180
+ "lastSmartAudioDetectEventId",
1181
+ "lastSmartDetects",
1182
+ "lastSmartAudioDetects",
1183
+ "lastSmartDetectEventIds",
1184
+ "lastSmartAudioDetectEventIds",
1185
+ "talkbackStream",
1186
+ ),
1187
+ )
1072
1188
  if "lcdMessage" in data and data["lcdMessage"] is None:
1073
1189
  data["lcdMessage"] = {}
1074
1190
 
@@ -1084,7 +1200,7 @@ class Camera(ProtectMotionDeviceModel):
1084
1200
  updated["lcd_message"] = {"reset_at": utc_now() - timedelta(seconds=10)}
1085
1201
  # otherwise, pass full LCD message to prevent issues
1086
1202
  elif self.lcd_message is not None:
1087
- updated["lcd_message"] = self.lcd_message.dict()
1203
+ updated["lcd_message"] = self.lcd_message.model_dump()
1088
1204
 
1089
1205
  # if reset_at is not passed in, it will default to reset in 1 minute
1090
1206
  if lcd_message is not None and "reset_at" not in lcd_message:
@@ -1096,12 +1212,14 @@ class Camera(ProtectMotionDeviceModel):
1096
1212
  return updated
1097
1213
 
1098
1214
  def update_from_dict(self, data: dict[str, Any]) -> Camera:
1099
- # a message in the past is actually a singal to wipe the message
1100
- reset_at = data.get("lcd_message", {}).get("reset_at")
1101
- if reset_at is not None:
1102
- reset_at = from_js_time(reset_at)
1103
- if utc_now() > reset_at:
1104
- data["lcd_message"] = None
1215
+ # a message in the past is actually a signal to wipe the message
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
1105
1223
 
1106
1224
  return super().update_from_dict(data)
1107
1225
 
@@ -1118,6 +1236,23 @@ class Camera(ProtectMotionDeviceModel):
1118
1236
  return None
1119
1237
  return self._api.bootstrap.events.get(last_smart_detect_event_id)
1120
1238
 
1239
+ @property
1240
+ def last_nfc_card_scanned_event(self) -> Event | None:
1241
+ if (
1242
+ last_nfc_card_scanned_event_id := self.last_nfc_card_scanned_event_id
1243
+ ) is None:
1244
+ return None
1245
+ return self._api.bootstrap.events.get(last_nfc_card_scanned_event_id)
1246
+
1247
+ @property
1248
+ def last_fingerprint_identified_event(self) -> Event | None:
1249
+ if (
1250
+ last_fingerprint_identified_event_id
1251
+ := self.last_fingerprint_identified_event_id
1252
+ ) is None:
1253
+ return None
1254
+ return self._api.bootstrap.events.get(last_fingerprint_identified_event_id)
1255
+
1121
1256
  @property
1122
1257
  def hdr_mode_display(self) -> Literal["auto", "off", "always"]:
1123
1258
  """Get HDR mode similar to how Protect interface works."""
@@ -1140,11 +1275,9 @@ class Camera(ProtectMotionDeviceModel):
1140
1275
  smart_type: SmartDetectObjectType,
1141
1276
  ) -> Event | None:
1142
1277
  """Get the last smart detect event for given type."""
1143
- event_id = self.last_smart_detect_event_ids.get(smart_type)
1144
- if event_id is None:
1145
- return None
1146
-
1147
- return self._api.bootstrap.events.get(event_id)
1278
+ if event_id := self.last_smart_detect_event_ids.get(smart_type):
1279
+ return self._api.bootstrap.events.get(event_id)
1280
+ return None
1148
1281
 
1149
1282
  @property
1150
1283
  def last_smart_audio_detect_event(self) -> Event | None:
@@ -1228,19 +1361,20 @@ class Camera(ProtectMotionDeviceModel):
1228
1361
  """Get active smart detection types."""
1229
1362
  if self.use_global:
1230
1363
  return set(self.smart_detect_settings.object_types).intersection(
1231
- self.feature_flags.smart_detect_types,
1364
+ self.feature_flags.smart_detect_types
1232
1365
  )
1233
1366
  return set(self.smart_detect_settings.object_types)
1234
1367
 
1235
1368
  @property
1236
1369
  def active_audio_detect_types(self) -> set[SmartDetectAudioType]:
1237
1370
  """Get active audio detection types."""
1371
+ if not (enabled_audio_types := self.smart_detect_settings.audio_types):
1372
+ return set()
1238
1373
  if self.use_global:
1239
- return set(self.smart_detect_settings.audio_types or []).intersection(
1240
- self.feature_flags.smart_detect_audio_types or [],
1241
- )
1242
-
1243
- return set(self.smart_detect_settings.audio_types or [])
1374
+ if not (feature_audio_types := self.feature_flags.smart_detect_audio_types):
1375
+ return set()
1376
+ return set(feature_audio_types).intersection(enabled_audio_types)
1377
+ return set(enabled_audio_types)
1244
1378
 
1245
1379
  @property
1246
1380
  def is_motion_detection_on(self) -> bool:
@@ -1382,6 +1516,40 @@ class Camera(ProtectMotionDeviceModel):
1382
1516
  """Toggles vehicle smart detection. Requires camera to have smart detection"""
1383
1517
  return await self._set_object_detect(SmartDetectObjectType.VEHICLE, enabled)
1384
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
+
1385
1553
  # endregion
1386
1554
  # region License Plate
1387
1555
 
@@ -1846,18 +2014,14 @@ class Camera(ProtectMotionDeviceModel):
1846
2014
  # endregion
1847
2015
 
1848
2016
  @property
1849
- def is_ringing(self) -> bool:
1850
- if self._last_ring_timeout is None:
1851
- return False
1852
- return utc_now() < self._last_ring_timeout
2017
+ def chime_type(self) -> ChimeType:
2018
+ return _chime_type_from_total_seconds(
2019
+ timedelta_total_seconds(self.chime_duration)
2020
+ )
1853
2021
 
1854
2022
  @property
1855
- def chime_type(self) -> ChimeType:
1856
- if self.chime_duration.total_seconds() == 0.3:
1857
- return ChimeType.MECHANICAL
1858
- if self.chime_duration.total_seconds() > 0.3:
1859
- return ChimeType.DIGITAL
1860
- return ChimeType.NONE
2023
+ def chime_duration_seconds(self) -> float:
2024
+ return timedelta_total_seconds(self.chime_duration)
1861
2025
 
1862
2026
  @property
1863
2027
  def is_digital_chime(self) -> bool:
@@ -1935,10 +2099,6 @@ class Camera(ProtectMotionDeviceModel):
1935
2099
 
1936
2100
  return False
1937
2101
 
1938
- def set_ring_timeout(self) -> None:
1939
- self._last_ring_timeout = utc_now() + EVENT_PING_INTERVAL
1940
- self._event_callback_ping()
1941
-
1942
2102
  def get_privacy_zone(self) -> tuple[int | None, CameraZone | None]:
1943
2103
  for index, zone in enumerate(self.privacy_zones):
1944
2104
  if zone.name == PRIVACY_ZONE_NAME:
@@ -1971,13 +2131,19 @@ class Camera(ProtectMotionDeviceModel):
1971
2131
 
1972
2132
  Datetime of screenshot is approximate. It may be +/- a few seconds.
1973
2133
  """
1974
- if not self._api.bootstrap.auth_user.can(
1975
- ModelType.CAMERA,
1976
- PermissionNode.READ_MEDIA,
1977
- self,
1978
- ):
2134
+ # Use READ_LIVE if dt is None, otherwise READ_MEDIA
2135
+ auth_user = self._api.bootstrap.auth_user
2136
+ if dt is None:
2137
+ if not (
2138
+ auth_user.can(ModelType.CAMERA, PermissionNode.READ_LIVE, self)
2139
+ or auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self)
2140
+ ):
2141
+ raise NotAuthorized(
2142
+ f"Do not have permission to read live or media for camera: {self.id}"
2143
+ )
2144
+ elif not auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self):
1979
2145
  raise NotAuthorized(
1980
- f"Do not have permission to read media for camera: {self.id}",
2146
+ f"Do not have permission to read media for camera: {self.id}"
1981
2147
  )
1982
2148
 
1983
2149
  if height is None and width is None and self.high_camera_channel is not None:
@@ -1985,6 +2151,43 @@ class Camera(ProtectMotionDeviceModel):
1985
2151
 
1986
2152
  return await self._api.get_camera_snapshot(self.id, width, height, dt=dt)
1987
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
+
1988
2191
  async def get_package_snapshot(
1989
2192
  self,
1990
2193
  width: int | None = None,
@@ -1999,13 +2202,19 @@ class Camera(ProtectMotionDeviceModel):
1999
2202
  if not self.feature_flags.has_package_camera:
2000
2203
  raise BadRequest("Device does not have package camera")
2001
2204
 
2002
- if not self._api.bootstrap.auth_user.can(
2003
- ModelType.CAMERA,
2004
- PermissionNode.READ_MEDIA,
2005
- self,
2006
- ):
2205
+ auth_user = self._api.bootstrap.auth_user
2206
+ # Use READ_LIVE if dt is None, otherwise READ_MEDIA
2207
+ if dt is None:
2208
+ if not (
2209
+ auth_user.can(ModelType.CAMERA, PermissionNode.READ_LIVE, self)
2210
+ or auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self)
2211
+ ):
2212
+ raise NotAuthorized(
2213
+ f"Do not have permission to read live or media for camera: {self.id}"
2214
+ )
2215
+ elif not auth_user.can(ModelType.CAMERA, PermissionNode.READ_MEDIA, self):
2007
2216
  raise NotAuthorized(
2008
- f"Do not have permission to read media for camera: {self.id}",
2217
+ f"Do not have permission to read media for camera: {self.id}"
2009
2218
  )
2010
2219
 
2011
2220
  if height is None and width is None and self.package_camera_channel is not None:
@@ -2111,7 +2320,9 @@ class Camera(ProtectMotionDeviceModel):
2111
2320
 
2112
2321
  def callback() -> None:
2113
2322
  self.led_settings.is_enabled = enabled
2114
- 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
2115
2326
 
2116
2327
  await self.queue_update(callback)
2117
2328
 
@@ -2201,7 +2412,17 @@ class Camera(ProtectMotionDeviceModel):
2201
2412
  await self.queue_update(callback)
2202
2413
 
2203
2414
  async def set_speaker_volume(self, level: int) -> None:
2204
- """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"""
2205
2426
  if not self.feature_flags.has_speaker:
2206
2427
  raise BadRequest("Camera does not have speaker")
2207
2428
 
@@ -2210,6 +2431,16 @@ class Camera(ProtectMotionDeviceModel):
2210
2431
 
2211
2432
  await self.queue_update(callback)
2212
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
+
2213
2444
  async def set_chime_type(self, chime_type: ChimeType) -> None:
2214
2445
  """Sets chime type for doorbell. Requires camera to be a doorbell"""
2215
2446
  await self.set_chime_duration(timedelta(milliseconds=chime_type.value))
@@ -2716,8 +2947,8 @@ class SensorThresholdSettings(SensorSettingsBase):
2716
2947
  margin: float # read only
2717
2948
  # "safe" thresholds for alerting
2718
2949
  # anything below/above will trigger alert
2719
- low_threshold: float | None
2720
- high_threshold: float | None
2950
+ low_threshold: float | None = None
2951
+ high_threshold: float | None = None
2721
2952
 
2722
2953
 
2723
2954
  class SensorSensitivitySettings(SensorSettingsBase):
@@ -2725,12 +2956,12 @@ class SensorSensitivitySettings(SensorSettingsBase):
2725
2956
 
2726
2957
 
2727
2958
  class SensorBatteryStatus(ProtectBaseObject):
2728
- percentage: PercentInt | None
2959
+ percentage: PercentInt | None = None
2729
2960
  is_low: bool
2730
2961
 
2731
2962
 
2732
2963
  class SensorStat(ProtectBaseObject):
2733
- value: float | None
2964
+ value: float | None = None
2734
2965
  status: SensorStatusType
2735
2966
 
2736
2967
 
@@ -2742,20 +2973,20 @@ class SensorStats(ProtectBaseObject):
2742
2973
 
2743
2974
  class Sensor(ProtectAdoptableDeviceModel):
2744
2975
  alarm_settings: SensorSettingsBase
2745
- alarm_triggered_at: datetime | None
2976
+ alarm_triggered_at: datetime | None = None
2746
2977
  battery_status: SensorBatteryStatus
2747
- camera_id: str | None
2978
+ camera_id: str | None = None
2748
2979
  humidity_settings: SensorThresholdSettings
2749
2980
  is_motion_detected: bool
2750
2981
  is_opened: bool
2751
- leak_detected_at: datetime | None
2752
- led_settings: SensorSettingsBase
2982
+ leak_detected_at: datetime | None = None
2983
+ led_settings: LEDSettings
2753
2984
  light_settings: SensorThresholdSettings
2754
- motion_detected_at: datetime | None
2985
+ motion_detected_at: datetime | None = None
2755
2986
  motion_settings: SensorSensitivitySettings
2756
- open_status_changed_at: datetime | None
2987
+ open_status_changed_at: datetime | None = None
2757
2988
  stats: SensorStats
2758
- tampering_detected_at: datetime | None
2989
+ tampering_detected_at: datetime | None = None
2759
2990
  temperature_settings: SensorThresholdSettings
2760
2991
  mount_type: MountType
2761
2992
 
@@ -2794,15 +3025,16 @@ class Sensor(ProtectAdoptableDeviceModel):
2794
3025
  exclude: set[str] | None = None,
2795
3026
  ) -> dict[str, Any]:
2796
3027
  data = super().unifi_dict(data=data, exclude=exclude)
2797
- for key in (
2798
- "lastMotionEventId",
2799
- "lastContactEventId",
2800
- "lastValueEventId",
2801
- "lastAlarmEventId",
2802
- "extremeValueDetectedAt",
2803
- ):
2804
- if key in data:
2805
- del data[key]
3028
+ pop_dict_tuple(
3029
+ data,
3030
+ (
3031
+ "lastMotionEventId",
3032
+ "lastContactEventId",
3033
+ "lastValueEventId",
3034
+ "lastAlarmEventId",
3035
+ "extremeValueDetectedAt",
3036
+ ),
3037
+ )
2806
3038
  return data
2807
3039
 
2808
3040
  @property
@@ -3048,13 +3280,13 @@ class Sensor(ProtectAdoptableDeviceModel):
3048
3280
 
3049
3281
 
3050
3282
  class Doorlock(ProtectAdoptableDeviceModel):
3051
- credentials: str | None
3283
+ credentials: str | None = None
3052
3284
  lock_status: LockStatusType
3053
3285
  enable_homekit: bool
3054
3286
  auto_close_time: timedelta
3055
3287
  led_settings: SensorSettingsBase
3056
3288
  battery_status: SensorBatteryStatus
3057
- camera_id: str | None
3289
+ camera_id: str | None = None
3058
3290
  has_homekit: bool
3059
3291
  private_token: str
3060
3292
 
@@ -3191,7 +3423,7 @@ class ChimeTrack(ProtectBaseObject):
3191
3423
  class Chime(ProtectAdoptableDeviceModel):
3192
3424
  volume: PercentInt
3193
3425
  is_probing_for_wifi: bool
3194
- last_ring: datetime | None
3426
+ last_ring: datetime | None = None
3195
3427
  is_wireless_uplink_enabled: bool
3196
3428
  camera_ids: list[str]
3197
3429
  # requires 2.6.17+
@@ -3326,3 +3558,16 @@ class Chime(ProtectAdoptableDeviceModel):
3326
3558
  raise BadRequest("Camera %s is not paired with chime", camera.id)
3327
3559
 
3328
3560
  await self.queue_update(callback)
3561
+
3562
+
3563
+ class AiPort(Camera):
3564
+ paired_cameras: list[str]
3565
+
3566
+
3567
+ class Ringtone(ProtectBaseObject):
3568
+ id: str
3569
+ name: str
3570
+ size: int
3571
+ is_default: bool
3572
+ nvr_mac: str
3573
+ model_key: str