uiprotect 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of uiprotect might be problematic. Click here for more details.
- uiprotect/__init__.py +13 -0
- uiprotect/__main__.py +24 -0
- uiprotect/api.py +1936 -0
- uiprotect/cli/__init__.py +314 -0
- uiprotect/cli/backup.py +1103 -0
- uiprotect/cli/base.py +238 -0
- uiprotect/cli/cameras.py +574 -0
- uiprotect/cli/chimes.py +180 -0
- uiprotect/cli/doorlocks.py +125 -0
- uiprotect/cli/events.py +258 -0
- uiprotect/cli/lights.py +119 -0
- uiprotect/cli/liveviews.py +65 -0
- uiprotect/cli/nvr.py +154 -0
- uiprotect/cli/sensors.py +278 -0
- uiprotect/cli/viewers.py +76 -0
- uiprotect/data/__init__.py +157 -0
- uiprotect/data/base.py +1116 -0
- uiprotect/data/bootstrap.py +634 -0
- uiprotect/data/convert.py +77 -0
- uiprotect/data/devices.py +3384 -0
- uiprotect/data/nvr.py +1520 -0
- uiprotect/data/types.py +630 -0
- uiprotect/data/user.py +236 -0
- uiprotect/data/websocket.py +236 -0
- uiprotect/exceptions.py +41 -0
- uiprotect/py.typed +0 -0
- uiprotect/release_cache.json +1 -0
- uiprotect/stream.py +166 -0
- uiprotect/test_util/__init__.py +531 -0
- uiprotect/test_util/anonymize.py +257 -0
- uiprotect/utils.py +610 -0
- uiprotect/websocket.py +225 -0
- uiprotect-0.1.0.dist-info/LICENSE +23 -0
- uiprotect-0.1.0.dist-info/METADATA +245 -0
- uiprotect-0.1.0.dist-info/RECORD +37 -0
- uiprotect-0.1.0.dist-info/WHEEL +4 -0
- uiprotect-0.1.0.dist-info/entry_points.txt +3 -0
uiprotect/api.py
ADDED
|
@@ -0,0 +1,1936 @@
|
|
|
1
|
+
"""UniFi Protect Server Wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from http.cookies import Morsel, SimpleCookie
|
|
15
|
+
from ipaddress import IPv4Address, IPv6Address
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal, cast
|
|
18
|
+
from urllib.parse import urljoin
|
|
19
|
+
from uuid import UUID
|
|
20
|
+
|
|
21
|
+
import aiofiles
|
|
22
|
+
import aiohttp
|
|
23
|
+
import orjson
|
|
24
|
+
from aiofiles import os as aos
|
|
25
|
+
from aiohttp import CookieJar, client_exceptions
|
|
26
|
+
from platformdirs import user_cache_dir, user_config_dir
|
|
27
|
+
from yarl import URL
|
|
28
|
+
|
|
29
|
+
from uiprotect.data import (
|
|
30
|
+
NVR,
|
|
31
|
+
Bootstrap,
|
|
32
|
+
Bridge,
|
|
33
|
+
Camera,
|
|
34
|
+
Doorlock,
|
|
35
|
+
Event,
|
|
36
|
+
EventCategories,
|
|
37
|
+
EventType,
|
|
38
|
+
Light,
|
|
39
|
+
Liveview,
|
|
40
|
+
ModelType,
|
|
41
|
+
ProtectAdoptableDeviceModel,
|
|
42
|
+
ProtectModel,
|
|
43
|
+
PTZPosition,
|
|
44
|
+
PTZPreset,
|
|
45
|
+
Sensor,
|
|
46
|
+
SmartDetectObjectType,
|
|
47
|
+
SmartDetectTrack,
|
|
48
|
+
Version,
|
|
49
|
+
Viewer,
|
|
50
|
+
WSPacket,
|
|
51
|
+
WSSubscriptionMessage,
|
|
52
|
+
create_from_unifi_dict,
|
|
53
|
+
)
|
|
54
|
+
from uiprotect.data.base import ProtectModelWithId
|
|
55
|
+
from uiprotect.data.devices import Chime
|
|
56
|
+
from uiprotect.data.types import IteratorCallback, ProgressCallback, RecordingMode
|
|
57
|
+
from uiprotect.exceptions import BadRequest, NotAuthorized, NvrError
|
|
58
|
+
from uiprotect.utils import (
|
|
59
|
+
decode_token_cookie,
|
|
60
|
+
get_response_reason,
|
|
61
|
+
ip_from_host,
|
|
62
|
+
set_debug,
|
|
63
|
+
to_js_time,
|
|
64
|
+
utc_now,
|
|
65
|
+
)
|
|
66
|
+
from uiprotect.websocket import Websocket
|
|
67
|
+
|
|
68
|
+
TOKEN_COOKIE_MAX_EXP_SECONDS = 60
|
|
69
|
+
|
|
70
|
+
NEVER_RAN = -1000
|
|
71
|
+
# how many seconds before the bootstrap is refreshed from Protect
|
|
72
|
+
DEVICE_UPDATE_INTERVAL = 900
|
|
73
|
+
# retry timeout for thumbnails/heatmaps
|
|
74
|
+
RETRY_TIMEOUT = 10
|
|
75
|
+
PROTECT_APT_URLS = [
|
|
76
|
+
"https://apt.artifacts.ui.com/dists/stretch/release/binary-arm64/Packages",
|
|
77
|
+
"https://apt.artifacts.ui.com/dists/bullseye/release/binary-arm64/Packages",
|
|
78
|
+
]
|
|
79
|
+
TYPES_BUG_MESSAGE = """There is currently a bug in UniFi Protect that makes `start` / `end` not work if `types` is not provided. This means uiprotect has to iterate over all of the events matching the filters provided to return values.
|
|
80
|
+
|
|
81
|
+
If your Protect instance has a lot of events, this request will take much longer then expected. It is recommended adding additional filters to speed the request up."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_LOGGER = logging.getLogger(__name__)
|
|
85
|
+
_COOKIE_RE = re.compile(r"^set-cookie: ", re.IGNORECASE)
|
|
86
|
+
|
|
87
|
+
# TODO: Urls to still support
|
|
88
|
+
# Backups
|
|
89
|
+
# * GET /backups - list backends
|
|
90
|
+
# * POST /backups/import - import backup
|
|
91
|
+
# * POST /backups - create backup
|
|
92
|
+
# * GET /backups/{id} - download backup
|
|
93
|
+
# * POST /backups/{id}/restore - restore backup
|
|
94
|
+
# * DELETE /backups/{id} - delete backup
|
|
95
|
+
#
|
|
96
|
+
# Cameras
|
|
97
|
+
# * POST /cameras/{id}/reset - factory reset camera
|
|
98
|
+
# * POST /cameras/{id}/reset-isp - reset ISP settings
|
|
99
|
+
# * POST /cameras/{id}/reset-isp - reset ISP settings
|
|
100
|
+
# * POST /cameras/{id}/wake - battery powered cameras
|
|
101
|
+
# * POST /cameras/{id}/sleep
|
|
102
|
+
# * POST /cameras/{id}/homekit-talkback-speaker-muted
|
|
103
|
+
# * GET /cameras/{id}/live-heatmap - add live heatmap to WebRTC stream
|
|
104
|
+
# * GET /cameras/{id}/enable-control - PTZ controls
|
|
105
|
+
# * GET /cameras/{id}/disable-control
|
|
106
|
+
# * POST /cameras/{id}/move
|
|
107
|
+
# * POST /cameras/{id}/ptz/position
|
|
108
|
+
# * GET|POST /cameras/{id}/ptz/preset
|
|
109
|
+
# * GET /cameras/{id}/ptz/snapshot
|
|
110
|
+
# * POST /cameras/{id}/ptz/goto
|
|
111
|
+
# * GET /cameras/{id}/analytics-heatmap - analytics
|
|
112
|
+
# * GET /cameras/{id}/analytics-detections
|
|
113
|
+
# * GET /cameras/{id}/wifi-list - WiFi scan
|
|
114
|
+
# * POST /cameras/{id}/wifi-setup - Change WiFi settings
|
|
115
|
+
# * GET /cameras/{id}/playback-history
|
|
116
|
+
# * GET|POST|DELETE /cameras/{id}/sharedStream - stream sharing, unfinished?
|
|
117
|
+
#
|
|
118
|
+
# Device Groups
|
|
119
|
+
# * GET|POST|PUT|DELETE /device-groups
|
|
120
|
+
# * GET|PATCH|DELETE /device-groups/{id}
|
|
121
|
+
# * PATCH /device-groups/{id}/items
|
|
122
|
+
#
|
|
123
|
+
# Events
|
|
124
|
+
# POST /events/{id}/animated-thumbnail
|
|
125
|
+
#
|
|
126
|
+
# Lights
|
|
127
|
+
# POST /lights/{id}/locate
|
|
128
|
+
#
|
|
129
|
+
# NVR
|
|
130
|
+
# GET|PATCH /nvr/device-password
|
|
131
|
+
#
|
|
132
|
+
# Schedules
|
|
133
|
+
# GET|POST /recordingSchedules
|
|
134
|
+
# PATCH|DELETE /recordingSchedules/{id}
|
|
135
|
+
#
|
|
136
|
+
# Sensors
|
|
137
|
+
# POST /sensors/{id}/locate
|
|
138
|
+
#
|
|
139
|
+
# Timeline
|
|
140
|
+
# GET /timeline
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_user_hash(host: str, username: str) -> str:
|
|
144
|
+
session = hashlib.sha256()
|
|
145
|
+
session.update(host.encode("utf8"))
|
|
146
|
+
session.update(username.encode("utf8"))
|
|
147
|
+
return session.hexdigest()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class BaseApiClient:
|
|
151
|
+
_host: str
|
|
152
|
+
_port: int
|
|
153
|
+
_username: str
|
|
154
|
+
_password: str
|
|
155
|
+
_verify_ssl: bool
|
|
156
|
+
_ws_timeout: int
|
|
157
|
+
|
|
158
|
+
_is_authenticated: bool = False
|
|
159
|
+
_last_update: float = NEVER_RAN
|
|
160
|
+
_last_ws_status: bool = False
|
|
161
|
+
_last_token_cookie: Morsel[str] | None = None
|
|
162
|
+
_last_token_cookie_decode: dict[str, Any] | None = None
|
|
163
|
+
_session: aiohttp.ClientSession | None = None
|
|
164
|
+
_loaded_session: bool = False
|
|
165
|
+
|
|
166
|
+
headers: dict[str, str] | None = None
|
|
167
|
+
_websocket: Websocket | None = None
|
|
168
|
+
|
|
169
|
+
api_path: str = "/proxy/protect/api/"
|
|
170
|
+
ws_path: str = "/proxy/protect/ws/updates"
|
|
171
|
+
|
|
172
|
+
cache_dir: Path
|
|
173
|
+
config_dir: Path
|
|
174
|
+
store_sessions: bool
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
host: str,
|
|
179
|
+
port: int,
|
|
180
|
+
username: str,
|
|
181
|
+
password: str,
|
|
182
|
+
verify_ssl: bool = True,
|
|
183
|
+
session: aiohttp.ClientSession | None = None,
|
|
184
|
+
ws_timeout: int = 30,
|
|
185
|
+
cache_dir: Path | None = None,
|
|
186
|
+
config_dir: Path | None = None,
|
|
187
|
+
store_sessions: bool = True,
|
|
188
|
+
) -> None:
|
|
189
|
+
self._auth_lock = asyncio.Lock()
|
|
190
|
+
self._host = host
|
|
191
|
+
self._port = port
|
|
192
|
+
|
|
193
|
+
self._username = username
|
|
194
|
+
self._password = password
|
|
195
|
+
self._verify_ssl = verify_ssl
|
|
196
|
+
self._ws_timeout = ws_timeout
|
|
197
|
+
self._loaded_session = False
|
|
198
|
+
|
|
199
|
+
self.config_dir = config_dir or (Path(user_config_dir()) / "ufp")
|
|
200
|
+
self.cache_dir = cache_dir or (Path(user_cache_dir()) / "ufp_cache")
|
|
201
|
+
self.store_sessions = store_sessions
|
|
202
|
+
|
|
203
|
+
if session is not None:
|
|
204
|
+
self._session = session
|
|
205
|
+
|
|
206
|
+
self._update_url()
|
|
207
|
+
|
|
208
|
+
def _update_url(self) -> None:
|
|
209
|
+
"""Updates the url after changing _host or _port."""
|
|
210
|
+
if self._port != 443:
|
|
211
|
+
self._url = URL(f"https://{self._host}:{self._port}")
|
|
212
|
+
else:
|
|
213
|
+
self._url = URL(f"https://{self._host}")
|
|
214
|
+
|
|
215
|
+
self.base_url = str(self._url)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def ws_url(self) -> str:
|
|
219
|
+
url = f"wss://{self._host}"
|
|
220
|
+
if self._port != 443:
|
|
221
|
+
url += f":{self._port}"
|
|
222
|
+
|
|
223
|
+
url += self.ws_path
|
|
224
|
+
last_update_id = self._get_last_update_id()
|
|
225
|
+
if last_update_id is None:
|
|
226
|
+
return url
|
|
227
|
+
return f"{url}?lastUpdateId={last_update_id}"
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def config_file(self) -> Path:
|
|
231
|
+
return self.config_dir / "unifi_protect.json"
|
|
232
|
+
|
|
233
|
+
async def get_session(self) -> aiohttp.ClientSession:
|
|
234
|
+
"""Gets or creates current client session"""
|
|
235
|
+
if self._session is None or self._session.closed:
|
|
236
|
+
if self._session is not None and self._session.closed:
|
|
237
|
+
_LOGGER.debug("Session was closed, creating a new one")
|
|
238
|
+
# need unsafe to access httponly cookies
|
|
239
|
+
self._session = aiohttp.ClientSession(cookie_jar=CookieJar(unsafe=True))
|
|
240
|
+
|
|
241
|
+
return self._session
|
|
242
|
+
|
|
243
|
+
async def get_websocket(self) -> Websocket:
|
|
244
|
+
"""Gets or creates current Websocket."""
|
|
245
|
+
|
|
246
|
+
async def _auth(force: bool) -> dict[str, str] | None:
|
|
247
|
+
if force:
|
|
248
|
+
if self._session is not None:
|
|
249
|
+
self._session.cookie_jar.clear()
|
|
250
|
+
self.set_header("cookie", None)
|
|
251
|
+
self.set_header("x-csrf-token", None)
|
|
252
|
+
|
|
253
|
+
await self.ensure_authenticated()
|
|
254
|
+
return self.headers
|
|
255
|
+
|
|
256
|
+
if self._websocket is None:
|
|
257
|
+
self._websocket = Websocket(
|
|
258
|
+
self.ws_url,
|
|
259
|
+
_auth,
|
|
260
|
+
verify=self._verify_ssl,
|
|
261
|
+
timeout=self._ws_timeout,
|
|
262
|
+
)
|
|
263
|
+
self._websocket.subscribe(self._process_ws_message)
|
|
264
|
+
|
|
265
|
+
return self._websocket
|
|
266
|
+
|
|
267
|
+
async def close_session(self) -> None:
|
|
268
|
+
"""Closing and delets client session"""
|
|
269
|
+
if self._session is not None:
|
|
270
|
+
await self._session.close()
|
|
271
|
+
self._session = None
|
|
272
|
+
self._loaded_session = False
|
|
273
|
+
|
|
274
|
+
def set_header(self, key: str, value: str | None) -> None:
|
|
275
|
+
"""Set header."""
|
|
276
|
+
self.headers = self.headers or {}
|
|
277
|
+
if value is None:
|
|
278
|
+
self.headers.pop(key, None)
|
|
279
|
+
else:
|
|
280
|
+
self.headers[key] = value
|
|
281
|
+
|
|
282
|
+
async def request(
|
|
283
|
+
self,
|
|
284
|
+
method: str,
|
|
285
|
+
url: str,
|
|
286
|
+
require_auth: bool = False,
|
|
287
|
+
auto_close: bool = True,
|
|
288
|
+
**kwargs: Any,
|
|
289
|
+
) -> aiohttp.ClientResponse:
|
|
290
|
+
"""Make a request to UniFi Protect"""
|
|
291
|
+
if require_auth:
|
|
292
|
+
await self.ensure_authenticated()
|
|
293
|
+
|
|
294
|
+
request_url = self._url.joinpath(url[1:])
|
|
295
|
+
headers = kwargs.get("headers") or self.headers
|
|
296
|
+
_LOGGER.debug("Request url: %s", request_url)
|
|
297
|
+
if not self._verify_ssl:
|
|
298
|
+
kwargs["ssl"] = False
|
|
299
|
+
session = await self.get_session()
|
|
300
|
+
|
|
301
|
+
for attempt in range(2):
|
|
302
|
+
try:
|
|
303
|
+
req_context = session.request(
|
|
304
|
+
method,
|
|
305
|
+
request_url,
|
|
306
|
+
headers=headers,
|
|
307
|
+
**kwargs,
|
|
308
|
+
)
|
|
309
|
+
response = await req_context.__aenter__()
|
|
310
|
+
|
|
311
|
+
await self._update_last_token_cookie(response)
|
|
312
|
+
if auto_close:
|
|
313
|
+
try:
|
|
314
|
+
_LOGGER.debug(
|
|
315
|
+
"%s %s %s",
|
|
316
|
+
response.status,
|
|
317
|
+
response.content_type,
|
|
318
|
+
response,
|
|
319
|
+
)
|
|
320
|
+
response.release()
|
|
321
|
+
except Exception:
|
|
322
|
+
# make sure response is released
|
|
323
|
+
response.release()
|
|
324
|
+
# re-raise exception
|
|
325
|
+
raise
|
|
326
|
+
|
|
327
|
+
return response
|
|
328
|
+
except aiohttp.ServerDisconnectedError as err:
|
|
329
|
+
# If the server disconnected, try again
|
|
330
|
+
# since HTTP/1.1 allows the server to disconnect
|
|
331
|
+
# at any time
|
|
332
|
+
if attempt == 0:
|
|
333
|
+
continue
|
|
334
|
+
raise NvrError(
|
|
335
|
+
f"Error requesting data from {self._host}: {err}",
|
|
336
|
+
) from err
|
|
337
|
+
except client_exceptions.ClientError as err:
|
|
338
|
+
raise NvrError(
|
|
339
|
+
f"Error requesting data from {self._host}: {err}",
|
|
340
|
+
) from err
|
|
341
|
+
|
|
342
|
+
# should never happen
|
|
343
|
+
raise NvrError(f"Error requesting data from {self._host}")
|
|
344
|
+
|
|
345
|
+
async def api_request_raw(
|
|
346
|
+
self,
|
|
347
|
+
url: str,
|
|
348
|
+
method: str = "get",
|
|
349
|
+
require_auth: bool = True,
|
|
350
|
+
raise_exception: bool = True,
|
|
351
|
+
**kwargs: Any,
|
|
352
|
+
) -> bytes | None:
|
|
353
|
+
"""Make a request to UniFi Protect API"""
|
|
354
|
+
url = urljoin(self.api_path, url)
|
|
355
|
+
response = await self.request(
|
|
356
|
+
method,
|
|
357
|
+
url,
|
|
358
|
+
require_auth=require_auth,
|
|
359
|
+
auto_close=False,
|
|
360
|
+
**kwargs,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
if response.status != 200:
|
|
365
|
+
reason = await get_response_reason(response)
|
|
366
|
+
msg = "Request failed: %s - Status: %s - Reason: %s"
|
|
367
|
+
if raise_exception:
|
|
368
|
+
if response.status in {401, 403}:
|
|
369
|
+
raise NotAuthorized(msg % (url, response.status, reason))
|
|
370
|
+
if response.status >= 400 and response.status < 500:
|
|
371
|
+
raise BadRequest(msg % (url, response.status, reason))
|
|
372
|
+
raise NvrError(msg % (url, response.status, reason))
|
|
373
|
+
_LOGGER.debug(msg, url, response.status, reason)
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
data: bytes | None = await response.read()
|
|
377
|
+
response.release()
|
|
378
|
+
|
|
379
|
+
return data
|
|
380
|
+
except Exception:
|
|
381
|
+
# make sure response is released
|
|
382
|
+
response.release()
|
|
383
|
+
# re-raise exception
|
|
384
|
+
raise
|
|
385
|
+
|
|
386
|
+
async def api_request(
|
|
387
|
+
self,
|
|
388
|
+
url: str,
|
|
389
|
+
method: str = "get",
|
|
390
|
+
require_auth: bool = True,
|
|
391
|
+
raise_exception: bool = True,
|
|
392
|
+
**kwargs: Any,
|
|
393
|
+
) -> list[Any] | dict[str, Any] | None:
|
|
394
|
+
data = await self.api_request_raw(
|
|
395
|
+
url=url,
|
|
396
|
+
method=method,
|
|
397
|
+
require_auth=require_auth,
|
|
398
|
+
raise_exception=raise_exception,
|
|
399
|
+
**kwargs,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if data is not None:
|
|
403
|
+
json_data: list[Any] | dict[str, Any] = orjson.loads(data)
|
|
404
|
+
return json_data
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
async def api_request_obj(
|
|
408
|
+
self,
|
|
409
|
+
url: str,
|
|
410
|
+
method: str = "get",
|
|
411
|
+
require_auth: bool = True,
|
|
412
|
+
raise_exception: bool = True,
|
|
413
|
+
**kwargs: Any,
|
|
414
|
+
) -> dict[str, Any]:
|
|
415
|
+
data = await self.api_request(
|
|
416
|
+
url=url,
|
|
417
|
+
method=method,
|
|
418
|
+
require_auth=require_auth,
|
|
419
|
+
raise_exception=raise_exception,
|
|
420
|
+
**kwargs,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if not isinstance(data, dict):
|
|
424
|
+
raise NvrError(f"Could not decode object from {url}")
|
|
425
|
+
|
|
426
|
+
return data
|
|
427
|
+
|
|
428
|
+
async def api_request_list(
|
|
429
|
+
self,
|
|
430
|
+
url: str,
|
|
431
|
+
method: str = "get",
|
|
432
|
+
require_auth: bool = True,
|
|
433
|
+
raise_exception: bool = True,
|
|
434
|
+
**kwargs: Any,
|
|
435
|
+
) -> list[Any]:
|
|
436
|
+
data = await self.api_request(
|
|
437
|
+
url=url,
|
|
438
|
+
method=method,
|
|
439
|
+
require_auth=require_auth,
|
|
440
|
+
raise_exception=raise_exception,
|
|
441
|
+
**kwargs,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if not isinstance(data, list):
|
|
445
|
+
raise NvrError(f"Could not decode list from {url}")
|
|
446
|
+
|
|
447
|
+
return data
|
|
448
|
+
|
|
449
|
+
async def ensure_authenticated(self) -> None:
|
|
450
|
+
"""Ensure we are authenticated."""
|
|
451
|
+
await self._load_session()
|
|
452
|
+
if self.is_authenticated() is False:
|
|
453
|
+
await self.authenticate()
|
|
454
|
+
|
|
455
|
+
async def authenticate(self) -> None:
|
|
456
|
+
"""Authenticate and get a token."""
|
|
457
|
+
if self._auth_lock.locked():
|
|
458
|
+
# If an auth is already in progress
|
|
459
|
+
# do not start another one
|
|
460
|
+
async with self._auth_lock:
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
async with self._auth_lock:
|
|
464
|
+
url = "/api/auth/login"
|
|
465
|
+
|
|
466
|
+
if self._session is not None:
|
|
467
|
+
self._session.cookie_jar.clear()
|
|
468
|
+
self.set_header("cookie", None)
|
|
469
|
+
|
|
470
|
+
auth = {
|
|
471
|
+
"username": self._username,
|
|
472
|
+
"password": self._password,
|
|
473
|
+
"rememberMe": self.store_sessions,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
response = await self.request("post", url=url, json=auth)
|
|
477
|
+
self.set_header("cookie", response.headers.get("set-cookie", ""))
|
|
478
|
+
self._is_authenticated = True
|
|
479
|
+
await self._update_last_token_cookie(response)
|
|
480
|
+
_LOGGER.debug("Authenticated successfully!")
|
|
481
|
+
|
|
482
|
+
async def _update_last_token_cookie(self, response: aiohttp.ClientResponse) -> None:
|
|
483
|
+
"""Update the last token cookie."""
|
|
484
|
+
csrf_token = response.headers.get("x-csrf-token")
|
|
485
|
+
if (
|
|
486
|
+
csrf_token is not None
|
|
487
|
+
and self.headers
|
|
488
|
+
and csrf_token != self.headers.get("x-csrf-token")
|
|
489
|
+
):
|
|
490
|
+
self.set_header("x-csrf-token", csrf_token)
|
|
491
|
+
await self._update_last_token_cookie(response)
|
|
492
|
+
|
|
493
|
+
if (
|
|
494
|
+
token_cookie := response.cookies.get("TOKEN")
|
|
495
|
+
) and token_cookie != self._last_token_cookie:
|
|
496
|
+
self._last_token_cookie = token_cookie
|
|
497
|
+
if self.store_sessions:
|
|
498
|
+
await self._update_auth_config(self._last_token_cookie)
|
|
499
|
+
self._last_token_cookie_decode = None
|
|
500
|
+
|
|
501
|
+
async def _update_auth_config(self, cookie: Morsel[str]) -> None:
|
|
502
|
+
"""Updates auth cookie on disk for persistent sessions."""
|
|
503
|
+
if self._last_token_cookie is None:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
await aos.makedirs(self.config_dir, exist_ok=True)
|
|
507
|
+
|
|
508
|
+
config: dict[str, Any] = {}
|
|
509
|
+
session_hash = get_user_hash(str(self._url), self._username)
|
|
510
|
+
try:
|
|
511
|
+
async with aiofiles.open(self.config_file, "rb") as f:
|
|
512
|
+
config_data = await f.read()
|
|
513
|
+
if config_data:
|
|
514
|
+
try:
|
|
515
|
+
config = orjson.loads(config_data)
|
|
516
|
+
except Exception:
|
|
517
|
+
_LOGGER.warning("Invalid config file, ignoring.")
|
|
518
|
+
except FileNotFoundError:
|
|
519
|
+
pass
|
|
520
|
+
|
|
521
|
+
config["sessions"] = config.get("sessions", {})
|
|
522
|
+
config["sessions"][session_hash] = {
|
|
523
|
+
"metadata": dict(cookie),
|
|
524
|
+
"value": cookie.value,
|
|
525
|
+
"csrf": self.headers.get("x-csrf-token") if self.headers else None,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async with aiofiles.open(self.config_file, "wb") as f:
|
|
529
|
+
await f.write(orjson.dumps(config, option=orjson.OPT_INDENT_2))
|
|
530
|
+
|
|
531
|
+
async def _load_session(self) -> None:
|
|
532
|
+
if self._session is None:
|
|
533
|
+
await self.get_session()
|
|
534
|
+
assert self._session is not None
|
|
535
|
+
|
|
536
|
+
if not self._loaded_session and self.store_sessions:
|
|
537
|
+
session_cookie = await self._read_auth_config()
|
|
538
|
+
self._loaded_session = True
|
|
539
|
+
if session_cookie:
|
|
540
|
+
_LOGGER.debug("Successfully loaded session from config")
|
|
541
|
+
self._session.cookie_jar.update_cookies(session_cookie)
|
|
542
|
+
|
|
543
|
+
async def _read_auth_config(self) -> SimpleCookie | None:
|
|
544
|
+
"""Read auth cookie from config."""
|
|
545
|
+
try:
|
|
546
|
+
async with aiofiles.open(self.config_file, "rb") as f:
|
|
547
|
+
config_data = await f.read()
|
|
548
|
+
if config_data:
|
|
549
|
+
try:
|
|
550
|
+
config = orjson.loads(config_data)
|
|
551
|
+
except Exception:
|
|
552
|
+
_LOGGER.warning("Invalid config file, ignoring.")
|
|
553
|
+
return None
|
|
554
|
+
except FileNotFoundError:
|
|
555
|
+
_LOGGER.debug("no config file, not loading session")
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
session_hash = get_user_hash(str(self._url), self._username)
|
|
559
|
+
session = config.get("sessions", {}).get(session_hash)
|
|
560
|
+
if not session:
|
|
561
|
+
_LOGGER.debug("No existing session for %s", session_hash)
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
cookie = SimpleCookie()
|
|
565
|
+
cookie["TOKEN"] = session.get("value")
|
|
566
|
+
for key, value in session.get("metadata", {}).items():
|
|
567
|
+
cookie["TOKEN"][key] = value
|
|
568
|
+
|
|
569
|
+
cookie_value = _COOKIE_RE.sub("", str(cookie["TOKEN"]))
|
|
570
|
+
self._last_token_cookie = cookie["TOKEN"]
|
|
571
|
+
self._last_token_cookie_decode = None
|
|
572
|
+
self._is_authenticated = True
|
|
573
|
+
self.set_header("cookie", cookie_value)
|
|
574
|
+
if session.get("csrf"):
|
|
575
|
+
self.set_header("x-csrf-token", session["csrf"])
|
|
576
|
+
return cookie
|
|
577
|
+
|
|
578
|
+
def is_authenticated(self) -> bool:
|
|
579
|
+
"""Check to see if we are already authenticated."""
|
|
580
|
+
if self._session is None:
|
|
581
|
+
return False
|
|
582
|
+
|
|
583
|
+
if self._is_authenticated is False:
|
|
584
|
+
return False
|
|
585
|
+
|
|
586
|
+
if self._last_token_cookie is None:
|
|
587
|
+
return False
|
|
588
|
+
|
|
589
|
+
# Lazy decode the token cookie
|
|
590
|
+
if self._last_token_cookie and self._last_token_cookie_decode is None:
|
|
591
|
+
self._last_token_cookie_decode = decode_token_cookie(
|
|
592
|
+
self._last_token_cookie,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
if (
|
|
596
|
+
self._last_token_cookie_decode is None
|
|
597
|
+
or "exp" not in self._last_token_cookie_decode
|
|
598
|
+
):
|
|
599
|
+
return False
|
|
600
|
+
|
|
601
|
+
token_expires_at = cast(int, self._last_token_cookie_decode["exp"])
|
|
602
|
+
max_expire_time = time.time() + TOKEN_COOKIE_MAX_EXP_SECONDS
|
|
603
|
+
|
|
604
|
+
return token_expires_at >= max_expire_time
|
|
605
|
+
|
|
606
|
+
async def async_connect_ws(self, force: bool) -> None:
|
|
607
|
+
"""Connect to Websocket."""
|
|
608
|
+
if force and self._websocket is not None:
|
|
609
|
+
await self._websocket.disconnect()
|
|
610
|
+
self._websocket = None
|
|
611
|
+
|
|
612
|
+
websocket = await self.get_websocket()
|
|
613
|
+
# important to make sure WS URL is always current
|
|
614
|
+
websocket.url = self.ws_url
|
|
615
|
+
|
|
616
|
+
if not websocket.is_connected:
|
|
617
|
+
self._last_ws_status = False
|
|
618
|
+
with contextlib.suppress(
|
|
619
|
+
TimeoutError,
|
|
620
|
+
asyncio.TimeoutError,
|
|
621
|
+
asyncio.CancelledError,
|
|
622
|
+
):
|
|
623
|
+
await websocket.connect()
|
|
624
|
+
|
|
625
|
+
async def async_disconnect_ws(self) -> None:
|
|
626
|
+
"""Disconnect from Websocket."""
|
|
627
|
+
if self._websocket is None:
|
|
628
|
+
return
|
|
629
|
+
await self._websocket.disconnect()
|
|
630
|
+
|
|
631
|
+
def check_ws(self) -> bool:
|
|
632
|
+
"""Checks current state of Websocket."""
|
|
633
|
+
if self._websocket is None:
|
|
634
|
+
return False
|
|
635
|
+
|
|
636
|
+
if not self._websocket.is_connected:
|
|
637
|
+
log = _LOGGER.debug
|
|
638
|
+
if self._last_ws_status:
|
|
639
|
+
log = _LOGGER.warning
|
|
640
|
+
log("Websocket connection not active, failing back to polling")
|
|
641
|
+
elif not self._last_ws_status:
|
|
642
|
+
_LOGGER.info("Websocket re-connected successfully")
|
|
643
|
+
|
|
644
|
+
self._last_ws_status = self._websocket.is_connected
|
|
645
|
+
return self._last_ws_status
|
|
646
|
+
|
|
647
|
+
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
648
|
+
raise NotImplementedError
|
|
649
|
+
|
|
650
|
+
def _get_last_update_id(self) -> UUID | None:
|
|
651
|
+
raise NotImplementedError
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
class ProtectApiClient(BaseApiClient):
|
|
655
|
+
"""
|
|
656
|
+
Main UFP API Client
|
|
657
|
+
|
|
658
|
+
UniFi Protect is a full async application. "normal" use of interacting with it is
|
|
659
|
+
to call `.update()` which will initialize the `.bootstrap` and create a Websocket
|
|
660
|
+
connection to UFP. This Websocket connection will emit messages that will automatically
|
|
661
|
+
update the `.bootstrap` over time. Caling `.udpate` again (without `force`) will
|
|
662
|
+
verify the integry of the Websocket connection.
|
|
663
|
+
|
|
664
|
+
You can use the `.get_` methods to one off pull devices from the UFP API, but should
|
|
665
|
+
not be used for building an aplication on top of.
|
|
666
|
+
|
|
667
|
+
All objects inside of `.bootstrap` have a refernce back to the API client so they can
|
|
668
|
+
use `.save_device()` and update themselves using their own `.set_` methods on the object.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
----
|
|
672
|
+
host: UFP hostname / IP address
|
|
673
|
+
port: UFP HTTPS port
|
|
674
|
+
username: UFP username
|
|
675
|
+
password: UFP password
|
|
676
|
+
verify_ssl: Verify HTTPS certificate (default: `True`)
|
|
677
|
+
session: Optional aiohttp session to use (default: generate one)
|
|
678
|
+
override_connection_host: Use `host` as your `connection_host` for RTSP stream instead of using the one provided by UniFi Protect.
|
|
679
|
+
minimum_score: minimum score for events (default: `0`)
|
|
680
|
+
subscribed_models: Model types you want to filter events for WS. You will need to manually check the bootstrap for updates for events that not subscibred.
|
|
681
|
+
ignore_stats: Ignore storage, system, etc. stats/metrics from NVR and cameras (default: false)
|
|
682
|
+
debug: Use full type validation (default: false)
|
|
683
|
+
|
|
684
|
+
"""
|
|
685
|
+
|
|
686
|
+
_minimum_score: int
|
|
687
|
+
_subscribed_models: set[ModelType]
|
|
688
|
+
_ignore_stats: bool
|
|
689
|
+
_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
690
|
+
_bootstrap: Bootstrap | None = None
|
|
691
|
+
_last_update_dt: datetime | None = None
|
|
692
|
+
_connection_host: IPv4Address | IPv6Address | str | None = None
|
|
693
|
+
|
|
694
|
+
ignore_unadopted: bool
|
|
695
|
+
|
|
696
|
+
def __init__(
|
|
697
|
+
self,
|
|
698
|
+
host: str,
|
|
699
|
+
port: int,
|
|
700
|
+
username: str,
|
|
701
|
+
password: str,
|
|
702
|
+
verify_ssl: bool = True,
|
|
703
|
+
session: aiohttp.ClientSession | None = None,
|
|
704
|
+
ws_timeout: int = 30,
|
|
705
|
+
cache_dir: Path | None = None,
|
|
706
|
+
config_dir: Path | None = None,
|
|
707
|
+
store_sessions: bool = True,
|
|
708
|
+
override_connection_host: bool = False,
|
|
709
|
+
minimum_score: int = 0,
|
|
710
|
+
subscribed_models: set[ModelType] | None = None,
|
|
711
|
+
ignore_stats: bool = False,
|
|
712
|
+
ignore_unadopted: bool = True,
|
|
713
|
+
debug: bool = False,
|
|
714
|
+
) -> None:
|
|
715
|
+
super().__init__(
|
|
716
|
+
host=host,
|
|
717
|
+
port=port,
|
|
718
|
+
username=username,
|
|
719
|
+
password=password,
|
|
720
|
+
verify_ssl=verify_ssl,
|
|
721
|
+
session=session,
|
|
722
|
+
ws_timeout=ws_timeout,
|
|
723
|
+
cache_dir=cache_dir,
|
|
724
|
+
config_dir=config_dir,
|
|
725
|
+
store_sessions=store_sessions,
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
self._minimum_score = minimum_score
|
|
729
|
+
self._subscribed_models = subscribed_models or set()
|
|
730
|
+
self._ignore_stats = ignore_stats
|
|
731
|
+
self._ws_subscriptions = []
|
|
732
|
+
self.ignore_unadopted = ignore_unadopted
|
|
733
|
+
|
|
734
|
+
if override_connection_host:
|
|
735
|
+
self._connection_host = ip_from_host(self._host)
|
|
736
|
+
|
|
737
|
+
if debug:
|
|
738
|
+
set_debug()
|
|
739
|
+
|
|
740
|
+
@property
|
|
741
|
+
def is_ready(self) -> bool:
|
|
742
|
+
return self._bootstrap is not None
|
|
743
|
+
|
|
744
|
+
@property
|
|
745
|
+
def bootstrap(self) -> Bootstrap:
|
|
746
|
+
if self._bootstrap is None:
|
|
747
|
+
raise BadRequest("Client not initalized, run `update` first")
|
|
748
|
+
|
|
749
|
+
return self._bootstrap
|
|
750
|
+
|
|
751
|
+
@property
|
|
752
|
+
def connection_host(self) -> IPv4Address | IPv6Address | str:
|
|
753
|
+
"""Connection host to use for generating RTSP URLs"""
|
|
754
|
+
if self._connection_host is None:
|
|
755
|
+
# fallback if cannot find user supplied host
|
|
756
|
+
index = 0
|
|
757
|
+
try:
|
|
758
|
+
# check if user supplied host is avaiable
|
|
759
|
+
index = self.bootstrap.nvr.hosts.index(self._host)
|
|
760
|
+
except ValueError:
|
|
761
|
+
# check if IP of user supplied host is avaiable
|
|
762
|
+
host = ip_from_host(self._host)
|
|
763
|
+
with contextlib.suppress(ValueError):
|
|
764
|
+
index = self.bootstrap.nvr.hosts.index(host)
|
|
765
|
+
|
|
766
|
+
self._connection_host = self.bootstrap.nvr.hosts[index]
|
|
767
|
+
|
|
768
|
+
return self._connection_host
|
|
769
|
+
|
|
770
|
+
async def update(self, force: bool = False) -> Bootstrap | None:
|
|
771
|
+
"""
|
|
772
|
+
Updates the state of devices, initalizes `.bootstrap` and
|
|
773
|
+
connects to UFP Websocket for real time updates
|
|
774
|
+
|
|
775
|
+
You can use the various other `get_` methods if you need one off data from UFP
|
|
776
|
+
"""
|
|
777
|
+
now = time.monotonic()
|
|
778
|
+
now_dt = utc_now()
|
|
779
|
+
max_event_dt = now_dt - timedelta(hours=1)
|
|
780
|
+
if force:
|
|
781
|
+
self._last_update = NEVER_RAN
|
|
782
|
+
self._last_update_dt = max_event_dt
|
|
783
|
+
|
|
784
|
+
bootstrap_updated = False
|
|
785
|
+
if self._bootstrap is None or now - self._last_update > DEVICE_UPDATE_INTERVAL:
|
|
786
|
+
bootstrap_updated = True
|
|
787
|
+
self._bootstrap = await self.get_bootstrap()
|
|
788
|
+
self._last_update = now
|
|
789
|
+
self._last_update_dt = now_dt
|
|
790
|
+
|
|
791
|
+
await self.async_connect_ws(force)
|
|
792
|
+
if self.check_ws():
|
|
793
|
+
# If the websocket is connected/connecting
|
|
794
|
+
# we do not need to get events
|
|
795
|
+
_LOGGER.debug("Skipping update since websocket is active")
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
if bootstrap_updated:
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
events = await self.get_events(
|
|
802
|
+
start=self._last_update_dt or max_event_dt,
|
|
803
|
+
end=now_dt,
|
|
804
|
+
)
|
|
805
|
+
for event in events:
|
|
806
|
+
self.bootstrap.process_event(event)
|
|
807
|
+
|
|
808
|
+
self._last_update = now
|
|
809
|
+
self._last_update_dt = now_dt
|
|
810
|
+
return self._bootstrap
|
|
811
|
+
|
|
812
|
+
def emit_message(self, msg: WSSubscriptionMessage) -> None:
|
|
813
|
+
if msg.new_obj is not None:
|
|
814
|
+
_LOGGER.debug(
|
|
815
|
+
"emitting message: %s:%s:%s:%s",
|
|
816
|
+
msg.action,
|
|
817
|
+
msg.new_obj.model,
|
|
818
|
+
msg.new_obj.id,
|
|
819
|
+
list(msg.changed_data.keys()),
|
|
820
|
+
)
|
|
821
|
+
elif msg.old_obj is not None:
|
|
822
|
+
_LOGGER.debug(
|
|
823
|
+
"emitting message: %s:%s:%s",
|
|
824
|
+
msg.action,
|
|
825
|
+
msg.old_obj.model,
|
|
826
|
+
msg.old_obj.id,
|
|
827
|
+
)
|
|
828
|
+
else:
|
|
829
|
+
_LOGGER.debug("emitting message: %s", msg.action)
|
|
830
|
+
for sub in self._ws_subscriptions:
|
|
831
|
+
try:
|
|
832
|
+
sub(msg)
|
|
833
|
+
except Exception:
|
|
834
|
+
_LOGGER.exception("Exception while running subscription handler")
|
|
835
|
+
|
|
836
|
+
def _get_last_update_id(self) -> UUID | None:
|
|
837
|
+
if self._bootstrap is None:
|
|
838
|
+
return None
|
|
839
|
+
return self._bootstrap.last_update_id
|
|
840
|
+
|
|
841
|
+
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
842
|
+
packet = WSPacket(msg.data)
|
|
843
|
+
processed_message = self.bootstrap.process_ws_packet(
|
|
844
|
+
packet,
|
|
845
|
+
models=self._subscribed_models,
|
|
846
|
+
ignore_stats=self._ignore_stats,
|
|
847
|
+
)
|
|
848
|
+
# update websocket URL after every message to ensure the latest last_update_id
|
|
849
|
+
if self._websocket is not None:
|
|
850
|
+
self._websocket.url = self.ws_url
|
|
851
|
+
|
|
852
|
+
if processed_message is None:
|
|
853
|
+
return
|
|
854
|
+
|
|
855
|
+
self.emit_message(processed_message)
|
|
856
|
+
|
|
857
|
+
async def _get_event_paginate(
|
|
858
|
+
self,
|
|
859
|
+
params: dict[str, Any],
|
|
860
|
+
*,
|
|
861
|
+
start: datetime,
|
|
862
|
+
end: datetime | None,
|
|
863
|
+
) -> list[dict[str, Any]]:
|
|
864
|
+
start_int = to_js_time(start)
|
|
865
|
+
end_int = to_js_time(end) if end else None
|
|
866
|
+
offset = 0
|
|
867
|
+
current_start = sys.maxsize
|
|
868
|
+
events: list[dict[str, Any]] = []
|
|
869
|
+
request_count = 0
|
|
870
|
+
logged = False
|
|
871
|
+
|
|
872
|
+
params["limit"] = 100
|
|
873
|
+
# greedy algorithm
|
|
874
|
+
# always force desc to receive faster results in the vast majority of cases
|
|
875
|
+
params["orderDirection"] = "DESC"
|
|
876
|
+
|
|
877
|
+
_LOGGER.debug("paginate desc %s %s", start_int, end_int)
|
|
878
|
+
while current_start > start_int:
|
|
879
|
+
params["offset"] = offset
|
|
880
|
+
|
|
881
|
+
_LOGGER.debug("page desc %s %s", offset, current_start)
|
|
882
|
+
new_events = await self.api_request_list("events", params=params)
|
|
883
|
+
request_count += 1
|
|
884
|
+
if not new_events:
|
|
885
|
+
break
|
|
886
|
+
|
|
887
|
+
if end_int is not None:
|
|
888
|
+
_LOGGER.debug("page end %s (%s)", new_events[0]["end"], end_int)
|
|
889
|
+
for event in new_events:
|
|
890
|
+
if event["start"] <= end_int:
|
|
891
|
+
events.append(event)
|
|
892
|
+
else:
|
|
893
|
+
break
|
|
894
|
+
else:
|
|
895
|
+
events += new_events
|
|
896
|
+
|
|
897
|
+
offset += 100
|
|
898
|
+
if events:
|
|
899
|
+
current_start = events[-1]["start"]
|
|
900
|
+
if not logged and request_count > 5:
|
|
901
|
+
logged = True
|
|
902
|
+
_LOGGER.warning(TYPES_BUG_MESSAGE)
|
|
903
|
+
|
|
904
|
+
to_remove = 0
|
|
905
|
+
for event in reversed(events):
|
|
906
|
+
if event["start"] < start_int:
|
|
907
|
+
to_remove += 1
|
|
908
|
+
else:
|
|
909
|
+
break
|
|
910
|
+
if to_remove:
|
|
911
|
+
events = events[:-to_remove]
|
|
912
|
+
|
|
913
|
+
return events
|
|
914
|
+
|
|
915
|
+
async def get_events_raw(
|
|
916
|
+
self,
|
|
917
|
+
*,
|
|
918
|
+
start: datetime | None = None,
|
|
919
|
+
end: datetime | None = None,
|
|
920
|
+
limit: int | None = None,
|
|
921
|
+
offset: int | None = None,
|
|
922
|
+
types: list[EventType] | None = None,
|
|
923
|
+
smart_detect_types: list[SmartDetectObjectType] | None = None,
|
|
924
|
+
sorting: Literal["asc", "desc"] = "asc",
|
|
925
|
+
descriptions: bool = True,
|
|
926
|
+
all_cameras: bool | None = None,
|
|
927
|
+
category: EventCategories | None = None,
|
|
928
|
+
# used for testing
|
|
929
|
+
_allow_manual_paginate: bool = True,
|
|
930
|
+
) -> list[dict[str, Any]]:
|
|
931
|
+
"""
|
|
932
|
+
Get list of events from Protect
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
----
|
|
936
|
+
start: start time for events
|
|
937
|
+
end: end time for events
|
|
938
|
+
limit: max number of events to return
|
|
939
|
+
offset: offset to start fetching events from
|
|
940
|
+
types: list of EventTypes to get events for
|
|
941
|
+
smart_detect_types: Filters the Smart detection types for the events
|
|
942
|
+
sorting: sort events by ascending or decending, defaults to ascending (chronologic order)
|
|
943
|
+
description: included additional event metadata
|
|
944
|
+
category: event category, will provide additional category/subcategory fields
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours.
|
|
948
|
+
|
|
949
|
+
If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or
|
|
950
|
+
`limit` must be provided. Otherwise, you will get a 400 error from UniFi Protect
|
|
951
|
+
|
|
952
|
+
"""
|
|
953
|
+
# if no parameters are passed in, default to all events from last 24 hours
|
|
954
|
+
if limit is None and start is None and end is None:
|
|
955
|
+
end = utc_now() + timedelta(seconds=10)
|
|
956
|
+
start = end - timedelta(hours=1)
|
|
957
|
+
|
|
958
|
+
params: dict[str, Any] = {
|
|
959
|
+
"orderDirection": sorting.upper(),
|
|
960
|
+
"withoutDescriptions": str(not descriptions).lower(),
|
|
961
|
+
}
|
|
962
|
+
if limit is not None:
|
|
963
|
+
params["limit"] = limit
|
|
964
|
+
if offset is not None:
|
|
965
|
+
params["offset"] = offset
|
|
966
|
+
|
|
967
|
+
if start is not None:
|
|
968
|
+
params["start"] = to_js_time(start)
|
|
969
|
+
|
|
970
|
+
if end is not None:
|
|
971
|
+
params["end"] = to_js_time(end)
|
|
972
|
+
|
|
973
|
+
if types is not None:
|
|
974
|
+
params["types"] = [e.value for e in types]
|
|
975
|
+
|
|
976
|
+
if smart_detect_types is not None:
|
|
977
|
+
params["smartDetectTypes"] = [e.value for e in smart_detect_types]
|
|
978
|
+
|
|
979
|
+
if all_cameras is not None:
|
|
980
|
+
params["allCameras"] = str(all_cameras).lower()
|
|
981
|
+
|
|
982
|
+
if category is not None:
|
|
983
|
+
params["categories"] = category
|
|
984
|
+
|
|
985
|
+
# manual workaround for a UniFi Protect bug
|
|
986
|
+
# if types if missing from query params
|
|
987
|
+
if _allow_manual_paginate and "types" not in params and start is not None:
|
|
988
|
+
if sorting == "asc":
|
|
989
|
+
events = await self._get_event_paginate(
|
|
990
|
+
params,
|
|
991
|
+
start=start,
|
|
992
|
+
end=end,
|
|
993
|
+
)
|
|
994
|
+
events = list(reversed(events))
|
|
995
|
+
else:
|
|
996
|
+
events = await self._get_event_paginate(
|
|
997
|
+
params,
|
|
998
|
+
start=start,
|
|
999
|
+
end=end,
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
if limit:
|
|
1003
|
+
offset = offset or 0
|
|
1004
|
+
events = events[offset : limit + offset]
|
|
1005
|
+
elif offset:
|
|
1006
|
+
events = events[offset:]
|
|
1007
|
+
return events
|
|
1008
|
+
|
|
1009
|
+
return await self.api_request_list("events", params=params)
|
|
1010
|
+
|
|
1011
|
+
async def get_events(
|
|
1012
|
+
self,
|
|
1013
|
+
start: datetime | None = None,
|
|
1014
|
+
end: datetime | None = None,
|
|
1015
|
+
limit: int | None = None,
|
|
1016
|
+
offset: int | None = None,
|
|
1017
|
+
types: list[EventType] | None = None,
|
|
1018
|
+
smart_detect_types: list[SmartDetectObjectType] | None = None,
|
|
1019
|
+
sorting: Literal["asc", "desc"] = "asc",
|
|
1020
|
+
descriptions: bool = True,
|
|
1021
|
+
category: EventCategories | None = None,
|
|
1022
|
+
# used for testing
|
|
1023
|
+
_allow_manual_paginate: bool = True,
|
|
1024
|
+
) -> list[Event]:
|
|
1025
|
+
"""
|
|
1026
|
+
Same as `get_events_raw`, except
|
|
1027
|
+
|
|
1028
|
+
* returns actual `Event` objects instead of raw Python dictionaries
|
|
1029
|
+
* filers out non-device events
|
|
1030
|
+
* filters out events with too low of a score
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
----
|
|
1034
|
+
start: start time for events
|
|
1035
|
+
end: end time for events
|
|
1036
|
+
limit: max number of events to return
|
|
1037
|
+
offset: offset to start fetching events from
|
|
1038
|
+
types: list of EventTypes to get events for
|
|
1039
|
+
smart_detect_types: Filters the Smart detection types for the events
|
|
1040
|
+
sorting: sort events by ascending or decending, defaults to ascending (chronologic order)
|
|
1041
|
+
description: included additional event metadata
|
|
1042
|
+
category: event category, will provide additional category/subcategory fields
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
If `limit`, `start` and `end` are not provided, it will default to all events in the last 24 hours.
|
|
1046
|
+
|
|
1047
|
+
If `start` is provided, then `end` or `limit` must be provided. If `end` is provided, then `start` or
|
|
1048
|
+
`limit` must be provided. Otherwise, you will get a 400 error from UniFi Protect
|
|
1049
|
+
|
|
1050
|
+
"""
|
|
1051
|
+
response = await self.get_events_raw(
|
|
1052
|
+
start=start,
|
|
1053
|
+
end=end,
|
|
1054
|
+
limit=limit,
|
|
1055
|
+
offset=offset,
|
|
1056
|
+
types=types,
|
|
1057
|
+
smart_detect_types=smart_detect_types,
|
|
1058
|
+
sorting=sorting,
|
|
1059
|
+
descriptions=descriptions,
|
|
1060
|
+
category=category,
|
|
1061
|
+
_allow_manual_paginate=_allow_manual_paginate,
|
|
1062
|
+
)
|
|
1063
|
+
events = []
|
|
1064
|
+
|
|
1065
|
+
for event_dict in response:
|
|
1066
|
+
# ignore unknown events
|
|
1067
|
+
if "type" not in event_dict or event_dict["type"] not in EventType.values():
|
|
1068
|
+
_LOGGER.debug("Unknown event type: %s", event_dict)
|
|
1069
|
+
continue
|
|
1070
|
+
|
|
1071
|
+
event = create_from_unifi_dict(event_dict, api=self)
|
|
1072
|
+
|
|
1073
|
+
# should never happen
|
|
1074
|
+
if not isinstance(event, Event):
|
|
1075
|
+
continue
|
|
1076
|
+
|
|
1077
|
+
if (
|
|
1078
|
+
event.type.value in EventType.device_events()
|
|
1079
|
+
and event.score >= self._minimum_score
|
|
1080
|
+
):
|
|
1081
|
+
events.append(event)
|
|
1082
|
+
|
|
1083
|
+
return events
|
|
1084
|
+
|
|
1085
|
+
def subscribe_websocket(
|
|
1086
|
+
self,
|
|
1087
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1088
|
+
) -> Callable[[], None]:
|
|
1089
|
+
"""
|
|
1090
|
+
Subscribe to websocket events.
|
|
1091
|
+
|
|
1092
|
+
Returns a callback that will unsubscribe.
|
|
1093
|
+
"""
|
|
1094
|
+
|
|
1095
|
+
def _unsub_ws_callback() -> None:
|
|
1096
|
+
self._ws_subscriptions.remove(ws_callback)
|
|
1097
|
+
|
|
1098
|
+
_LOGGER.debug("Adding subscription: %s", ws_callback)
|
|
1099
|
+
self._ws_subscriptions.append(ws_callback)
|
|
1100
|
+
return _unsub_ws_callback
|
|
1101
|
+
|
|
1102
|
+
async def get_bootstrap(self) -> Bootstrap:
|
|
1103
|
+
"""
|
|
1104
|
+
Gets bootstrap object from UFP instance
|
|
1105
|
+
|
|
1106
|
+
This is a great alternative if you need metadata about the NVR without connecting to the Websocket
|
|
1107
|
+
"""
|
|
1108
|
+
data = await self.api_request_obj("bootstrap")
|
|
1109
|
+
# fix for UniFi Protect bug, some cameras may come back with and old recording mode
|
|
1110
|
+
# "motion" and "smartDetect" recording modes was combined into "detections" in Protect 1.20.0
|
|
1111
|
+
call_again = False
|
|
1112
|
+
for camera_dict in data["cameras"]:
|
|
1113
|
+
if camera_dict.get("recordingSettings", {}).get("mode", "detections") in {
|
|
1114
|
+
"motion",
|
|
1115
|
+
"smartDetect",
|
|
1116
|
+
}:
|
|
1117
|
+
await self.update_device(
|
|
1118
|
+
ModelType.CAMERA,
|
|
1119
|
+
camera_dict["id"],
|
|
1120
|
+
{"recordingSettings": {"mode": RecordingMode.DETECTIONS.value}},
|
|
1121
|
+
)
|
|
1122
|
+
call_again = True
|
|
1123
|
+
|
|
1124
|
+
if call_again:
|
|
1125
|
+
data = await self.api_request_obj("bootstrap")
|
|
1126
|
+
return Bootstrap.from_unifi_dict(**data, api=self)
|
|
1127
|
+
|
|
1128
|
+
async def get_devices_raw(self, model_type: ModelType) -> list[dict[str, Any]]:
|
|
1129
|
+
"""Gets a raw device list given a model_type"""
|
|
1130
|
+
return await self.api_request_list(f"{model_type.value}s")
|
|
1131
|
+
|
|
1132
|
+
async def get_devices(
|
|
1133
|
+
self,
|
|
1134
|
+
model_type: ModelType,
|
|
1135
|
+
expected_type: type[ProtectModel] | None = None,
|
|
1136
|
+
) -> list[ProtectModel]:
|
|
1137
|
+
"""Gets a device list given a model_type, converted into Python objects"""
|
|
1138
|
+
objs: list[ProtectModel] = []
|
|
1139
|
+
|
|
1140
|
+
for obj_dict in await self.get_devices_raw(model_type):
|
|
1141
|
+
obj = create_from_unifi_dict(obj_dict)
|
|
1142
|
+
|
|
1143
|
+
if expected_type is not None and not isinstance(obj, expected_type):
|
|
1144
|
+
raise NvrError(f"Unexpected model returned: {obj.model}")
|
|
1145
|
+
if (
|
|
1146
|
+
self.ignore_unadopted
|
|
1147
|
+
and isinstance(obj, ProtectAdoptableDeviceModel)
|
|
1148
|
+
and not obj.is_adopted
|
|
1149
|
+
):
|
|
1150
|
+
continue
|
|
1151
|
+
|
|
1152
|
+
objs.append(obj)
|
|
1153
|
+
|
|
1154
|
+
return objs
|
|
1155
|
+
|
|
1156
|
+
async def get_cameras(self) -> list[Camera]:
|
|
1157
|
+
"""
|
|
1158
|
+
Gets the list of cameras straight from the NVR.
|
|
1159
|
+
|
|
1160
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.cameras`
|
|
1161
|
+
"""
|
|
1162
|
+
return cast(list[Camera], await self.get_devices(ModelType.CAMERA, Camera))
|
|
1163
|
+
|
|
1164
|
+
async def get_lights(self) -> list[Light]:
|
|
1165
|
+
"""
|
|
1166
|
+
Gets the list of lights straight from the NVR.
|
|
1167
|
+
|
|
1168
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.lights`
|
|
1169
|
+
"""
|
|
1170
|
+
return cast(list[Light], await self.get_devices(ModelType.LIGHT, Light))
|
|
1171
|
+
|
|
1172
|
+
async def get_sensors(self) -> list[Sensor]:
|
|
1173
|
+
"""
|
|
1174
|
+
Gets the list of sensors straight from the NVR.
|
|
1175
|
+
|
|
1176
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.sensors`
|
|
1177
|
+
"""
|
|
1178
|
+
return cast(list[Sensor], await self.get_devices(ModelType.SENSOR, Sensor))
|
|
1179
|
+
|
|
1180
|
+
async def get_doorlocks(self) -> list[Doorlock]:
|
|
1181
|
+
"""
|
|
1182
|
+
Gets the list of doorlocks straight from the NVR.
|
|
1183
|
+
|
|
1184
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.doorlocks`
|
|
1185
|
+
"""
|
|
1186
|
+
return cast(
|
|
1187
|
+
list[Doorlock],
|
|
1188
|
+
await self.get_devices(ModelType.DOORLOCK, Doorlock),
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
async def get_chimes(self) -> list[Chime]:
|
|
1192
|
+
"""
|
|
1193
|
+
Gets the list of chimes straight from the NVR.
|
|
1194
|
+
|
|
1195
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.chimes`
|
|
1196
|
+
"""
|
|
1197
|
+
return cast(list[Chime], await self.get_devices(ModelType.CHIME, Chime))
|
|
1198
|
+
|
|
1199
|
+
async def get_viewers(self) -> list[Viewer]:
|
|
1200
|
+
"""
|
|
1201
|
+
Gets the list of viewers straight from the NVR.
|
|
1202
|
+
|
|
1203
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.viewers`
|
|
1204
|
+
"""
|
|
1205
|
+
return cast(list[Viewer], await self.get_devices(ModelType.VIEWPORT, Viewer))
|
|
1206
|
+
|
|
1207
|
+
async def get_bridges(self) -> list[Bridge]:
|
|
1208
|
+
"""
|
|
1209
|
+
Gets the list of bridges straight from the NVR.
|
|
1210
|
+
|
|
1211
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.bridges`
|
|
1212
|
+
"""
|
|
1213
|
+
return cast(list[Bridge], await self.get_devices(ModelType.BRIDGE, Bridge))
|
|
1214
|
+
|
|
1215
|
+
async def get_liveviews(self) -> list[Liveview]:
|
|
1216
|
+
"""
|
|
1217
|
+
Gets the list of liveviews straight from the NVR.
|
|
1218
|
+
|
|
1219
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.liveviews`
|
|
1220
|
+
"""
|
|
1221
|
+
return cast(
|
|
1222
|
+
list[Liveview],
|
|
1223
|
+
await self.get_devices(ModelType.LIVEVIEW, Liveview),
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
async def get_device_raw(
|
|
1227
|
+
self,
|
|
1228
|
+
model_type: ModelType,
|
|
1229
|
+
device_id: str,
|
|
1230
|
+
) -> dict[str, Any]:
|
|
1231
|
+
"""Gets a raw device give the device model_type and id"""
|
|
1232
|
+
return await self.api_request_obj(f"{model_type.value}s/{device_id}")
|
|
1233
|
+
|
|
1234
|
+
async def get_device(
|
|
1235
|
+
self,
|
|
1236
|
+
model_type: ModelType,
|
|
1237
|
+
device_id: str,
|
|
1238
|
+
expected_type: type[ProtectModelWithId] | None = None,
|
|
1239
|
+
) -> ProtectModelWithId:
|
|
1240
|
+
"""Gets a device give the device model_type and id, converted into Python object"""
|
|
1241
|
+
obj = create_from_unifi_dict(
|
|
1242
|
+
await self.get_device_raw(model_type, device_id),
|
|
1243
|
+
api=self,
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
if expected_type is not None and not isinstance(obj, expected_type):
|
|
1247
|
+
raise NvrError(f"Unexpected model returned: {obj.model}")
|
|
1248
|
+
if (
|
|
1249
|
+
self.ignore_unadopted
|
|
1250
|
+
and isinstance(obj, ProtectAdoptableDeviceModel)
|
|
1251
|
+
and not obj.is_adopted
|
|
1252
|
+
):
|
|
1253
|
+
raise NvrError("Device is not adopted")
|
|
1254
|
+
|
|
1255
|
+
return cast(ProtectModelWithId, obj)
|
|
1256
|
+
|
|
1257
|
+
async def get_nvr(self) -> NVR:
|
|
1258
|
+
"""
|
|
1259
|
+
Gets an NVR object straight from the NVR.
|
|
1260
|
+
|
|
1261
|
+
This is a great alternative if you need metadata about the NVR without connecting to the Websocket
|
|
1262
|
+
"""
|
|
1263
|
+
data = await self.api_request_obj("nvr")
|
|
1264
|
+
return NVR.from_unifi_dict(**data, api=self)
|
|
1265
|
+
|
|
1266
|
+
async def get_event(self, event_id: str) -> Event:
|
|
1267
|
+
"""
|
|
1268
|
+
Gets an event straight from the NVR.
|
|
1269
|
+
|
|
1270
|
+
This is a great alternative if the event is no longer in the `self.bootstrap.events[event_id]` cache
|
|
1271
|
+
"""
|
|
1272
|
+
return cast(Event, await self.get_device(ModelType.EVENT, event_id, Event))
|
|
1273
|
+
|
|
1274
|
+
async def get_camera(self, device_id: str) -> Camera:
|
|
1275
|
+
"""
|
|
1276
|
+
Gets a camera straight from the NVR.
|
|
1277
|
+
|
|
1278
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.cameras[device_id]`
|
|
1279
|
+
"""
|
|
1280
|
+
return cast(Camera, await self.get_device(ModelType.CAMERA, device_id, Camera))
|
|
1281
|
+
|
|
1282
|
+
async def get_light(self, device_id: str) -> Light:
|
|
1283
|
+
"""
|
|
1284
|
+
Gets a light straight from the NVR.
|
|
1285
|
+
|
|
1286
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.lights[device_id]`
|
|
1287
|
+
"""
|
|
1288
|
+
return cast(Light, await self.get_device(ModelType.LIGHT, device_id, Light))
|
|
1289
|
+
|
|
1290
|
+
async def get_sensor(self, device_id: str) -> Sensor:
|
|
1291
|
+
"""
|
|
1292
|
+
Gets a sensor straight from the NVR.
|
|
1293
|
+
|
|
1294
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.sensors[device_id]`
|
|
1295
|
+
"""
|
|
1296
|
+
return cast(Sensor, await self.get_device(ModelType.SENSOR, device_id, Sensor))
|
|
1297
|
+
|
|
1298
|
+
async def get_doorlock(self, device_id: str) -> Doorlock:
|
|
1299
|
+
"""
|
|
1300
|
+
Gets a doorlock straight from the NVR.
|
|
1301
|
+
|
|
1302
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.doorlocks[device_id]`
|
|
1303
|
+
"""
|
|
1304
|
+
return cast(
|
|
1305
|
+
Doorlock,
|
|
1306
|
+
await self.get_device(ModelType.DOORLOCK, device_id, Doorlock),
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
async def get_chime(self, device_id: str) -> Chime:
|
|
1310
|
+
"""
|
|
1311
|
+
Gets a chime straight from the NVR.
|
|
1312
|
+
|
|
1313
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.chimes[device_id]`
|
|
1314
|
+
"""
|
|
1315
|
+
return cast(Chime, await self.get_device(ModelType.CHIME, device_id, Chime))
|
|
1316
|
+
|
|
1317
|
+
async def get_viewer(self, device_id: str) -> Viewer:
|
|
1318
|
+
"""
|
|
1319
|
+
Gets a viewer straight from the NVR.
|
|
1320
|
+
|
|
1321
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.viewers[device_id]`
|
|
1322
|
+
"""
|
|
1323
|
+
return cast(
|
|
1324
|
+
Viewer,
|
|
1325
|
+
await self.get_device(ModelType.VIEWPORT, device_id, Viewer),
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
async def get_bridge(self, device_id: str) -> Bridge:
|
|
1329
|
+
"""
|
|
1330
|
+
Gets a bridge straight from the NVR.
|
|
1331
|
+
|
|
1332
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.bridges[device_id]`
|
|
1333
|
+
"""
|
|
1334
|
+
return cast(Bridge, await self.get_device(ModelType.BRIDGE, device_id, Bridge))
|
|
1335
|
+
|
|
1336
|
+
async def get_liveview(self, device_id: str) -> Liveview:
|
|
1337
|
+
"""
|
|
1338
|
+
Gets a liveview straight from the NVR.
|
|
1339
|
+
|
|
1340
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.liveviews[device_id]`
|
|
1341
|
+
"""
|
|
1342
|
+
return cast(
|
|
1343
|
+
Liveview,
|
|
1344
|
+
await self.get_device(ModelType.LIVEVIEW, device_id, Liveview),
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
async def get_camera_snapshot(
|
|
1348
|
+
self,
|
|
1349
|
+
camera_id: str,
|
|
1350
|
+
width: int | None = None,
|
|
1351
|
+
height: int | None = None,
|
|
1352
|
+
dt: datetime | None = None,
|
|
1353
|
+
) -> bytes | None:
|
|
1354
|
+
"""
|
|
1355
|
+
Gets snapshot for a camera.
|
|
1356
|
+
|
|
1357
|
+
Datetime of screenshot is approximate. It may be +/- a few seconds.
|
|
1358
|
+
"""
|
|
1359
|
+
params = {
|
|
1360
|
+
"ts": to_js_time(dt or utc_now()),
|
|
1361
|
+
"force": "true",
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if width is not None:
|
|
1365
|
+
params.update({"w": width})
|
|
1366
|
+
|
|
1367
|
+
if height is not None:
|
|
1368
|
+
params.update({"h": height})
|
|
1369
|
+
|
|
1370
|
+
path = "snapshot"
|
|
1371
|
+
if dt is not None:
|
|
1372
|
+
path = "recording-snapshot"
|
|
1373
|
+
del params["force"]
|
|
1374
|
+
|
|
1375
|
+
return await self.api_request_raw(
|
|
1376
|
+
f"cameras/{camera_id}/{path}",
|
|
1377
|
+
params=params,
|
|
1378
|
+
raise_exception=False,
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
async def get_package_camera_snapshot(
|
|
1382
|
+
self,
|
|
1383
|
+
camera_id: str,
|
|
1384
|
+
width: int | None = None,
|
|
1385
|
+
height: int | None = None,
|
|
1386
|
+
dt: datetime | None = None,
|
|
1387
|
+
) -> bytes | None:
|
|
1388
|
+
"""
|
|
1389
|
+
Gets snapshot from the package camera.
|
|
1390
|
+
|
|
1391
|
+
Datetime of screenshot is approximate. It may be +/- a few seconds.
|
|
1392
|
+
"""
|
|
1393
|
+
params = {
|
|
1394
|
+
"ts": to_js_time(dt or utc_now()),
|
|
1395
|
+
"force": "true",
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if width is not None:
|
|
1399
|
+
params.update({"w": width})
|
|
1400
|
+
|
|
1401
|
+
if height is not None:
|
|
1402
|
+
params.update({"h": height})
|
|
1403
|
+
|
|
1404
|
+
path = "package-snapshot"
|
|
1405
|
+
if dt is not None:
|
|
1406
|
+
path = "recording-snapshot"
|
|
1407
|
+
del params["force"]
|
|
1408
|
+
params.update({"lens": 2})
|
|
1409
|
+
|
|
1410
|
+
return await self.api_request_raw(
|
|
1411
|
+
f"cameras/{camera_id}/{path}",
|
|
1412
|
+
params=params,
|
|
1413
|
+
raise_exception=False,
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
async def _stream_response(
|
|
1417
|
+
self,
|
|
1418
|
+
response: aiohttp.ClientResponse,
|
|
1419
|
+
chunk_size: int,
|
|
1420
|
+
iterator_callback: IteratorCallback | None = None,
|
|
1421
|
+
progress_callback: ProgressCallback | None = None,
|
|
1422
|
+
) -> None:
|
|
1423
|
+
total = response.content_length or 0
|
|
1424
|
+
current = 0
|
|
1425
|
+
if iterator_callback is not None:
|
|
1426
|
+
await iterator_callback(total, None)
|
|
1427
|
+
async for chunk in response.content.iter_chunked(chunk_size):
|
|
1428
|
+
step = len(chunk)
|
|
1429
|
+
current += step
|
|
1430
|
+
if iterator_callback is not None:
|
|
1431
|
+
await iterator_callback(total, chunk)
|
|
1432
|
+
if progress_callback is not None:
|
|
1433
|
+
await progress_callback(step, current, total)
|
|
1434
|
+
|
|
1435
|
+
async def get_camera_video(
|
|
1436
|
+
self,
|
|
1437
|
+
camera_id: str,
|
|
1438
|
+
start: datetime,
|
|
1439
|
+
end: datetime,
|
|
1440
|
+
channel_index: int = 0,
|
|
1441
|
+
validate_channel_id: bool = True,
|
|
1442
|
+
output_file: Path | None = None,
|
|
1443
|
+
iterator_callback: IteratorCallback | None = None,
|
|
1444
|
+
progress_callback: ProgressCallback | None = None,
|
|
1445
|
+
chunk_size: int = 65536,
|
|
1446
|
+
fps: int | None = None,
|
|
1447
|
+
) -> bytes | None:
|
|
1448
|
+
"""
|
|
1449
|
+
Exports MP4 video from a given camera at a specific time.
|
|
1450
|
+
|
|
1451
|
+
Start/End of video export are approximate. It may be +/- a few seconds.
|
|
1452
|
+
|
|
1453
|
+
It is recommended to provide a output file or progress callback for larger
|
|
1454
|
+
video clips, otherwise the full video must be downloaded to memory before
|
|
1455
|
+
being written.
|
|
1456
|
+
|
|
1457
|
+
Providing the `fps` parameter creates a "timelapse" export wtih the given FPS
|
|
1458
|
+
value. Protect app gives the options for 60x (fps=4), 120x (fps=8), 300x
|
|
1459
|
+
(fps=20), and 600x (fps=40).
|
|
1460
|
+
"""
|
|
1461
|
+
if validate_channel_id and self._bootstrap is not None:
|
|
1462
|
+
camera = self._bootstrap.cameras[camera_id]
|
|
1463
|
+
try:
|
|
1464
|
+
camera.channels[channel_index]
|
|
1465
|
+
except IndexError as e:
|
|
1466
|
+
raise BadRequest from e
|
|
1467
|
+
|
|
1468
|
+
params = {
|
|
1469
|
+
"camera": camera_id,
|
|
1470
|
+
"start": to_js_time(start),
|
|
1471
|
+
"end": to_js_time(end),
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if fps is not None:
|
|
1475
|
+
params["fps"] = fps
|
|
1476
|
+
params["type"] = "timelapse"
|
|
1477
|
+
|
|
1478
|
+
if channel_index == 3:
|
|
1479
|
+
params.update({"lens": 2})
|
|
1480
|
+
else:
|
|
1481
|
+
params.update({"channel": channel_index})
|
|
1482
|
+
|
|
1483
|
+
path = "video/export"
|
|
1484
|
+
if (
|
|
1485
|
+
iterator_callback is None
|
|
1486
|
+
and progress_callback is None
|
|
1487
|
+
and output_file is None
|
|
1488
|
+
):
|
|
1489
|
+
return await self.api_request_raw(
|
|
1490
|
+
path,
|
|
1491
|
+
params=params,
|
|
1492
|
+
raise_exception=False,
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
r = await self.request(
|
|
1496
|
+
"get",
|
|
1497
|
+
urljoin(self.api_path, path),
|
|
1498
|
+
auto_close=False,
|
|
1499
|
+
timeout=0,
|
|
1500
|
+
params=params,
|
|
1501
|
+
)
|
|
1502
|
+
if output_file is not None:
|
|
1503
|
+
async with aiofiles.open(output_file, "wb") as output:
|
|
1504
|
+
|
|
1505
|
+
async def callback(total: int, chunk: bytes | None) -> None:
|
|
1506
|
+
if iterator_callback is not None:
|
|
1507
|
+
await iterator_callback(total, chunk)
|
|
1508
|
+
if chunk is not None:
|
|
1509
|
+
await output.write(chunk)
|
|
1510
|
+
|
|
1511
|
+
await self._stream_response(r, chunk_size, callback, progress_callback)
|
|
1512
|
+
else:
|
|
1513
|
+
await self._stream_response(
|
|
1514
|
+
r,
|
|
1515
|
+
chunk_size,
|
|
1516
|
+
iterator_callback,
|
|
1517
|
+
progress_callback,
|
|
1518
|
+
)
|
|
1519
|
+
r.close()
|
|
1520
|
+
return None
|
|
1521
|
+
|
|
1522
|
+
async def _get_image_with_retry(
|
|
1523
|
+
self,
|
|
1524
|
+
path: str,
|
|
1525
|
+
retry_timeout: int = RETRY_TIMEOUT,
|
|
1526
|
+
**kwargs: Any,
|
|
1527
|
+
) -> bytes | None:
|
|
1528
|
+
"""
|
|
1529
|
+
Retries image request until it returns or timesout. Used for event images like thumbnails and heatmaps.
|
|
1530
|
+
|
|
1531
|
+
Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
|
|
1532
|
+
your retry timeout will always return None.
|
|
1533
|
+
"""
|
|
1534
|
+
now = time.monotonic()
|
|
1535
|
+
timeout = now + retry_timeout
|
|
1536
|
+
data: bytes | None = None
|
|
1537
|
+
while data is None and now < timeout:
|
|
1538
|
+
data = await self.api_request_raw(path, raise_exception=False, **kwargs)
|
|
1539
|
+
if data is None:
|
|
1540
|
+
await asyncio.sleep(0.5)
|
|
1541
|
+
now = time.monotonic()
|
|
1542
|
+
|
|
1543
|
+
return data
|
|
1544
|
+
|
|
1545
|
+
async def get_event_thumbnail(
|
|
1546
|
+
self,
|
|
1547
|
+
thumbnail_id: str,
|
|
1548
|
+
width: int | None = None,
|
|
1549
|
+
height: int | None = None,
|
|
1550
|
+
retry_timeout: int = RETRY_TIMEOUT,
|
|
1551
|
+
) -> bytes | None:
|
|
1552
|
+
"""
|
|
1553
|
+
Gets given thumbanail from a given event.
|
|
1554
|
+
|
|
1555
|
+
Thumbnail response is a JPEG image.
|
|
1556
|
+
|
|
1557
|
+
Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
|
|
1558
|
+
your retry timeout will always return 404.
|
|
1559
|
+
"""
|
|
1560
|
+
params: dict[str, Any] = {}
|
|
1561
|
+
|
|
1562
|
+
if width is not None:
|
|
1563
|
+
params.update({"w": width})
|
|
1564
|
+
|
|
1565
|
+
if height is not None:
|
|
1566
|
+
params.update({"h": height})
|
|
1567
|
+
|
|
1568
|
+
# old thumbnail URL use thumbnail ID, which is just `e-{event_id}`
|
|
1569
|
+
thumbnail_id = thumbnail_id.replace("e-", "")
|
|
1570
|
+
return await self._get_image_with_retry(
|
|
1571
|
+
f"events/{thumbnail_id}/thumbnail",
|
|
1572
|
+
params=params,
|
|
1573
|
+
retry_timeout=retry_timeout,
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
async def get_event_animated_thumbnail(
|
|
1577
|
+
self,
|
|
1578
|
+
thumbnail_id: str,
|
|
1579
|
+
width: int | None = None,
|
|
1580
|
+
height: int | None = None,
|
|
1581
|
+
*,
|
|
1582
|
+
speedup: int = 10,
|
|
1583
|
+
retry_timeout: int = RETRY_TIMEOUT,
|
|
1584
|
+
) -> bytes | None:
|
|
1585
|
+
"""
|
|
1586
|
+
Gets given animated thumbanil from a given event.
|
|
1587
|
+
|
|
1588
|
+
Animated thumbnail response is a GIF image.
|
|
1589
|
+
|
|
1590
|
+
Note: thumbnails / do not generate _until after the event ends_. Events that last longer then
|
|
1591
|
+
your retry timeout will always return 404.
|
|
1592
|
+
"""
|
|
1593
|
+
params: dict[str, Any] = {
|
|
1594
|
+
"keyFrameOnly": "true",
|
|
1595
|
+
"speedup": speedup,
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
if width is not None:
|
|
1599
|
+
params.update({"w": width})
|
|
1600
|
+
|
|
1601
|
+
if height is not None:
|
|
1602
|
+
params.update({"h": height})
|
|
1603
|
+
|
|
1604
|
+
# old thumbnail URL use thumbnail ID, which is just `e-{event_id}`
|
|
1605
|
+
thumbnail_id = thumbnail_id.replace("e-", "")
|
|
1606
|
+
return await self._get_image_with_retry(
|
|
1607
|
+
f"events/{thumbnail_id}/animated-thumbnail",
|
|
1608
|
+
params=params,
|
|
1609
|
+
retry_timeout=retry_timeout,
|
|
1610
|
+
)
|
|
1611
|
+
|
|
1612
|
+
async def get_event_heatmap(
|
|
1613
|
+
self,
|
|
1614
|
+
heatmap_id: str,
|
|
1615
|
+
retry_timeout: int = RETRY_TIMEOUT,
|
|
1616
|
+
) -> bytes | None:
|
|
1617
|
+
"""
|
|
1618
|
+
Gets given heatmap from a given event.
|
|
1619
|
+
|
|
1620
|
+
Heatmap response is a PNG image.
|
|
1621
|
+
|
|
1622
|
+
Note: thumbnails / heatmaps do not generate _until after the event ends_. Events that last longer then
|
|
1623
|
+
your retry timeout will always return None.
|
|
1624
|
+
"""
|
|
1625
|
+
# old heatmap URL use heatmap ID, which is just `e-{event_id}`
|
|
1626
|
+
heatmap_id = heatmap_id.replace("e-", "")
|
|
1627
|
+
return await self._get_image_with_retry(
|
|
1628
|
+
f"events/{heatmap_id}/heatmap",
|
|
1629
|
+
retry_timeout=retry_timeout,
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
async def get_event_smart_detect_track_raw(self, event_id: str) -> dict[str, Any]:
|
|
1633
|
+
"""Gets raw Smart Detect Track for a Smart Detection"""
|
|
1634
|
+
return await self.api_request_obj(f"events/{event_id}/smartDetectTrack")
|
|
1635
|
+
|
|
1636
|
+
async def get_event_smart_detect_track(self, event_id: str) -> SmartDetectTrack:
|
|
1637
|
+
"""Gets raw Smart Detect Track for a Smart Detection"""
|
|
1638
|
+
data = await self.api_request_obj(f"events/{event_id}/smartDetectTrack")
|
|
1639
|
+
|
|
1640
|
+
return SmartDetectTrack.from_unifi_dict(api=self, **data)
|
|
1641
|
+
|
|
1642
|
+
async def update_device(
|
|
1643
|
+
self,
|
|
1644
|
+
model_type: ModelType,
|
|
1645
|
+
device_id: str,
|
|
1646
|
+
data: dict[str, Any],
|
|
1647
|
+
) -> None:
|
|
1648
|
+
"""
|
|
1649
|
+
Sends an update for a device back to UFP
|
|
1650
|
+
|
|
1651
|
+
USE WITH CAUTION, all possible combinations of updating objects have not been fully tested.
|
|
1652
|
+
May have unexpected side effects.
|
|
1653
|
+
|
|
1654
|
+
Tested updates have been added a methods on applicable devices.
|
|
1655
|
+
"""
|
|
1656
|
+
await self.api_request(
|
|
1657
|
+
f"{model_type.value}s/{device_id}",
|
|
1658
|
+
method="patch",
|
|
1659
|
+
json=data,
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
async def update_nvr(self, data: dict[str, Any]) -> None:
|
|
1663
|
+
"""
|
|
1664
|
+
Sends an update for main UFP NVR device
|
|
1665
|
+
|
|
1666
|
+
USE WITH CAUTION, all possible combinations of updating objects have not been fully tested.
|
|
1667
|
+
May have unexpected side effects.
|
|
1668
|
+
|
|
1669
|
+
Tested updates have been added a methods on applicable devices.
|
|
1670
|
+
"""
|
|
1671
|
+
await self.api_request("nvr", method="patch", json=data)
|
|
1672
|
+
|
|
1673
|
+
async def reboot_nvr(self) -> None:
|
|
1674
|
+
"""Reboots NVR"""
|
|
1675
|
+
await self.api_request("nvr/reboot", method="post")
|
|
1676
|
+
|
|
1677
|
+
async def reboot_device(self, model_type: ModelType, device_id: str) -> None:
|
|
1678
|
+
"""Reboots an adopted device"""
|
|
1679
|
+
await self.api_request(f"{model_type.value}s/{device_id}/reboot", method="post")
|
|
1680
|
+
|
|
1681
|
+
async def unadopt_device(self, model_type: ModelType, device_id: str) -> None:
|
|
1682
|
+
"""Unadopt/Unmanage adopted device"""
|
|
1683
|
+
await self.api_request(f"{model_type.value}s/{device_id}", method="delete")
|
|
1684
|
+
|
|
1685
|
+
async def adopt_device(self, model_type: ModelType, device_id: str) -> None:
|
|
1686
|
+
"""Adopts a device"""
|
|
1687
|
+
key = f"{model_type.value}s"
|
|
1688
|
+
data = await self.api_request_obj(
|
|
1689
|
+
"devices/adopt",
|
|
1690
|
+
method="post",
|
|
1691
|
+
json={key: {device_id: {}}},
|
|
1692
|
+
)
|
|
1693
|
+
|
|
1694
|
+
if not data.get(key, {}).get(device_id, {}).get("adopted", False):
|
|
1695
|
+
raise BadRequest("Could not adopt device")
|
|
1696
|
+
|
|
1697
|
+
async def close_lock(self, device_id: str) -> None:
|
|
1698
|
+
"""Close doorlock (lock)"""
|
|
1699
|
+
await self.api_request(f"doorlocks/{device_id}/close", method="post")
|
|
1700
|
+
|
|
1701
|
+
async def open_lock(self, device_id: str) -> None:
|
|
1702
|
+
"""Open doorlock (unlock)"""
|
|
1703
|
+
await self.api_request(f"doorlocks/{device_id}/open", method="post")
|
|
1704
|
+
|
|
1705
|
+
async def calibrate_lock(self, device_id: str) -> None:
|
|
1706
|
+
"""
|
|
1707
|
+
Calibrate the doorlock.
|
|
1708
|
+
|
|
1709
|
+
Door must be open and lock unlocked.
|
|
1710
|
+
"""
|
|
1711
|
+
await self.api_request(
|
|
1712
|
+
f"doorlocks/{device_id}/calibrate",
|
|
1713
|
+
method="post",
|
|
1714
|
+
json={"auto": True},
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
async def play_speaker(
|
|
1718
|
+
self,
|
|
1719
|
+
device_id: str,
|
|
1720
|
+
*,
|
|
1721
|
+
volume: int | None = None,
|
|
1722
|
+
repeat_times: int | None = None,
|
|
1723
|
+
) -> None:
|
|
1724
|
+
"""Plays chime tones on a chime"""
|
|
1725
|
+
data: dict[str, Any] | None = None
|
|
1726
|
+
if volume or repeat_times:
|
|
1727
|
+
chime = self.bootstrap.chimes.get(device_id)
|
|
1728
|
+
if chime is None:
|
|
1729
|
+
raise BadRequest("Invalid chime ID %s", device_id)
|
|
1730
|
+
|
|
1731
|
+
data = {
|
|
1732
|
+
"volume": volume or chime.volume,
|
|
1733
|
+
"repeatTimes": repeat_times or chime.repeat_times,
|
|
1734
|
+
"trackNo": chime.track_no,
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
await self.api_request(
|
|
1738
|
+
f"chimes/{device_id}/play-speaker",
|
|
1739
|
+
method="post",
|
|
1740
|
+
json=data,
|
|
1741
|
+
)
|
|
1742
|
+
|
|
1743
|
+
async def play_buzzer(self, device_id: str) -> None:
|
|
1744
|
+
"""Plays chime tones on a chime"""
|
|
1745
|
+
await self.api_request(f"chimes/{device_id}/play-buzzer", method="post")
|
|
1746
|
+
|
|
1747
|
+
async def clear_tamper_sensor(self, device_id: str) -> None:
|
|
1748
|
+
"""Clears tamper status for sensor"""
|
|
1749
|
+
await self.api_request(f"sensors/{device_id}/clear-tamper-flag", method="post")
|
|
1750
|
+
|
|
1751
|
+
async def _get_versions_from_api(
|
|
1752
|
+
self,
|
|
1753
|
+
url: str,
|
|
1754
|
+
package: str = "unifi-protect",
|
|
1755
|
+
) -> set[Version]:
|
|
1756
|
+
session = await self.get_session()
|
|
1757
|
+
versions: set[Version] = set()
|
|
1758
|
+
|
|
1759
|
+
try:
|
|
1760
|
+
async with session.get(url) as response:
|
|
1761
|
+
is_package = False
|
|
1762
|
+
for line in (await response.text()).split("\n"):
|
|
1763
|
+
if line.startswith("Package: "):
|
|
1764
|
+
is_package = False
|
|
1765
|
+
if line == f"Package: {package}":
|
|
1766
|
+
is_package = True
|
|
1767
|
+
|
|
1768
|
+
if is_package and line.startswith("Version: "):
|
|
1769
|
+
versions.add(Version(line.split(": ")[-1]))
|
|
1770
|
+
except (
|
|
1771
|
+
TimeoutError,
|
|
1772
|
+
asyncio.TimeoutError,
|
|
1773
|
+
asyncio.CancelledError,
|
|
1774
|
+
aiohttp.ServerDisconnectedError,
|
|
1775
|
+
client_exceptions.ClientError,
|
|
1776
|
+
) as err:
|
|
1777
|
+
raise NvrError(f"Error packages from {url}: {err}") from err
|
|
1778
|
+
|
|
1779
|
+
return versions
|
|
1780
|
+
|
|
1781
|
+
async def get_release_versions(self) -> set[Version]:
|
|
1782
|
+
"""Get all release versions for UniFi Protect"""
|
|
1783
|
+
versions: set[Version] = set()
|
|
1784
|
+
for url in PROTECT_APT_URLS:
|
|
1785
|
+
try:
|
|
1786
|
+
versions |= await self._get_versions_from_api(url)
|
|
1787
|
+
except NvrError:
|
|
1788
|
+
_LOGGER.warning("Failed to retrieve release versions from online.")
|
|
1789
|
+
|
|
1790
|
+
return versions
|
|
1791
|
+
|
|
1792
|
+
async def relative_move_ptz_camera(
|
|
1793
|
+
self,
|
|
1794
|
+
device_id: str,
|
|
1795
|
+
*,
|
|
1796
|
+
pan: float,
|
|
1797
|
+
tilt: float,
|
|
1798
|
+
pan_speed: int = 10,
|
|
1799
|
+
tilt_speed: int = 10,
|
|
1800
|
+
scale: int = 0,
|
|
1801
|
+
) -> None:
|
|
1802
|
+
"""
|
|
1803
|
+
Move PTZ Camera relatively.
|
|
1804
|
+
|
|
1805
|
+
Pan/tilt values vary from camera to camera, but for G4 PTZ:
|
|
1806
|
+
* Pan values range from 1 (0°) to 35200 (360°/0°).
|
|
1807
|
+
* Tilt values range from 1 (-20°) to 9777 (90°).
|
|
1808
|
+
|
|
1809
|
+
Relative positions cannot move more then 4095 units in either direction at a time.
|
|
1810
|
+
|
|
1811
|
+
Camera objects have ptz values in feature flags and the methods on them provide better
|
|
1812
|
+
control.
|
|
1813
|
+
"""
|
|
1814
|
+
data = {
|
|
1815
|
+
"type": "relative",
|
|
1816
|
+
"payload": {
|
|
1817
|
+
"panPos": pan,
|
|
1818
|
+
"tiltPos": tilt,
|
|
1819
|
+
"panSpeed": pan_speed,
|
|
1820
|
+
"tiltSpeed": tilt_speed,
|
|
1821
|
+
"scale": scale,
|
|
1822
|
+
},
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
|
|
1826
|
+
|
|
1827
|
+
async def center_ptz_camera(
|
|
1828
|
+
self,
|
|
1829
|
+
device_id: str,
|
|
1830
|
+
*,
|
|
1831
|
+
x: int,
|
|
1832
|
+
y: int,
|
|
1833
|
+
z: int,
|
|
1834
|
+
) -> None:
|
|
1835
|
+
"""
|
|
1836
|
+
Center PTZ Camera on point in viewport.
|
|
1837
|
+
|
|
1838
|
+
x, y, z values range from 0 to 1000.
|
|
1839
|
+
|
|
1840
|
+
x, y are relative coords for the current viewport:
|
|
1841
|
+
* (0, 0) is top left
|
|
1842
|
+
* (500, 500) is the center
|
|
1843
|
+
* (1000, 1000) is the bottom right
|
|
1844
|
+
|
|
1845
|
+
z value is zoom, but since it is capped at 1000, probably better to use `ptz_zoom_camera`.
|
|
1846
|
+
"""
|
|
1847
|
+
data = {
|
|
1848
|
+
"type": "center",
|
|
1849
|
+
"payload": {
|
|
1850
|
+
"x": x,
|
|
1851
|
+
"y": y,
|
|
1852
|
+
"z": z,
|
|
1853
|
+
},
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
|
|
1857
|
+
|
|
1858
|
+
async def zoom_ptz_camera(
|
|
1859
|
+
self,
|
|
1860
|
+
device_id: str,
|
|
1861
|
+
*,
|
|
1862
|
+
zoom: float,
|
|
1863
|
+
speed: int = 10,
|
|
1864
|
+
) -> None:
|
|
1865
|
+
"""
|
|
1866
|
+
Zoom PTZ Camera.
|
|
1867
|
+
|
|
1868
|
+
Zoom levels vary from camera to camera, but for G4 PTZ it goes from 0 (1x) to 2010 (22x).
|
|
1869
|
+
|
|
1870
|
+
Zoom speed does not seem to do much, if anything.
|
|
1871
|
+
|
|
1872
|
+
Camera objects have ptz values in feature flags and the methods on them provide better
|
|
1873
|
+
control.
|
|
1874
|
+
"""
|
|
1875
|
+
data = {
|
|
1876
|
+
"type": "zoom",
|
|
1877
|
+
"payload": {
|
|
1878
|
+
"zoomPos": zoom,
|
|
1879
|
+
"zoomSpeed": speed,
|
|
1880
|
+
},
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
await self.api_request(f"cameras/{device_id}/move", method="post", json=data)
|
|
1884
|
+
|
|
1885
|
+
async def get_position_ptz_camera(self, device_id: str) -> PTZPosition:
|
|
1886
|
+
"""Get current PTZ camera position."""
|
|
1887
|
+
pos = await self.api_request_obj(f"cameras/{device_id}/ptz/position")
|
|
1888
|
+
return PTZPosition(**pos)
|
|
1889
|
+
|
|
1890
|
+
async def goto_ptz_camera(self, device_id: str, *, slot: int = -1) -> None:
|
|
1891
|
+
"""
|
|
1892
|
+
Goto PTZ slot position.
|
|
1893
|
+
|
|
1894
|
+
-1 is Home slot.
|
|
1895
|
+
"""
|
|
1896
|
+
await self.api_request(f"cameras/{device_id}/ptz/goto/{slot}", method="post")
|
|
1897
|
+
|
|
1898
|
+
async def create_preset_ptz_camera(self, device_id: str, *, name: str) -> PTZPreset:
|
|
1899
|
+
"""Create PTZ Preset for camera based on current camera settings."""
|
|
1900
|
+
preset = await self.api_request_obj(
|
|
1901
|
+
f"cameras/{device_id}/ptz/preset",
|
|
1902
|
+
method="post",
|
|
1903
|
+
json={"name": name},
|
|
1904
|
+
)
|
|
1905
|
+
|
|
1906
|
+
return PTZPreset(**preset)
|
|
1907
|
+
|
|
1908
|
+
async def get_presets_ptz_camera(self, device_id: str) -> list[PTZPreset]:
|
|
1909
|
+
"""Get PTZ Presets for camera."""
|
|
1910
|
+
presets = await self.api_request(f"cameras/{device_id}/ptz/preset")
|
|
1911
|
+
|
|
1912
|
+
if not presets:
|
|
1913
|
+
return []
|
|
1914
|
+
|
|
1915
|
+
presets = cast(list[dict[str, Any]], presets)
|
|
1916
|
+
return [PTZPreset(**p) for p in presets]
|
|
1917
|
+
|
|
1918
|
+
async def delete_preset_ptz_camera(self, device_id: str, *, slot: int) -> None:
|
|
1919
|
+
"""Delete PTZ preset for camera."""
|
|
1920
|
+
await self.api_request(
|
|
1921
|
+
f"cameras/{device_id}/ptz/preset/{slot}",
|
|
1922
|
+
method="delete",
|
|
1923
|
+
)
|
|
1924
|
+
|
|
1925
|
+
async def get_home_ptz_camera(self, device_id: str) -> PTZPreset:
|
|
1926
|
+
"""Get PTZ home preset (-1)."""
|
|
1927
|
+
preset = await self.api_request_obj(f"cameras/{device_id}/ptz/home")
|
|
1928
|
+
return PTZPreset(**preset)
|
|
1929
|
+
|
|
1930
|
+
async def set_home_ptz_camera(self, device_id: str) -> PTZPreset:
|
|
1931
|
+
"""Set PTZ home preset (-1) to current position."""
|
|
1932
|
+
preset = await self.api_request_obj(
|
|
1933
|
+
f"cameras/{device_id}/ptz/home",
|
|
1934
|
+
method="post",
|
|
1935
|
+
)
|
|
1936
|
+
return PTZPreset(**preset)
|