uiprotect 0.1.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.

Potentially problematic release.


This version of uiprotect might be problematic. Click here for more details.

@@ -0,0 +1,3384 @@
1
+ """UniFi Protect Data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import warnings
8
+ from collections.abc import Iterable
9
+ from datetime import datetime, timedelta
10
+ from functools import cache
11
+ from ipaddress import IPv4Address
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, Literal, cast
14
+
15
+ from pydantic.v1.fields import PrivateAttr
16
+
17
+ from uiprotect.data.base import (
18
+ EVENT_PING_INTERVAL,
19
+ ProtectAdoptableDeviceModel,
20
+ ProtectBaseObject,
21
+ ProtectMotionDeviceModel,
22
+ )
23
+ from uiprotect.data.types import (
24
+ DEFAULT,
25
+ DEFAULT_TYPE,
26
+ AudioCodecs,
27
+ AudioStyle,
28
+ AutoExposureMode,
29
+ ChimeType,
30
+ Color,
31
+ DoorbellMessageType,
32
+ FocusMode,
33
+ GeofencingSetting,
34
+ HDRMode,
35
+ ICRCustomValue,
36
+ ICRLuxValue,
37
+ ICRSensitivity,
38
+ IRLEDMode,
39
+ IteratorCallback,
40
+ LEDLevel,
41
+ LensType,
42
+ LightModeEnableType,
43
+ LightModeType,
44
+ LockStatusType,
45
+ LowMedHigh,
46
+ ModelType,
47
+ MotionAlgorithm,
48
+ MountPosition,
49
+ MountType,
50
+ Percent,
51
+ PercentInt,
52
+ PermissionNode,
53
+ ProgressCallback,
54
+ PTZPosition,
55
+ PTZPreset,
56
+ RecordingMode,
57
+ RepeatTimes,
58
+ SensorStatusType,
59
+ SmartDetectAudioType,
60
+ SmartDetectObjectType,
61
+ TwoByteInt,
62
+ VideoMode,
63
+ WDRLevel,
64
+ )
65
+ from uiprotect.data.user import User
66
+ from uiprotect.exceptions import BadRequest, NotAuthorized, StreamError
67
+ from uiprotect.stream import TalkbackStream
68
+ from uiprotect.utils import (
69
+ clamp_value,
70
+ convert_smart_audio_types,
71
+ convert_smart_types,
72
+ convert_video_modes,
73
+ from_js_time,
74
+ process_datetime,
75
+ serialize_point,
76
+ to_js_time,
77
+ utc_now,
78
+ )
79
+
80
+ if TYPE_CHECKING:
81
+ from uiprotect.data.nvr import Event, Liveview
82
+
83
+ 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
+ ]
96
+
97
+ _LOGGER = logging.getLogger(__name__)
98
+
99
+
100
+ class LightDeviceSettings(ProtectBaseObject):
101
+ # Status LED
102
+ is_indicator_enabled: bool
103
+ # Brightness
104
+ led_level: LEDLevel
105
+ lux_sensitivity: LowMedHigh
106
+ pir_duration: timedelta
107
+ pir_sensitivity: PercentInt
108
+
109
+ @classmethod
110
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
111
+ if "pirDuration" in data and not isinstance(data["pirDuration"], timedelta):
112
+ data["pirDuration"] = timedelta(milliseconds=data["pirDuration"])
113
+
114
+ return super().unifi_dict_to_dict(data)
115
+
116
+
117
+ class LightOnSettings(ProtectBaseObject):
118
+ # Manual toggle in UI
119
+ is_led_force_on: bool
120
+
121
+
122
+ class LightModeSettings(ProtectBaseObject):
123
+ # main "Lighting" settings
124
+ mode: LightModeType
125
+ enable_at: LightModeEnableType
126
+
127
+
128
+ class Light(ProtectMotionDeviceModel):
129
+ is_pir_motion_detected: bool
130
+ is_light_on: bool
131
+ is_locating: bool
132
+ light_device_settings: LightDeviceSettings
133
+ light_on_settings: LightOnSettings
134
+ light_mode_settings: LightModeSettings
135
+ camera_id: str | None
136
+ is_camera_paired: bool
137
+
138
+ @classmethod
139
+ @cache
140
+ def _get_unifi_remaps(cls) -> dict[str, str]:
141
+ return {**super()._get_unifi_remaps(), "camera": "cameraId"}
142
+
143
+ @classmethod
144
+ @cache
145
+ def _get_read_only_fields(cls) -> set[str]:
146
+ return super()._get_read_only_fields() | {
147
+ "isPirMotionDetected",
148
+ "isLightOn",
149
+ "isLocating",
150
+ }
151
+
152
+ @property
153
+ def camera(self) -> Camera | None:
154
+ """Paired Camera will always be none if no camera is paired"""
155
+ if self.camera_id is None:
156
+ return None
157
+
158
+ return self.api.bootstrap.cameras[self.camera_id]
159
+
160
+ async def set_paired_camera(self, camera: Camera | None) -> None:
161
+ """Sets the camera paired with the light"""
162
+ async with self._update_lock:
163
+ await asyncio.sleep(
164
+ 0,
165
+ ) # yield to the event loop once we have the lock to process any pending updates
166
+ data_before_changes = self.dict_with_excludes()
167
+ if camera is None:
168
+ self.camera_id = None
169
+ else:
170
+ self.camera_id = camera.id
171
+ await self.save_device(data_before_changes, force_emit=True)
172
+
173
+ async def set_status_light(self, enabled: bool) -> None:
174
+ """Sets the status indicator light for the light"""
175
+
176
+ def callback() -> None:
177
+ self.light_device_settings.is_indicator_enabled = enabled
178
+
179
+ await self.queue_update(callback)
180
+
181
+ async def set_led_level(self, led_level: int) -> None:
182
+ """Sets the LED level for the light"""
183
+
184
+ def callback() -> None:
185
+ self.light_device_settings.led_level = LEDLevel(led_level)
186
+
187
+ await self.queue_update(callback)
188
+
189
+ async def set_light(self, enabled: bool, led_level: int | None = None) -> None:
190
+ """Force turns on/off the light"""
191
+
192
+ def callback() -> None:
193
+ self.light_on_settings.is_led_force_on = enabled
194
+ if led_level is not None:
195
+ self.light_device_settings.led_level = LEDLevel(led_level)
196
+
197
+ await self.queue_update(callback)
198
+
199
+ async def set_sensitivity(self, sensitivity: int) -> None:
200
+ """Sets motion sensitivity"""
201
+
202
+ def callback() -> None:
203
+ self.light_device_settings.pir_sensitivity = PercentInt(sensitivity)
204
+
205
+ await self.queue_update(callback)
206
+
207
+ async def set_duration(self, duration: timedelta) -> None:
208
+ """Sets motion sensitivity"""
209
+ if duration.total_seconds() < 15 or duration.total_seconds() > 900:
210
+ raise BadRequest("Duration outside of 15s to 900s range")
211
+
212
+ def callback() -> None:
213
+ self.light_device_settings.pir_duration = duration
214
+
215
+ await self.queue_update(callback)
216
+
217
+ async def set_light_settings(
218
+ self,
219
+ mode: LightModeType,
220
+ enable_at: LightModeEnableType | None = None,
221
+ duration: timedelta | None = None,
222
+ sensitivity: int | None = None,
223
+ ) -> None:
224
+ """
225
+ Updates various Light settings.
226
+
227
+ Args:
228
+ ----
229
+ mode: Light trigger mode
230
+ enable_at: Then the light automatically turns on by itself
231
+ duration: How long the light should remain on after motion, must be timedelta between 15s and 900s
232
+ sensitivity: PIR Motion sensitivity
233
+
234
+ """
235
+ if duration is not None and (
236
+ duration.total_seconds() < 15 or duration.total_seconds() > 900
237
+ ):
238
+ raise BadRequest("Duration outside of 15s to 900s range")
239
+
240
+ def callback() -> None:
241
+ self.light_mode_settings.mode = mode
242
+ if enable_at is not None:
243
+ self.light_mode_settings.enable_at = enable_at
244
+ if duration is not None:
245
+ self.light_device_settings.pir_duration = duration
246
+ if sensitivity is not None:
247
+ self.light_device_settings.pir_sensitivity = PercentInt(sensitivity)
248
+
249
+ await self.queue_update(callback)
250
+
251
+
252
+ class CameraChannel(ProtectBaseObject):
253
+ id: int # read only
254
+ video_id: str # read only
255
+ name: str # read only
256
+ enabled: bool # read only
257
+ is_rtsp_enabled: bool
258
+ rtsp_alias: str | None # read only
259
+ width: int
260
+ height: int
261
+ fps: int
262
+ bitrate: int
263
+ min_bitrate: int # read only
264
+ max_bitrate: int # read only
265
+ min_client_adaptive_bit_rate: int | None # read only
266
+ min_motion_adaptive_bit_rate: int | None # read only
267
+ fps_values: list[int] # read only
268
+ idr_interval: int
269
+ # 3.0.22+
270
+ auto_bitrate: bool | None = None
271
+ auto_fps: bool | None = None
272
+
273
+ _rtsp_url: str | None = PrivateAttr(None)
274
+ _rtsps_url: str | None = PrivateAttr(None)
275
+
276
+ @property
277
+ def rtsp_url(self) -> str | None:
278
+ if not self.is_rtsp_enabled or self.rtsp_alias is None:
279
+ return None
280
+
281
+ if self._rtsp_url is not None:
282
+ return self._rtsp_url
283
+ self._rtsp_url = f"rtsp://{self.api.connection_host}:{self.api.bootstrap.nvr.ports.rtsp}/{self.rtsp_alias}"
284
+ return self._rtsp_url
285
+
286
+ @property
287
+ def rtsps_url(self) -> str | None:
288
+ if not self.is_rtsp_enabled or self.rtsp_alias is None:
289
+ return None
290
+
291
+ if self._rtsps_url is not None:
292
+ return self._rtsps_url
293
+ self._rtsps_url = f"rtsps://{self.api.connection_host}:{self.api.bootstrap.nvr.ports.rtsps}/{self.rtsp_alias}?enableSrtp"
294
+ return self._rtsps_url
295
+
296
+ @property
297
+ def is_package(self) -> bool:
298
+ return self.fps <= 2
299
+
300
+
301
+ class ISPSettings(ProtectBaseObject):
302
+ ae_mode: AutoExposureMode
303
+ ir_led_mode: IRLEDMode
304
+ ir_led_level: TwoByteInt
305
+ wdr: WDRLevel
306
+ icr_sensitivity: ICRSensitivity
307
+ brightness: int
308
+ contrast: int
309
+ hue: int
310
+ saturation: int
311
+ sharpness: int
312
+ denoise: int
313
+ is_flipped_vertical: bool
314
+ is_flipped_horizontal: bool
315
+ is_auto_rotate_enabled: bool
316
+ is_ldc_enabled: bool
317
+ is_3dnr_enabled: bool
318
+ is_external_ir_enabled: bool
319
+ is_aggressive_anti_flicker_enabled: bool
320
+ is_pause_motion_enabled: bool
321
+ d_zoom_center_x: int
322
+ d_zoom_center_y: int
323
+ d_zoom_scale: int
324
+ d_zoom_stream_id: int
325
+ focus_mode: FocusMode | None = None
326
+ focus_position: int
327
+ touch_focus_x: int | None
328
+ touch_focus_y: int | None
329
+ zoom_position: PercentInt
330
+ mount_position: MountPosition | None = None
331
+ # requires 2.8.14+
332
+ is_color_night_vision_enabled: bool | None = None
333
+ # 3.0.22+
334
+ hdr_mode: HDRMode | None = None
335
+ icr_custom_value: ICRCustomValue | None = None
336
+ icr_switch_mode: str | None = None
337
+ spotlight_duration: int | None = None
338
+
339
+ def unifi_dict(
340
+ self,
341
+ data: dict[str, Any] | None = None,
342
+ exclude: set[str] | None = None,
343
+ ) -> dict[str, Any]:
344
+ data = super().unifi_dict(data=data, exclude=exclude)
345
+
346
+ if "focusMode" in data and data["focusMode"] is None:
347
+ del data["focusMode"]
348
+
349
+ return data
350
+
351
+
352
+ class OSDSettings(ProtectBaseObject):
353
+ # Overlay Information
354
+ is_name_enabled: bool
355
+ is_date_enabled: bool
356
+ is_logo_enabled: bool
357
+ is_debug_enabled: bool
358
+
359
+
360
+ class LEDSettings(ProtectBaseObject):
361
+ # Status Light
362
+ is_enabled: bool
363
+ blink_rate: int # in milliseconds betweeen blinks, 0 = solid
364
+
365
+
366
+ class SpeakerSettings(ProtectBaseObject):
367
+ is_enabled: bool
368
+ # Status Sounds
369
+ are_system_sounds_enabled: bool
370
+ volume: PercentInt
371
+
372
+
373
+ class RecordingSettings(ProtectBaseObject):
374
+ # Seconds to record before Motion
375
+ pre_padding: timedelta
376
+ # Seconds to record after Motion
377
+ post_padding: timedelta
378
+ # Seconds of Motion Needed
379
+ min_motion_event_trigger: timedelta
380
+ end_motion_event_delay: timedelta
381
+ suppress_illumination_surge: bool
382
+ # High Frame Rate Mode
383
+ mode: RecordingMode
384
+ geofencing: GeofencingSetting
385
+ motion_algorithm: MotionAlgorithm
386
+ enable_motion_detection: bool | None = None
387
+ use_new_motion_algorithm: bool
388
+ # requires 2.9.20+
389
+ in_schedule_mode: str | None = None
390
+ out_schedule_mode: str | None = None
391
+ # 2.11.13+
392
+ retention_duration: datetime | None = None
393
+ smart_detect_post_padding: timedelta | None = None
394
+ smart_detect_pre_padding: timedelta | None = None
395
+
396
+ @classmethod
397
+ @cache
398
+ def _get_unifi_remaps(cls) -> dict[str, str]:
399
+ return {
400
+ **super()._get_unifi_remaps(),
401
+ "retentionDurationMs": "retentionDuration",
402
+ }
403
+
404
+ @classmethod
405
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
406
+ if "prePaddingSecs" in data:
407
+ data["prePadding"] = timedelta(seconds=data.pop("prePaddingSecs"))
408
+ if "postPaddingSecs" in data:
409
+ data["postPadding"] = timedelta(seconds=data.pop("postPaddingSecs"))
410
+ if "smartDetectPrePaddingSecs" in data:
411
+ data["smartDetectPrePadding"] = timedelta(
412
+ seconds=data.pop("smartDetectPrePaddingSecs"),
413
+ )
414
+ if "smartDetectPostPaddingSecs" in data:
415
+ data["smartDetectPostPadding"] = timedelta(
416
+ seconds=data.pop("smartDetectPostPaddingSecs"),
417
+ )
418
+ if "minMotionEventTrigger" in data and not isinstance(
419
+ data["minMotionEventTrigger"],
420
+ timedelta,
421
+ ):
422
+ data["minMotionEventTrigger"] = timedelta(
423
+ seconds=data["minMotionEventTrigger"],
424
+ )
425
+ if "endMotionEventDelay" in data and not isinstance(
426
+ data["endMotionEventDelay"],
427
+ timedelta,
428
+ ):
429
+ data["endMotionEventDelay"] = timedelta(seconds=data["endMotionEventDelay"])
430
+
431
+ return super().unifi_dict_to_dict(data)
432
+
433
+ def unifi_dict(
434
+ self,
435
+ data: dict[str, Any] | None = None,
436
+ exclude: set[str] | None = None,
437
+ ) -> dict[str, Any]:
438
+ data = super().unifi_dict(data=data, exclude=exclude)
439
+
440
+ if "prePadding" in data:
441
+ data["prePaddingSecs"] = data.pop("prePadding") // 1000
442
+ if "postPadding" in data:
443
+ data["postPaddingSecs"] = data.pop("postPadding") // 1000
444
+ if (
445
+ "smartDetectPrePadding" in data
446
+ and data["smartDetectPrePadding"] is not None
447
+ ):
448
+ data["smartDetectPrePaddingSecs"] = (
449
+ data.pop("smartDetectPrePadding") // 1000
450
+ )
451
+ if (
452
+ "smartDetectPostPadding" in data
453
+ and data["smartDetectPostPadding"] is not None
454
+ ):
455
+ data["smartDetectPostPaddingSecs"] = (
456
+ data.pop("smartDetectPostPadding") // 1000
457
+ )
458
+ if "minMotionEventTrigger" in data:
459
+ data["minMotionEventTrigger"] = data.pop("minMotionEventTrigger") // 1000
460
+ if "endMotionEventDelay" in data:
461
+ data["endMotionEventDelay"] = data.pop("endMotionEventDelay") // 1000
462
+
463
+ return data
464
+
465
+
466
+ class SmartDetectSettings(ProtectBaseObject):
467
+ object_types: list[SmartDetectObjectType]
468
+ audio_types: list[SmartDetectAudioType] | None = None
469
+ # requires 2.8.22+
470
+ auto_tracking_object_types: list[SmartDetectObjectType] | None = None
471
+
472
+ @classmethod
473
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
474
+ if "objectTypes" in data:
475
+ data["objectTypes"] = convert_smart_types(data.pop("objectTypes"))
476
+ if "audioTypes" in data:
477
+ data["audioTypes"] = convert_smart_audio_types(data.pop("audioTypes"))
478
+ if "autoTrackingObjectTypes" in data:
479
+ data["autoTrackingObjectTypes"] = convert_smart_types(
480
+ data.pop("autoTrackingObjectTypes"),
481
+ )
482
+
483
+ return super().unifi_dict_to_dict(data)
484
+
485
+
486
+ class LCDMessage(ProtectBaseObject):
487
+ type: DoorbellMessageType
488
+ text: str
489
+ reset_at: datetime | None = None
490
+
491
+ @classmethod
492
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
493
+ if "resetAt" in data:
494
+ data["resetAt"] = process_datetime(data, "resetAt")
495
+ if "text" in data:
496
+ # UniFi Protect bug: some times LCD messages can get into a bad state where message = DEFAULT MESSAGE, but no type
497
+ if "type" not in data:
498
+ data["type"] = DoorbellMessageType.CUSTOM_MESSAGE.value
499
+
500
+ data["text"] = cls._fix_text(data["text"], data["type"])
501
+
502
+ return super().unifi_dict_to_dict(data)
503
+
504
+ @classmethod
505
+ def _fix_text(cls, text: str, text_type: str | None) -> str:
506
+ if text_type is None:
507
+ text_type = cls.type.value
508
+
509
+ if text_type != DoorbellMessageType.CUSTOM_MESSAGE.value:
510
+ text = text_type.replace("_", " ")
511
+
512
+ return text
513
+
514
+ def unifi_dict(
515
+ self,
516
+ data: dict[str, Any] | None = None,
517
+ exclude: set[str] | None = None,
518
+ ) -> dict[str, Any]:
519
+ data = super().unifi_dict(data=data, exclude=exclude)
520
+
521
+ if "text" in data:
522
+ try:
523
+ msg_type = self.type.value
524
+ except AttributeError:
525
+ msg_type = None
526
+
527
+ data["text"] = self._fix_text(data["text"], data.get("type", msg_type))
528
+ if "resetAt" in data:
529
+ data["resetAt"] = to_js_time(data["resetAt"])
530
+
531
+ return data
532
+
533
+
534
+ class TalkbackSettings(ProtectBaseObject):
535
+ type_fmt: AudioCodecs
536
+ type_in: str
537
+ bind_addr: IPv4Address
538
+ bind_port: int
539
+ filter_addr: str | None # can be used to restrict sender address
540
+ filter_port: int | None # can be used to restrict sender port
541
+ channels: int # 1 or 2
542
+ sampling_rate: int # 8000, 11025, 22050, 44100, 48000
543
+ bits_per_sample: int
544
+ quality: PercentInt # only for vorbis
545
+
546
+
547
+ class WifiStats(ProtectBaseObject):
548
+ channel: int | None
549
+ frequency: int | None
550
+ link_speed_mbps: str | None
551
+ signal_quality: PercentInt
552
+ signal_strength: int
553
+
554
+
555
+ class VideoStats(ProtectBaseObject):
556
+ recording_start: datetime | None
557
+ recording_end: datetime | None
558
+ recording_start_lq: datetime | None
559
+ recording_end_lq: datetime | None
560
+ timelapse_start: datetime | None
561
+ timelapse_end: datetime | None
562
+ timelapse_start_lq: datetime | None
563
+ timelapse_end_lq: datetime | None
564
+
565
+ @classmethod
566
+ @cache
567
+ def _get_unifi_remaps(cls) -> dict[str, str]:
568
+ return {
569
+ **super()._get_unifi_remaps(),
570
+ "recordingStartLQ": "recordingStartLq",
571
+ "recordingEndLQ": "recordingEndLq",
572
+ "timelapseStartLQ": "timelapseStartLq",
573
+ "timelapseEndLQ": "timelapseEndLq",
574
+ }
575
+
576
+ @classmethod
577
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
578
+ if "recordingStart" in data:
579
+ data["recordingStart"] = process_datetime(data, "recordingStart")
580
+ if "recordingEnd" in data:
581
+ data["recordingEnd"] = process_datetime(data, "recordingEnd")
582
+ if "recordingStartLQ" in data:
583
+ data["recordingStartLQ"] = process_datetime(data, "recordingStartLQ")
584
+ if "recordingEndLQ" in data:
585
+ data["recordingEndLQ"] = process_datetime(data, "recordingEndLQ")
586
+ if "timelapseStart" in data:
587
+ data["timelapseStart"] = process_datetime(data, "timelapseStart")
588
+ if "timelapseEnd" in data:
589
+ data["timelapseEnd"] = process_datetime(data, "timelapseEnd")
590
+ if "timelapseStartLQ" in data:
591
+ data["timelapseStartLQ"] = process_datetime(data, "timelapseStartLQ")
592
+ if "timelapseEndLQ" in data:
593
+ data["timelapseEndLQ"] = process_datetime(data, "timelapseEndLQ")
594
+
595
+ return super().unifi_dict_to_dict(data)
596
+
597
+
598
+ class StorageStats(ProtectBaseObject):
599
+ used: int | None # bytes
600
+ rate: float | None # bytes / millisecond
601
+
602
+ @property
603
+ def rate_per_second(self) -> float | None:
604
+ if self.rate is None:
605
+ return None
606
+
607
+ return self.rate * 1000
608
+
609
+ @classmethod
610
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
611
+ if "rate" not in data:
612
+ data["rate"] = None
613
+
614
+ return super().unifi_dict_to_dict(data)
615
+
616
+ def unifi_dict(
617
+ self,
618
+ data: dict[str, Any] | None = None,
619
+ exclude: set[str] | None = None,
620
+ ) -> dict[str, Any]:
621
+ data = super().unifi_dict(data=data, exclude=exclude)
622
+
623
+ if "rate" in data and data["rate"] is None:
624
+ del data["rate"]
625
+
626
+ return data
627
+
628
+
629
+ class CameraStats(ProtectBaseObject):
630
+ rx_bytes: int
631
+ tx_bytes: int
632
+ wifi: WifiStats
633
+ video: VideoStats
634
+ storage: StorageStats | None
635
+ wifi_quality: PercentInt
636
+ wifi_strength: int
637
+
638
+ @classmethod
639
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
640
+ if "storage" in data and data["storage"] == {}:
641
+ del data["storage"]
642
+
643
+ return super().unifi_dict_to_dict(data)
644
+
645
+ def unifi_dict(
646
+ self,
647
+ data: dict[str, Any] | None = None,
648
+ exclude: set[str] | None = None,
649
+ ) -> dict[str, Any]:
650
+ data = super().unifi_dict(data=data, exclude=exclude)
651
+
652
+ if "storage" in data and data["storage"] is None:
653
+ data["storage"] = {}
654
+
655
+ return data
656
+
657
+
658
+ class CameraZone(ProtectBaseObject):
659
+ id: int
660
+ name: str
661
+ color: Color
662
+ points: list[tuple[Percent, Percent]]
663
+
664
+ @classmethod
665
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
666
+ data = super().unifi_dict_to_dict(data)
667
+ if "points" in data and isinstance(data["points"], Iterable):
668
+ data["points"] = [(p[0], p[1]) for p in data["points"]]
669
+
670
+ return data
671
+
672
+ def unifi_dict(
673
+ self,
674
+ data: dict[str, Any] | None = None,
675
+ exclude: set[str] | None = None,
676
+ ) -> dict[str, Any]:
677
+ data = super().unifi_dict(data=data, exclude=exclude)
678
+
679
+ if "points" in data:
680
+ data["points"] = [serialize_point(p) for p in data["points"]]
681
+
682
+ return data
683
+
684
+ @staticmethod
685
+ def create_privacy_zone(zone_id: int) -> CameraZone:
686
+ return CameraZone(
687
+ id=zone_id,
688
+ name=PRIVACY_ZONE_NAME,
689
+ color=Color("#85BCEC"),
690
+ points=[[0, 0], [1, 0], [1, 1], [0, 1]], # type: ignore[list-item]
691
+ )
692
+
693
+
694
+ class MotionZone(CameraZone):
695
+ sensitivity: PercentInt
696
+
697
+
698
+ class SmartMotionZone(MotionZone):
699
+ object_types: list[SmartDetectObjectType]
700
+
701
+ @classmethod
702
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
703
+ if "objectTypes" in data:
704
+ data["objectTypes"] = convert_smart_types(data.pop("objectTypes"))
705
+
706
+ return super().unifi_dict_to_dict(data)
707
+
708
+
709
+ class PrivacyMaskCapability(ProtectBaseObject):
710
+ max_masks: int | None
711
+ rectangle_only: bool
712
+
713
+
714
+ class HotplugExtender(ProtectBaseObject):
715
+ has_flash: bool | None = None
716
+ has_ir: bool | None = None
717
+ has_radar: bool | None = None
718
+ is_attached: bool | None = None
719
+ # 3.0.22+
720
+ flash_range: Any | None = None
721
+
722
+ @classmethod
723
+ @cache
724
+ def _get_unifi_remaps(cls) -> dict[str, str]:
725
+ return {**super()._get_unifi_remaps(), "hasIR": "hasIr"}
726
+
727
+
728
+ class Hotplug(ProtectBaseObject):
729
+ audio: bool | None = None
730
+ video: bool | None = None
731
+ extender: HotplugExtender | None = None
732
+ # 2.8.35+
733
+ standalone_adoption: bool | None = None
734
+
735
+
736
+ class PTZRangeSingle(ProtectBaseObject):
737
+ max: float | None
738
+ min: float | None
739
+ step: float | None
740
+
741
+
742
+ class PTZRange(ProtectBaseObject):
743
+ steps: PTZRangeSingle
744
+ degrees: PTZRangeSingle
745
+
746
+ def to_native_value(self, degree_value: float, is_relative: bool = False) -> float:
747
+ """Convert degree values to step values."""
748
+ if (
749
+ self.degrees.max is None
750
+ or self.degrees.min is None
751
+ or self.degrees.step is None
752
+ or self.steps.max is None
753
+ or self.steps.min is None
754
+ or self.steps.step is None
755
+ ):
756
+ raise BadRequest("degree to step conversion not supported.")
757
+
758
+ if not is_relative:
759
+ degree_value -= self.degrees.min
760
+
761
+ step_range = self.steps.max - self.steps.min
762
+ degree_range = self.degrees.max - self.degrees.min
763
+ ratio = step_range / degree_range
764
+
765
+ step_value = clamp_value(degree_value * ratio, self.steps.step)
766
+ if not is_relative:
767
+ step_value = self.steps.min + step_value
768
+
769
+ return step_value
770
+
771
+
772
+ class PTZZoomRange(PTZRange):
773
+ ratio: float
774
+
775
+ def to_native_value(self, zoom_value: float, is_relative: bool = False) -> float:
776
+ """Convert zoom values to step values."""
777
+ if self.steps.max is None or self.steps.min is None or self.steps.step is None:
778
+ raise BadRequest("step conversion not supported.")
779
+
780
+ step_range = self.steps.max - self.steps.min
781
+ # zoom levels start at 1
782
+ ratio = step_range / (self.ratio - 1)
783
+ if not is_relative:
784
+ zoom_value -= 1
785
+
786
+ step_value = clamp_value(zoom_value * ratio, self.steps.step)
787
+ if not is_relative:
788
+ step_value = self.steps.min + step_value
789
+
790
+ return step_value
791
+
792
+
793
+ class CameraFeatureFlags(ProtectBaseObject):
794
+ can_adjust_ir_led_level: bool
795
+ can_magic_zoom: bool
796
+ can_optical_zoom: bool
797
+ can_touch_focus: bool
798
+ has_accelerometer: bool
799
+ has_aec: bool
800
+ has_bluetooth: bool
801
+ has_chime: bool
802
+ has_external_ir: bool
803
+ has_icr_sensitivity: bool
804
+ has_ldc: bool
805
+ has_led_ir: bool
806
+ has_led_status: bool
807
+ has_line_in: bool
808
+ has_mic: bool
809
+ has_privacy_mask: bool
810
+ has_rtc: bool
811
+ has_sd_card: bool
812
+ has_speaker: bool
813
+ has_wifi: bool
814
+ has_hdr: bool
815
+ has_auto_icr_only: bool
816
+ video_modes: list[VideoMode]
817
+ video_mode_max_fps: list[int]
818
+ has_motion_zones: bool
819
+ has_lcd_screen: bool
820
+ smart_detect_types: list[SmartDetectObjectType]
821
+ motion_algorithms: list[MotionAlgorithm]
822
+ has_square_event_thumbnail: bool
823
+ has_package_camera: bool
824
+ privacy_mask_capability: PrivacyMaskCapability
825
+ has_smart_detect: bool
826
+ audio: list[str] = []
827
+ audio_codecs: list[AudioCodecs] = []
828
+ mount_positions: list[MountPosition] = []
829
+ has_infrared: bool | None = None
830
+ lens_type: LensType | None = None
831
+ hotplug: Hotplug | None = None
832
+ smart_detect_audio_types: list[SmartDetectAudioType] | None = None
833
+ # 2.7.18+
834
+ is_doorbell: bool
835
+ # 2.8.22+
836
+ lens_model: str | None = None
837
+ # 2.9.20+
838
+ has_color_lcd_screen: bool | None = None
839
+ has_line_crossing: bool | None = None
840
+ has_line_crossing_counting: bool | None = None
841
+ has_liveview_tracking: bool | None = None
842
+ # 2.10.10+
843
+ has_flash: bool | None = None
844
+ is_ptz: bool | None = None
845
+ # 2.11.13+
846
+ audio_style: list[AudioStyle] | None = None
847
+ has_vertical_flip: bool | None = None
848
+ # 3.0.22+
849
+ flash_range: Any | None = None
850
+
851
+ focus: PTZRange
852
+ pan: PTZRange
853
+ tilt: PTZRange
854
+ zoom: PTZZoomRange
855
+
856
+ @classmethod
857
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
858
+ if "smartDetectTypes" in data:
859
+ data["smartDetectTypes"] = convert_smart_types(data.pop("smartDetectTypes"))
860
+ if "smartDetectAudioTypes" in data:
861
+ data["smartDetectAudioTypes"] = convert_smart_audio_types(
862
+ data.pop("smartDetectAudioTypes"),
863
+ )
864
+ if "videoModes" in data:
865
+ data["videoModes"] = convert_video_modes(data.pop("videoModes"))
866
+
867
+ # backport support for `is_doorbell` to older versions of Protect
868
+ if "hasChime" in data and "isDoorbell" not in data:
869
+ data["isDoorbell"] = data["hasChime"]
870
+
871
+ return super().unifi_dict_to_dict(data)
872
+
873
+ @classmethod
874
+ @cache
875
+ def _get_unifi_remaps(cls) -> dict[str, str]:
876
+ return {**super()._get_unifi_remaps(), "hasAutoICROnly": "hasAutoIcrOnly"}
877
+
878
+ @property
879
+ def has_highfps(self) -> bool:
880
+ return VideoMode.HIGH_FPS in self.video_modes
881
+
882
+ @property
883
+ def has_wdr(self) -> bool:
884
+ return not self.has_hdr
885
+
886
+
887
+ class CameraLenses(ProtectBaseObject):
888
+ id: int
889
+ video: VideoStats
890
+
891
+
892
+ class CameraHomekitSettings(ProtectBaseObject):
893
+ microphone_muted: bool
894
+ speaker_muted: bool
895
+ stream_in_progress: bool
896
+ talkback_settings_active: bool
897
+
898
+
899
+ class CameraAudioSettings(ProtectBaseObject):
900
+ style: list[AudioStyle]
901
+
902
+
903
+ class Camera(ProtectMotionDeviceModel):
904
+ is_deleting: bool
905
+ # Microphone Sensitivity
906
+ mic_volume: PercentInt
907
+ is_mic_enabled: bool
908
+ is_recording: bool
909
+ is_motion_detected: bool
910
+ is_smart_detected: bool
911
+ phy_rate: float | None
912
+ hdr_mode: bool
913
+ # Recording Quality -> High Frame
914
+ video_mode: VideoMode
915
+ is_probing_for_wifi: bool
916
+ chime_duration: timedelta
917
+ last_ring: datetime | None
918
+ is_live_heatmap_enabled: bool
919
+ video_reconfiguration_in_progress: bool
920
+ channels: list[CameraChannel]
921
+ isp_settings: ISPSettings
922
+ talkback_settings: TalkbackSettings
923
+ osd_settings: OSDSettings
924
+ led_settings: LEDSettings
925
+ speaker_settings: SpeakerSettings
926
+ recording_settings: RecordingSettings
927
+ smart_detect_settings: SmartDetectSettings
928
+ motion_zones: list[MotionZone]
929
+ privacy_zones: list[CameraZone]
930
+ smart_detect_zones: list[SmartMotionZone]
931
+ stats: CameraStats
932
+ feature_flags: CameraFeatureFlags
933
+ lcd_message: LCDMessage | None
934
+ lenses: list[CameraLenses]
935
+ platform: str
936
+ has_speaker: bool
937
+ has_wifi: bool
938
+ audio_bitrate: int
939
+ can_manage: bool
940
+ is_managed: bool
941
+ voltage: float | None
942
+ # requires 1.21+
943
+ is_poor_network: bool | None
944
+ is_wireless_uplink_enabled: bool | None
945
+ # requires 2.6.13+
946
+ homekit_settings: CameraHomekitSettings | None = None
947
+ # requires 2.6.17+
948
+ ap_mgmt_ip: IPv4Address | None = None
949
+ # requires 2.7.5+
950
+ is_waterproof_case_attached: bool | None = None
951
+ last_disconnect: datetime | None = None
952
+ # requires 2.8.14+
953
+ is_2k: bool | None = None
954
+ is_4k: bool | None = None
955
+ use_global: bool | None = None
956
+ # requires 2.8.22+
957
+ user_configured_ap: bool | None = None
958
+ # requires 2.9.20+
959
+ has_recordings: bool | None = None
960
+ # requires 2.10.10+
961
+ is_ptz: bool | None = None
962
+ # requires 2.11.13+
963
+ audio_settings: CameraAudioSettings | None = None
964
+
965
+ # TODO: used for adopting
966
+ # apMac read only
967
+ # apRssi read only
968
+ # elementInfo read only
969
+
970
+ # TODO:
971
+ # lastPrivacyZonePositionId
972
+ # smartDetectLines
973
+ # streamSharing read only
974
+ # stopStreamLevel
975
+ # uplinkDevice
976
+ # recordingSchedulesV2
977
+
978
+ # not directly from UniFi
979
+ last_ring_event_id: str | None = None
980
+ last_smart_detect: datetime | None = None
981
+ last_smart_audio_detect: datetime | None = None
982
+ last_smart_detect_event_id: str | None = None
983
+ last_smart_audio_detect_event_id: str | None = None
984
+ last_smart_detects: dict[SmartDetectObjectType, datetime] = {}
985
+ last_smart_audio_detects: dict[SmartDetectAudioType, datetime] = {}
986
+ last_smart_detect_event_ids: dict[SmartDetectObjectType, str] = {}
987
+ last_smart_audio_detect_event_ids: dict[SmartDetectAudioType, str] = {}
988
+ talkback_stream: TalkbackStream | None = None
989
+ _last_ring_timeout: datetime | None = PrivateAttr(None)
990
+
991
+ @classmethod
992
+ @cache
993
+ def _get_unifi_remaps(cls) -> dict[str, str]:
994
+ return {**super()._get_unifi_remaps(), "is2K": "is2k", "is4K": "is4k"}
995
+
996
+ @classmethod
997
+ @cache
998
+ def _get_excluded_changed_fields(cls) -> set[str]:
999
+ return super()._get_excluded_changed_fields() | {
1000
+ "last_ring_event_id",
1001
+ "last_smart_detect",
1002
+ "last_smart_audio_detect",
1003
+ "last_smart_detect_event_id",
1004
+ "last_smart_audio_detect_event_id",
1005
+ "last_smart_detects",
1006
+ "last_smart_audio_detects",
1007
+ "last_smart_detect_event_ids",
1008
+ "last_smart_audio_detect_event_ids",
1009
+ "talkback_stream",
1010
+ }
1011
+
1012
+ @classmethod
1013
+ @cache
1014
+ def _get_read_only_fields(cls) -> set[str]:
1015
+ return super()._get_read_only_fields() | {
1016
+ "stats",
1017
+ "isDeleting",
1018
+ "isRecording",
1019
+ "isMotionDetected",
1020
+ "isSmartDetected",
1021
+ "phyRate",
1022
+ "isProbingForWifi",
1023
+ "lastRing",
1024
+ "isLiveHeatmapEnabled",
1025
+ "videoReconfigurationInProgress",
1026
+ "lenses",
1027
+ "isPoorNetwork",
1028
+ "featureFlags",
1029
+ }
1030
+
1031
+ @classmethod
1032
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
1033
+ # LCD messages comes back as empty dict {}
1034
+ if "lcdMessage" in data and len(data["lcdMessage"].keys()) == 0:
1035
+ del data["lcdMessage"]
1036
+ if "chimeDuration" in data and not isinstance(data["chimeDuration"], timedelta):
1037
+ data["chimeDuration"] = timedelta(milliseconds=data["chimeDuration"])
1038
+
1039
+ return super().unifi_dict_to_dict(data)
1040
+
1041
+ def unifi_dict(
1042
+ self,
1043
+ data: dict[str, Any] | None = None,
1044
+ exclude: set[str] | None = None,
1045
+ ) -> dict[str, Any]:
1046
+ if data is not None:
1047
+ if "motion_zones" in data:
1048
+ data["motion_zones"] = [
1049
+ MotionZone(**z).unifi_dict() for z in data["motion_zones"]
1050
+ ]
1051
+ if "privacy_zones" in data:
1052
+ data["privacy_zones"] = [
1053
+ CameraZone(**z).unifi_dict() for z in data["privacy_zones"]
1054
+ ]
1055
+ if "smart_detect_zones" in data:
1056
+ data["smart_detect_zones"] = [
1057
+ SmartMotionZone(**z).unifi_dict()
1058
+ for z in data["smart_detect_zones"]
1059
+ ]
1060
+
1061
+ data = super().unifi_dict(data=data, exclude=exclude)
1062
+
1063
+ if "lastRingEventId" in data:
1064
+ del data["lastRingEventId"]
1065
+ if "lastSmartDetect" in data:
1066
+ del data["lastSmartDetect"]
1067
+ if "lastSmartAudioDetect" in data:
1068
+ del data["lastSmartAudioDetect"]
1069
+ if "lastSmartDetectEventId" in data:
1070
+ del data["lastSmartDetectEventId"]
1071
+ if "lastSmartAudioDetectEventId" in data:
1072
+ del data["lastSmartAudioDetectEventId"]
1073
+ if "lastSmartDetects" in data:
1074
+ del data["lastSmartDetects"]
1075
+ if "lastSmartAudioDetects" in data:
1076
+ del data["lastSmartAudioDetects"]
1077
+ if "lastSmartDetectEventIds" in data:
1078
+ del data["lastSmartDetectEventIds"]
1079
+ if "lastSmartAudioDetectEventIds" in data:
1080
+ del data["lastSmartAudioDetectEventIds"]
1081
+ if "talkbackStream" in data:
1082
+ del data["talkbackStream"]
1083
+ if "lcdMessage" in data and data["lcdMessage"] is None:
1084
+ data["lcdMessage"] = {}
1085
+
1086
+ return data
1087
+
1088
+ def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]:
1089
+ updated = super().get_changed(data_before_changes)
1090
+
1091
+ if "lcd_message" in updated:
1092
+ lcd_message = updated["lcd_message"]
1093
+ # to "clear" LCD message, set reset_at to a time in the past
1094
+ if lcd_message is None:
1095
+ updated["lcd_message"] = {"reset_at": utc_now() - timedelta(seconds=10)}
1096
+ # otherwise, pass full LCD message to prevent issues
1097
+ elif self.lcd_message is not None:
1098
+ updated["lcd_message"] = self.lcd_message.dict()
1099
+
1100
+ # if reset_at is not passed in, it will default to reset in 1 minute
1101
+ if lcd_message is not None and "reset_at" not in lcd_message:
1102
+ if self.lcd_message is None:
1103
+ updated["lcd_message"]["reset_at"] = None
1104
+ else:
1105
+ updated["lcd_message"]["reset_at"] = self.lcd_message.reset_at
1106
+
1107
+ return updated
1108
+
1109
+ def update_from_dict(self, data: dict[str, Any]) -> Camera:
1110
+ # a message in the past is actually a singal to wipe the message
1111
+ reset_at = data.get("lcd_message", {}).get("reset_at")
1112
+ if reset_at is not None:
1113
+ reset_at = from_js_time(reset_at)
1114
+ if utc_now() > reset_at:
1115
+ data["lcd_message"] = None
1116
+
1117
+ return super().update_from_dict(data)
1118
+
1119
+ @property
1120
+ def last_ring_event(self) -> Event | None:
1121
+ if self.last_ring_event_id is None:
1122
+ return None
1123
+
1124
+ return self.api.bootstrap.events.get(self.last_ring_event_id)
1125
+
1126
+ @property
1127
+ def last_smart_detect_event(self) -> Event | None:
1128
+ """Get the last smart detect event id."""
1129
+ if self.last_smart_detect_event_id is None:
1130
+ return None
1131
+
1132
+ return self.api.bootstrap.events.get(self.last_smart_detect_event_id)
1133
+
1134
+ @property
1135
+ def hdr_mode_display(self) -> Literal["auto", "off", "always"]:
1136
+ """Get HDR mode similar to how Protect interface works."""
1137
+ if not self.hdr_mode:
1138
+ return "off"
1139
+ if self.isp_settings.hdr_mode == HDRMode.NORMAL:
1140
+ return "auto"
1141
+ return "always"
1142
+
1143
+ @property
1144
+ def icr_lux_display(self) -> int | None:
1145
+ """Get ICR Custom Lux value similar to how the Protect interface works."""
1146
+ if self.isp_settings.icr_custom_value is None:
1147
+ return None
1148
+
1149
+ return LUX_MAPPING_VALUES[10 - self.isp_settings.icr_custom_value]
1150
+
1151
+ def get_last_smart_detect_event(
1152
+ self,
1153
+ smart_type: SmartDetectObjectType,
1154
+ ) -> Event | None:
1155
+ """Get the last smart detect event for given type."""
1156
+ event_id = self.last_smart_detect_event_ids.get(smart_type)
1157
+ if event_id is None:
1158
+ return None
1159
+
1160
+ return self.api.bootstrap.events.get(event_id)
1161
+
1162
+ @property
1163
+ def last_smart_audio_detect_event(self) -> Event | None:
1164
+ """Get the last smart audio detect event id."""
1165
+ if self.last_smart_audio_detect_event_id is None:
1166
+ return None
1167
+
1168
+ return self.api.bootstrap.events.get(self.last_smart_audio_detect_event_id)
1169
+
1170
+ def get_last_smart_audio_detect_event(
1171
+ self,
1172
+ smart_type: SmartDetectAudioType,
1173
+ ) -> Event | None:
1174
+ """Get the last smart audio detect event for given type."""
1175
+ event_id = self.last_smart_audio_detect_event_ids.get(smart_type)
1176
+ if event_id is None:
1177
+ return None
1178
+
1179
+ return self.api.bootstrap.events.get(event_id)
1180
+
1181
+ @property
1182
+ def timelapse_url(self) -> str:
1183
+ return f"{self.api.base_url}/protect/timelapse/{self.id}"
1184
+
1185
+ @property
1186
+ def is_privacy_on(self) -> bool:
1187
+ index, _ = self.get_privacy_zone()
1188
+ return index is not None
1189
+
1190
+ @property
1191
+ def is_recording_enabled(self) -> bool:
1192
+ """
1193
+ Is recording footage/events from the camera enabled?
1194
+
1195
+ If recording is not enabled, cameras will not produce any footage, thumbnails,
1196
+ motion/smart detection events.
1197
+ """
1198
+ if self.use_global:
1199
+ return self.api.bootstrap.nvr.is_global_recording_enabled
1200
+
1201
+ return self.recording_settings.mode is not RecordingMode.NEVER
1202
+
1203
+ @property
1204
+ def can_manage_recording_setting(self) -> bool:
1205
+ """Can this camera manage its own recording settings?"""
1206
+ return not self.use_global
1207
+
1208
+ @property
1209
+ def is_smart_detections_allowed(self) -> bool:
1210
+ """Is smart detections allowed for this camera?"""
1211
+ return (
1212
+ self.is_recording_enabled
1213
+ and self.api.bootstrap.nvr.is_smart_detections_enabled
1214
+ )
1215
+
1216
+ @property
1217
+ def can_manage_smart_detections(self) -> bool:
1218
+ """Can this camera manage its own recording settings?"""
1219
+ return (not self.use_global) and self.is_smart_detections_allowed
1220
+
1221
+ @property
1222
+ def is_license_plate_detections_allowed(self) -> bool:
1223
+ """Is license plate detections allowed for this camera?"""
1224
+ return (
1225
+ self.is_recording_enabled
1226
+ and self.api.bootstrap.nvr.is_license_plate_detections_enabled
1227
+ )
1228
+
1229
+ @property
1230
+ def can_manage_license_plate_detections(self) -> bool:
1231
+ """Can this camera manage its own license plate settings?"""
1232
+ return (not self.use_global) and self.is_license_plate_detections_allowed
1233
+
1234
+ @property
1235
+ def is_face_detections_allowed(self) -> bool:
1236
+ """Is face detections allowed for this camera?"""
1237
+ return (
1238
+ self.is_recording_enabled
1239
+ and self.api.bootstrap.nvr.is_face_detections_enabled
1240
+ )
1241
+
1242
+ @property
1243
+ def can_manage_face_detections(self) -> bool:
1244
+ """Can this camera manage its own face detection settings?"""
1245
+ return (not self.use_global) and self.is_face_detections_allowed
1246
+
1247
+ @property
1248
+ def active_recording_settings(self) -> RecordingSettings:
1249
+ """Get active recording settings."""
1250
+ if self.use_global and self.api.bootstrap.nvr.global_camera_settings:
1251
+ return self.api.bootstrap.nvr.global_camera_settings.recording_settings
1252
+
1253
+ return self.recording_settings
1254
+
1255
+ @property
1256
+ def active_smart_detect_settings(self) -> SmartDetectSettings:
1257
+ """Get active smart detection settings."""
1258
+ if self.use_global and self.api.bootstrap.nvr.global_camera_settings:
1259
+ return self.api.bootstrap.nvr.global_camera_settings.smart_detect_settings
1260
+
1261
+ return self.smart_detect_settings
1262
+
1263
+ @property
1264
+ def active_smart_detect_types(self) -> set[SmartDetectObjectType]:
1265
+ """Get active smart detection types."""
1266
+ if self.use_global:
1267
+ return set(self.smart_detect_settings.object_types).intersection(
1268
+ set(self.feature_flags.smart_detect_types),
1269
+ )
1270
+
1271
+ return set(self.smart_detect_settings.object_types)
1272
+
1273
+ @property
1274
+ def active_audio_detect_types(self) -> set[SmartDetectAudioType]:
1275
+ """Get active audio detection types."""
1276
+ if self.use_global:
1277
+ return set(self.smart_detect_settings.audio_types or []).intersection(
1278
+ set(self.feature_flags.smart_detect_audio_types or []),
1279
+ )
1280
+
1281
+ return set(self.smart_detect_settings.audio_types or [])
1282
+
1283
+ @property
1284
+ def is_motion_detection_on(self) -> bool:
1285
+ """Is Motion Detection available and enabled (camera will produce motion events)?"""
1286
+ return (
1287
+ self.is_recording_enabled
1288
+ and self.active_recording_settings.enable_motion_detection is not False
1289
+ and self.can_manage_recording_setting
1290
+ )
1291
+
1292
+ @property
1293
+ def is_motion_currently_detected(self) -> bool:
1294
+ """Is motion currently being detected"""
1295
+ return (
1296
+ self.is_motion_detection_on
1297
+ and self.is_motion_detected
1298
+ and self.last_motion_event is not None
1299
+ and self.last_motion_event.end is None
1300
+ )
1301
+
1302
+ async def set_motion_detection(self, enabled: bool) -> None:
1303
+ """Sets motion detection on camera"""
1304
+ if self.use_global:
1305
+ raise BadRequest("Camera is using global recording settings.")
1306
+
1307
+ def callback() -> None:
1308
+ self.recording_settings.enable_motion_detection = enabled
1309
+
1310
+ await self.queue_update(callback)
1311
+
1312
+ async def set_use_global(self, enabled: bool) -> None:
1313
+ """Sets if camera should use global recording settings or not."""
1314
+
1315
+ def callback() -> None:
1316
+ self.use_global = enabled
1317
+
1318
+ await self.queue_update(callback)
1319
+
1320
+ # region Object Smart Detections
1321
+
1322
+ def _is_smart_enabled(self, smart_type: SmartDetectObjectType) -> bool:
1323
+ return (
1324
+ self.is_recording_enabled
1325
+ and smart_type in self.active_smart_detect_types
1326
+ and self.can_manage_smart_detections
1327
+ )
1328
+
1329
+ def _is_smart_detected(self, smart_type: SmartDetectObjectType) -> bool:
1330
+ event = self.get_last_smart_detect_event(smart_type)
1331
+ return (
1332
+ self._is_smart_enabled(smart_type)
1333
+ and self.is_smart_detected
1334
+ and event is not None
1335
+ and event.end is None
1336
+ and smart_type in event.smart_detect_types
1337
+ )
1338
+
1339
+ @property
1340
+ def is_smart_currently_detected(self) -> bool:
1341
+ """Is smart detection currently being detected"""
1342
+ return (
1343
+ self.is_recording_enabled
1344
+ and bool(self.active_smart_detect_types)
1345
+ and self.is_smart_detected
1346
+ and self.last_smart_detect_event is not None
1347
+ and self.last_smart_detect_event.end is None
1348
+ )
1349
+
1350
+ # region Person
1351
+
1352
+ @property
1353
+ def can_detect_person(self) -> bool:
1354
+ return SmartDetectObjectType.PERSON in self.feature_flags.smart_detect_types
1355
+
1356
+ @property
1357
+ def is_person_detection_on(self) -> bool:
1358
+ """
1359
+ Is Person Detection available and enabled (camera will produce person smart
1360
+ detection events)?
1361
+ """
1362
+ return self._is_smart_enabled(SmartDetectObjectType.PERSON)
1363
+
1364
+ @property
1365
+ def last_person_detect_event(self) -> Event | None:
1366
+ """Get the last person smart detection event."""
1367
+ return self.get_last_smart_detect_event(SmartDetectObjectType.PERSON)
1368
+
1369
+ @property
1370
+ def last_person_detect(self) -> datetime | None:
1371
+ """Get the last person smart detection event."""
1372
+ return self.last_smart_detects.get(SmartDetectObjectType.PERSON)
1373
+
1374
+ @property
1375
+ def is_person_currently_detected(self) -> bool:
1376
+ """Is person currently being detected"""
1377
+ return self._is_smart_detected(SmartDetectObjectType.PERSON)
1378
+
1379
+ async def set_person_detection(self, enabled: bool) -> None:
1380
+ """Toggles person smart detection. Requires camera to have smart detection"""
1381
+ return await self._set_object_detect(SmartDetectObjectType.PERSON, enabled)
1382
+
1383
+ @property
1384
+ def is_person_tracking_enabled(self) -> bool:
1385
+ """Is person tracking enabled"""
1386
+ return (
1387
+ self.active_smart_detect_settings.auto_tracking_object_types is not None
1388
+ and SmartDetectObjectType.PERSON
1389
+ in self.active_smart_detect_settings.auto_tracking_object_types
1390
+ )
1391
+
1392
+ # endregion
1393
+ # region Vehicle
1394
+
1395
+ @property
1396
+ def can_detect_vehicle(self) -> bool:
1397
+ return SmartDetectObjectType.VEHICLE in self.feature_flags.smart_detect_types
1398
+
1399
+ @property
1400
+ def is_vehicle_detection_on(self) -> bool:
1401
+ """
1402
+ Is Vehicle Detection available and enabled (camera will produce vehicle smart
1403
+ detection events)?
1404
+ """
1405
+ return self._is_smart_enabled(SmartDetectObjectType.VEHICLE)
1406
+
1407
+ @property
1408
+ def last_vehicle_detect_event(self) -> Event | None:
1409
+ """Get the last vehicle smart detection event."""
1410
+ return self.get_last_smart_detect_event(SmartDetectObjectType.VEHICLE)
1411
+
1412
+ @property
1413
+ def last_vehicle_detect(self) -> datetime | None:
1414
+ """Get the last vehicle smart detection event."""
1415
+ return self.last_smart_detects.get(SmartDetectObjectType.VEHICLE)
1416
+
1417
+ @property
1418
+ def is_vehicle_currently_detected(self) -> bool:
1419
+ """Is vehicle currently being detected"""
1420
+ return self._is_smart_detected(SmartDetectObjectType.VEHICLE)
1421
+
1422
+ async def set_vehicle_detection(self, enabled: bool) -> None:
1423
+ """Toggles vehicle smart detection. Requires camera to have smart detection"""
1424
+ return await self._set_object_detect(SmartDetectObjectType.VEHICLE, enabled)
1425
+
1426
+ # endregion
1427
+ # region License Plate
1428
+
1429
+ @property
1430
+ def can_detect_license_plate(self) -> bool:
1431
+ return (
1432
+ SmartDetectObjectType.LICENSE_PLATE in self.feature_flags.smart_detect_types
1433
+ )
1434
+
1435
+ @property
1436
+ def is_license_plate_detection_on(self) -> bool:
1437
+ """
1438
+ Is License Plate Detection available and enabled (camera will produce face license
1439
+ plate detection events)?
1440
+ """
1441
+ return (
1442
+ self._is_smart_enabled(SmartDetectObjectType.LICENSE_PLATE)
1443
+ and self.is_license_plate_detections_allowed
1444
+ )
1445
+
1446
+ @property
1447
+ def last_license_plate_detect_event(self) -> Event | None:
1448
+ """Get the last license plate smart detection event."""
1449
+ return self.get_last_smart_detect_event(SmartDetectObjectType.LICENSE_PLATE)
1450
+
1451
+ @property
1452
+ def last_license_plate_detect(self) -> datetime | None:
1453
+ """Get the last license plate smart detection event."""
1454
+ return self.last_smart_detects.get(SmartDetectObjectType.LICENSE_PLATE)
1455
+
1456
+ @property
1457
+ def is_license_plate_currently_detected(self) -> bool:
1458
+ """Is license plate currently being detected"""
1459
+ return self._is_smart_detected(SmartDetectObjectType.LICENSE_PLATE)
1460
+
1461
+ async def set_license_plate_detection(self, enabled: bool) -> None:
1462
+ """Toggles license plate smart detection. Requires camera to have smart detection"""
1463
+ return await self._set_object_detect(
1464
+ SmartDetectObjectType.LICENSE_PLATE,
1465
+ enabled,
1466
+ )
1467
+
1468
+ # endregion
1469
+ # region Package
1470
+
1471
+ @property
1472
+ def can_detect_package(self) -> bool:
1473
+ return SmartDetectObjectType.PACKAGE in self.feature_flags.smart_detect_types
1474
+
1475
+ @property
1476
+ def is_package_detection_on(self) -> bool:
1477
+ """
1478
+ Is Package Detection available and enabled (camera will produce package smart
1479
+ detection events)?
1480
+ """
1481
+ return self._is_smart_enabled(SmartDetectObjectType.PACKAGE)
1482
+
1483
+ @property
1484
+ def last_package_detect_event(self) -> Event | None:
1485
+ """Get the last package smart detection event."""
1486
+ return self.get_last_smart_detect_event(SmartDetectObjectType.PACKAGE)
1487
+
1488
+ @property
1489
+ def last_package_detect(self) -> datetime | None:
1490
+ """Get the last package smart detection event."""
1491
+ return self.last_smart_detects.get(SmartDetectObjectType.PACKAGE)
1492
+
1493
+ @property
1494
+ def is_package_currently_detected(self) -> bool:
1495
+ """Is package currently being detected"""
1496
+ return self._is_smart_detected(SmartDetectObjectType.PACKAGE)
1497
+
1498
+ async def set_package_detection(self, enabled: bool) -> None:
1499
+ """Toggles package smart detection. Requires camera to have smart detection"""
1500
+ return await self._set_object_detect(SmartDetectObjectType.PACKAGE, enabled)
1501
+
1502
+ # endregion
1503
+ # region Animal
1504
+
1505
+ @property
1506
+ def can_detect_animal(self) -> bool:
1507
+ return SmartDetectObjectType.ANIMAL in self.feature_flags.smart_detect_types
1508
+
1509
+ @property
1510
+ def is_animal_detection_on(self) -> bool:
1511
+ """
1512
+ Is Animal Detection available and enabled (camera will produce package smart
1513
+ detection events)?
1514
+ """
1515
+ return self._is_smart_enabled(SmartDetectObjectType.ANIMAL)
1516
+
1517
+ @property
1518
+ def last_animal_detect_event(self) -> Event | None:
1519
+ """Get the last animal smart detection event."""
1520
+ return self.get_last_smart_detect_event(SmartDetectObjectType.ANIMAL)
1521
+
1522
+ @property
1523
+ def last_animal_detect(self) -> datetime | None:
1524
+ """Get the last animal smart detection event."""
1525
+ return self.last_smart_detects.get(SmartDetectObjectType.ANIMAL)
1526
+
1527
+ @property
1528
+ def is_animal_currently_detected(self) -> bool:
1529
+ """Is animal currently being detected"""
1530
+ return self._is_smart_detected(SmartDetectObjectType.ANIMAL)
1531
+
1532
+ async def set_animal_detection(self, enabled: bool) -> None:
1533
+ """Toggles animal smart detection. Requires camera to have smart detection"""
1534
+ return await self._set_object_detect(SmartDetectObjectType.ANIMAL, enabled)
1535
+
1536
+ # endregion
1537
+ # endregion
1538
+ # region Audio Smart Detections
1539
+
1540
+ def _can_detect_audio(self, smart_type: SmartDetectObjectType) -> bool:
1541
+ audio_type = smart_type.audio_type
1542
+ return (
1543
+ audio_type is not None
1544
+ and self.feature_flags.smart_detect_audio_types is not None
1545
+ and audio_type in self.feature_flags.smart_detect_audio_types
1546
+ )
1547
+
1548
+ def _is_audio_enabled(self, smart_type: SmartDetectObjectType) -> bool:
1549
+ audio_type = smart_type.audio_type
1550
+ return (
1551
+ audio_type is not None
1552
+ and self.is_recording_enabled
1553
+ and audio_type in self.active_audio_detect_types
1554
+ and self.can_manage_smart_detections
1555
+ )
1556
+
1557
+ def _is_audio_detected(self, smart_type: SmartDetectObjectType) -> bool:
1558
+ audio_type = smart_type.audio_type
1559
+ if audio_type is None:
1560
+ return False
1561
+
1562
+ event = self.get_last_smart_audio_detect_event(audio_type)
1563
+ return (
1564
+ self._is_audio_enabled(smart_type)
1565
+ and event is not None
1566
+ and event.end is None
1567
+ and smart_type in event.smart_detect_types
1568
+ )
1569
+
1570
+ @property
1571
+ def is_audio_currently_detected(self) -> bool:
1572
+ """Is audio detection currently being detected"""
1573
+ return (
1574
+ self.is_recording_enabled
1575
+ and bool(self.active_audio_detect_types)
1576
+ and self.last_smart_audio_detect_event is not None
1577
+ and self.last_smart_audio_detect_event.end is None
1578
+ )
1579
+
1580
+ # region Smoke Alarm
1581
+
1582
+ @property
1583
+ def can_detect_smoke(self) -> bool:
1584
+ return self._can_detect_audio(SmartDetectObjectType.SMOKE)
1585
+
1586
+ @property
1587
+ def is_smoke_detection_on(self) -> bool:
1588
+ """
1589
+ Is Smoke Alarm Detection available and enabled (camera will produce smoke
1590
+ smart detection events)?
1591
+ """
1592
+ return self._is_audio_enabled(SmartDetectObjectType.SMOKE)
1593
+
1594
+ @property
1595
+ def last_smoke_detect_event(self) -> Event | None:
1596
+ """Get the last person smart detection event."""
1597
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SMOKE)
1598
+
1599
+ @property
1600
+ def last_smoke_detect(self) -> datetime | None:
1601
+ """Get the last smoke smart detection event."""
1602
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.SMOKE)
1603
+
1604
+ @property
1605
+ def is_smoke_currently_detected(self) -> bool:
1606
+ """Is smoke alarm currently being detected"""
1607
+ return self._is_audio_detected(SmartDetectObjectType.SMOKE)
1608
+
1609
+ async def set_smoke_detection(self, enabled: bool) -> None:
1610
+ """Toggles smoke smart detection. Requires camera to have smart detection"""
1611
+ return await self._set_audio_detect(SmartDetectAudioType.SMOKE, enabled)
1612
+
1613
+ # endregion
1614
+ # region CO Alarm
1615
+
1616
+ @property
1617
+ def can_detect_co(self) -> bool:
1618
+ return self._can_detect_audio(SmartDetectObjectType.CMONX)
1619
+
1620
+ @property
1621
+ def is_co_detection_on(self) -> bool:
1622
+ """
1623
+ Is CO Alarm Detection available and enabled (camera will produce smoke smart
1624
+ detection events)?
1625
+ """
1626
+ return self._is_audio_enabled(SmartDetectObjectType.CMONX)
1627
+
1628
+ @property
1629
+ def last_cmonx_detect_event(self) -> Event | None:
1630
+ """Get the last CO alarm smart detection event."""
1631
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.CMONX)
1632
+
1633
+ @property
1634
+ def last_cmonx_detect(self) -> datetime | None:
1635
+ """Get the last CO alarm smart detection event."""
1636
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.CMONX)
1637
+
1638
+ @property
1639
+ def is_cmonx_currently_detected(self) -> bool:
1640
+ """Is CO alarm currently being detected"""
1641
+ return self._is_audio_detected(SmartDetectObjectType.CMONX)
1642
+
1643
+ async def set_cmonx_detection(self, enabled: bool) -> None:
1644
+ """Toggles smoke smart detection. Requires camera to have smart detection"""
1645
+ return await self._set_audio_detect(SmartDetectAudioType.CMONX, enabled)
1646
+
1647
+ # endregion
1648
+ # region Siren
1649
+
1650
+ @property
1651
+ def can_detect_siren(self) -> bool:
1652
+ return self._can_detect_audio(SmartDetectObjectType.SIREN)
1653
+
1654
+ @property
1655
+ def is_siren_detection_on(self) -> bool:
1656
+ """
1657
+ Is Siren Detection available and enabled (camera will produce siren smart
1658
+ detection events)?
1659
+ """
1660
+ return self._is_audio_enabled(SmartDetectObjectType.SIREN)
1661
+
1662
+ @property
1663
+ def last_siren_detect_event(self) -> Event | None:
1664
+ """Get the last Siren smart detection event."""
1665
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SIREN)
1666
+
1667
+ @property
1668
+ def last_siren_detect(self) -> datetime | None:
1669
+ """Get the last Siren smart detection event."""
1670
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.SIREN)
1671
+
1672
+ @property
1673
+ def is_siren_currently_detected(self) -> bool:
1674
+ """Is Siren currently being detected"""
1675
+ return self._is_audio_detected(SmartDetectObjectType.SIREN)
1676
+
1677
+ async def set_siren_detection(self, enabled: bool) -> None:
1678
+ """Toggles siren smart detection. Requires camera to have smart detection"""
1679
+ return await self._set_audio_detect(SmartDetectAudioType.SIREN, enabled)
1680
+
1681
+ # endregion
1682
+ # region Baby Cry
1683
+
1684
+ @property
1685
+ def can_detect_baby_cry(self) -> bool:
1686
+ return self._can_detect_audio(SmartDetectObjectType.BABY_CRY)
1687
+
1688
+ @property
1689
+ def is_baby_cry_detection_on(self) -> bool:
1690
+ """
1691
+ Is Baby Cry Detection available and enabled (camera will produce baby cry smart
1692
+ detection events)?
1693
+ """
1694
+ return self._is_audio_enabled(SmartDetectObjectType.BABY_CRY)
1695
+
1696
+ @property
1697
+ def last_baby_cry_detect_event(self) -> Event | None:
1698
+ """Get the last Baby Cry smart detection event."""
1699
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BABY_CRY)
1700
+
1701
+ @property
1702
+ def last_baby_cry_detect(self) -> datetime | None:
1703
+ """Get the last Baby Cry smart detection event."""
1704
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.BABY_CRY)
1705
+
1706
+ @property
1707
+ def is_baby_cry_currently_detected(self) -> bool:
1708
+ """Is Baby Cry currently being detected"""
1709
+ return self._is_audio_detected(SmartDetectObjectType.BABY_CRY)
1710
+
1711
+ async def set_baby_cry_detection(self, enabled: bool) -> None:
1712
+ """Toggles baby_cry smart detection. Requires camera to have smart detection"""
1713
+ return await self._set_audio_detect(SmartDetectAudioType.BABY_CRY, enabled)
1714
+
1715
+ # endregion
1716
+ # region Speaking
1717
+
1718
+ @property
1719
+ def can_detect_speaking(self) -> bool:
1720
+ return self._can_detect_audio(SmartDetectObjectType.SPEAK)
1721
+
1722
+ @property
1723
+ def is_speaking_detection_on(self) -> bool:
1724
+ """
1725
+ Is Speaking Detection available and enabled (camera will produce speaking smart
1726
+ detection events)?
1727
+ """
1728
+ return self._is_audio_enabled(SmartDetectObjectType.SPEAK)
1729
+
1730
+ @property
1731
+ def last_speaking_detect_event(self) -> Event | None:
1732
+ """Get the last Speaking smart detection event."""
1733
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.SPEAK)
1734
+
1735
+ @property
1736
+ def last_speaking_detect(self) -> datetime | None:
1737
+ """Get the last Speaking smart detection event."""
1738
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.SPEAK)
1739
+
1740
+ @property
1741
+ def is_speaking_currently_detected(self) -> bool:
1742
+ """Is Speaking currently being detected"""
1743
+ return self._is_audio_detected(SmartDetectObjectType.SPEAK)
1744
+
1745
+ async def set_speaking_detection(self, enabled: bool) -> None:
1746
+ """Toggles speaking smart detection. Requires camera to have smart detection"""
1747
+ return await self._set_audio_detect(SmartDetectAudioType.SPEAK, enabled)
1748
+
1749
+ # endregion
1750
+ # region Bark
1751
+
1752
+ @property
1753
+ def can_detect_bark(self) -> bool:
1754
+ return self._can_detect_audio(SmartDetectObjectType.BARK)
1755
+
1756
+ @property
1757
+ def is_bark_detection_on(self) -> bool:
1758
+ """
1759
+ Is Bark Detection available and enabled (camera will produce barking smart
1760
+ detection events)?
1761
+ """
1762
+ return self._is_audio_enabled(SmartDetectObjectType.BARK)
1763
+
1764
+ @property
1765
+ def last_bark_detect_event(self) -> Event | None:
1766
+ """Get the last Bark smart detection event."""
1767
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BARK)
1768
+
1769
+ @property
1770
+ def last_bark_detect(self) -> datetime | None:
1771
+ """Get the last Bark smart detection event."""
1772
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.BARK)
1773
+
1774
+ @property
1775
+ def is_bark_currently_detected(self) -> bool:
1776
+ """Is Bark currently being detected"""
1777
+ return self._is_audio_detected(SmartDetectObjectType.BARK)
1778
+
1779
+ async def set_bark_detection(self, enabled: bool) -> None:
1780
+ """Toggles bark smart detection. Requires camera to have smart detection"""
1781
+ return await self._set_audio_detect(SmartDetectAudioType.BARK, enabled)
1782
+
1783
+ # endregion
1784
+ # region Car Alarm
1785
+ # (burglar in code, car alarm in Protect UI)
1786
+
1787
+ @property
1788
+ def can_detect_car_alarm(self) -> bool:
1789
+ return self._can_detect_audio(SmartDetectObjectType.BURGLAR)
1790
+
1791
+ @property
1792
+ def is_car_alarm_detection_on(self) -> bool:
1793
+ """
1794
+ Is Car Alarm Detection available and enabled (camera will produce car alarm smart
1795
+ detection events)?
1796
+ """
1797
+ return self._is_audio_enabled(SmartDetectObjectType.BURGLAR)
1798
+
1799
+ @property
1800
+ def last_car_alarm_detect_event(self) -> Event | None:
1801
+ """Get the last Car Alarm smart detection event."""
1802
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.BURGLAR)
1803
+
1804
+ @property
1805
+ def last_car_alarm_detect(self) -> datetime | None:
1806
+ """Get the last Car Alarm smart detection event."""
1807
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.BURGLAR)
1808
+
1809
+ @property
1810
+ def is_car_alarm_currently_detected(self) -> bool:
1811
+ """Is Car Alarm currently being detected"""
1812
+ return self._is_audio_detected(SmartDetectObjectType.BURGLAR)
1813
+
1814
+ async def set_car_alarm_detection(self, enabled: bool) -> None:
1815
+ """Toggles car_alarm smart detection. Requires camera to have smart detection"""
1816
+ return await self._set_audio_detect(SmartDetectAudioType.BURGLAR, enabled)
1817
+
1818
+ # endregion
1819
+ # region Car Horn
1820
+
1821
+ @property
1822
+ def can_detect_car_horn(self) -> bool:
1823
+ return self._can_detect_audio(SmartDetectObjectType.CAR_HORN)
1824
+
1825
+ @property
1826
+ def is_car_horn_detection_on(self) -> bool:
1827
+ """
1828
+ Is Car Horn Detection available and enabled (camera will produce car horn smart
1829
+ detection events)?
1830
+ """
1831
+ return self._is_audio_enabled(SmartDetectObjectType.CAR_HORN)
1832
+
1833
+ @property
1834
+ def last_car_horn_detect_event(self) -> Event | None:
1835
+ """Get the last Car Horn smart detection event."""
1836
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.CAR_HORN)
1837
+
1838
+ @property
1839
+ def last_car_horn_detect(self) -> datetime | None:
1840
+ """Get the last Car Horn smart detection event."""
1841
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.CAR_HORN)
1842
+
1843
+ @property
1844
+ def is_car_horn_currently_detected(self) -> bool:
1845
+ """Is Car Horn currently being detected"""
1846
+ return self._is_audio_detected(SmartDetectObjectType.CAR_HORN)
1847
+
1848
+ async def set_car_horn_detection(self, enabled: bool) -> None:
1849
+ """Toggles car_horn smart detection. Requires camera to have smart detection"""
1850
+ return await self._set_audio_detect(SmartDetectAudioType.CAR_HORN, enabled)
1851
+
1852
+ # endregion
1853
+ # region Glass Break
1854
+
1855
+ @property
1856
+ def can_detect_glass_break(self) -> bool:
1857
+ return self._can_detect_audio(SmartDetectObjectType.GLASS_BREAK)
1858
+
1859
+ @property
1860
+ def is_glass_break_detection_on(self) -> bool:
1861
+ """
1862
+ Is Glass Break available and enabled (camera will produce glass break smart
1863
+ detection events)?
1864
+ """
1865
+ return self._is_audio_enabled(SmartDetectObjectType.GLASS_BREAK)
1866
+
1867
+ @property
1868
+ def last_glass_break_detect_event(self) -> Event | None:
1869
+ """Get the last Glass Break smart detection event."""
1870
+ return self.get_last_smart_audio_detect_event(SmartDetectAudioType.GLASS_BREAK)
1871
+
1872
+ @property
1873
+ def last_glass_break_detect(self) -> datetime | None:
1874
+ """Get the last Glass Break smart detection event."""
1875
+ return self.last_smart_audio_detects.get(SmartDetectAudioType.GLASS_BREAK)
1876
+
1877
+ @property
1878
+ def is_glass_break_currently_detected(self) -> bool:
1879
+ """Is Glass Break currently being detected"""
1880
+ return self._is_audio_detected(SmartDetectObjectType.GLASS_BREAK)
1881
+
1882
+ async def set_glass_break_detection(self, enabled: bool) -> None:
1883
+ """Toggles glass_break smart detection. Requires camera to have smart detection"""
1884
+ return await self._set_audio_detect(SmartDetectAudioType.GLASS_BREAK, enabled)
1885
+
1886
+ # endregion
1887
+ # endregion
1888
+
1889
+ @property
1890
+ def is_ringing(self) -> bool:
1891
+ if self._last_ring_timeout is None:
1892
+ return False
1893
+ return utc_now() < self._last_ring_timeout
1894
+
1895
+ @property
1896
+ def chime_type(self) -> ChimeType:
1897
+ if self.chime_duration.total_seconds() == 0.3:
1898
+ return ChimeType.MECHANICAL
1899
+ if self.chime_duration.total_seconds() > 0.3:
1900
+ return ChimeType.DIGITAL
1901
+ return ChimeType.NONE
1902
+
1903
+ @property
1904
+ def is_digital_chime(self) -> bool:
1905
+ return self.chime_type is ChimeType.DIGITAL
1906
+
1907
+ @property
1908
+ def high_camera_channel(self) -> CameraChannel | None:
1909
+ if len(self.channels) >= 3:
1910
+ return self.channels[0]
1911
+ return None
1912
+
1913
+ @property
1914
+ def medium_camera_channel(self) -> CameraChannel | None:
1915
+ if len(self.channels) >= 3:
1916
+ return self.channels[1]
1917
+ return None
1918
+
1919
+ @property
1920
+ def low_camera_channel(self) -> CameraChannel | None:
1921
+ if len(self.channels) >= 3:
1922
+ return self.channels[2]
1923
+ return None
1924
+
1925
+ @property
1926
+ def default_camera_channel(self) -> CameraChannel | None:
1927
+ for channel in [
1928
+ self.high_camera_channel,
1929
+ self.medium_camera_channel,
1930
+ self.low_camera_channel,
1931
+ ]:
1932
+ if channel is not None and channel.is_rtsp_enabled:
1933
+ return channel
1934
+ return self.high_camera_channel
1935
+
1936
+ @property
1937
+ def package_camera_channel(self) -> CameraChannel | None:
1938
+ if self.feature_flags.has_package_camera and len(self.channels) == 4:
1939
+ return self.channels[3]
1940
+ return None
1941
+
1942
+ @property
1943
+ def is_high_fps_enabled(self) -> bool:
1944
+ return self.video_mode == VideoMode.HIGH_FPS
1945
+
1946
+ @property
1947
+ def is_video_ready(self) -> bool:
1948
+ return (
1949
+ self.feature_flags.lens_type is None
1950
+ or self.feature_flags.lens_type != LensType.NONE
1951
+ )
1952
+
1953
+ @property
1954
+ def has_removable_lens(self) -> bool:
1955
+ return (
1956
+ self.feature_flags.hotplug is not None
1957
+ and self.feature_flags.hotplug.video is not None
1958
+ )
1959
+
1960
+ @property
1961
+ def has_removable_speaker(self) -> bool:
1962
+ return (
1963
+ self.feature_flags.hotplug is not None
1964
+ and self.feature_flags.hotplug.audio is not None
1965
+ )
1966
+
1967
+ @property
1968
+ def has_mic(self) -> bool:
1969
+ return self.feature_flags.has_mic or self.has_removable_speaker
1970
+
1971
+ @property
1972
+ def has_color_night_vision(self) -> bool:
1973
+ if (
1974
+ self.feature_flags.hotplug is not None
1975
+ and self.feature_flags.hotplug.extender is not None
1976
+ and self.feature_flags.hotplug.extender.is_attached is not None
1977
+ ):
1978
+ return self.feature_flags.hotplug.extender.is_attached
1979
+
1980
+ return False
1981
+
1982
+ def set_ring_timeout(self) -> None:
1983
+ self._last_ring_timeout = utc_now() + EVENT_PING_INTERVAL
1984
+ self._event_callback_ping()
1985
+
1986
+ def get_privacy_zone(self) -> tuple[int | None, CameraZone | None]:
1987
+ for index, zone in enumerate(self.privacy_zones):
1988
+ if zone.name == PRIVACY_ZONE_NAME:
1989
+ return index, zone
1990
+ return None, None
1991
+
1992
+ def add_privacy_zone(self) -> None:
1993
+ index, _ = self.get_privacy_zone()
1994
+ if index is None:
1995
+ zone_id = 0
1996
+ if len(self.privacy_zones) > 0:
1997
+ zone_id = self.privacy_zones[-1].id + 1
1998
+
1999
+ self.privacy_zones.append(CameraZone.create_privacy_zone(zone_id))
2000
+
2001
+ def remove_privacy_zone(self) -> None:
2002
+ index, _ = self.get_privacy_zone()
2003
+
2004
+ if index is not None:
2005
+ self.privacy_zones.pop(index)
2006
+
2007
+ async def get_snapshot(
2008
+ self,
2009
+ width: int | None = None,
2010
+ height: int | None = None,
2011
+ dt: datetime | None = None,
2012
+ ) -> bytes | None:
2013
+ """
2014
+ Gets snapshot for camera.
2015
+
2016
+ Datetime of screenshot is approximate. It may be +/- a few seconds.
2017
+ """
2018
+ if not self.api.bootstrap.auth_user.can(
2019
+ ModelType.CAMERA,
2020
+ PermissionNode.READ_MEDIA,
2021
+ self,
2022
+ ):
2023
+ raise NotAuthorized(
2024
+ f"Do not have permission to read media for camera: {self.id}",
2025
+ )
2026
+
2027
+ if height is None and width is None and self.high_camera_channel is not None:
2028
+ height = self.high_camera_channel.height
2029
+
2030
+ return await self.api.get_camera_snapshot(self.id, width, height, dt=dt)
2031
+
2032
+ async def get_package_snapshot(
2033
+ self,
2034
+ width: int | None = None,
2035
+ height: int | None = None,
2036
+ dt: datetime | None = None,
2037
+ ) -> bytes | None:
2038
+ """
2039
+ Gets snapshot from the package camera.
2040
+
2041
+ Datetime of screenshot is approximate. It may be +/- a few seconds.
2042
+ """
2043
+ if not self.feature_flags.has_package_camera:
2044
+ raise BadRequest("Device does not have package camera")
2045
+
2046
+ if not self.api.bootstrap.auth_user.can(
2047
+ ModelType.CAMERA,
2048
+ PermissionNode.READ_MEDIA,
2049
+ self,
2050
+ ):
2051
+ raise NotAuthorized(
2052
+ f"Do not have permission to read media for camera: {self.id}",
2053
+ )
2054
+
2055
+ if height is None and width is None and self.package_camera_channel is not None:
2056
+ height = self.package_camera_channel.height
2057
+
2058
+ return await self.api.get_package_camera_snapshot(self.id, width, height, dt=dt)
2059
+
2060
+ async def get_video(
2061
+ self,
2062
+ start: datetime,
2063
+ end: datetime,
2064
+ channel_index: int = 0,
2065
+ output_file: Path | None = None,
2066
+ iterator_callback: IteratorCallback | None = None,
2067
+ progress_callback: ProgressCallback | None = None,
2068
+ chunk_size: int = 65536,
2069
+ fps: int | None = None,
2070
+ ) -> bytes | None:
2071
+ """
2072
+ Exports MP4 video from a given camera at a specific time.
2073
+
2074
+ Start/End of video export are approximate. It may be +/- a few seconds.
2075
+
2076
+ It is recommended to provide a output file or progress callback for larger
2077
+ video clips, otherwise the full video must be downloaded to memory before
2078
+ being written.
2079
+
2080
+ Providing the `fps` parameter creates a "timelapse" export wtih the given FPS
2081
+ value. Protect app gives the options for 60x (fps=4), 120x (fps=8), 300x
2082
+ (fps=20), and 600x (fps=40).
2083
+ """
2084
+ if not self.api.bootstrap.auth_user.can(
2085
+ ModelType.CAMERA,
2086
+ PermissionNode.READ_MEDIA,
2087
+ self,
2088
+ ):
2089
+ raise NotAuthorized(
2090
+ f"Do not have permission to read media for camera: {self.id}",
2091
+ )
2092
+
2093
+ return await self.api.get_camera_video(
2094
+ self.id,
2095
+ start,
2096
+ end,
2097
+ channel_index,
2098
+ output_file=output_file,
2099
+ iterator_callback=iterator_callback,
2100
+ progress_callback=progress_callback,
2101
+ chunk_size=chunk_size,
2102
+ fps=fps,
2103
+ )
2104
+
2105
+ async def set_recording_mode(self, mode: RecordingMode) -> None:
2106
+ """Sets recording mode on camera"""
2107
+ if self.use_global:
2108
+ raise BadRequest("Camera is using global recording settings.")
2109
+
2110
+ def callback() -> None:
2111
+ self.recording_settings.mode = mode
2112
+
2113
+ await self.queue_update(callback)
2114
+
2115
+ async def set_ir_led_model(self, mode: IRLEDMode) -> None:
2116
+ """Sets IR LED mode on camera"""
2117
+ if not self.feature_flags.has_led_ir:
2118
+ raise BadRequest("Camera does not have an LED IR")
2119
+
2120
+ def callback() -> None:
2121
+ self.isp_settings.ir_led_mode = mode
2122
+
2123
+ await self.queue_update(callback)
2124
+
2125
+ async def set_icr_custom_lux(self, value: ICRLuxValue) -> None:
2126
+ """Set ICRCustomValue from lux value."""
2127
+ if not self.feature_flags.has_led_ir:
2128
+ raise BadRequest("Camera does not have an LED IR")
2129
+
2130
+ icr_value = 0
2131
+ for index, threshold in enumerate(LUX_MAPPING_VALUES):
2132
+ if value >= threshold:
2133
+ icr_value = 10 - index
2134
+ break
2135
+
2136
+ def callback() -> None:
2137
+ self.isp_settings.icr_custom_value = cast(ICRCustomValue, icr_value)
2138
+
2139
+ await self.queue_update(callback)
2140
+
2141
+ @property
2142
+ def is_ir_led_slider_enabled(self) -> bool:
2143
+ """Return if IR LED custom slider is enabled."""
2144
+ return (
2145
+ self.feature_flags.has_led_ir
2146
+ and self.isp_settings.ir_led_mode == IRLEDMode.CUSTOM
2147
+ )
2148
+
2149
+ async def set_status_light(self, enabled: bool) -> None:
2150
+ """Sets status indicicator light on camera"""
2151
+ if not self.feature_flags.has_led_status:
2152
+ raise BadRequest("Camera does not have status light")
2153
+
2154
+ def callback() -> None:
2155
+ self.led_settings.is_enabled = enabled
2156
+ self.led_settings.blink_rate = 0
2157
+
2158
+ await self.queue_update(callback)
2159
+
2160
+ async def set_hdr(self, enabled: bool) -> None:
2161
+ """Sets HDR (High Dynamic Range) on camera"""
2162
+ warnings.warn(
2163
+ "set_hdr is deprecated and replaced with set_hdr_mode for versions of UniFi Protect v3.0+",
2164
+ DeprecationWarning,
2165
+ stacklevel=2,
2166
+ )
2167
+
2168
+ if not self.feature_flags.has_hdr:
2169
+ raise BadRequest("Camera does not have HDR")
2170
+
2171
+ def callback() -> None:
2172
+ self.hdr_mode = enabled
2173
+
2174
+ await self.queue_update(callback)
2175
+
2176
+ async def set_hdr_mode(self, mode: Literal["auto", "off", "always"]) -> None:
2177
+ """Sets HDR mode similar to how Protect interface works."""
2178
+ if not self.feature_flags.has_hdr:
2179
+ raise BadRequest("Camera does not have HDR")
2180
+
2181
+ def callback() -> None:
2182
+ if mode == "off":
2183
+ self.hdr_mode = False
2184
+ if self.isp_settings.hdr_mode is not None:
2185
+ self.isp_settings.hdr_mode = HDRMode.NORMAL
2186
+ else:
2187
+ self.hdr_mode = True
2188
+ if self.isp_settings.hdr_mode is not None:
2189
+ self.isp_settings.hdr_mode = (
2190
+ HDRMode.NORMAL if mode == "auto" else HDRMode.ALWAYS_ON
2191
+ )
2192
+
2193
+ await self.queue_update(callback)
2194
+
2195
+ async def set_color_night_vision(self, enabled: bool) -> None:
2196
+ """Sets Color Night Vision on camera"""
2197
+ if not self.has_color_night_vision:
2198
+ raise BadRequest("Camera does not have Color Night Vision")
2199
+
2200
+ def callback() -> None:
2201
+ self.isp_settings.is_color_night_vision_enabled = enabled
2202
+
2203
+ await self.queue_update(callback)
2204
+
2205
+ async def set_video_mode(self, mode: VideoMode) -> None:
2206
+ """Sets video mode on camera"""
2207
+ if mode not in self.feature_flags.video_modes:
2208
+ raise BadRequest(f"Camera does not have {mode}")
2209
+
2210
+ def callback() -> None:
2211
+ self.video_mode = mode
2212
+
2213
+ await self.queue_update(callback)
2214
+
2215
+ async def set_camera_zoom(self, level: int) -> None:
2216
+ """Sets zoom level for camera"""
2217
+ if not self.feature_flags.can_optical_zoom:
2218
+ raise BadRequest("Camera cannot optical zoom")
2219
+
2220
+ def callback() -> None:
2221
+ self.isp_settings.zoom_position = PercentInt(level)
2222
+
2223
+ await self.queue_update(callback)
2224
+
2225
+ async def set_wdr_level(self, level: int) -> None:
2226
+ """Sets WDR (Wide Dynamic Range) on camera"""
2227
+ if self.feature_flags.has_hdr:
2228
+ raise BadRequest("Cannot set WDR on cameras with HDR")
2229
+
2230
+ def callback() -> None:
2231
+ self.isp_settings.wdr = WDRLevel(level)
2232
+
2233
+ await self.queue_update(callback)
2234
+
2235
+ async def set_mic_volume(self, level: int) -> None:
2236
+ """Sets the mic sensitivity level on camera"""
2237
+ if not self.feature_flags.has_mic:
2238
+ raise BadRequest("Camera does not have mic")
2239
+
2240
+ def callback() -> None:
2241
+ self.mic_volume = PercentInt(level)
2242
+
2243
+ await self.queue_update(callback)
2244
+
2245
+ async def set_speaker_volume(self, level: int) -> None:
2246
+ """Sets the speaker sensitivity level on camera. Requires camera to have speakers"""
2247
+ if not self.feature_flags.has_speaker:
2248
+ raise BadRequest("Camera does not have speaker")
2249
+
2250
+ def callback() -> None:
2251
+ self.speaker_settings.volume = PercentInt(level)
2252
+
2253
+ await self.queue_update(callback)
2254
+
2255
+ async def set_chime_type(self, chime_type: ChimeType) -> None:
2256
+ """Sets chime type for doorbell. Requires camera to be a doorbell"""
2257
+ await self.set_chime_duration(timedelta(milliseconds=chime_type.value))
2258
+
2259
+ async def set_chime_duration(self, duration: timedelta | float) -> None:
2260
+ """Sets chime duration for doorbell. Requires camera to be a doorbell"""
2261
+ if not self.feature_flags.has_chime:
2262
+ raise BadRequest("Camera does not have a chime")
2263
+
2264
+ if isinstance(duration, (float, int)):
2265
+ if duration < 0:
2266
+ raise BadRequest("Chime duration must be a positive number of seconds")
2267
+ duration_td = timedelta(seconds=duration)
2268
+ else:
2269
+ duration_td = duration
2270
+
2271
+ if duration_td.total_seconds() > 10:
2272
+ raise BadRequest("Chime duration is too long")
2273
+
2274
+ def callback() -> None:
2275
+ self.chime_duration = duration_td
2276
+
2277
+ await self.queue_update(callback)
2278
+
2279
+ async def set_system_sounds(self, enabled: bool) -> None:
2280
+ """Sets system sound playback through speakers. Requires camera to have speakers"""
2281
+ if not self.feature_flags.has_speaker:
2282
+ raise BadRequest("Camera does not have speaker")
2283
+
2284
+ def callback() -> None:
2285
+ self.speaker_settings.are_system_sounds_enabled = enabled
2286
+
2287
+ await self.queue_update(callback)
2288
+
2289
+ async def set_osd_name(self, enabled: bool) -> None:
2290
+ """Sets whether camera name is in the On Screen Display"""
2291
+ if self.use_global:
2292
+ raise BadRequest("Camera is using global recording settings.")
2293
+
2294
+ def callback() -> None:
2295
+ self.osd_settings.is_name_enabled = enabled
2296
+
2297
+ await self.queue_update(callback)
2298
+
2299
+ async def set_osd_date(self, enabled: bool) -> None:
2300
+ """Sets whether current date is in the On Screen Display"""
2301
+ if self.use_global:
2302
+ raise BadRequest("Camera is using global recording settings.")
2303
+
2304
+ def callback() -> None:
2305
+ self.osd_settings.is_date_enabled = enabled
2306
+
2307
+ await self.queue_update(callback)
2308
+
2309
+ async def set_osd_logo(self, enabled: bool) -> None:
2310
+ """Sets whether the UniFi logo is in the On Screen Display"""
2311
+ if self.use_global:
2312
+ raise BadRequest("Camera is using global recording settings.")
2313
+
2314
+ def callback() -> None:
2315
+ self.osd_settings.is_logo_enabled = enabled
2316
+
2317
+ await self.queue_update(callback)
2318
+
2319
+ async def set_osd_bitrate(self, enabled: bool) -> None:
2320
+ """Sets whether camera bitrate is in the On Screen Display"""
2321
+ if self.use_global:
2322
+ raise BadRequest("Camera is using global recording settings.")
2323
+
2324
+ def callback() -> None:
2325
+ # mismatch between UI internal data structure debug = bitrate data
2326
+ self.osd_settings.is_debug_enabled = enabled
2327
+
2328
+ await self.queue_update(callback)
2329
+
2330
+ async def set_smart_detect_types(self, types: list[SmartDetectObjectType]) -> None:
2331
+ """Sets current enabled smart detection types. Requires camera to have smart detection"""
2332
+ if not self.feature_flags.has_smart_detect:
2333
+ raise BadRequest("Camera does not have a smart detections")
2334
+
2335
+ if self.use_global:
2336
+ raise BadRequest("Camera is using global recording settings.")
2337
+
2338
+ def callback() -> None:
2339
+ self.smart_detect_settings.object_types = types
2340
+
2341
+ await self.queue_update(callback)
2342
+
2343
+ async def set_smart_audio_detect_types(
2344
+ self,
2345
+ types: list[SmartDetectAudioType],
2346
+ ) -> None:
2347
+ """Sets current enabled smart audio detection types. Requires camera to have smart detection"""
2348
+ if not self.feature_flags.has_smart_detect:
2349
+ raise BadRequest("Camera does not have a smart detections")
2350
+
2351
+ if self.use_global:
2352
+ raise BadRequest("Camera is using global recording settings.")
2353
+
2354
+ def callback() -> None:
2355
+ self.smart_detect_settings.audio_types = types
2356
+
2357
+ await self.queue_update(callback)
2358
+
2359
+ async def _set_object_detect(
2360
+ self,
2361
+ obj_to_mod: SmartDetectObjectType,
2362
+ enabled: bool,
2363
+ ) -> None:
2364
+ if obj_to_mod not in self.feature_flags.smart_detect_types:
2365
+ raise BadRequest(f"Camera does not support the {obj_to_mod} detection type")
2366
+
2367
+ if self.use_global:
2368
+ raise BadRequest("Camera is using global recording settings.")
2369
+
2370
+ def callback() -> None:
2371
+ objects = self.smart_detect_settings.object_types
2372
+ if enabled:
2373
+ if obj_to_mod not in objects:
2374
+ objects = [*objects, obj_to_mod]
2375
+ objects.sort()
2376
+ elif obj_to_mod in objects:
2377
+ objects.remove(obj_to_mod)
2378
+ self.smart_detect_settings.object_types = objects
2379
+
2380
+ await self.queue_update(callback)
2381
+
2382
+ async def _set_audio_detect(
2383
+ self,
2384
+ obj_to_mod: SmartDetectAudioType,
2385
+ enabled: bool,
2386
+ ) -> None:
2387
+ if (
2388
+ self.feature_flags.smart_detect_audio_types is None
2389
+ or obj_to_mod not in self.feature_flags.smart_detect_audio_types
2390
+ ):
2391
+ raise BadRequest(f"Camera does not support the {obj_to_mod} detection type")
2392
+
2393
+ if self.use_global:
2394
+ raise BadRequest("Camera is using global recording settings.")
2395
+
2396
+ def callback() -> None:
2397
+ objects = self.smart_detect_settings.audio_types or []
2398
+ if enabled:
2399
+ if obj_to_mod not in objects:
2400
+ objects = [*objects, obj_to_mod]
2401
+ objects.sort()
2402
+ elif obj_to_mod in objects:
2403
+ objects.remove(obj_to_mod)
2404
+ self.smart_detect_settings.audio_types = objects
2405
+
2406
+ await self.queue_update(callback)
2407
+
2408
+ async def set_lcd_text(
2409
+ self,
2410
+ text_type: DoorbellMessageType | None,
2411
+ text: str | None = None,
2412
+ reset_at: None | datetime | DEFAULT_TYPE = None,
2413
+ ) -> None:
2414
+ """Sets doorbell LCD text. Requires camera to be doorbell"""
2415
+ if not self.feature_flags.has_lcd_screen:
2416
+ raise BadRequest("Camera does not have an LCD screen")
2417
+
2418
+ if text_type is None:
2419
+ async with self._update_lock:
2420
+ await asyncio.sleep(
2421
+ 0,
2422
+ ) # yield to the event loop once we have the lock to process any pending updates
2423
+ data_before_changes = self.dict_with_excludes()
2424
+ self.lcd_message = None
2425
+ # UniFi Protect bug: clearing LCD text message does _not_ emit a WS message
2426
+ await self.save_device(data_before_changes, force_emit=True)
2427
+ return
2428
+
2429
+ if text_type != DoorbellMessageType.CUSTOM_MESSAGE:
2430
+ if text is not None:
2431
+ raise BadRequest("Can only set text if text_type is CUSTOM_MESSAGE")
2432
+ text = text_type.value.replace("_", " ")
2433
+
2434
+ if reset_at == DEFAULT:
2435
+ reset_at = (
2436
+ utc_now()
2437
+ + self.api.bootstrap.nvr.doorbell_settings.default_message_reset_timeout
2438
+ )
2439
+
2440
+ def callback() -> None:
2441
+ self.lcd_message = LCDMessage( # type: ignore[call-arg]
2442
+ api=self._api,
2443
+ type=text_type,
2444
+ text=text, # type: ignore[arg-type]
2445
+ reset_at=reset_at, # type: ignore[arg-type]
2446
+ )
2447
+
2448
+ await self.queue_update(callback)
2449
+
2450
+ async def set_privacy(
2451
+ self,
2452
+ enabled: bool,
2453
+ mic_level: int | None = None,
2454
+ recording_mode: RecordingMode | None = None,
2455
+ reenable_global: bool = False,
2456
+ ) -> None:
2457
+ """Adds/removes a privacy zone that blacks out the whole camera."""
2458
+ if not self.feature_flags.has_privacy_mask:
2459
+ raise BadRequest("Camera does not allow privacy zones")
2460
+
2461
+ def callback() -> None:
2462
+ if enabled:
2463
+ self.use_global = False
2464
+ self.add_privacy_zone()
2465
+ else:
2466
+ if reenable_global:
2467
+ self.use_global = True
2468
+ self.remove_privacy_zone()
2469
+
2470
+ if not reenable_global:
2471
+ if mic_level is not None:
2472
+ self.mic_volume = PercentInt(mic_level)
2473
+
2474
+ if recording_mode is not None:
2475
+ self.recording_settings.mode = recording_mode
2476
+
2477
+ await self.queue_update(callback)
2478
+
2479
+ async def set_person_track(self, enabled: bool) -> None:
2480
+ """Sets person tracking on camera"""
2481
+ if not self.feature_flags.is_ptz:
2482
+ raise BadRequest("Camera does not support person tracking")
2483
+
2484
+ if self.use_global:
2485
+ raise BadRequest("Camera is using global recording settings.")
2486
+
2487
+ def callback() -> None:
2488
+ self.smart_detect_settings.auto_tracking_object_types = (
2489
+ [SmartDetectObjectType.PERSON] if enabled else []
2490
+ )
2491
+
2492
+ await self.queue_update(callback)
2493
+
2494
+ def create_talkback_stream(
2495
+ self,
2496
+ content_url: str,
2497
+ ffmpeg_path: Path | None = None,
2498
+ ) -> TalkbackStream:
2499
+ """
2500
+ Creates a subprocess to play audio to a camera through its speaker.
2501
+
2502
+ Requires ffmpeg to use.
2503
+
2504
+ Args:
2505
+ ----
2506
+ content_url: Either a URL accessible by python or a path to a file (ffmepg's `-i` parameter)
2507
+ ffmpeg_path: Optional path to ffmpeg binary
2508
+
2509
+ Use either `await stream.run_until_complete()` or `await stream.start()` to start subprocess command
2510
+ after getting the stream.
2511
+
2512
+ `.play_audio()` is a helper that wraps this method and automatically runs the subprocess as well
2513
+
2514
+ """
2515
+ if self.talkback_stream is not None and self.talkback_stream.is_running:
2516
+ raise BadRequest("Camera is already playing audio")
2517
+
2518
+ self.talkback_stream = TalkbackStream(self, content_url, ffmpeg_path)
2519
+ return self.talkback_stream
2520
+
2521
+ async def play_audio(
2522
+ self,
2523
+ content_url: str,
2524
+ ffmpeg_path: Path | None = None,
2525
+ blocking: bool = True,
2526
+ ) -> None:
2527
+ """
2528
+ Plays audio to a camera through its speaker.
2529
+
2530
+ Requires ffmpeg to use.
2531
+
2532
+ Args:
2533
+ ----
2534
+ content_url: Either a URL accessible by python or a path to a file (ffmepg's `-i` parameter)
2535
+ ffmpeg_path: Optional path to ffmpeg binary
2536
+ blocking: Awaits stream completion and logs stdout/stderr
2537
+
2538
+ """
2539
+ stream = self.create_talkback_stream(content_url, ffmpeg_path)
2540
+ await stream.start()
2541
+
2542
+ if blocking:
2543
+ await self.wait_until_audio_completes()
2544
+
2545
+ async def wait_until_audio_completes(self) -> None:
2546
+ """Awaits stream completion of audio and logs stdout/stderr."""
2547
+ stream = self.talkback_stream
2548
+ if stream is None:
2549
+ raise StreamError("No audio playing to wait for")
2550
+
2551
+ await stream.run_until_complete()
2552
+
2553
+ _LOGGER.debug("ffmpeg stdout:\n%s", "\n".join(stream.stdout))
2554
+ _LOGGER.debug("ffmpeg stderr:\n%s", "\n".join(stream.stderr))
2555
+ if stream.is_error:
2556
+ error = "\n".join(stream.stderr)
2557
+ raise StreamError("Error while playing audio (ffmpeg): \n" + error)
2558
+
2559
+ async def stop_audio(self) -> None:
2560
+ """Stop currently playing audio."""
2561
+ stream = self.talkback_stream
2562
+ if stream is None:
2563
+ raise StreamError("No audio playing to stop")
2564
+
2565
+ await stream.stop()
2566
+
2567
+ def can_read_media(self, user: User) -> bool:
2568
+ if self.model is None:
2569
+ return True
2570
+
2571
+ return user.can(self.model, PermissionNode.READ_MEDIA, self)
2572
+
2573
+ def can_delete_media(self, user: User) -> bool:
2574
+ if self.model is None:
2575
+ return True
2576
+
2577
+ return user.can(self.model, PermissionNode.DELETE_MEDIA, self)
2578
+
2579
+ # region PTZ
2580
+
2581
+ async def ptz_relative_move(
2582
+ self,
2583
+ *,
2584
+ pan: float,
2585
+ tilt: float,
2586
+ pan_speed: int = 10,
2587
+ tilt_speed: int = 10,
2588
+ scale: int = 0,
2589
+ use_native: bool = False,
2590
+ ) -> None:
2591
+ """
2592
+ Move PTZ relative to current position.
2593
+
2594
+ Pan/tilt values vary from camera to camera, but for G4 PTZ:
2595
+ * Pan values range from 0° and go to 360°/0°
2596
+ * Tilt values range from -20° and go to 90°
2597
+
2598
+ Relative positions cannot move more then 4095 steps at a time in any direction.
2599
+
2600
+ For the G4 PTZ, 4095 steps is ~41° for pan and ~45° for tilt.
2601
+
2602
+ `use_native` lets you use the native step values instead of degrees.
2603
+ """
2604
+ if not self.feature_flags.is_ptz:
2605
+ raise BadRequest("Camera does not support PTZ features.")
2606
+
2607
+ if not use_native:
2608
+ pan = self.feature_flags.pan.to_native_value(pan, is_relative=True)
2609
+ tilt = self.feature_flags.tilt.to_native_value(tilt, is_relative=True)
2610
+
2611
+ await self.api.relative_move_ptz_camera(
2612
+ self.id,
2613
+ pan=pan,
2614
+ tilt=tilt,
2615
+ pan_speed=pan_speed,
2616
+ tilt_speed=tilt_speed,
2617
+ scale=scale,
2618
+ )
2619
+
2620
+ async def ptz_center(self, *, x: int, y: int, z: int) -> None:
2621
+ """
2622
+ Center PTZ Camera on point in viewport.
2623
+
2624
+ x, y, z values range from 0 to 1000.
2625
+
2626
+ x, y are relative coords for the current viewport:
2627
+ * (0, 0) is top left
2628
+ * (500, 500) is the center
2629
+ * (1000, 1000) is the bottom right
2630
+
2631
+ z value is zoom, but since it is capped at 1000, probably better to use `ptz_zoom`.
2632
+ """
2633
+ await self.api.center_ptz_camera(self.id, x=x, y=y, z=z)
2634
+
2635
+ async def ptz_zoom(
2636
+ self,
2637
+ *,
2638
+ zoom: float,
2639
+ speed: int = 100,
2640
+ use_native: bool = False,
2641
+ ) -> None:
2642
+ """
2643
+ Zoom PTZ Camera.
2644
+
2645
+ Zoom levels vary from camera to camera, but for G4 PTZ it goes from 1x to 22x.
2646
+
2647
+ Zoom speed seems to range from 0 to 100. Any value over 100 results in a speed of 0.
2648
+ """
2649
+ if not self.feature_flags.is_ptz:
2650
+ raise BadRequest("Camera does not support PTZ features.")
2651
+
2652
+ if not use_native:
2653
+ zoom = self.feature_flags.zoom.to_native_value(zoom)
2654
+
2655
+ await self.api.zoom_ptz_camera(self.id, zoom=zoom, speed=speed)
2656
+
2657
+ async def get_ptz_position(self) -> PTZPosition:
2658
+ """Get current PTZ Position."""
2659
+ if not self.feature_flags.is_ptz:
2660
+ raise BadRequest("Camera does not support PTZ features.")
2661
+
2662
+ return await self.api.get_position_ptz_camera(self.id)
2663
+
2664
+ async def goto_ptz_slot(self, *, slot: int) -> None:
2665
+ """
2666
+ Goto PTZ slot position.
2667
+
2668
+ -1 is Home slot.
2669
+ """
2670
+ if not self.feature_flags.is_ptz:
2671
+ raise BadRequest("Camera does not support PTZ features.")
2672
+
2673
+ await self.api.goto_ptz_camera(self.id, slot=slot)
2674
+
2675
+ async def create_ptz_preset(self, *, name: str) -> PTZPreset:
2676
+ """Create PTZ Preset for camera based on current camera settings."""
2677
+ if not self.feature_flags.is_ptz:
2678
+ raise BadRequest("Camera does not support PTZ features.")
2679
+
2680
+ return await self.api.create_preset_ptz_camera(self.id, name=name)
2681
+
2682
+ async def get_ptz_presets(self) -> list[PTZPreset]:
2683
+ """Get PTZ Presets for camera."""
2684
+ if not self.feature_flags.is_ptz:
2685
+ raise BadRequest("Camera does not support PTZ features.")
2686
+
2687
+ return await self.api.get_presets_ptz_camera(self.id)
2688
+
2689
+ async def delete_ptz_preset(self, *, slot: int) -> None:
2690
+ """Delete PTZ preset for camera."""
2691
+ if not self.feature_flags.is_ptz:
2692
+ raise BadRequest("Camera does not support PTZ features.")
2693
+
2694
+ await self.api.delete_preset_ptz_camera(self.id, slot=slot)
2695
+
2696
+ async def get_ptz_home(self) -> PTZPreset:
2697
+ """Get PTZ home preset (-1)."""
2698
+ if not self.feature_flags.is_ptz:
2699
+ raise BadRequest("Camera does not support PTZ features.")
2700
+
2701
+ return await self.api.get_home_ptz_camera(self.id)
2702
+
2703
+ async def set_ptz_home(self) -> PTZPreset:
2704
+ """Get PTZ home preset (-1) to current position."""
2705
+ if not self.feature_flags.is_ptz:
2706
+ raise BadRequest("Camera does not support PTZ features.")
2707
+
2708
+ return await self.api.set_home_ptz_camera(self.id)
2709
+
2710
+ # endregion
2711
+
2712
+
2713
+ class Viewer(ProtectAdoptableDeviceModel):
2714
+ stream_limit: int
2715
+ software_version: str
2716
+ liveview_id: str
2717
+
2718
+ @classmethod
2719
+ @cache
2720
+ def _get_unifi_remaps(cls) -> dict[str, str]:
2721
+ return {**super()._get_unifi_remaps(), "liveview": "liveviewId"}
2722
+
2723
+ @classmethod
2724
+ @cache
2725
+ def _get_read_only_fields(cls) -> set[str]:
2726
+ return super()._get_read_only_fields() | {"softwareVersion"}
2727
+
2728
+ @property
2729
+ def liveview(self) -> Liveview | None:
2730
+ # user may not have permission to see the liveview
2731
+ return self.api.bootstrap.liveviews.get(self.liveview_id)
2732
+
2733
+ async def set_liveview(self, liveview: Liveview) -> None:
2734
+ """
2735
+ Sets the liveview current set for the viewer
2736
+
2737
+ Args:
2738
+ ----
2739
+ liveview: The liveview you want to set
2740
+
2741
+ """
2742
+ if self._api is not None and liveview.id not in self._api.bootstrap.liveviews:
2743
+ raise BadRequest("Unknown liveview")
2744
+
2745
+ async with self._update_lock:
2746
+ await asyncio.sleep(
2747
+ 0,
2748
+ ) # yield to the event loop once we have the lock to process any pending updates
2749
+ data_before_changes = self.dict_with_excludes()
2750
+ self.liveview_id = liveview.id
2751
+ # UniFi Protect bug: changing the liveview does _not_ emit a WS message
2752
+ await self.save_device(data_before_changes, force_emit=True)
2753
+
2754
+
2755
+ class Bridge(ProtectAdoptableDeviceModel):
2756
+ platform: str
2757
+
2758
+
2759
+ class SensorSettingsBase(ProtectBaseObject):
2760
+ is_enabled: bool
2761
+
2762
+
2763
+ class SensorThresholdSettings(SensorSettingsBase):
2764
+ margin: float # read only
2765
+ # "safe" thresholds for alerting
2766
+ # anything below/above will trigger alert
2767
+ low_threshold: float | None
2768
+ high_threshold: float | None
2769
+
2770
+
2771
+ class SensorSensitivitySettings(SensorSettingsBase):
2772
+ sensitivity: PercentInt
2773
+
2774
+
2775
+ class SensorBatteryStatus(ProtectBaseObject):
2776
+ percentage: PercentInt | None
2777
+ is_low: bool
2778
+
2779
+
2780
+ class SensorStat(ProtectBaseObject):
2781
+ value: float | None
2782
+ status: SensorStatusType
2783
+
2784
+
2785
+ class SensorStats(ProtectBaseObject):
2786
+ light: SensorStat
2787
+ humidity: SensorStat
2788
+ temperature: SensorStat
2789
+
2790
+
2791
+ class Sensor(ProtectAdoptableDeviceModel):
2792
+ alarm_settings: SensorSettingsBase
2793
+ alarm_triggered_at: datetime | None
2794
+ battery_status: SensorBatteryStatus
2795
+ camera_id: str | None
2796
+ humidity_settings: SensorThresholdSettings
2797
+ is_motion_detected: bool
2798
+ is_opened: bool
2799
+ leak_detected_at: datetime | None
2800
+ led_settings: SensorSettingsBase
2801
+ light_settings: SensorThresholdSettings
2802
+ motion_detected_at: datetime | None
2803
+ motion_settings: SensorSensitivitySettings
2804
+ open_status_changed_at: datetime | None
2805
+ stats: SensorStats
2806
+ tampering_detected_at: datetime | None
2807
+ temperature_settings: SensorThresholdSettings
2808
+ mount_type: MountType
2809
+
2810
+ # not directly from UniFi
2811
+ last_motion_event_id: str | None = None
2812
+ last_contact_event_id: str | None = None
2813
+ last_value_event_id: str | None = None
2814
+ last_alarm_event_id: str | None = None
2815
+ extreme_value_detected_at: datetime | None = None
2816
+ _tamper_timeout: datetime | None = PrivateAttr(None)
2817
+ _alarm_timeout: datetime | None = PrivateAttr(None)
2818
+
2819
+ @classmethod
2820
+ @cache
2821
+ def _get_unifi_remaps(cls) -> dict[str, str]:
2822
+ return {**super()._get_unifi_remaps(), "camera": "cameraId"}
2823
+
2824
+ @classmethod
2825
+ @cache
2826
+ def _get_read_only_fields(cls) -> set[str]:
2827
+ return super()._get_read_only_fields() | {
2828
+ "batteryStatus",
2829
+ "isMotionDetected",
2830
+ "leakDetectedAt",
2831
+ "tamperingDetectedAt",
2832
+ "isOpened",
2833
+ "openStatusChangedAt",
2834
+ "alarmTriggeredAt",
2835
+ "motionDetectedAt",
2836
+ "stats",
2837
+ }
2838
+
2839
+ def unifi_dict(
2840
+ self,
2841
+ data: dict[str, Any] | None = None,
2842
+ exclude: set[str] | None = None,
2843
+ ) -> dict[str, Any]:
2844
+ data = super().unifi_dict(data=data, exclude=exclude)
2845
+
2846
+ if "lastMotionEventId" in data:
2847
+ del data["lastMotionEventId"]
2848
+ if "lastContactEventId" in data:
2849
+ del data["lastContactEventId"]
2850
+ if "lastValueEventId" in data:
2851
+ del data["lastValueEventId"]
2852
+ if "lastAlarmEventId" in data:
2853
+ del data["lastAlarmEventId"]
2854
+ if "extremeValueDetectedAt" in data:
2855
+ del data["extremeValueDetectedAt"]
2856
+
2857
+ return data
2858
+
2859
+ @property
2860
+ def camera(self) -> Camera | None:
2861
+ """Paired Camera will always be none if no camera is paired"""
2862
+ if self.camera_id is None:
2863
+ return None
2864
+
2865
+ return self.api.bootstrap.cameras[self.camera_id]
2866
+
2867
+ @property
2868
+ def is_tampering_detected(self) -> bool:
2869
+ return self.tampering_detected_at is not None
2870
+
2871
+ @property
2872
+ def is_alarm_detected(self) -> bool:
2873
+ if self._alarm_timeout is None:
2874
+ return False
2875
+ return utc_now() < self._alarm_timeout
2876
+
2877
+ @property
2878
+ def is_contact_sensor_enabled(self) -> bool:
2879
+ return self.mount_type in {MountType.DOOR, MountType.WINDOW, MountType.GARAGE}
2880
+
2881
+ @property
2882
+ def is_motion_sensor_enabled(self) -> bool:
2883
+ return self.mount_type != MountType.LEAK and self.motion_settings.is_enabled
2884
+
2885
+ @property
2886
+ def is_alarm_sensor_enabled(self) -> bool:
2887
+ return self.mount_type != MountType.LEAK and self.alarm_settings.is_enabled
2888
+
2889
+ @property
2890
+ def is_light_sensor_enabled(self) -> bool:
2891
+ return self.mount_type != MountType.LEAK and self.light_settings.is_enabled
2892
+
2893
+ @property
2894
+ def is_temperature_sensor_enabled(self) -> bool:
2895
+ return (
2896
+ self.mount_type != MountType.LEAK and self.temperature_settings.is_enabled
2897
+ )
2898
+
2899
+ @property
2900
+ def is_humidity_sensor_enabled(self) -> bool:
2901
+ return self.mount_type != MountType.LEAK and self.humidity_settings.is_enabled
2902
+
2903
+ @property
2904
+ def is_leak_sensor_enabled(self) -> bool:
2905
+ return self.mount_type is MountType.LEAK
2906
+
2907
+ def set_alarm_timeout(self) -> None:
2908
+ self._alarm_timeout = utc_now() + EVENT_PING_INTERVAL
2909
+ self._event_callback_ping()
2910
+
2911
+ @property
2912
+ def last_motion_event(self) -> Event | None:
2913
+ if self.last_motion_event_id is None:
2914
+ return None
2915
+
2916
+ return self.api.bootstrap.events.get(self.last_motion_event_id)
2917
+
2918
+ @property
2919
+ def last_contact_event(self) -> Event | None:
2920
+ if self.last_contact_event_id is None:
2921
+ return None
2922
+
2923
+ return self.api.bootstrap.events.get(self.last_contact_event_id)
2924
+
2925
+ @property
2926
+ def last_value_event(self) -> Event | None:
2927
+ if self.last_value_event_id is None:
2928
+ return None
2929
+
2930
+ return self.api.bootstrap.events.get(self.last_value_event_id)
2931
+
2932
+ @property
2933
+ def last_alarm_event(self) -> Event | None:
2934
+ if self.last_alarm_event_id is None:
2935
+ return None
2936
+
2937
+ return self.api.bootstrap.events.get(self.last_alarm_event_id)
2938
+
2939
+ @property
2940
+ def is_leak_detected(self) -> bool:
2941
+ return self.leak_detected_at is not None
2942
+
2943
+ async def set_status_light(self, enabled: bool) -> None:
2944
+ """Sets the status indicator light for the sensor"""
2945
+
2946
+ def callback() -> None:
2947
+ self.led_settings.is_enabled = enabled
2948
+
2949
+ await self.queue_update(callback)
2950
+
2951
+ async def set_mount_type(self, mount_type: MountType) -> None:
2952
+ """Sets current mount type for sensor"""
2953
+
2954
+ def callback() -> None:
2955
+ self.mount_type = mount_type
2956
+
2957
+ await self.queue_update(callback)
2958
+
2959
+ async def set_motion_status(self, enabled: bool) -> None:
2960
+ """Sets the motion detection type for the sensor"""
2961
+
2962
+ def callback() -> None:
2963
+ self.motion_settings.is_enabled = enabled
2964
+
2965
+ await self.queue_update(callback)
2966
+
2967
+ async def set_motion_sensitivity(self, sensitivity: int) -> None:
2968
+ """Sets the motion sensitivity for the sensor"""
2969
+
2970
+ def callback() -> None:
2971
+ self.motion_settings.sensitivity = PercentInt(sensitivity)
2972
+
2973
+ await self.queue_update(callback)
2974
+
2975
+ async def set_temperature_status(self, enabled: bool) -> None:
2976
+ """Sets the temperature detection type for the sensor"""
2977
+
2978
+ def callback() -> None:
2979
+ self.temperature_settings.is_enabled = enabled
2980
+
2981
+ await self.queue_update(callback)
2982
+
2983
+ async def set_temperature_safe_range(self, low: float, high: float) -> None:
2984
+ """Sets the temperature safe range for the sensor"""
2985
+ if low < 0.0:
2986
+ raise BadRequest("Minimum value is 0°C")
2987
+ if high > 45.0:
2988
+ raise BadRequest("Maximum value is 45°C")
2989
+ if high <= low:
2990
+ raise BadRequest("High value must be above low value")
2991
+
2992
+ def callback() -> None:
2993
+ self.temperature_settings.low_threshold = low
2994
+ self.temperature_settings.high_threshold = high
2995
+
2996
+ await self.queue_update(callback)
2997
+
2998
+ async def remove_temperature_safe_range(self) -> None:
2999
+ """Removes the temperature safe range for the sensor"""
3000
+
3001
+ def callback() -> None:
3002
+ self.temperature_settings.low_threshold = None
3003
+ self.temperature_settings.high_threshold = None
3004
+
3005
+ await self.queue_update(callback)
3006
+
3007
+ async def set_humidity_status(self, enabled: bool) -> None:
3008
+ """Sets the humidity detection type for the sensor"""
3009
+
3010
+ def callback() -> None:
3011
+ self.humidity_settings.is_enabled = enabled
3012
+
3013
+ await self.queue_update(callback)
3014
+
3015
+ async def set_humidity_safe_range(self, low: float, high: float) -> None:
3016
+ """Sets the humidity safe range for the sensor"""
3017
+ if low < 1.0:
3018
+ raise BadRequest("Minimum value is 1%")
3019
+ if high > 99.0:
3020
+ raise BadRequest("Maximum value is 99%")
3021
+ if high <= low:
3022
+ raise BadRequest("High value must be above low value")
3023
+
3024
+ def callback() -> None:
3025
+ self.humidity_settings.low_threshold = low
3026
+ self.humidity_settings.high_threshold = high
3027
+
3028
+ await self.queue_update(callback)
3029
+
3030
+ async def remove_humidity_safe_range(self) -> None:
3031
+ """Removes the humidity safe range for the sensor"""
3032
+
3033
+ def callback() -> None:
3034
+ self.humidity_settings.low_threshold = None
3035
+ self.humidity_settings.high_threshold = None
3036
+
3037
+ await self.queue_update(callback)
3038
+
3039
+ async def set_light_status(self, enabled: bool) -> None:
3040
+ """Sets the light detection type for the sensor"""
3041
+
3042
+ def callback() -> None:
3043
+ self.light_settings.is_enabled = enabled
3044
+
3045
+ await self.queue_update(callback)
3046
+
3047
+ async def set_light_safe_range(self, low: float, high: float) -> None:
3048
+ """Sets the light safe range for the sensor"""
3049
+ if low < 1.0:
3050
+ raise BadRequest("Minimum value is 1 lux")
3051
+ if high > 1000.0:
3052
+ raise BadRequest("Maximum value is 1000 lux")
3053
+ if high <= low:
3054
+ raise BadRequest("High value must be above low value")
3055
+
3056
+ def callback() -> None:
3057
+ self.light_settings.low_threshold = low
3058
+ self.light_settings.high_threshold = high
3059
+
3060
+ await self.queue_update(callback)
3061
+
3062
+ async def remove_light_safe_range(self) -> None:
3063
+ """Removes the light safe range for the sensor"""
3064
+
3065
+ def callback() -> None:
3066
+ self.light_settings.low_threshold = None
3067
+ self.light_settings.high_threshold = None
3068
+
3069
+ await self.queue_update(callback)
3070
+
3071
+ async def set_alarm_status(self, enabled: bool) -> None:
3072
+ """Sets the alarm detection type for the sensor"""
3073
+
3074
+ def callback() -> None:
3075
+ self.alarm_settings.is_enabled = enabled
3076
+
3077
+ await self.queue_update(callback)
3078
+
3079
+ async def set_paired_camera(self, camera: Camera | None) -> None:
3080
+ """Sets the camera paired with the sensor"""
3081
+
3082
+ def callback() -> None:
3083
+ if camera is None:
3084
+ self.camera_id = None
3085
+ else:
3086
+ self.camera_id = camera.id
3087
+
3088
+ await self.queue_update(callback)
3089
+
3090
+ async def clear_tamper(self) -> None:
3091
+ """Clears tamper status for sensor"""
3092
+ if not self.api.bootstrap.auth_user.can(
3093
+ ModelType.SENSOR,
3094
+ PermissionNode.WRITE,
3095
+ self,
3096
+ ):
3097
+ raise NotAuthorized(
3098
+ f"Do not have permission to clear tamper for sensor: {self.id}",
3099
+ )
3100
+ await self.api.clear_tamper_sensor(self.id)
3101
+
3102
+
3103
+ class Doorlock(ProtectAdoptableDeviceModel):
3104
+ credentials: str | None
3105
+ lock_status: LockStatusType
3106
+ enable_homekit: bool
3107
+ auto_close_time: timedelta
3108
+ led_settings: SensorSettingsBase
3109
+ battery_status: SensorBatteryStatus
3110
+ camera_id: str | None
3111
+ has_homekit: bool
3112
+ private_token: str
3113
+
3114
+ @classmethod
3115
+ @cache
3116
+ def _get_unifi_remaps(cls) -> dict[str, str]:
3117
+ return {
3118
+ **super()._get_unifi_remaps(),
3119
+ "camera": "cameraId",
3120
+ "autoCloseTimeMs": "autoCloseTime",
3121
+ }
3122
+
3123
+ @classmethod
3124
+ @cache
3125
+ def _get_read_only_fields(cls) -> set[str]:
3126
+ return super()._get_read_only_fields() | {
3127
+ "credentials",
3128
+ "lockStatus",
3129
+ "batteryStatus",
3130
+ }
3131
+
3132
+ @classmethod
3133
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
3134
+ if "autoCloseTimeMs" in data and not isinstance(
3135
+ data["autoCloseTimeMs"],
3136
+ timedelta,
3137
+ ):
3138
+ data["autoCloseTimeMs"] = timedelta(milliseconds=data["autoCloseTimeMs"])
3139
+
3140
+ return super().unifi_dict_to_dict(data)
3141
+
3142
+ @property
3143
+ def camera(self) -> Camera | None:
3144
+ """Paired Camera will always be none if no camera is paired"""
3145
+ if self.camera_id is None:
3146
+ return None
3147
+
3148
+ return self.api.bootstrap.cameras[self.camera_id]
3149
+
3150
+ async def set_paired_camera(self, camera: Camera | None) -> None:
3151
+ """Sets the camera paired with the sensor"""
3152
+
3153
+ def callback() -> None:
3154
+ if camera is None:
3155
+ self.camera_id = None
3156
+ else:
3157
+ self.camera_id = camera.id
3158
+
3159
+ await self.queue_update(callback)
3160
+
3161
+ async def set_status_light(self, enabled: bool) -> None:
3162
+ """Sets the status indicator light for the doorlock"""
3163
+
3164
+ def callback() -> None:
3165
+ self.led_settings.is_enabled = enabled
3166
+
3167
+ await self.queue_update(callback)
3168
+
3169
+ async def set_auto_close_time(self, duration: timedelta) -> None:
3170
+ """Sets the auto-close time for doorlock. 0 seconds = disabled."""
3171
+ if duration > timedelta(hours=1):
3172
+ raise BadRequest("Max duration is 1 hour")
3173
+
3174
+ def callback() -> None:
3175
+ self.auto_close_time = duration
3176
+
3177
+ await self.queue_update(callback)
3178
+
3179
+ async def close_lock(self) -> None:
3180
+ """Close doorlock (lock)"""
3181
+ if self.lock_status != LockStatusType.OPEN:
3182
+ raise BadRequest("Lock is not open")
3183
+
3184
+ await self.api.close_lock(self.id)
3185
+
3186
+ async def open_lock(self) -> None:
3187
+ """Open doorlock (unlock)"""
3188
+ if self.lock_status != LockStatusType.CLOSED:
3189
+ raise BadRequest("Lock is not closed")
3190
+
3191
+ await self.api.open_lock(self.id)
3192
+
3193
+ async def calibrate(self) -> None:
3194
+ """
3195
+ Calibrate the doorlock.
3196
+
3197
+ Door must be open and lock unlocked.
3198
+ """
3199
+ await self.api.calibrate_lock(self.id)
3200
+
3201
+
3202
+ class ChimeFeatureFlags(ProtectBaseObject):
3203
+ has_wifi: bool
3204
+ # 2.9.20+
3205
+ has_https_client_ota: bool | None = None
3206
+
3207
+ @classmethod
3208
+ @cache
3209
+ def _get_unifi_remaps(cls) -> dict[str, str]:
3210
+ return {**super()._get_unifi_remaps(), "hasHttpsClientOTA": "hasHttpsClientOta"}
3211
+
3212
+
3213
+ class RingSetting(ProtectBaseObject):
3214
+ camera_id: str
3215
+ repeat_times: RepeatTimes
3216
+ track_no: int
3217
+ volume: int
3218
+
3219
+ @classmethod
3220
+ @cache
3221
+ def _get_unifi_remaps(cls) -> dict[str, str]:
3222
+ return {**super()._get_unifi_remaps(), "camera": "cameraId"}
3223
+
3224
+ @property
3225
+ def camera(self) -> Camera | None:
3226
+ """Paired Camera will always be none if no camera is paired"""
3227
+ if self.camera_id is None:
3228
+ return None # type: ignore[unreachable]
3229
+
3230
+ return self.api.bootstrap.cameras[self.camera_id]
3231
+
3232
+
3233
+ class ChimeTrack(ProtectBaseObject):
3234
+ md5: str
3235
+ name: str
3236
+ state: str
3237
+ track_no: int
3238
+ volume: int
3239
+ size: int
3240
+
3241
+ @classmethod
3242
+ @cache
3243
+ def _get_unifi_remaps(cls) -> dict[str, str]:
3244
+ return {**super()._get_unifi_remaps(), "track_no": "trackNo"}
3245
+
3246
+
3247
+ class Chime(ProtectAdoptableDeviceModel):
3248
+ volume: PercentInt
3249
+ is_probing_for_wifi: bool
3250
+ last_ring: datetime | None
3251
+ is_wireless_uplink_enabled: bool
3252
+ camera_ids: list[str]
3253
+ # requires 2.6.17+
3254
+ ap_mgmt_ip: IPv4Address | None = None
3255
+ # requires 2.7.15+
3256
+ feature_flags: ChimeFeatureFlags | None = None
3257
+ # requires 2.8.22+
3258
+ user_configured_ap: bool | None = None
3259
+ # requires 3.0.22+
3260
+ has_https_client_ota: bool | None = None
3261
+ platform: str | None = None
3262
+ repeat_times: RepeatTimes | None = None
3263
+ track_no: int | None = None
3264
+ ring_settings: list[RingSetting] = []
3265
+ speaker_track_list: list[ChimeTrack] = []
3266
+
3267
+ # TODO: used for adoption
3268
+ # apMac read only
3269
+ # apRssi read only
3270
+ # elementInfo read only
3271
+
3272
+ @classmethod
3273
+ @cache
3274
+ def _get_unifi_remaps(cls) -> dict[str, str]:
3275
+ return {**super()._get_unifi_remaps(), "hasHttpsClientOTA": "hasHttpsClientOta"}
3276
+
3277
+ @classmethod
3278
+ @cache
3279
+ def _get_read_only_fields(cls) -> set[str]:
3280
+ return super()._get_read_only_fields() | {"isProbingForWifi", "lastRing"}
3281
+
3282
+ @property
3283
+ def cameras(self) -> list[Camera]:
3284
+ """Paired Cameras for chime"""
3285
+ if len(self.camera_ids) == 0:
3286
+ return []
3287
+ return [self.api.bootstrap.cameras[c] for c in self.camera_ids]
3288
+
3289
+ async def set_volume(self, level: int) -> None:
3290
+ """Set the volume on chime."""
3291
+ old_value = self.volume
3292
+ new_value = PercentInt(level)
3293
+
3294
+ def callback() -> None:
3295
+ self.volume = new_value
3296
+ for setting in self.ring_settings:
3297
+ if setting.volume == old_value:
3298
+ setting.volume = new_value
3299
+
3300
+ await self.queue_update(callback)
3301
+
3302
+ async def set_volume_for_camera(self, camera: Camera, level: int) -> None:
3303
+ """Set the volume on chime for specific camera."""
3304
+
3305
+ def callback() -> None:
3306
+ handled = False
3307
+ for setting in self.ring_settings:
3308
+ if setting.camera_id == camera.id:
3309
+ setting.volume = cast(PercentInt, level)
3310
+ handled = True
3311
+ break
3312
+
3313
+ if not handled:
3314
+ raise BadRequest("Camera %s is not paired with chime", camera.id)
3315
+
3316
+ await self.queue_update(callback)
3317
+
3318
+ async def add_camera(self, camera: Camera) -> None:
3319
+ """Adds new paired camera to chime"""
3320
+ if not camera.feature_flags.is_doorbell:
3321
+ raise BadRequest("Camera does not have a chime")
3322
+
3323
+ if camera.id in self.camera_ids:
3324
+ raise BadRequest("Camera is already paired")
3325
+
3326
+ def callback() -> None:
3327
+ self.camera_ids.append(camera.id)
3328
+
3329
+ await self.queue_update(callback)
3330
+
3331
+ async def remove_camera(self, camera: Camera) -> None:
3332
+ """Removes paired camera from chime"""
3333
+ if camera.id not in self.camera_ids:
3334
+ raise BadRequest("Camera is not paired")
3335
+
3336
+ def callback() -> None:
3337
+ self.camera_ids.remove(camera.id)
3338
+
3339
+ await self.queue_update(callback)
3340
+
3341
+ async def play(
3342
+ self,
3343
+ *,
3344
+ volume: int | None = None,
3345
+ repeat_times: int | None = None,
3346
+ ) -> None:
3347
+ """Plays chime tone"""
3348
+ await self.api.play_speaker(self.id, volume=volume, repeat_times=repeat_times)
3349
+
3350
+ async def play_buzzer(self) -> None:
3351
+ """Plays chime buzzer"""
3352
+ await self.api.play_buzzer(self.id)
3353
+
3354
+ async def set_repeat_times(self, value: int) -> None:
3355
+ """Set repeat times on chime."""
3356
+ old_value = self.repeat_times
3357
+
3358
+ def callback() -> None:
3359
+ self.repeat_times = cast(RepeatTimes, value)
3360
+ for setting in self.ring_settings:
3361
+ if setting.repeat_times == old_value:
3362
+ setting.repeat_times = cast(RepeatTimes, value)
3363
+
3364
+ await self.queue_update(callback)
3365
+
3366
+ async def set_repeat_times_for_camera(
3367
+ self,
3368
+ camera: Camera,
3369
+ value: int,
3370
+ ) -> None:
3371
+ """Set repeat times on chime for specific camera."""
3372
+
3373
+ def callback() -> None:
3374
+ handled = False
3375
+ for setting in self.ring_settings:
3376
+ if setting.camera_id == camera.id:
3377
+ setting.repeat_times = cast(RepeatTimes, value)
3378
+ handled = True
3379
+ break
3380
+
3381
+ if not handled:
3382
+ raise BadRequest("Camera %s is not paired with chime", camera.id)
3383
+
3384
+ await self.queue_update(callback)