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