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/__init__.py +13 -0
- uiprotect/__main__.py +24 -0
- uiprotect/api.py +1936 -0
- uiprotect/cli/__init__.py +314 -0
- uiprotect/cli/backup.py +1103 -0
- uiprotect/cli/base.py +238 -0
- uiprotect/cli/cameras.py +574 -0
- uiprotect/cli/chimes.py +180 -0
- uiprotect/cli/doorlocks.py +125 -0
- uiprotect/cli/events.py +258 -0
- uiprotect/cli/lights.py +119 -0
- uiprotect/cli/liveviews.py +65 -0
- uiprotect/cli/nvr.py +154 -0
- uiprotect/cli/sensors.py +278 -0
- uiprotect/cli/viewers.py +76 -0
- uiprotect/data/__init__.py +157 -0
- uiprotect/data/base.py +1116 -0
- uiprotect/data/bootstrap.py +634 -0
- uiprotect/data/convert.py +77 -0
- uiprotect/data/devices.py +3384 -0
- uiprotect/data/nvr.py +1520 -0
- uiprotect/data/types.py +630 -0
- uiprotect/data/user.py +236 -0
- uiprotect/data/websocket.py +236 -0
- uiprotect/exceptions.py +41 -0
- uiprotect/py.typed +0 -0
- uiprotect/release_cache.json +1 -0
- uiprotect/stream.py +166 -0
- uiprotect/test_util/__init__.py +531 -0
- uiprotect/test_util/anonymize.py +257 -0
- uiprotect/utils.py +610 -0
- uiprotect/websocket.py +225 -0
- uiprotect-0.1.0.dist-info/LICENSE +23 -0
- uiprotect-0.1.0.dist-info/METADATA +245 -0
- uiprotect-0.1.0.dist-info/RECORD +37 -0
- uiprotect-0.1.0.dist-info/WHEEL +4 -0
- uiprotect-0.1.0.dist-info/entry_points.txt +3 -0
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}"
|