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.

uiprotect/data/nvr.py ADDED
@@ -0,0 +1,1520 @@
1
+ """UniFi Protect Data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from datetime import datetime, timedelta, tzinfo
8
+ from functools import cache
9
+ from ipaddress import IPv4Address, IPv6Address
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal
12
+ from uuid import UUID
13
+
14
+ import aiofiles
15
+ import orjson
16
+ import zoneinfo
17
+ from aiofiles import os as aos
18
+ from pydantic.v1.fields import PrivateAttr
19
+
20
+ from uiprotect.data.base import (
21
+ ProtectBaseObject,
22
+ ProtectDeviceModel,
23
+ ProtectModelWithId,
24
+ )
25
+ from uiprotect.data.devices import (
26
+ Camera,
27
+ CameraZone,
28
+ Light,
29
+ OSDSettings,
30
+ RecordingSettings,
31
+ Sensor,
32
+ SmartDetectSettings,
33
+ )
34
+ from uiprotect.data.types import (
35
+ AnalyticsOption,
36
+ DoorbellMessageType,
37
+ DoorbellText,
38
+ EventCategories,
39
+ EventType,
40
+ FirmwareReleaseChannel,
41
+ IteratorCallback,
42
+ ModelType,
43
+ MountType,
44
+ PercentFloat,
45
+ PercentInt,
46
+ PermissionNode,
47
+ ProgressCallback,
48
+ RecordingMode,
49
+ RecordingType,
50
+ ResolutionStorageType,
51
+ SensorStatusType,
52
+ SensorType,
53
+ SmartDetectObjectType,
54
+ StorageType,
55
+ Version,
56
+ )
57
+ from uiprotect.data.user import User, UserLocation
58
+ from uiprotect.exceptions import BadRequest, NotAuthorized
59
+ from uiprotect.utils import RELEASE_CACHE, process_datetime
60
+
61
+ if TYPE_CHECKING:
62
+ from pydantic.v1.typing import SetStr
63
+
64
+
65
+ _LOGGER = logging.getLogger(__name__)
66
+ MAX_SUPPORTED_CAMERAS = 256
67
+ MAX_EVENT_HISTORY_IN_STATE_MACHINE = MAX_SUPPORTED_CAMERAS * 2
68
+ DELETE_KEYS_THUMB = {"color", "vehicleType"}
69
+ DELETE_KEYS_EVENT = {"deletedAt", "category", "subCategory"}
70
+
71
+
72
+ class NVRLocation(UserLocation):
73
+ is_geofencing_enabled: bool
74
+ radius: int
75
+ model: ModelType | None = None
76
+
77
+
78
+ class SmartDetectItem(ProtectBaseObject):
79
+ id: str
80
+ timestamp: datetime
81
+ level: PercentInt
82
+ coord: tuple[int, int, int, int]
83
+ object_type: SmartDetectObjectType
84
+ zone_ids: list[int]
85
+ duration: timedelta
86
+
87
+ @classmethod
88
+ @cache
89
+ def _get_unifi_remaps(cls) -> dict[str, str]:
90
+ return {
91
+ **super()._get_unifi_remaps(),
92
+ "zones": "zoneIds",
93
+ }
94
+
95
+ @classmethod
96
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
97
+ if "duration" in data:
98
+ data["duration"] = timedelta(milliseconds=data["duration"])
99
+
100
+ return super().unifi_dict_to_dict(data)
101
+
102
+
103
+ class SmartDetectTrack(ProtectBaseObject):
104
+ id: str
105
+ payload: list[SmartDetectItem]
106
+ camera_id: str
107
+ event_id: str
108
+
109
+ @classmethod
110
+ @cache
111
+ def _get_unifi_remaps(cls) -> dict[str, str]:
112
+ return {
113
+ **super()._get_unifi_remaps(),
114
+ "camera": "cameraId",
115
+ "event": "eventId",
116
+ }
117
+
118
+ @property
119
+ def camera(self) -> Camera:
120
+ return self.api.bootstrap.cameras[self.camera_id]
121
+
122
+ @property
123
+ def event(self) -> Event | None:
124
+ return self.api.bootstrap.events.get(self.event_id)
125
+
126
+
127
+ class LicensePlateMetadata(ProtectBaseObject):
128
+ name: str
129
+ confidence_level: int
130
+
131
+
132
+ class EventThumbnailAttribute(ProtectBaseObject):
133
+ confidence: int
134
+ val: str
135
+
136
+
137
+ class EventThumbnailAttributes(ProtectBaseObject):
138
+ color: EventThumbnailAttribute | None = None
139
+ vehicle_type: EventThumbnailAttribute | None = None
140
+
141
+ def unifi_dict(
142
+ self,
143
+ data: dict[str, Any] | None = None,
144
+ exclude: set[str] | None = None,
145
+ ) -> dict[str, Any]:
146
+ data = super().unifi_dict(data=data, exclude=exclude)
147
+
148
+ for key in DELETE_KEYS_THUMB.intersection(data.keys()):
149
+ if data[key] is None:
150
+ del data[key]
151
+
152
+ return data
153
+
154
+
155
+ class EventDetectedThumbnail(ProtectBaseObject):
156
+ clock_best_wall: datetime | None = None
157
+ type: str
158
+ cropped_id: str
159
+ attributes: EventThumbnailAttributes | None = None
160
+ name: str | None
161
+
162
+ @classmethod
163
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
164
+ if "clockBestWall" in data:
165
+ if data["clockBestWall"]:
166
+ data["clockBestWall"] = process_datetime(data, "clockBestWall")
167
+ else:
168
+ del data["clockBestWall"]
169
+
170
+ return super().unifi_dict_to_dict(data)
171
+
172
+ def unifi_dict(
173
+ self,
174
+ data: dict[str, Any] | None = None,
175
+ exclude: set[str] | None = None,
176
+ ) -> dict[str, Any]:
177
+ data = super().unifi_dict(data=data, exclude=exclude)
178
+
179
+ if "name" in data and data["name"] is None:
180
+ del data["name"]
181
+
182
+ return data
183
+
184
+
185
+ class EventMetadata(ProtectBaseObject):
186
+ client_platform: str | None
187
+ reason: str | None
188
+ app_update: str | None
189
+ light_id: str | None
190
+ light_name: str | None
191
+ type: str | None
192
+ sensor_id: str | None
193
+ sensor_name: str | None
194
+ sensor_type: SensorType | None
195
+ doorlock_id: str | None
196
+ doorlock_name: str | None
197
+ from_value: str | None
198
+ to_value: str | None
199
+ mount_type: MountType | None
200
+ status: SensorStatusType | None
201
+ alarm_type: str | None
202
+ device_id: str | None
203
+ mac: str | None
204
+ # require 2.7.5+
205
+ license_plate: LicensePlateMetadata | None = None
206
+ # requires 2.11.13+
207
+ detected_thumbnails: list[EventDetectedThumbnail] | None = None
208
+
209
+ _collapse_keys: ClassVar[SetStr] = {
210
+ "lightId",
211
+ "lightName",
212
+ "type",
213
+ "sensorId",
214
+ "sensorName",
215
+ "sensorType",
216
+ "doorlockId",
217
+ "doorlockName",
218
+ "mountType",
219
+ "status",
220
+ "alarmType",
221
+ "deviceId",
222
+ "mac",
223
+ }
224
+
225
+ @classmethod
226
+ @cache
227
+ def _get_unifi_remaps(cls) -> dict[str, str]:
228
+ return {
229
+ **super()._get_unifi_remaps(),
230
+ "from": "fromValue",
231
+ "to": "toValue",
232
+ }
233
+
234
+ @classmethod
235
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
236
+ for key in cls._collapse_keys.intersection(data.keys()):
237
+ if isinstance(data[key], dict):
238
+ data[key] = data[key]["text"]
239
+
240
+ return super().unifi_dict_to_dict(data)
241
+
242
+ def unifi_dict(
243
+ self,
244
+ data: dict[str, Any] | None = None,
245
+ exclude: set[str] | None = None,
246
+ ) -> dict[str, Any]:
247
+ data = super().unifi_dict(data=data, exclude=exclude)
248
+
249
+ # all metadata keys optionally appear
250
+ for key, value in list(data.items()):
251
+ if value is None:
252
+ del data[key]
253
+
254
+ for key in self._collapse_keys.intersection(data.keys()):
255
+ # AI Theta/Hotplug exception
256
+ if key != "type" or data[key] not in {"audio", "video", "extender"}:
257
+ data[key] = {"text": data[key]}
258
+
259
+ return data
260
+
261
+
262
+ class Event(ProtectModelWithId):
263
+ type: EventType
264
+ start: datetime
265
+ end: datetime | None
266
+ score: int
267
+ heatmap_id: str | None
268
+ camera_id: str | None
269
+ smart_detect_types: list[SmartDetectObjectType]
270
+ smart_detect_event_ids: list[str]
271
+ thumbnail_id: str | None
272
+ user_id: str | None
273
+ timestamp: datetime | None
274
+ metadata: EventMetadata | None
275
+ # requires 2.7.5+
276
+ deleted_at: datetime | None = None
277
+ deletion_type: Literal["manual", "automatic"] | None = None
278
+ # only appears if `get_events` is called with category
279
+ category: EventCategories | None = None
280
+ sub_category: str | None = None
281
+
282
+ # TODO:
283
+ # partition
284
+ # description
285
+
286
+ _smart_detect_events: list[Event] | None = PrivateAttr(None)
287
+ _smart_detect_track: SmartDetectTrack | None = PrivateAttr(None)
288
+ _smart_detect_zones: dict[int, CameraZone] | None = PrivateAttr(None)
289
+
290
+ @classmethod
291
+ @cache
292
+ def _get_unifi_remaps(cls) -> dict[str, str]:
293
+ return {
294
+ **super()._get_unifi_remaps(),
295
+ "camera": "cameraId",
296
+ "heatmap": "heatmapId",
297
+ "user": "userId",
298
+ "thumbnail": "thumbnailId",
299
+ "smartDetectEvents": "smartDetectEventIds",
300
+ }
301
+
302
+ @classmethod
303
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
304
+ for key in {"start", "end", "timestamp", "deletedAt"}.intersection(data.keys()):
305
+ data[key] = process_datetime(data, key)
306
+
307
+ return super().unifi_dict_to_dict(data)
308
+
309
+ def unifi_dict(
310
+ self,
311
+ data: dict[str, Any] | None = None,
312
+ exclude: set[str] | None = None,
313
+ ) -> dict[str, Any]:
314
+ data = super().unifi_dict(data=data, exclude=exclude)
315
+
316
+ for key in DELETE_KEYS_EVENT.intersection(data.keys()):
317
+ if data[key] is None:
318
+ del data[key]
319
+
320
+ return data
321
+
322
+ @property
323
+ def camera(self) -> Camera | None:
324
+ if self.camera_id is None:
325
+ return None
326
+
327
+ return self.api.bootstrap.cameras.get(self.camera_id)
328
+
329
+ @property
330
+ def light(self) -> Light | None:
331
+ if self.metadata is None or self.metadata.light_id is None:
332
+ return None
333
+
334
+ return self.api.bootstrap.lights.get(self.metadata.light_id)
335
+
336
+ @property
337
+ def sensor(self) -> Sensor | None:
338
+ if self.metadata is None or self.metadata.sensor_id is None:
339
+ return None
340
+
341
+ return self.api.bootstrap.sensors.get(self.metadata.sensor_id)
342
+
343
+ @property
344
+ def user(self) -> User | None:
345
+ if self.user_id is None:
346
+ return None
347
+
348
+ return self.api.bootstrap.users.get(self.user_id)
349
+
350
+ @property
351
+ def smart_detect_events(self) -> list[Event]:
352
+ if self._smart_detect_events is not None:
353
+ return self._smart_detect_events
354
+
355
+ self._smart_detect_events = [
356
+ self.api.bootstrap.events[g]
357
+ for g in self.smart_detect_event_ids
358
+ if g in self.api.bootstrap.events
359
+ ]
360
+ return self._smart_detect_events
361
+
362
+ async def get_thumbnail(
363
+ self,
364
+ width: int | None = None,
365
+ height: int | None = None,
366
+ ) -> bytes | None:
367
+ """Gets thumbnail for event"""
368
+ if self.thumbnail_id is None:
369
+ return None
370
+ if not self.api.bootstrap.auth_user.can(
371
+ ModelType.CAMERA,
372
+ PermissionNode.READ_MEDIA,
373
+ self.camera,
374
+ ):
375
+ raise NotAuthorized(
376
+ f"Do not have permission to read media for camera: {self.id}",
377
+ )
378
+ return await self.api.get_event_thumbnail(self.thumbnail_id, width, height)
379
+
380
+ async def get_animated_thumbnail(
381
+ self,
382
+ width: int | None = None,
383
+ height: int | None = None,
384
+ *,
385
+ speedup: int = 10,
386
+ ) -> bytes | None:
387
+ """Gets animated thumbnail for event"""
388
+ if self.thumbnail_id is None:
389
+ return None
390
+ if not self.api.bootstrap.auth_user.can(
391
+ ModelType.CAMERA,
392
+ PermissionNode.READ_MEDIA,
393
+ self.camera,
394
+ ):
395
+ raise NotAuthorized(
396
+ f"Do not have permission to read media for camera: {self.id}",
397
+ )
398
+ return await self.api.get_event_animated_thumbnail(
399
+ self.thumbnail_id,
400
+ width,
401
+ height,
402
+ speedup=speedup,
403
+ )
404
+
405
+ async def get_heatmap(self) -> bytes | None:
406
+ """Gets heatmap for event"""
407
+ if self.heatmap_id is None:
408
+ return None
409
+ if not self.api.bootstrap.auth_user.can(
410
+ ModelType.CAMERA,
411
+ PermissionNode.READ_MEDIA,
412
+ self.camera,
413
+ ):
414
+ raise NotAuthorized(
415
+ f"Do not have permission to read media for camera: {self.id}",
416
+ )
417
+ return await self.api.get_event_heatmap(self.heatmap_id)
418
+
419
+ async def get_video(
420
+ self,
421
+ channel_index: int = 0,
422
+ output_file: Path | None = None,
423
+ iterator_callback: IteratorCallback | None = None,
424
+ progress_callback: ProgressCallback | None = None,
425
+ chunk_size: int = 65536,
426
+ ) -> bytes | None:
427
+ """
428
+ Get the MP4 video clip for this given event
429
+
430
+ Args:
431
+ ----
432
+ channel_index: index of `CameraChannel` on the camera to use to retrieve video from
433
+
434
+ Will raise an exception if event does not have a camera, end time or the channel index is wrong.
435
+
436
+ """
437
+ if self.camera is None:
438
+ raise BadRequest("Event does not have a camera")
439
+ if self.end is None:
440
+ raise BadRequest("Event is ongoing")
441
+
442
+ if not self.api.bootstrap.auth_user.can(
443
+ ModelType.CAMERA,
444
+ PermissionNode.READ_MEDIA,
445
+ self.camera,
446
+ ):
447
+ raise NotAuthorized(
448
+ f"Do not have permission to read media for camera: {self.id}",
449
+ )
450
+ return await self.api.get_camera_video(
451
+ self.camera.id,
452
+ self.start,
453
+ self.end,
454
+ channel_index,
455
+ output_file=output_file,
456
+ iterator_callback=iterator_callback,
457
+ progress_callback=progress_callback,
458
+ chunk_size=chunk_size,
459
+ )
460
+
461
+ async def get_smart_detect_track(self) -> SmartDetectTrack:
462
+ """
463
+ Gets smart detect track for given smart detect event.
464
+
465
+ If event is not a smart detect event, it will raise a `BadRequest`
466
+ """
467
+ if self.type not in {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE}:
468
+ raise BadRequest("Not a smart detect event")
469
+
470
+ if self._smart_detect_track is None:
471
+ self._smart_detect_track = await self.api.get_event_smart_detect_track(
472
+ self.id,
473
+ )
474
+
475
+ return self._smart_detect_track
476
+
477
+ async def get_smart_detect_zones(self) -> dict[int, CameraZone]:
478
+ """Gets the triggering zones for the smart detection"""
479
+ if self.camera is None:
480
+ raise BadRequest("No camera on event")
481
+
482
+ if self._smart_detect_zones is None:
483
+ smart_track = await self.get_smart_detect_track()
484
+
485
+ ids: set[int] = set()
486
+ for item in smart_track.payload:
487
+ ids |= set(item.zone_ids)
488
+
489
+ self._smart_detect_zones = {
490
+ z.id: z for z in self.camera.smart_detect_zones if z.id in ids
491
+ }
492
+
493
+ return self._smart_detect_zones
494
+
495
+
496
+ class PortConfig(ProtectBaseObject):
497
+ ump: int
498
+ http: int
499
+ https: int
500
+ rtsp: int
501
+ rtsps: int
502
+ rtmp: int
503
+ devices_wss: int
504
+ camera_https: int
505
+ live_ws: int
506
+ live_wss: int
507
+ tcp_streams: int
508
+ playback: int
509
+ ems_cli: int
510
+ ems_live_flv: int
511
+ camera_events: int
512
+ tcp_bridge: int
513
+ ucore: int
514
+ discovery_client: int
515
+ piongw: int | None = None
516
+ ems_json_cli: int | None = None
517
+ stacking: int | None = None
518
+ # 3.0.22+
519
+ ai_feature_console: int | None = None
520
+
521
+ @classmethod
522
+ @cache
523
+ def _get_unifi_remaps(cls) -> dict[str, str]:
524
+ return {
525
+ **super()._get_unifi_remaps(),
526
+ "emsCLI": "emsCli",
527
+ "emsLiveFLV": "emsLiveFlv",
528
+ "emsJsonCLI": "emsJsonCli",
529
+ }
530
+
531
+
532
+ class CPUInfo(ProtectBaseObject):
533
+ average_load: float
534
+ temperature: float
535
+
536
+
537
+ class MemoryInfo(ProtectBaseObject):
538
+ available: int | None
539
+ free: int | None
540
+ total: int | None
541
+
542
+
543
+ class StorageDevice(ProtectBaseObject):
544
+ model: str
545
+ size: int
546
+ healthy: bool | str
547
+
548
+
549
+ class StorageInfo(ProtectBaseObject):
550
+ available: int
551
+ is_recycling: bool
552
+ size: int
553
+ type: StorageType
554
+ used: int
555
+ devices: list[StorageDevice]
556
+ # requires 2.8.14+
557
+ capability: str | None = None
558
+
559
+ @classmethod
560
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
561
+ if "type" in data:
562
+ storage_type = data.pop("type")
563
+ try:
564
+ data["type"] = StorageType(storage_type)
565
+ except ValueError:
566
+ _LOGGER.warning("Unknown storage type: %s", storage_type)
567
+ data["type"] = StorageType.UNKNOWN
568
+
569
+ return super().unifi_dict_to_dict(data)
570
+
571
+
572
+ class StorageSpace(ProtectBaseObject):
573
+ total: int
574
+ used: int
575
+ available: int
576
+
577
+
578
+ class TMPFSInfo(ProtectBaseObject):
579
+ available: int
580
+ total: int
581
+ used: int
582
+ path: Path
583
+
584
+
585
+ class UOSDisk(ProtectBaseObject):
586
+ slot: int
587
+ state: str
588
+
589
+ type: Literal["SSD", "HDD"] | None = None
590
+ model: str | None = None
591
+ serial: str | None = None
592
+ firmware: str | None = None
593
+ rpm: int | None = None
594
+ ata: str | None = None
595
+ sata: str | None = None
596
+ action: str | None = None
597
+ healthy: str | None = None
598
+ reason: list[Any] | None = None
599
+ temperature: int | None = None
600
+ power_on_hours: int | None = None
601
+ life_span: PercentFloat | None = None
602
+ bad_sector: int | None = None
603
+ threshold: int | None = None
604
+ progress: PercentFloat | None = None
605
+ estimate: timedelta | None = None
606
+ # 2.10.10+
607
+ size: int | None = None
608
+
609
+ @classmethod
610
+ @cache
611
+ def _get_unifi_remaps(cls) -> dict[str, str]:
612
+ return {
613
+ **super()._get_unifi_remaps(),
614
+ "poweronhrs": "powerOnHours",
615
+ "life_span": "lifeSpan",
616
+ "bad_sector": "badSector",
617
+ }
618
+
619
+ @classmethod
620
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
621
+ if "estimate" in data and data["estimate"] is not None:
622
+ data["estimate"] = timedelta(seconds=data.pop("estimate"))
623
+
624
+ return super().unifi_dict_to_dict(data)
625
+
626
+ def unifi_dict(
627
+ self,
628
+ data: dict[str, Any] | None = None,
629
+ exclude: set[str] | None = None,
630
+ ) -> dict[str, Any]:
631
+ data = super().unifi_dict(data=data, exclude=exclude)
632
+
633
+ # estimate is actually in seconds, not milliseconds
634
+ if "estimate" in data and data["estimate"] is not None:
635
+ data["estimate"] /= 1000
636
+
637
+ if "state" in data and data["state"] == "nodisk":
638
+ delete_keys = [
639
+ "action",
640
+ "ata",
641
+ "bad_sector",
642
+ "estimate",
643
+ "firmware",
644
+ "healthy",
645
+ "life_span",
646
+ "model",
647
+ "poweronhrs",
648
+ "progress",
649
+ "reason",
650
+ "rpm",
651
+ "sata",
652
+ "serial",
653
+ "tempature",
654
+ "temperature",
655
+ "threshold",
656
+ "type",
657
+ ]
658
+ for key in delete_keys:
659
+ if key in data:
660
+ del data[key]
661
+
662
+ return data
663
+
664
+ @property
665
+ def has_disk(self) -> bool:
666
+ return self.state != "nodisk"
667
+
668
+ @property
669
+ def is_healthy(self) -> bool:
670
+ return self.state in {
671
+ "initializing",
672
+ "expanding",
673
+ "spare",
674
+ "normal",
675
+ }
676
+
677
+
678
+ class UOSSpace(ProtectBaseObject):
679
+ device: str
680
+ total_bytes: int
681
+ used_bytes: int
682
+ action: str
683
+ progress: PercentFloat | None = None
684
+ estimate: timedelta | None = None
685
+ # requires 2.8.14+
686
+ health: str | None = None
687
+ # requires 2.8.22+
688
+ space_type: str | None = None
689
+
690
+ # TODO:
691
+ # reasons
692
+
693
+ @classmethod
694
+ @cache
695
+ def _get_unifi_remaps(cls) -> dict[str, str]:
696
+ return {
697
+ **super()._get_unifi_remaps(),
698
+ "total_bytes": "totalBytes",
699
+ "used_bytes": "usedBytes",
700
+ "space_type": "spaceType",
701
+ }
702
+
703
+ @classmethod
704
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
705
+ if "estimate" in data and data["estimate"] is not None:
706
+ data["estimate"] = timedelta(seconds=data.pop("estimate"))
707
+
708
+ return super().unifi_dict_to_dict(data)
709
+
710
+ def unifi_dict(
711
+ self,
712
+ data: dict[str, Any] | None = None,
713
+ exclude: set[str] | None = None,
714
+ ) -> dict[str, Any]:
715
+ data = super().unifi_dict(data=data, exclude=exclude)
716
+
717
+ # estimate is actually in seconds, not milliseconds
718
+ if "estimate" in data and data["estimate"] is not None:
719
+ data["estimate"] /= 1000
720
+
721
+ return data
722
+
723
+
724
+ class UOSStorage(ProtectBaseObject):
725
+ disks: list[UOSDisk]
726
+ space: list[UOSSpace]
727
+
728
+ # TODO:
729
+ # sdcards
730
+
731
+
732
+ class SystemInfo(ProtectBaseObject):
733
+ cpu: CPUInfo
734
+ memory: MemoryInfo
735
+ storage: StorageInfo
736
+ tmpfs: TMPFSInfo
737
+ ustorage: UOSStorage | None = None
738
+
739
+ def unifi_dict(
740
+ self,
741
+ data: dict[str, Any] | None = None,
742
+ exclude: set[str] | None = None,
743
+ ) -> dict[str, Any]:
744
+ data = super().unifi_dict(data=data, exclude=exclude)
745
+
746
+ if data is not None and "ustorage" in data and data["ustorage"] is None:
747
+ del data["ustorage"]
748
+
749
+ return data
750
+
751
+
752
+ class DoorbellMessage(ProtectBaseObject):
753
+ type: DoorbellMessageType
754
+ text: DoorbellText
755
+
756
+
757
+ class DoorbellSettings(ProtectBaseObject):
758
+ default_message_text: DoorbellText
759
+ default_message_reset_timeout: timedelta
760
+ all_messages: list[DoorbellMessage]
761
+ custom_messages: list[DoorbellText]
762
+
763
+ @classmethod
764
+ @cache
765
+ def _get_unifi_remaps(cls) -> dict[str, str]:
766
+ return {
767
+ **super()._get_unifi_remaps(),
768
+ "defaultMessageResetTimeoutMs": "defaultMessageResetTimeout",
769
+ }
770
+
771
+ @classmethod
772
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
773
+ if "defaultMessageResetTimeoutMs" in data:
774
+ data["defaultMessageResetTimeout"] = timedelta(
775
+ milliseconds=data.pop("defaultMessageResetTimeoutMs"),
776
+ )
777
+
778
+ return super().unifi_dict_to_dict(data)
779
+
780
+
781
+ class RecordingTypeDistribution(ProtectBaseObject):
782
+ recording_type: RecordingType
783
+ size: int
784
+ percentage: float
785
+
786
+
787
+ class ResolutionDistribution(ProtectBaseObject):
788
+ resolution: ResolutionStorageType
789
+ size: int
790
+ percentage: float
791
+
792
+
793
+ class StorageDistribution(ProtectBaseObject):
794
+ recording_type_distributions: list[RecordingTypeDistribution]
795
+ resolution_distributions: list[ResolutionDistribution]
796
+
797
+ _recording_type_dict: dict[RecordingType, RecordingTypeDistribution] | None = (
798
+ PrivateAttr(None)
799
+ )
800
+ _resolution_dict: dict[ResolutionStorageType, ResolutionDistribution] | None = (
801
+ PrivateAttr(None)
802
+ )
803
+
804
+ def _get_recording_type_dict(
805
+ self,
806
+ ) -> dict[RecordingType, RecordingTypeDistribution]:
807
+ if self._recording_type_dict is None:
808
+ self._recording_type_dict = {}
809
+ for recording_type in self.recording_type_distributions:
810
+ self._recording_type_dict[recording_type.recording_type] = (
811
+ recording_type
812
+ )
813
+
814
+ return self._recording_type_dict
815
+
816
+ def _get_resolution_dict(
817
+ self,
818
+ ) -> dict[ResolutionStorageType, ResolutionDistribution]:
819
+ if self._resolution_dict is None:
820
+ self._resolution_dict = {}
821
+ for resolution in self.resolution_distributions:
822
+ self._resolution_dict[resolution.resolution] = resolution
823
+
824
+ return self._resolution_dict
825
+
826
+ @property
827
+ def timelapse_recordings(self) -> RecordingTypeDistribution | None:
828
+ return self._get_recording_type_dict().get(RecordingType.TIMELAPSE)
829
+
830
+ @property
831
+ def continuous_recordings(self) -> RecordingTypeDistribution | None:
832
+ return self._get_recording_type_dict().get(RecordingType.CONTINUOUS)
833
+
834
+ @property
835
+ def detections_recordings(self) -> RecordingTypeDistribution | None:
836
+ return self._get_recording_type_dict().get(RecordingType.DETECTIONS)
837
+
838
+ @property
839
+ def uhd_usage(self) -> ResolutionDistribution | None:
840
+ return self._get_resolution_dict().get(ResolutionStorageType.UHD)
841
+
842
+ @property
843
+ def hd_usage(self) -> ResolutionDistribution | None:
844
+ return self._get_resolution_dict().get(ResolutionStorageType.HD)
845
+
846
+ @property
847
+ def free(self) -> ResolutionDistribution | None:
848
+ return self._get_resolution_dict().get(ResolutionStorageType.FREE)
849
+
850
+ def update_from_dict(self, data: dict[str, Any]) -> StorageDistribution:
851
+ # reset internal look ups when data changes
852
+ self._recording_type_dict = None
853
+ self._resolution_dict = None
854
+
855
+ return super().update_from_dict(data)
856
+
857
+
858
+ class StorageStats(ProtectBaseObject):
859
+ utilization: float
860
+ capacity: timedelta | None
861
+ remaining_capacity: timedelta | None
862
+ recording_space: StorageSpace
863
+ storage_distribution: StorageDistribution
864
+
865
+ @classmethod
866
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
867
+ if "capacity" in data and data["capacity"] is not None:
868
+ data["capacity"] = timedelta(milliseconds=data.pop("capacity"))
869
+ if "remainingCapacity" in data and data["remainingCapacity"] is not None:
870
+ data["remainingCapacity"] = timedelta(
871
+ milliseconds=data.pop("remainingCapacity"),
872
+ )
873
+
874
+ return super().unifi_dict_to_dict(data)
875
+
876
+
877
+ class NVRFeatureFlags(ProtectBaseObject):
878
+ beta: bool
879
+ dev: bool
880
+ notifications_v2: bool
881
+ homekit_paired: bool | None = None
882
+ ulp_role_management: bool | None = None
883
+ # 2.9.20+
884
+ detection_labels: bool | None = None
885
+ has_two_way_audio_media_streams: bool | None = None
886
+
887
+
888
+ class NVRSmartDetection(ProtectBaseObject):
889
+ enable: bool
890
+ face_recognition: bool
891
+ license_plate_recognition: bool
892
+
893
+
894
+ class GlobalRecordingSettings(ProtectBaseObject):
895
+ osd_settings: OSDSettings
896
+ recording_settings: RecordingSettings
897
+ smart_detect_settings: SmartDetectSettings
898
+
899
+ # TODO:
900
+ # recordingSchedulesV2
901
+
902
+
903
+ class NVR(ProtectDeviceModel):
904
+ can_auto_update: bool
905
+ is_stats_gathering_enabled: bool
906
+ timezone: tzinfo
907
+ version: Version
908
+ ucore_version: str
909
+ hardware_platform: str
910
+ ports: PortConfig
911
+ last_update_at: datetime | None
912
+ is_station: bool
913
+ enable_automatic_backups: bool
914
+ enable_stats_reporting: bool
915
+ release_channel: FirmwareReleaseChannel
916
+ hosts: list[IPv4Address | IPv6Address | str]
917
+ enable_bridge_auto_adoption: bool
918
+ hardware_id: UUID
919
+ host_type: int
920
+ host_shortname: str
921
+ is_hardware: bool
922
+ is_wireless_uplink_enabled: bool | None
923
+ time_format: Literal["12h", "24h"]
924
+ temperature_unit: Literal["C", "F"]
925
+ recording_retention_duration: timedelta | None
926
+ enable_crash_reporting: bool
927
+ disable_audio: bool
928
+ analytics_data: AnalyticsOption
929
+ anonymous_device_id: UUID | None
930
+ camera_utilization: int
931
+ is_recycling: bool
932
+ disable_auto_link: bool
933
+ skip_firmware_update: bool
934
+ location_settings: NVRLocation
935
+ feature_flags: NVRFeatureFlags
936
+ system_info: SystemInfo
937
+ doorbell_settings: DoorbellSettings
938
+ storage_stats: StorageStats
939
+ is_away: bool
940
+ is_setup: bool
941
+ network: str
942
+ max_camera_capacity: dict[Literal["4K", "2K", "HD"], int]
943
+ market_name: str | None = None
944
+ stream_sharing_available: bool | None = None
945
+ is_db_available: bool | None = None
946
+ is_insights_enabled: bool | None = None
947
+ is_recording_disabled: bool | None = None
948
+ is_recording_motion_only: bool | None = None
949
+ ui_version: str | None = None
950
+ sso_channel: FirmwareReleaseChannel | None = None
951
+ is_stacked: bool | None = None
952
+ is_primary: bool | None = None
953
+ last_drive_slow_event: datetime | None = None
954
+ is_u_core_setup: bool | None = None
955
+ vault_camera_ids: list[str] = []
956
+ # requires 2.8.14+
957
+ corruption_state: str | None = None
958
+ country_code: str | None = None
959
+ has_gateway: bool | None = None
960
+ is_vault_registered: bool | None = None
961
+ public_ip: IPv4Address | None = None
962
+ ulp_version: str | None = None
963
+ wan_ip: IPv4Address | IPv6Address | None = None
964
+ # requires 2.9.20+
965
+ hard_drive_state: str | None = None
966
+ is_network_installed: bool | None = None
967
+ is_protect_updatable: bool | None = None
968
+ is_ucore_updatable: bool | None = None
969
+ # requires 2.11.13+
970
+ last_device_fw_updates_checked_at: datetime | None = None
971
+ # requires 3.0.22+
972
+ smart_detection: NVRSmartDetection | None = None
973
+ is_ucore_stacked: bool | None = None
974
+ global_camera_settings: GlobalRecordingSettings | None = None
975
+
976
+ # TODO:
977
+ # errorCode read only
978
+ # wifiSettings
979
+ # smartDetectAgreement
980
+ # dbRecoveryOptions
981
+ # portStatus
982
+ # cameraCapacity
983
+ # deviceFirmwareSettings
984
+
985
+ @classmethod
986
+ @cache
987
+ def _get_unifi_remaps(cls) -> dict[str, str]:
988
+ return {
989
+ **super()._get_unifi_remaps(),
990
+ "recordingRetentionDurationMs": "recordingRetentionDuration",
991
+ "vaultCameras": "vaultCameraIds",
992
+ "lastDeviceFWUpdatesCheckedAt": "lastDeviceFwUpdatesCheckedAt",
993
+ "isUCoreStacked": "isUcoreStacked",
994
+ }
995
+
996
+ @classmethod
997
+ @cache
998
+ def _get_read_only_fields(cls) -> set[str]:
999
+ return super()._get_read_only_fields() | {
1000
+ "version",
1001
+ "uiVersion",
1002
+ "hardwarePlatform",
1003
+ "ports",
1004
+ "lastUpdateAt",
1005
+ "isStation",
1006
+ "hosts",
1007
+ "hostShortname",
1008
+ "isDbAvailable",
1009
+ "isRecordingDisabled",
1010
+ "isRecordingMotionOnly",
1011
+ "cameraUtilization",
1012
+ "storageStats",
1013
+ "isRecycling",
1014
+ "avgMotions",
1015
+ "streamSharingAvailable",
1016
+ }
1017
+
1018
+ @classmethod
1019
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
1020
+ if "lastUpdateAt" in data:
1021
+ data["lastUpdateAt"] = process_datetime(data, "lastUpdateAt")
1022
+ if "lastDeviceFwUpdatesCheckedAt" in data:
1023
+ data["lastDeviceFwUpdatesCheckedAt"] = process_datetime(
1024
+ data,
1025
+ "lastDeviceFwUpdatesCheckedAt",
1026
+ )
1027
+ if (
1028
+ "recordingRetentionDurationMs" in data
1029
+ and data["recordingRetentionDurationMs"] is not None
1030
+ ):
1031
+ data["recordingRetentionDuration"] = timedelta(
1032
+ milliseconds=data.pop("recordingRetentionDurationMs"),
1033
+ )
1034
+ if "timezone" in data and not isinstance(data["timezone"], tzinfo):
1035
+ data["timezone"] = zoneinfo.ZoneInfo(data["timezone"])
1036
+
1037
+ return super().unifi_dict_to_dict(data)
1038
+
1039
+ async def _api_update(self, data: dict[str, Any]) -> None:
1040
+ return await self.api.update_nvr(data)
1041
+
1042
+ @property
1043
+ def is_analytics_enabled(self) -> bool:
1044
+ return self.analytics_data != AnalyticsOption.NONE
1045
+
1046
+ @property
1047
+ def protect_url(self) -> str:
1048
+ return f"{self.api.base_url}/protect/devices/{self.api.bootstrap.nvr.id}"
1049
+
1050
+ @property
1051
+ def display_name(self) -> str:
1052
+ return self.name or self.market_name or self.type
1053
+
1054
+ @property
1055
+ def vault_cameras(self) -> list[Camera]:
1056
+ """Vault Cameras for NVR"""
1057
+ if len(self.vault_camera_ids) == 0:
1058
+ return []
1059
+ return [self.api.bootstrap.cameras[c] for c in self.vault_camera_ids]
1060
+
1061
+ @property
1062
+ def is_global_recording_enabled(self) -> bool:
1063
+ """
1064
+ Is recording footage/events from the camera enabled?
1065
+
1066
+ If recording is not enabled, cameras will not produce any footage, thumbnails,
1067
+ motion/smart detection events.
1068
+ """
1069
+ return (
1070
+ self.global_camera_settings is not None
1071
+ and self.global_camera_settings.recording_settings.mode
1072
+ is not RecordingMode.NEVER
1073
+ )
1074
+
1075
+ @property
1076
+ def is_smart_detections_enabled(self) -> bool:
1077
+ """If smart detected enabled globally."""
1078
+ return self.smart_detection is not None and self.smart_detection.enable
1079
+
1080
+ @property
1081
+ def is_license_plate_detections_enabled(self) -> bool:
1082
+ """If smart detected enabled globally."""
1083
+ return (
1084
+ self.smart_detection is not None
1085
+ and self.smart_detection.enable
1086
+ and self.smart_detection.license_plate_recognition
1087
+ )
1088
+
1089
+ @property
1090
+ def is_face_detections_enabled(self) -> bool:
1091
+ """If smart detected enabled globally."""
1092
+ return (
1093
+ self.smart_detection is not None
1094
+ and self.smart_detection.enable
1095
+ and self.smart_detection.face_recognition
1096
+ )
1097
+
1098
+ def update_all_messages(self) -> None:
1099
+ """Updates doorbell_settings.all_messages after adding/removing custom message"""
1100
+ messages = self.doorbell_settings.custom_messages
1101
+ self.doorbell_settings.all_messages = [
1102
+ DoorbellMessage(
1103
+ type=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR,
1104
+ text=DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR.value.replace("_", " "), # type: ignore[arg-type]
1105
+ ),
1106
+ DoorbellMessage(
1107
+ type=DoorbellMessageType.DO_NOT_DISTURB,
1108
+ text=DoorbellMessageType.DO_NOT_DISTURB.value.replace("_", " "), # type: ignore[arg-type]
1109
+ ),
1110
+ *(
1111
+ DoorbellMessage(
1112
+ type=DoorbellMessageType.CUSTOM_MESSAGE,
1113
+ text=message,
1114
+ )
1115
+ for message in messages
1116
+ ),
1117
+ ]
1118
+
1119
+ async def set_insights(self, enabled: bool) -> None:
1120
+ """Sets analytics collection for NVR"""
1121
+
1122
+ def callback() -> None:
1123
+ self.is_insights_enabled = enabled
1124
+
1125
+ await self.queue_update(callback)
1126
+
1127
+ async def set_analytics(self, value: AnalyticsOption) -> None:
1128
+ """Sets analytics collection for NVR"""
1129
+
1130
+ def callback() -> None:
1131
+ self.analytics_data = value
1132
+
1133
+ await self.queue_update(callback)
1134
+
1135
+ async def set_anonymous_analytics(self, enabled: bool) -> None:
1136
+ """Enables or disables anonymous analystics for NVR"""
1137
+ if enabled:
1138
+ await self.set_analytics(AnalyticsOption.ANONYMOUS)
1139
+ else:
1140
+ await self.set_analytics(AnalyticsOption.NONE)
1141
+
1142
+ async def set_default_reset_timeout(self, timeout: timedelta) -> None:
1143
+ """Sets the default message reset timeout"""
1144
+
1145
+ def callback() -> None:
1146
+ self.doorbell_settings.default_message_reset_timeout = timeout
1147
+
1148
+ await self.queue_update(callback)
1149
+
1150
+ async def set_default_doorbell_message(self, message: str) -> None:
1151
+ """Sets default doorbell message"""
1152
+
1153
+ def callback() -> None:
1154
+ self.doorbell_settings.default_message_text = DoorbellText(message)
1155
+
1156
+ await self.queue_update(callback)
1157
+
1158
+ async def add_custom_doorbell_message(self, message: str) -> None:
1159
+ """Adds custom doorbell message"""
1160
+ if len(message) > 30:
1161
+ raise BadRequest("Message length over 30 characters")
1162
+
1163
+ if message in self.doorbell_settings.custom_messages:
1164
+ raise BadRequest("Custom doorbell message already exists")
1165
+
1166
+ async with self._update_lock:
1167
+ await asyncio.sleep(
1168
+ 0,
1169
+ ) # yield to the event loop once we have the look to ensure websocket updates are processed
1170
+ data_before_changes = self.dict_with_excludes()
1171
+ self.doorbell_settings.custom_messages.append(DoorbellText(message))
1172
+ await self.save_device(data_before_changes)
1173
+ self.update_all_messages()
1174
+
1175
+ async def remove_custom_doorbell_message(self, message: str) -> None:
1176
+ """Removes custom doorbell message"""
1177
+ if message not in self.doorbell_settings.custom_messages:
1178
+ raise BadRequest("Custom doorbell message does not exists")
1179
+
1180
+ async with self._update_lock:
1181
+ await asyncio.sleep(
1182
+ 0,
1183
+ ) # yield to the event loop once we have the look to ensure websocket updates are processed
1184
+ data_before_changes = self.dict_with_excludes()
1185
+ self.doorbell_settings.custom_messages.remove(DoorbellText(message))
1186
+ await self.save_device(data_before_changes)
1187
+ self.update_all_messages()
1188
+
1189
+ async def reboot(self) -> None:
1190
+ """Reboots the NVR"""
1191
+ await self.api.reboot_nvr()
1192
+
1193
+ async def _read_cache_file(self, file_path: Path) -> set[Version] | None:
1194
+ versions: set[Version] | None = None
1195
+
1196
+ if file_path.is_file():
1197
+ try:
1198
+ _LOGGER.debug("Reading release cache file: %s", file_path)
1199
+ async with aiofiles.open(file_path, "rb") as cache_file:
1200
+ versions = {
1201
+ Version(v) for v in orjson.loads(await cache_file.read())
1202
+ }
1203
+ except Exception:
1204
+ _LOGGER.warning("Failed to parse cache file: %s", file_path)
1205
+
1206
+ return versions
1207
+
1208
+ async def get_is_prerelease(self) -> bool:
1209
+ """Get if current version of Protect is a prerelease version."""
1210
+ # only EA versions have `-beta` in versions
1211
+ if self.version.is_prerelease:
1212
+ return True
1213
+
1214
+ # 2.6.14 is an EA version that looks like a release version
1215
+ cache_file_path = self.api.cache_dir / "release_cache.json"
1216
+ versions = await self._read_cache_file(
1217
+ cache_file_path,
1218
+ ) or await self._read_cache_file(RELEASE_CACHE)
1219
+ if versions is None or self.version not in versions:
1220
+ versions = await self.api.get_release_versions()
1221
+ try:
1222
+ _LOGGER.debug("Fetching releases from APT repos...")
1223
+ tmp = self.api.cache_dir / "release_cache.tmp.json"
1224
+ await aos.makedirs(self.api.cache_dir, exist_ok=True)
1225
+ async with aiofiles.open(tmp, "wb") as cache_file:
1226
+ await cache_file.write(orjson.dumps([str(v) for v in versions]))
1227
+ await aos.rename(tmp, cache_file_path)
1228
+ except Exception:
1229
+ _LOGGER.warning("Failed write cache file.")
1230
+
1231
+ return self.version not in versions
1232
+
1233
+ async def set_smart_detections(self, value: bool) -> None:
1234
+ """Set if smart detections are enabled."""
1235
+
1236
+ def callback() -> None:
1237
+ if self.smart_detection is not None:
1238
+ self.smart_detection.enable = value
1239
+
1240
+ await self.queue_update(callback)
1241
+
1242
+ async def set_face_recognition(self, value: bool) -> None:
1243
+ """Set if face detections are enabled. Requires smart detections to be enabled."""
1244
+ if self.smart_detection is None or not self.smart_detection.enable:
1245
+ raise BadRequest("Smart detections are not enabled.")
1246
+
1247
+ def callback() -> None:
1248
+ if self.smart_detection is not None:
1249
+ self.smart_detection.face_recognition = value
1250
+
1251
+ await self.queue_update(callback)
1252
+
1253
+ async def set_license_plate_recognition(self, value: bool) -> None:
1254
+ """Set if license plate detections are enabled. Requires smart detections to be enabled."""
1255
+ if self.smart_detection is None or not self.smart_detection.enable:
1256
+ raise BadRequest("Smart detections are not enabled.")
1257
+
1258
+ def callback() -> None:
1259
+ if self.smart_detection is not None:
1260
+ self.smart_detection.license_plate_recognition = value
1261
+
1262
+ await self.queue_update(callback)
1263
+
1264
+ async def set_global_osd_name(self, enabled: bool) -> None:
1265
+ """Sets whether camera name is in the On Screen Display"""
1266
+
1267
+ def callback() -> None:
1268
+ if self.global_camera_settings:
1269
+ self.global_camera_settings.osd_settings.is_name_enabled = enabled
1270
+
1271
+ await self.queue_update(callback)
1272
+
1273
+ async def set_global_osd_date(self, enabled: bool) -> None:
1274
+ """Sets whether current date is in the On Screen Display"""
1275
+
1276
+ def callback() -> None:
1277
+ if self.global_camera_settings:
1278
+ self.global_camera_settings.osd_settings.is_date_enabled = enabled
1279
+
1280
+ await self.queue_update(callback)
1281
+
1282
+ async def set_global_osd_logo(self, enabled: bool) -> None:
1283
+ """Sets whether the UniFi logo is in the On Screen Display"""
1284
+
1285
+ def callback() -> None:
1286
+ if self.global_camera_settings:
1287
+ self.global_camera_settings.osd_settings.is_logo_enabled = enabled
1288
+
1289
+ await self.queue_update(callback)
1290
+
1291
+ async def set_global_osd_bitrate(self, enabled: bool) -> None:
1292
+ """Sets whether camera bitrate is in the On Screen Display"""
1293
+
1294
+ def callback() -> None:
1295
+ # mismatch between UI internal data structure debug = bitrate data
1296
+ if self.global_camera_settings:
1297
+ self.global_camera_settings.osd_settings.is_debug_enabled = enabled
1298
+
1299
+ await self.queue_update(callback)
1300
+
1301
+ async def set_global_motion_detection(self, enabled: bool) -> None:
1302
+ """Sets motion detection on camera"""
1303
+
1304
+ def callback() -> None:
1305
+ if self.global_camera_settings:
1306
+ self.global_camera_settings.recording_settings.enable_motion_detection = enabled
1307
+
1308
+ await self.queue_update(callback)
1309
+
1310
+ async def set_global_recording_mode(self, mode: RecordingMode) -> None:
1311
+ """Sets recording mode on camera"""
1312
+
1313
+ def callback() -> None:
1314
+ if self.global_camera_settings:
1315
+ self.global_camera_settings.recording_settings.mode = mode
1316
+
1317
+ await self.queue_update(callback)
1318
+
1319
+ # object smart detections
1320
+
1321
+ def _is_smart_enabled(self, smart_type: SmartDetectObjectType) -> bool:
1322
+ return (
1323
+ self.is_global_recording_enabled
1324
+ and self.global_camera_settings is not None
1325
+ and smart_type
1326
+ in self.global_camera_settings.smart_detect_settings.object_types
1327
+ )
1328
+
1329
+ @property
1330
+ def is_global_person_detection_on(self) -> bool:
1331
+ """
1332
+ Is Person Detection available and enabled (camera will produce person smart
1333
+ detection events)?
1334
+ """
1335
+ return self._is_smart_enabled(SmartDetectObjectType.PERSON)
1336
+
1337
+ @property
1338
+ def is_global_person_tracking_enabled(self) -> bool:
1339
+ """Is person tracking enabled"""
1340
+ return (
1341
+ self.global_camera_settings is not None
1342
+ and self.global_camera_settings.smart_detect_settings.auto_tracking_object_types
1343
+ is not None
1344
+ and SmartDetectObjectType.PERSON
1345
+ in self.global_camera_settings.smart_detect_settings.auto_tracking_object_types
1346
+ )
1347
+
1348
+ @property
1349
+ def is_global_vehicle_detection_on(self) -> bool:
1350
+ """
1351
+ Is Vehicle Detection available and enabled (camera will produce vehicle smart
1352
+ detection events)?
1353
+ """
1354
+ return self._is_smart_enabled(SmartDetectObjectType.VEHICLE)
1355
+
1356
+ @property
1357
+ def is_global_license_plate_detection_on(self) -> bool:
1358
+ """
1359
+ Is License Plate Detection available and enabled (camera will produce face license
1360
+ plate detection events)?
1361
+ """
1362
+ return self._is_smart_enabled(SmartDetectObjectType.LICENSE_PLATE)
1363
+
1364
+ @property
1365
+ def is_global_package_detection_on(self) -> bool:
1366
+ """
1367
+ Is Package Detection available and enabled (camera will produce package smart
1368
+ detection events)?
1369
+ """
1370
+ return self._is_smart_enabled(SmartDetectObjectType.PACKAGE)
1371
+
1372
+ @property
1373
+ def is_global_animal_detection_on(self) -> bool:
1374
+ """
1375
+ Is Animal Detection available and enabled (camera will produce package smart
1376
+ detection events)?
1377
+ """
1378
+ return self._is_smart_enabled(SmartDetectObjectType.ANIMAL)
1379
+
1380
+ def _is_audio_enabled(self, smart_type: SmartDetectObjectType) -> bool:
1381
+ audio_type = smart_type.audio_type
1382
+ return (
1383
+ audio_type is not None
1384
+ and self.is_global_recording_enabled
1385
+ and self.global_camera_settings is not None
1386
+ and self.global_camera_settings.smart_detect_settings.audio_types
1387
+ is not None
1388
+ and audio_type
1389
+ in self.global_camera_settings.smart_detect_settings.audio_types
1390
+ )
1391
+
1392
+ @property
1393
+ def is_global_smoke_detection_on(self) -> bool:
1394
+ """
1395
+ Is Smoke Alarm Detection available and enabled (camera will produce smoke
1396
+ smart detection events)?
1397
+ """
1398
+ return self._is_audio_enabled(SmartDetectObjectType.SMOKE)
1399
+
1400
+ @property
1401
+ def is_global_co_detection_on(self) -> bool:
1402
+ """
1403
+ Is CO Alarm Detection available and enabled (camera will produce smoke smart
1404
+ detection events)?
1405
+ """
1406
+ return self._is_audio_enabled(SmartDetectObjectType.CMONX)
1407
+
1408
+ @property
1409
+ def is_global_siren_detection_on(self) -> bool:
1410
+ """
1411
+ Is Siren Detection available and enabled (camera will produce siren smart
1412
+ detection events)?
1413
+ """
1414
+ return self._is_audio_enabled(SmartDetectObjectType.SIREN)
1415
+
1416
+ @property
1417
+ def is_global_baby_cry_detection_on(self) -> bool:
1418
+ """
1419
+ Is Baby Cry Detection available and enabled (camera will produce baby cry smart
1420
+ detection events)?
1421
+ """
1422
+ return self._is_audio_enabled(SmartDetectObjectType.BABY_CRY)
1423
+
1424
+ @property
1425
+ def is_global_speaking_detection_on(self) -> bool:
1426
+ """
1427
+ Is Speaking Detection available and enabled (camera will produce speaking smart
1428
+ detection events)?
1429
+ """
1430
+ return self._is_audio_enabled(SmartDetectObjectType.SPEAK)
1431
+
1432
+ @property
1433
+ def is_global_bark_detection_on(self) -> bool:
1434
+ """
1435
+ Is Bark Detection available and enabled (camera will produce barking smart
1436
+ detection events)?
1437
+ """
1438
+ return self._is_audio_enabled(SmartDetectObjectType.BARK)
1439
+
1440
+ @property
1441
+ def is_global_car_alarm_detection_on(self) -> bool:
1442
+ """
1443
+ Is Car Alarm Detection available and enabled (camera will produce car alarm smart
1444
+ detection events)?
1445
+ """
1446
+ return self._is_audio_enabled(SmartDetectObjectType.BURGLAR)
1447
+
1448
+ @property
1449
+ def is_global_car_horn_detection_on(self) -> bool:
1450
+ """
1451
+ Is Car Horn Detection available and enabled (camera will produce car horn smart
1452
+ detection events)?
1453
+ """
1454
+ return self._is_audio_enabled(SmartDetectObjectType.CAR_HORN)
1455
+
1456
+ @property
1457
+ def is_global_glass_break_detection_on(self) -> bool:
1458
+ """
1459
+ Is Glass Break available and enabled (camera will produce glass break smart
1460
+ detection events)?
1461
+ """
1462
+ return self._is_audio_enabled(SmartDetectObjectType.GLASS_BREAK)
1463
+
1464
+
1465
+ class LiveviewSlot(ProtectBaseObject):
1466
+ camera_ids: list[str]
1467
+ cycle_mode: str
1468
+ cycle_interval: int
1469
+
1470
+ _cameras: list[Camera] | None = PrivateAttr(None)
1471
+
1472
+ @classmethod
1473
+ @cache
1474
+ def _get_unifi_remaps(cls) -> dict[str, str]:
1475
+ return {**super()._get_unifi_remaps(), "cameras": "cameraIds"}
1476
+
1477
+ @property
1478
+ def cameras(self) -> list[Camera]:
1479
+ if self._cameras is not None:
1480
+ return self._cameras
1481
+
1482
+ # user may not have permission to see the cameras in the liveview
1483
+ self._cameras = [
1484
+ self.api.bootstrap.cameras[g]
1485
+ for g in self.camera_ids
1486
+ if g in self.api.bootstrap.cameras
1487
+ ]
1488
+ return self._cameras
1489
+
1490
+
1491
+ class Liveview(ProtectModelWithId):
1492
+ name: str
1493
+ is_default: bool
1494
+ is_global: bool
1495
+ layout: int
1496
+ slots: list[LiveviewSlot]
1497
+ owner_id: str
1498
+
1499
+ @classmethod
1500
+ @cache
1501
+ def _get_unifi_remaps(cls) -> dict[str, str]:
1502
+ return {**super()._get_unifi_remaps(), "owner": "ownerId"}
1503
+
1504
+ @classmethod
1505
+ @cache
1506
+ def _get_read_only_fields(cls) -> set[str]:
1507
+ return super()._get_read_only_fields() | {"isDefault", "owner"}
1508
+
1509
+ @property
1510
+ def owner(self) -> User | None:
1511
+ """
1512
+ Owner of liveview.
1513
+
1514
+ Will be none if the user only has read only access and it was not made by their user.
1515
+ """
1516
+ return self.api.bootstrap.users.get(self.owner_id)
1517
+
1518
+ @property
1519
+ def protect_url(self) -> str:
1520
+ return f"{self.api.base_url}/protect/liveview/{self.id}"