uiprotect 3.8.0__py3-none-any.whl → 7.32.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.
- uiprotect/__init__.py +1 -3
- uiprotect/_compat.py +13 -0
- uiprotect/api.py +975 -92
- uiprotect/cli/__init__.py +111 -24
- uiprotect/cli/aiports.py +58 -0
- uiprotect/cli/backup.py +5 -4
- uiprotect/cli/base.py +5 -5
- uiprotect/cli/cameras.py +152 -13
- uiprotect/cli/chimes.py +5 -6
- uiprotect/cli/doorlocks.py +2 -3
- uiprotect/cli/events.py +7 -8
- uiprotect/cli/lights.py +11 -3
- uiprotect/cli/liveviews.py +1 -2
- uiprotect/cli/sensors.py +2 -3
- uiprotect/cli/viewers.py +2 -3
- uiprotect/data/__init__.py +2 -0
- uiprotect/data/base.py +96 -97
- uiprotect/data/bootstrap.py +116 -45
- uiprotect/data/convert.py +17 -2
- uiprotect/data/devices.py +409 -164
- uiprotect/data/nvr.py +236 -118
- uiprotect/data/types.py +94 -59
- uiprotect/data/user.py +132 -13
- uiprotect/data/websocket.py +2 -1
- uiprotect/stream.py +13 -6
- uiprotect/test_util/__init__.py +47 -7
- uiprotect/test_util/anonymize.py +4 -5
- uiprotect/utils.py +99 -45
- uiprotect/websocket.py +11 -6
- {uiprotect-3.8.0.dist-info → uiprotect-7.32.0.dist-info}/METADATA +77 -21
- uiprotect-7.32.0.dist-info/RECORD +39 -0
- {uiprotect-3.8.0.dist-info → uiprotect-7.32.0.dist-info}/WHEEL +1 -1
- uiprotect-3.8.0.dist-info/RECORD +0 -37
- {uiprotect-3.8.0.dist-info → uiprotect-7.32.0.dist-info}/entry_points.txt +0 -0
- {uiprotect-3.8.0.dist-info → uiprotect-7.32.0.dist-info/licenses}/LICENSE +0 -0
uiprotect/api.py
CHANGED
|
@@ -11,13 +11,13 @@ import sys
|
|
|
11
11
|
import time
|
|
12
12
|
from collections.abc import Callable
|
|
13
13
|
from datetime import datetime, timedelta
|
|
14
|
-
from functools import
|
|
15
|
-
from http import HTTPStatus
|
|
14
|
+
from functools import partial
|
|
15
|
+
from http import HTTPStatus, cookies
|
|
16
16
|
from http.cookies import Morsel, SimpleCookie
|
|
17
|
-
from ipaddress import IPv4Address, IPv6Address
|
|
17
|
+
from ipaddress import IPv4Address, IPv6Address, ip_address
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import Any, Literal, cast
|
|
20
|
-
from urllib.parse import
|
|
19
|
+
from typing import Any, Literal, TypedDict, cast
|
|
20
|
+
from urllib.parse import SplitResult
|
|
21
21
|
|
|
22
22
|
import aiofiles
|
|
23
23
|
import aiohttp
|
|
@@ -27,6 +27,13 @@ from aiohttp import CookieJar, client_exceptions
|
|
|
27
27
|
from platformdirs import user_cache_dir, user_config_dir
|
|
28
28
|
from yarl import URL
|
|
29
29
|
|
|
30
|
+
from uiprotect.data.base import ProtectBaseObject
|
|
31
|
+
from uiprotect.data.convert import list_from_unifi_list
|
|
32
|
+
from uiprotect.data.devices import LightDeviceSettings, LightModeSettings
|
|
33
|
+
from uiprotect.data.nvr import MetaInfo
|
|
34
|
+
from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers
|
|
35
|
+
|
|
36
|
+
from ._compat import cached_property
|
|
30
37
|
from .data import (
|
|
31
38
|
NVR,
|
|
32
39
|
Bootstrap,
|
|
@@ -48,41 +55,49 @@ from .data import (
|
|
|
48
55
|
SmartDetectTrack,
|
|
49
56
|
Version,
|
|
50
57
|
Viewer,
|
|
58
|
+
WSAction,
|
|
51
59
|
WSPacket,
|
|
52
60
|
WSSubscriptionMessage,
|
|
53
61
|
create_from_unifi_dict,
|
|
54
62
|
)
|
|
55
63
|
from .data.base import ProtectModelWithId
|
|
56
|
-
from .data.devices import Chime
|
|
64
|
+
from .data.devices import AiPort, Chime
|
|
57
65
|
from .data.types import IteratorCallback, ProgressCallback
|
|
58
66
|
from .exceptions import BadRequest, NotAuthorized, NvrError
|
|
59
67
|
from .utils import (
|
|
60
68
|
decode_token_cookie,
|
|
69
|
+
format_host_for_url,
|
|
61
70
|
get_response_reason,
|
|
62
71
|
ip_from_host,
|
|
72
|
+
pybool_to_json_bool,
|
|
63
73
|
set_debug,
|
|
64
74
|
to_js_time,
|
|
65
75
|
utc_now,
|
|
66
76
|
)
|
|
67
77
|
from .websocket import Websocket, WebsocketState
|
|
68
78
|
|
|
69
|
-
if
|
|
70
|
-
from http import cookies
|
|
71
|
-
|
|
79
|
+
if "partitioned" not in cookies.Morsel._reserved: # type: ignore[attr-defined]
|
|
72
80
|
# See: https://github.com/python/cpython/issues/112713
|
|
73
81
|
cookies.Morsel._reserved["partitioned"] = "partitioned" # type: ignore[attr-defined]
|
|
74
82
|
cookies.Morsel._flags.add("partitioned") # type: ignore[attr-defined]
|
|
75
83
|
|
|
84
|
+
|
|
85
|
+
class LightPatchRequest(TypedDict, total=False):
|
|
86
|
+
"""Type for PATCH /v1/lights/{id} request body."""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
isLightForceEnabled: bool
|
|
90
|
+
lightModeSettings: dict[str, Any]
|
|
91
|
+
lightDeviceSettings: dict[str, Any]
|
|
92
|
+
|
|
93
|
+
|
|
76
94
|
TOKEN_COOKIE_MAX_EXP_SECONDS = 60
|
|
77
95
|
|
|
78
96
|
# how many seconds before the bootstrap is refreshed from Protect
|
|
79
97
|
DEVICE_UPDATE_INTERVAL = 900
|
|
80
98
|
# retry timeout for thumbnails/heatmaps
|
|
81
99
|
RETRY_TIMEOUT = 10
|
|
82
|
-
|
|
83
|
-
"https://apt.artifacts.ui.com/dists/stretch/release/binary-arm64/Packages",
|
|
84
|
-
"https://apt.artifacts.ui.com/dists/bullseye/release/binary-arm64/Packages",
|
|
85
|
-
]
|
|
100
|
+
|
|
86
101
|
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.
|
|
87
102
|
|
|
88
103
|
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."""
|
|
@@ -91,6 +106,8 @@ If your Protect instance has a lot of events, this request will take much longer
|
|
|
91
106
|
_LOGGER = logging.getLogger(__name__)
|
|
92
107
|
_COOKIE_RE = re.compile(r"^set-cookie: ", re.IGNORECASE)
|
|
93
108
|
|
|
109
|
+
NFC_FINGERPRINT_SUPPORT_VERSION = Version("5.1.57")
|
|
110
|
+
|
|
94
111
|
# TODO: Urls to still support
|
|
95
112
|
# Backups
|
|
96
113
|
# * GET /backups - list backends
|
|
@@ -154,11 +171,52 @@ def get_user_hash(host: str, username: str) -> str:
|
|
|
154
171
|
return session.hexdigest()
|
|
155
172
|
|
|
156
173
|
|
|
174
|
+
class RTSPSStreams(ProtectBaseObject):
|
|
175
|
+
"""RTSPS stream URLs for a camera."""
|
|
176
|
+
|
|
177
|
+
model_config = {"extra": "allow"}
|
|
178
|
+
# Intentionally no variables like 'high', 'medium', 'low' are defined here.
|
|
179
|
+
# The API naming appears inconsistent - what's called "quality" might actually be "channels".
|
|
180
|
+
# Besides standard qualities (high/medium/low), there are special cases like "package" for doorbells
|
|
181
|
+
# and unclear implementation for 180° cameras with dual sensors. Dynamic handling via __pydantic_extra__ is safer.
|
|
182
|
+
|
|
183
|
+
def get_stream_url(self, quality: str) -> str | None:
|
|
184
|
+
"""Get stream URL for a specific quality level."""
|
|
185
|
+
return getattr(self, quality, None)
|
|
186
|
+
|
|
187
|
+
def get_available_stream_qualities(self) -> list[str]:
|
|
188
|
+
"""Get list of available RTSPS stream quality levels (including inactive ones with null values)."""
|
|
189
|
+
if self.__pydantic_extra__ is None:
|
|
190
|
+
return []
|
|
191
|
+
return list(self.__pydantic_extra__.keys())
|
|
192
|
+
|
|
193
|
+
def get_active_stream_qualities(self) -> list[str]:
|
|
194
|
+
"""Get list of currently active RTSPS stream quality levels (only those with stream URLs)."""
|
|
195
|
+
if self.__pydantic_extra__ is None:
|
|
196
|
+
return []
|
|
197
|
+
return [
|
|
198
|
+
key
|
|
199
|
+
for key, value in self.__pydantic_extra__.items()
|
|
200
|
+
if isinstance(value, str) and value is not None
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
def get_inactive_stream_qualities(self) -> list[str]:
|
|
204
|
+
"""Get list of inactive RTSPS stream quality levels (supported but not currently active)."""
|
|
205
|
+
if self.__pydantic_extra__ is None:
|
|
206
|
+
return []
|
|
207
|
+
return [
|
|
208
|
+
key
|
|
209
|
+
for key, value in self.__pydantic_extra__.items()
|
|
210
|
+
if not (isinstance(value, str) and value is not None)
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
|
|
157
214
|
class BaseApiClient:
|
|
158
215
|
_host: str
|
|
159
216
|
_port: int
|
|
160
217
|
_username: str
|
|
161
218
|
_password: str
|
|
219
|
+
_api_key: str | None = None
|
|
162
220
|
_verify_ssl: bool
|
|
163
221
|
_ws_timeout: int
|
|
164
222
|
|
|
@@ -166,14 +224,20 @@ class BaseApiClient:
|
|
|
166
224
|
_last_token_cookie: Morsel[str] | None = None
|
|
167
225
|
_last_token_cookie_decode: dict[str, Any] | None = None
|
|
168
226
|
_session: aiohttp.ClientSession | None = None
|
|
227
|
+
_public_api_session: aiohttp.ClientSession | None = None
|
|
169
228
|
_loaded_session: bool = False
|
|
170
229
|
_cookiename = "TOKEN"
|
|
171
230
|
|
|
172
231
|
headers: dict[str, str] | None = None
|
|
173
|
-
|
|
232
|
+
_private_websocket: Websocket | None = None
|
|
233
|
+
_events_websocket: Websocket | None = None
|
|
234
|
+
_devices_websocket: Websocket | None = None
|
|
174
235
|
|
|
175
|
-
|
|
176
|
-
|
|
236
|
+
private_api_path: str = "/proxy/protect/api/"
|
|
237
|
+
public_api_path: str = "/proxy/protect/integration"
|
|
238
|
+
private_ws_path: str = "/proxy/protect/ws/updates"
|
|
239
|
+
events_ws_path: str = "/proxy/protect/integration/v1/subscribe/events"
|
|
240
|
+
devices_ws_path: str = "/proxy/protect/integration/v1/subscribe/devices"
|
|
177
241
|
|
|
178
242
|
cache_dir: Path
|
|
179
243
|
config_dir: Path
|
|
@@ -185,12 +249,15 @@ class BaseApiClient:
|
|
|
185
249
|
port: int,
|
|
186
250
|
username: str,
|
|
187
251
|
password: str,
|
|
252
|
+
api_key: str | None = None,
|
|
188
253
|
verify_ssl: bool = True,
|
|
189
254
|
session: aiohttp.ClientSession | None = None,
|
|
255
|
+
public_api_session: aiohttp.ClientSession | None = None,
|
|
190
256
|
ws_timeout: int = 30,
|
|
191
257
|
cache_dir: Path | None = None,
|
|
192
258
|
config_dir: Path | None = None,
|
|
193
259
|
store_sessions: bool = True,
|
|
260
|
+
ws_receive_timeout: int | None = None,
|
|
194
261
|
) -> None:
|
|
195
262
|
self._auth_lock = asyncio.Lock()
|
|
196
263
|
self._host = host
|
|
@@ -198,8 +265,10 @@ class BaseApiClient:
|
|
|
198
265
|
|
|
199
266
|
self._username = username
|
|
200
267
|
self._password = password
|
|
268
|
+
self._api_key = api_key
|
|
201
269
|
self._verify_ssl = verify_ssl
|
|
202
270
|
self._ws_timeout = ws_timeout
|
|
271
|
+
self._ws_receive_timeout = ws_receive_timeout
|
|
203
272
|
self._loaded_session = False
|
|
204
273
|
self._update_task: asyncio.Task[Bootstrap | None] | None = None
|
|
205
274
|
|
|
@@ -210,6 +279,9 @@ class BaseApiClient:
|
|
|
210
279
|
if session is not None:
|
|
211
280
|
self._session = session
|
|
212
281
|
|
|
282
|
+
if public_api_session is not None:
|
|
283
|
+
self._public_api_session = public_api_session
|
|
284
|
+
|
|
213
285
|
self._update_url()
|
|
214
286
|
|
|
215
287
|
def _update_cookiename(self, cookie: SimpleCookie) -> None:
|
|
@@ -218,12 +290,26 @@ class BaseApiClient:
|
|
|
218
290
|
|
|
219
291
|
def _update_url(self) -> None:
|
|
220
292
|
"""Updates the url after changing _host or _port."""
|
|
293
|
+
formatted_host = format_host_for_url(self._host)
|
|
294
|
+
|
|
221
295
|
if self._port != 443:
|
|
222
|
-
self._url = URL(f"https://{
|
|
223
|
-
self._ws_url = URL(
|
|
296
|
+
self._url = URL(f"https://{formatted_host}:{self._port}")
|
|
297
|
+
self._ws_url = URL(
|
|
298
|
+
f"wss://{formatted_host}:{self._port}{self.private_ws_path}"
|
|
299
|
+
)
|
|
300
|
+
self._events_ws_url = URL(
|
|
301
|
+
f"https://{formatted_host}:{self._port}{self.events_ws_path}"
|
|
302
|
+
)
|
|
303
|
+
self._devices_ws_url = URL(
|
|
304
|
+
f"https://{formatted_host}:{self._port}{self.devices_ws_path}"
|
|
305
|
+
)
|
|
224
306
|
else:
|
|
225
|
-
self._url = URL(f"https://{
|
|
226
|
-
self._ws_url = URL(f"wss://{
|
|
307
|
+
self._url = URL(f"https://{formatted_host}")
|
|
308
|
+
self._ws_url = URL(f"wss://{formatted_host}{self.private_ws_path}")
|
|
309
|
+
self._events_ws_url = URL(f"https://{formatted_host}{self.events_ws_path}")
|
|
310
|
+
self._devices_ws_url = URL(
|
|
311
|
+
f"https://{formatted_host}{self.devices_ws_path}"
|
|
312
|
+
)
|
|
227
313
|
|
|
228
314
|
self.base_url = str(self._url)
|
|
229
315
|
|
|
@@ -239,6 +325,16 @@ class BaseApiClient:
|
|
|
239
325
|
"""Get Websocket URL."""
|
|
240
326
|
return str(self._ws_url_object)
|
|
241
327
|
|
|
328
|
+
@property
|
|
329
|
+
def events_ws_url(self) -> str:
|
|
330
|
+
"""Get Events Websocket URL."""
|
|
331
|
+
return str(self._events_ws_url)
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def devices_ws_url(self) -> str:
|
|
335
|
+
"""Get Devices Websocket URL."""
|
|
336
|
+
return str(self._devices_ws_url)
|
|
337
|
+
|
|
242
338
|
@property
|
|
243
339
|
def config_file(self) -> Path:
|
|
244
340
|
return self.config_dir / "unifi_protect.json"
|
|
@@ -253,6 +349,15 @@ class BaseApiClient:
|
|
|
253
349
|
|
|
254
350
|
return self._session
|
|
255
351
|
|
|
352
|
+
async def get_public_api_session(self) -> aiohttp.ClientSession:
|
|
353
|
+
"""Gets or creates current public API client session"""
|
|
354
|
+
if self._public_api_session is None or self._public_api_session.closed:
|
|
355
|
+
if self._public_api_session is not None and self._public_api_session.closed:
|
|
356
|
+
_LOGGER.debug("Public API session was closed, creating a new one")
|
|
357
|
+
self._public_api_session = aiohttp.ClientSession()
|
|
358
|
+
|
|
359
|
+
return self._public_api_session
|
|
360
|
+
|
|
256
361
|
async def _auth_websocket(self, force: bool) -> dict[str, str] | None:
|
|
257
362
|
"""Authenticate for Websocket."""
|
|
258
363
|
if force:
|
|
@@ -265,10 +370,19 @@ class BaseApiClient:
|
|
|
265
370
|
await self.ensure_authenticated()
|
|
266
371
|
return self.headers
|
|
267
372
|
|
|
373
|
+
async def _auth_public_api_websocket(
|
|
374
|
+
self, force: bool = False
|
|
375
|
+
) -> dict[str, str] | None:
|
|
376
|
+
"""Authenticate for Public API Websocket."""
|
|
377
|
+
if self._api_key is None:
|
|
378
|
+
raise NotAuthorized("API key is required for public API WebSocket")
|
|
379
|
+
|
|
380
|
+
return {"X-API-KEY": self._api_key}
|
|
381
|
+
|
|
268
382
|
def _get_websocket(self) -> Websocket:
|
|
269
383
|
"""Gets or creates current Websocket."""
|
|
270
|
-
if self.
|
|
271
|
-
self.
|
|
384
|
+
if self._private_websocket is None:
|
|
385
|
+
self._private_websocket = Websocket(
|
|
272
386
|
self._get_websocket_url,
|
|
273
387
|
self._auth_websocket,
|
|
274
388
|
self._update_bootstrap_soon,
|
|
@@ -277,8 +391,41 @@ class BaseApiClient:
|
|
|
277
391
|
self._on_websocket_state_change,
|
|
278
392
|
verify=self._verify_ssl,
|
|
279
393
|
timeout=self._ws_timeout,
|
|
394
|
+
receive_timeout=self._ws_receive_timeout,
|
|
395
|
+
)
|
|
396
|
+
return self._private_websocket
|
|
397
|
+
|
|
398
|
+
def _get_events_websocket(self) -> Websocket:
|
|
399
|
+
"""Gets or creates current Events Websocket."""
|
|
400
|
+
if self._events_websocket is None:
|
|
401
|
+
self._events_websocket = Websocket(
|
|
402
|
+
lambda: self._events_ws_url,
|
|
403
|
+
self._auth_public_api_websocket,
|
|
404
|
+
lambda: None,
|
|
405
|
+
self.get_public_api_session,
|
|
406
|
+
self._process_events_ws_message,
|
|
407
|
+
self._on_events_websocket_state_change,
|
|
408
|
+
verify=self._verify_ssl,
|
|
409
|
+
timeout=self._ws_timeout,
|
|
410
|
+
receive_timeout=self._ws_receive_timeout,
|
|
280
411
|
)
|
|
281
|
-
return self.
|
|
412
|
+
return self._events_websocket
|
|
413
|
+
|
|
414
|
+
def _get_devices_websocket(self) -> Websocket:
|
|
415
|
+
"""Gets or creates current Devices Websocket."""
|
|
416
|
+
if self._devices_websocket is None:
|
|
417
|
+
self._devices_websocket = Websocket(
|
|
418
|
+
lambda: self._devices_ws_url,
|
|
419
|
+
self._auth_public_api_websocket,
|
|
420
|
+
lambda: None,
|
|
421
|
+
self.get_public_api_session,
|
|
422
|
+
self._process_devices_ws_message,
|
|
423
|
+
self._on_devices_websocket_state_change,
|
|
424
|
+
verify=self._verify_ssl,
|
|
425
|
+
timeout=self._ws_timeout,
|
|
426
|
+
receive_timeout=self._ws_receive_timeout,
|
|
427
|
+
)
|
|
428
|
+
return self._devices_websocket
|
|
282
429
|
|
|
283
430
|
def _update_bootstrap_soon(self) -> None:
|
|
284
431
|
"""Update bootstrap soon."""
|
|
@@ -297,6 +444,12 @@ class BaseApiClient:
|
|
|
297
444
|
self._session = None
|
|
298
445
|
self._loaded_session = False
|
|
299
446
|
|
|
447
|
+
async def close_public_api_session(self) -> None:
|
|
448
|
+
"""Closing and deletes public API client session"""
|
|
449
|
+
if self._public_api_session is not None:
|
|
450
|
+
await self._public_api_session.close()
|
|
451
|
+
self._public_api_session = None
|
|
452
|
+
|
|
300
453
|
async def _cancel_update_task(self) -> None:
|
|
301
454
|
if self._update_task:
|
|
302
455
|
self._update_task.cancel()
|
|
@@ -318,18 +471,29 @@ class BaseApiClient:
|
|
|
318
471
|
url: str,
|
|
319
472
|
require_auth: bool = False,
|
|
320
473
|
auto_close: bool = True,
|
|
474
|
+
public_api: bool = False,
|
|
321
475
|
**kwargs: Any,
|
|
322
476
|
) -> aiohttp.ClientResponse:
|
|
323
477
|
"""Make a request to UniFi Protect"""
|
|
324
|
-
if require_auth:
|
|
478
|
+
if require_auth and not public_api:
|
|
325
479
|
await self.ensure_authenticated()
|
|
326
480
|
|
|
327
|
-
request_url = self._url.
|
|
328
|
-
|
|
481
|
+
request_url = self._url.join(
|
|
482
|
+
URL(SplitResult("", "", url, "", ""), encoded=True)
|
|
483
|
+
)
|
|
484
|
+
headers = kwargs.get("headers") or self.headers or {}
|
|
485
|
+
if require_auth and public_api:
|
|
486
|
+
if self._api_key is None:
|
|
487
|
+
raise NotAuthorized("API key is required for public API requests")
|
|
488
|
+
headers = {"X-API-KEY": self._api_key}
|
|
329
489
|
_LOGGER.debug("Request url: %s", request_url)
|
|
330
490
|
if not self._verify_ssl:
|
|
331
491
|
kwargs["ssl"] = False
|
|
332
|
-
|
|
492
|
+
|
|
493
|
+
if public_api:
|
|
494
|
+
session = await self.get_public_api_session()
|
|
495
|
+
else:
|
|
496
|
+
session = await self.get_session()
|
|
333
497
|
|
|
334
498
|
for attempt in range(2):
|
|
335
499
|
try:
|
|
@@ -381,20 +545,29 @@ class BaseApiClient:
|
|
|
381
545
|
method: str = "get",
|
|
382
546
|
require_auth: bool = True,
|
|
383
547
|
raise_exception: bool = True,
|
|
548
|
+
api_path: str | None = None,
|
|
549
|
+
public_api: bool = False,
|
|
384
550
|
**kwargs: Any,
|
|
385
551
|
) -> bytes | None:
|
|
386
|
-
"""Make a request
|
|
387
|
-
|
|
552
|
+
"""Make a API request"""
|
|
553
|
+
path = self.private_api_path
|
|
554
|
+
if api_path is not None:
|
|
555
|
+
path = api_path
|
|
556
|
+
elif public_api:
|
|
557
|
+
path = self.public_api_path
|
|
558
|
+
|
|
388
559
|
response = await self.request(
|
|
389
560
|
method,
|
|
390
|
-
url,
|
|
561
|
+
f"{path}{url}",
|
|
391
562
|
require_auth=require_auth,
|
|
392
563
|
auto_close=False,
|
|
564
|
+
public_api=public_api,
|
|
393
565
|
**kwargs,
|
|
394
566
|
)
|
|
395
567
|
|
|
396
568
|
try:
|
|
397
|
-
|
|
569
|
+
# Check for successful status codes (2xx range)
|
|
570
|
+
if not (200 <= response.status < 300):
|
|
398
571
|
await self._raise_for_status(response, raise_exception)
|
|
399
572
|
return None
|
|
400
573
|
|
|
@@ -417,16 +590,20 @@ class BaseApiClient:
|
|
|
417
590
|
msg = "Request failed: %s - Status: %s - Reason: %s"
|
|
418
591
|
status = response.status
|
|
419
592
|
|
|
593
|
+
# Success status codes (2xx) should not raise exceptions
|
|
594
|
+
if 200 <= status < 300:
|
|
595
|
+
return
|
|
596
|
+
|
|
420
597
|
if raise_exception:
|
|
421
598
|
if status in {
|
|
422
599
|
HTTPStatus.UNAUTHORIZED.value,
|
|
423
600
|
HTTPStatus.FORBIDDEN.value,
|
|
424
601
|
}:
|
|
425
602
|
raise NotAuthorized(msg % (url, status, reason))
|
|
426
|
-
|
|
603
|
+
if status == HTTPStatus.TOO_MANY_REQUESTS.value:
|
|
427
604
|
_LOGGER.debug("Too many requests - Login is rate limited: %s", response)
|
|
428
605
|
raise NvrError(msg % (url, status, reason))
|
|
429
|
-
|
|
606
|
+
if (
|
|
430
607
|
status >= HTTPStatus.BAD_REQUEST.value
|
|
431
608
|
and status < HTTPStatus.INTERNAL_SERVER_ERROR.value
|
|
432
609
|
):
|
|
@@ -441,6 +618,8 @@ class BaseApiClient:
|
|
|
441
618
|
method: str = "get",
|
|
442
619
|
require_auth: bool = True,
|
|
443
620
|
raise_exception: bool = True,
|
|
621
|
+
api_path: str | None = None,
|
|
622
|
+
public_api: bool = False,
|
|
444
623
|
**kwargs: Any,
|
|
445
624
|
) -> list[Any] | dict[str, Any] | None:
|
|
446
625
|
data = await self.api_request_raw(
|
|
@@ -448,6 +627,8 @@ class BaseApiClient:
|
|
|
448
627
|
method=method,
|
|
449
628
|
require_auth=require_auth,
|
|
450
629
|
raise_exception=raise_exception,
|
|
630
|
+
api_path=api_path,
|
|
631
|
+
public_api=public_api,
|
|
451
632
|
**kwargs,
|
|
452
633
|
)
|
|
453
634
|
|
|
@@ -467,6 +648,7 @@ class BaseApiClient:
|
|
|
467
648
|
method: str = "get",
|
|
468
649
|
require_auth: bool = True,
|
|
469
650
|
raise_exception: bool = True,
|
|
651
|
+
public_api: bool = False,
|
|
470
652
|
**kwargs: Any,
|
|
471
653
|
) -> dict[str, Any]:
|
|
472
654
|
data = await self.api_request(
|
|
@@ -474,6 +656,7 @@ class BaseApiClient:
|
|
|
474
656
|
method=method,
|
|
475
657
|
require_auth=require_auth,
|
|
476
658
|
raise_exception=raise_exception,
|
|
659
|
+
public_api=public_api,
|
|
477
660
|
**kwargs,
|
|
478
661
|
)
|
|
479
662
|
|
|
@@ -488,6 +671,7 @@ class BaseApiClient:
|
|
|
488
671
|
method: str = "get",
|
|
489
672
|
require_auth: bool = True,
|
|
490
673
|
raise_exception: bool = True,
|
|
674
|
+
public_api: bool = False,
|
|
491
675
|
**kwargs: Any,
|
|
492
676
|
) -> list[Any]:
|
|
493
677
|
data = await self.api_request(
|
|
@@ -495,6 +679,7 @@ class BaseApiClient:
|
|
|
495
679
|
method=method,
|
|
496
680
|
require_auth=require_auth,
|
|
497
681
|
raise_exception=raise_exception,
|
|
682
|
+
public_api=public_api,
|
|
498
683
|
**kwargs,
|
|
499
684
|
)
|
|
500
685
|
|
|
@@ -533,8 +718,25 @@ class BaseApiClient:
|
|
|
533
718
|
response = await self.request("post", url=url, json=auth)
|
|
534
719
|
if response.status != 200:
|
|
535
720
|
await self._raise_for_status(response, True)
|
|
536
|
-
|
|
721
|
+
|
|
722
|
+
csrf_token = response.headers.get("x-csrf-token")
|
|
723
|
+
if csrf_token:
|
|
724
|
+
self.set_header("x-csrf-token", csrf_token)
|
|
725
|
+
|
|
726
|
+
cookie_header = response.headers.get("set-cookie", "")
|
|
727
|
+
self.set_header("cookie", cookie_header)
|
|
537
728
|
self._is_authenticated = True
|
|
729
|
+
|
|
730
|
+
# parse and store the cookie for session persistence
|
|
731
|
+
if self.store_sessions and cookie_header:
|
|
732
|
+
# extract cookie from header to save in session file
|
|
733
|
+
cookie = SimpleCookie(cookie_header)
|
|
734
|
+
if cookie:
|
|
735
|
+
for cookie_obj in cookie.values():
|
|
736
|
+
self._last_token_cookie = cookie_obj
|
|
737
|
+
await self._update_auth_config(cookie_obj)
|
|
738
|
+
break # auth response only contains single cookie (TOKEN or UOS_TOKEN)
|
|
739
|
+
|
|
538
740
|
_LOGGER.debug("Authenticated successfully!")
|
|
539
741
|
|
|
540
742
|
async def _update_last_token_cookie(self, response: aiohttp.ClientResponse) -> None:
|
|
@@ -546,7 +748,6 @@ class BaseApiClient:
|
|
|
546
748
|
and csrf_token != self.headers.get("x-csrf-token")
|
|
547
749
|
):
|
|
548
750
|
self.set_header("x-csrf-token", csrf_token)
|
|
549
|
-
await self._update_last_token_cookie(response)
|
|
550
751
|
self._update_cookiename(response.cookies)
|
|
551
752
|
|
|
552
753
|
if (
|
|
@@ -602,6 +803,7 @@ class BaseApiClient:
|
|
|
602
803
|
|
|
603
804
|
async def _read_auth_config(self) -> SimpleCookie | None:
|
|
604
805
|
"""Read auth cookie from config."""
|
|
806
|
+
config: dict[str, Any] = {}
|
|
605
807
|
try:
|
|
606
808
|
async with aiofiles.open(self.config_file, "rb") as f:
|
|
607
809
|
config_data = await f.read()
|
|
@@ -632,10 +834,16 @@ class BaseApiClient:
|
|
|
632
834
|
cookie_value = _COOKIE_RE.sub("", str(cookie[cookie_name]))
|
|
633
835
|
self._last_token_cookie = cookie[cookie_name]
|
|
634
836
|
self._last_token_cookie_decode = None
|
|
837
|
+
|
|
838
|
+
# Only mark as authenticated if we have both cookie and CSRF token
|
|
839
|
+
csrf_token = session.get("csrf")
|
|
840
|
+
if not csrf_token:
|
|
841
|
+
_LOGGER.debug("Session found but missing CSRF token, will re-authenticate")
|
|
842
|
+
return None
|
|
843
|
+
|
|
635
844
|
self._is_authenticated = True
|
|
636
845
|
self.set_header("cookie", cookie_value)
|
|
637
|
-
|
|
638
|
-
self.set_header("x-csrf-token", session["csrf"])
|
|
846
|
+
self.set_header("x-csrf-token", csrf_token)
|
|
639
847
|
return cookie
|
|
640
848
|
|
|
641
849
|
def is_authenticated(self) -> bool:
|
|
@@ -666,21 +874,90 @@ class BaseApiClient:
|
|
|
666
874
|
|
|
667
875
|
return token_expires_at >= max_expire_time
|
|
668
876
|
|
|
877
|
+
async def clear_session(self) -> None:
|
|
878
|
+
"""Clears stored session for this specific user/host combination."""
|
|
879
|
+
if not self.store_sessions:
|
|
880
|
+
return
|
|
881
|
+
|
|
882
|
+
config: dict[str, Any] = {}
|
|
883
|
+
session_hash = get_user_hash(str(self._url), self._username)
|
|
884
|
+
try:
|
|
885
|
+
async with aiofiles.open(self.config_file, "rb") as f:
|
|
886
|
+
config_data = await f.read()
|
|
887
|
+
if config_data:
|
|
888
|
+
try:
|
|
889
|
+
config = orjson.loads(config_data)
|
|
890
|
+
except orjson.JSONDecodeError:
|
|
891
|
+
_LOGGER.warning("Invalid config file, ignoring.")
|
|
892
|
+
return
|
|
893
|
+
except FileNotFoundError:
|
|
894
|
+
return
|
|
895
|
+
|
|
896
|
+
if "sessions" in config and session_hash in config["sessions"]:
|
|
897
|
+
del config["sessions"][session_hash]
|
|
898
|
+
|
|
899
|
+
async with aiofiles.open(self.config_file, "wb") as f:
|
|
900
|
+
await f.write(orjson.dumps(config, option=orjson.OPT_INDENT_2))
|
|
901
|
+
|
|
902
|
+
_LOGGER.debug("Cleared session for %s", session_hash)
|
|
903
|
+
|
|
904
|
+
# Clear authentication state only when session was actually removed
|
|
905
|
+
self._is_authenticated = False
|
|
906
|
+
self._last_token_cookie = None
|
|
907
|
+
self._last_token_cookie_decode = None
|
|
908
|
+
|
|
909
|
+
async def clear_all_sessions(self) -> None:
|
|
910
|
+
"""Clears all stored sessions from the config file."""
|
|
911
|
+
if not self.store_sessions:
|
|
912
|
+
return
|
|
913
|
+
|
|
914
|
+
try:
|
|
915
|
+
await aos.remove(self.config_file)
|
|
916
|
+
except FileNotFoundError:
|
|
917
|
+
# File already gone - either never existed or removed by another process
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
# If we get here, the file was successfully removed (no exception raised)
|
|
921
|
+
_LOGGER.debug("Cleared all sessions from config file")
|
|
922
|
+
|
|
923
|
+
# Clear authentication state only after successful deletion
|
|
924
|
+
self._is_authenticated = False
|
|
925
|
+
self._last_token_cookie = None
|
|
926
|
+
self._last_token_cookie_decode = None
|
|
927
|
+
|
|
669
928
|
def _get_websocket_url(self) -> URL:
|
|
670
929
|
"""Get Websocket URL."""
|
|
671
930
|
return self._ws_url_object
|
|
672
931
|
|
|
673
932
|
async def async_disconnect_ws(self) -> None:
|
|
674
933
|
"""Disconnect from Websocket."""
|
|
675
|
-
if self.
|
|
934
|
+
if self._private_websocket:
|
|
676
935
|
websocket = self._get_websocket()
|
|
677
936
|
websocket.stop()
|
|
678
937
|
await websocket.wait_closed()
|
|
679
|
-
self.
|
|
938
|
+
self._private_websocket = None
|
|
939
|
+
if self._events_websocket:
|
|
940
|
+
events_websocket = self._get_events_websocket()
|
|
941
|
+
events_websocket.stop()
|
|
942
|
+
await events_websocket.wait_closed()
|
|
943
|
+
self._events_websocket = None
|
|
944
|
+
if self._devices_websocket:
|
|
945
|
+
devices_websocket = self._get_devices_websocket()
|
|
946
|
+
devices_websocket.stop()
|
|
947
|
+
await devices_websocket.wait_closed()
|
|
948
|
+
self._devices_websocket = None
|
|
680
949
|
|
|
681
950
|
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
682
951
|
raise NotImplementedError
|
|
683
952
|
|
|
953
|
+
def _process_events_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
954
|
+
"""Process events websocket message - to be implemented by subclass."""
|
|
955
|
+
raise NotImplementedError
|
|
956
|
+
|
|
957
|
+
def _process_devices_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
958
|
+
"""Process devices websocket message - to be implemented by subclass."""
|
|
959
|
+
raise NotImplementedError
|
|
960
|
+
|
|
684
961
|
def _get_last_update_id(self) -> str | None:
|
|
685
962
|
raise NotImplementedError
|
|
686
963
|
|
|
@@ -691,6 +968,14 @@ class BaseApiClient:
|
|
|
691
968
|
"""Websocket state changed."""
|
|
692
969
|
_LOGGER.debug("Websocket state changed: %s", state)
|
|
693
970
|
|
|
971
|
+
def _on_events_websocket_state_change(self, state: WebsocketState) -> None:
|
|
972
|
+
"""Events websocket state changed."""
|
|
973
|
+
_LOGGER.debug("Events websocket state changed: %s", state)
|
|
974
|
+
|
|
975
|
+
def _on_devices_websocket_state_change(self, state: WebsocketState) -> None:
|
|
976
|
+
"""Devices websocket state changed."""
|
|
977
|
+
_LOGGER.debug("Devices websocket state changed: %s", state)
|
|
978
|
+
|
|
694
979
|
|
|
695
980
|
class ProtectApiClient(BaseApiClient):
|
|
696
981
|
"""
|
|
@@ -713,6 +998,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
713
998
|
port: UFP HTTPS port
|
|
714
999
|
username: UFP username
|
|
715
1000
|
password: UFP password
|
|
1001
|
+
api_key: API key for UFP
|
|
716
1002
|
verify_ssl: Verify HTTPS certificate (default: `True`)
|
|
717
1003
|
session: Optional aiohttp session to use (default: generate one)
|
|
718
1004
|
override_connection_host: Use `host` as your `connection_host` for RTSP stream instead of using the one provided by UniFi Protect.
|
|
@@ -727,7 +1013,11 @@ class ProtectApiClient(BaseApiClient):
|
|
|
727
1013
|
_subscribed_models: set[ModelType]
|
|
728
1014
|
_ignore_stats: bool
|
|
729
1015
|
_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
1016
|
+
_events_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
1017
|
+
_devices_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
730
1018
|
_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
|
|
1019
|
+
_events_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
|
|
1020
|
+
_devices_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
|
|
731
1021
|
_bootstrap: Bootstrap | None = None
|
|
732
1022
|
_last_update_dt: datetime | None = None
|
|
733
1023
|
_connection_host: IPv4Address | IPv6Address | str | None = None
|
|
@@ -740,8 +1030,10 @@ class ProtectApiClient(BaseApiClient):
|
|
|
740
1030
|
port: int,
|
|
741
1031
|
username: str,
|
|
742
1032
|
password: str,
|
|
1033
|
+
api_key: str | None = None,
|
|
743
1034
|
verify_ssl: bool = True,
|
|
744
1035
|
session: aiohttp.ClientSession | None = None,
|
|
1036
|
+
public_api_session: aiohttp.ClientSession | None = None,
|
|
745
1037
|
ws_timeout: int = 30,
|
|
746
1038
|
cache_dir: Path | None = None,
|
|
747
1039
|
config_dir: Path | None = None,
|
|
@@ -752,15 +1044,19 @@ class ProtectApiClient(BaseApiClient):
|
|
|
752
1044
|
ignore_stats: bool = False,
|
|
753
1045
|
ignore_unadopted: bool = True,
|
|
754
1046
|
debug: bool = False,
|
|
1047
|
+
ws_receive_timeout: int | None = None,
|
|
755
1048
|
) -> None:
|
|
756
1049
|
super().__init__(
|
|
757
1050
|
host=host,
|
|
758
1051
|
port=port,
|
|
759
1052
|
username=username,
|
|
760
1053
|
password=password,
|
|
1054
|
+
api_key=api_key,
|
|
761
1055
|
verify_ssl=verify_ssl,
|
|
762
1056
|
session=session,
|
|
1057
|
+
public_api_session=public_api_session,
|
|
763
1058
|
ws_timeout=ws_timeout,
|
|
1059
|
+
ws_receive_timeout=ws_receive_timeout,
|
|
764
1060
|
cache_dir=cache_dir,
|
|
765
1061
|
config_dir=config_dir,
|
|
766
1062
|
store_sessions=store_sessions,
|
|
@@ -770,16 +1066,62 @@ class ProtectApiClient(BaseApiClient):
|
|
|
770
1066
|
self._subscribed_models = subscribed_models or set()
|
|
771
1067
|
self._ignore_stats = ignore_stats
|
|
772
1068
|
self._ws_subscriptions = []
|
|
1069
|
+
self._events_ws_subscriptions = []
|
|
1070
|
+
self._devices_ws_subscriptions = []
|
|
773
1071
|
self._ws_state_subscriptions = []
|
|
1072
|
+
self._events_ws_state_subscriptions = []
|
|
1073
|
+
self._devices_ws_state_subscriptions = []
|
|
774
1074
|
self.ignore_unadopted = ignore_unadopted
|
|
775
1075
|
self._update_lock = asyncio.Lock()
|
|
776
1076
|
|
|
777
1077
|
if override_connection_host:
|
|
778
|
-
self._connection_host =
|
|
1078
|
+
self._connection_host = ip_address(self._host)
|
|
779
1079
|
|
|
780
1080
|
if debug:
|
|
781
1081
|
set_debug()
|
|
782
1082
|
|
|
1083
|
+
def _set_connection_host_from_bootstrap(self) -> None:
|
|
1084
|
+
"""
|
|
1085
|
+
Set connection host from bootstrap NVR hosts (sync).
|
|
1086
|
+
|
|
1087
|
+
NOTE: Must stay in sync with _async_set_connection_host_from_bootstrap().
|
|
1088
|
+
Sync version for property getter, async version for update() method.
|
|
1089
|
+
"""
|
|
1090
|
+
index = 0
|
|
1091
|
+
try:
|
|
1092
|
+
index = self.bootstrap.nvr.hosts.index(self._host)
|
|
1093
|
+
except ValueError:
|
|
1094
|
+
try:
|
|
1095
|
+
host = ip_address(self._host)
|
|
1096
|
+
with contextlib.suppress(ValueError):
|
|
1097
|
+
index = self.bootstrap.nvr.hosts.index(host)
|
|
1098
|
+
except ValueError:
|
|
1099
|
+
pass
|
|
1100
|
+
|
|
1101
|
+
self._connection_host = self.bootstrap.nvr.hosts[index]
|
|
1102
|
+
|
|
1103
|
+
async def _async_set_connection_host_from_bootstrap(
|
|
1104
|
+
self,
|
|
1105
|
+
bootstrap: Bootstrap,
|
|
1106
|
+
) -> None:
|
|
1107
|
+
"""
|
|
1108
|
+
Set connection host from bootstrap NVR hosts (async).
|
|
1109
|
+
|
|
1110
|
+
NOTE: Must stay in sync with _set_connection_host_from_bootstrap().
|
|
1111
|
+
Async version allows DNS resolution via ip_from_host().
|
|
1112
|
+
"""
|
|
1113
|
+
index = 0
|
|
1114
|
+
try:
|
|
1115
|
+
index = bootstrap.nvr.hosts.index(self._host)
|
|
1116
|
+
except ValueError:
|
|
1117
|
+
try:
|
|
1118
|
+
host_ip = await ip_from_host(self._host)
|
|
1119
|
+
index = bootstrap.nvr.hosts.index(host_ip)
|
|
1120
|
+
except ValueError:
|
|
1121
|
+
pass
|
|
1122
|
+
|
|
1123
|
+
self._connection_host = bootstrap.nvr.hosts[index]
|
|
1124
|
+
|
|
783
1125
|
@cached_property
|
|
784
1126
|
def bootstrap(self) -> Bootstrap:
|
|
785
1127
|
if self._bootstrap is None:
|
|
@@ -789,21 +1131,11 @@ class ProtectApiClient(BaseApiClient):
|
|
|
789
1131
|
|
|
790
1132
|
@property
|
|
791
1133
|
def connection_host(self) -> IPv4Address | IPv6Address | str:
|
|
792
|
-
"""Connection host to use for generating RTSP URLs"""
|
|
1134
|
+
"""Connection host to use for generating RTSP URLs."""
|
|
793
1135
|
if self._connection_host is None:
|
|
794
|
-
|
|
795
|
-
index = 0
|
|
796
|
-
try:
|
|
797
|
-
# check if user supplied host is avaiable
|
|
798
|
-
index = self.bootstrap.nvr.hosts.index(self._host)
|
|
799
|
-
except ValueError:
|
|
800
|
-
# check if IP of user supplied host is avaiable
|
|
801
|
-
host = ip_from_host(self._host)
|
|
802
|
-
with contextlib.suppress(ValueError):
|
|
803
|
-
index = self.bootstrap.nvr.hosts.index(host)
|
|
804
|
-
|
|
805
|
-
self._connection_host = self.bootstrap.nvr.hosts[index]
|
|
1136
|
+
self._set_connection_host_from_bootstrap()
|
|
806
1137
|
|
|
1138
|
+
assert self._connection_host is not None
|
|
807
1139
|
return self._connection_host
|
|
808
1140
|
|
|
809
1141
|
async def update(self) -> Bootstrap:
|
|
@@ -818,8 +1150,30 @@ class ProtectApiClient(BaseApiClient):
|
|
|
818
1150
|
"""
|
|
819
1151
|
async with self._update_lock:
|
|
820
1152
|
bootstrap = await self.get_bootstrap()
|
|
1153
|
+
if bootstrap.nvr.version >= NFC_FINGERPRINT_SUPPORT_VERSION:
|
|
1154
|
+
try:
|
|
1155
|
+
keyrings = await self.api_request_list("keyrings")
|
|
1156
|
+
except NotAuthorized as err:
|
|
1157
|
+
_LOGGER.debug("No access to keyrings %s, skipping", err)
|
|
1158
|
+
keyrings = []
|
|
1159
|
+
try:
|
|
1160
|
+
ulp_users = await self.api_request_list("ulp-users")
|
|
1161
|
+
except NotAuthorized as err:
|
|
1162
|
+
_LOGGER.debug("No access to ulp-users %s, skipping", err)
|
|
1163
|
+
ulp_users = []
|
|
1164
|
+
bootstrap.keyrings = Keyrings.from_list(
|
|
1165
|
+
cast(list[Keyring], list_from_unifi_list(self, keyrings))
|
|
1166
|
+
)
|
|
1167
|
+
bootstrap.ulp_users = UlpUsers.from_list(
|
|
1168
|
+
cast(list[UlpUser], list_from_unifi_list(self, ulp_users))
|
|
1169
|
+
)
|
|
821
1170
|
self.__dict__.pop("bootstrap", None)
|
|
822
1171
|
self._bootstrap = bootstrap
|
|
1172
|
+
|
|
1173
|
+
# Set connection host if not set via override_connection_host
|
|
1174
|
+
if self._connection_host is None:
|
|
1175
|
+
await self._async_set_connection_host_from_bootstrap(bootstrap)
|
|
1176
|
+
|
|
823
1177
|
return bootstrap
|
|
824
1178
|
|
|
825
1179
|
async def poll_events(self) -> None:
|
|
@@ -861,6 +1215,62 @@ class ProtectApiClient(BaseApiClient):
|
|
|
861
1215
|
except Exception:
|
|
862
1216
|
_LOGGER.exception("Exception while running subscription handler")
|
|
863
1217
|
|
|
1218
|
+
def emit_events_message(self, msg: WSSubscriptionMessage) -> None:
|
|
1219
|
+
"""Emit message to all events subscriptions."""
|
|
1220
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
1221
|
+
if msg.new_obj is not None:
|
|
1222
|
+
_LOGGER.debug(
|
|
1223
|
+
"emitting events message: %s:%s:%s:%s",
|
|
1224
|
+
msg.action,
|
|
1225
|
+
msg.new_obj.model,
|
|
1226
|
+
msg.new_obj.id,
|
|
1227
|
+
list(msg.changed_data),
|
|
1228
|
+
)
|
|
1229
|
+
elif msg.old_obj is not None:
|
|
1230
|
+
_LOGGER.debug(
|
|
1231
|
+
"emitting events message: %s:%s:%s",
|
|
1232
|
+
msg.action,
|
|
1233
|
+
msg.old_obj.model,
|
|
1234
|
+
msg.old_obj.id,
|
|
1235
|
+
)
|
|
1236
|
+
else:
|
|
1237
|
+
_LOGGER.debug("emitting events message: %s", msg.action)
|
|
1238
|
+
|
|
1239
|
+
for sub in self._events_ws_subscriptions:
|
|
1240
|
+
try:
|
|
1241
|
+
sub(msg)
|
|
1242
|
+
except Exception:
|
|
1243
|
+
_LOGGER.exception("Exception while running events subscription handler")
|
|
1244
|
+
|
|
1245
|
+
def emit_devices_message(self, msg: WSSubscriptionMessage) -> None:
|
|
1246
|
+
"""Emit message to all devices subscriptions."""
|
|
1247
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
1248
|
+
if msg.new_obj is not None:
|
|
1249
|
+
_LOGGER.debug(
|
|
1250
|
+
"emitting devices message: %s:%s:%s:%s",
|
|
1251
|
+
msg.action,
|
|
1252
|
+
msg.new_obj.model,
|
|
1253
|
+
msg.new_obj.id,
|
|
1254
|
+
list(msg.changed_data),
|
|
1255
|
+
)
|
|
1256
|
+
elif msg.old_obj is not None:
|
|
1257
|
+
_LOGGER.debug(
|
|
1258
|
+
"emitting devices message: %s:%s:%s",
|
|
1259
|
+
msg.action,
|
|
1260
|
+
msg.old_obj.model,
|
|
1261
|
+
msg.old_obj.id,
|
|
1262
|
+
)
|
|
1263
|
+
else:
|
|
1264
|
+
_LOGGER.debug("emitting devices message: %s", msg.action)
|
|
1265
|
+
|
|
1266
|
+
for sub in self._devices_ws_subscriptions:
|
|
1267
|
+
try:
|
|
1268
|
+
sub(msg)
|
|
1269
|
+
except Exception:
|
|
1270
|
+
_LOGGER.exception(
|
|
1271
|
+
"Exception while running devices subscription handler"
|
|
1272
|
+
)
|
|
1273
|
+
|
|
864
1274
|
def _get_last_update_id(self) -> str | None:
|
|
865
1275
|
if self._bootstrap is None:
|
|
866
1276
|
return None
|
|
@@ -878,7 +1288,111 @@ class ProtectApiClient(BaseApiClient):
|
|
|
878
1288
|
|
|
879
1289
|
self.emit_message(processed_message)
|
|
880
1290
|
|
|
881
|
-
|
|
1291
|
+
def _process_events_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
1292
|
+
"""Process events websocket message (Public API - JSON format)."""
|
|
1293
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
1294
|
+
_LOGGER.debug("Ignoring non-text websocket message: %s", msg.type)
|
|
1295
|
+
return
|
|
1296
|
+
|
|
1297
|
+
try:
|
|
1298
|
+
data = orjson.loads(msg.data)
|
|
1299
|
+
action_type = data.get("type") # "update", "add", "remove"
|
|
1300
|
+
item = data.get("item", {})
|
|
1301
|
+
model_key = item.get("modelKey")
|
|
1302
|
+
|
|
1303
|
+
if not action_type or not model_key:
|
|
1304
|
+
_LOGGER.debug("Invalid public API websocket message: %s", data)
|
|
1305
|
+
return
|
|
1306
|
+
|
|
1307
|
+
# Create a WSSubscriptionMessage similar to private WS
|
|
1308
|
+
model_type = ModelType.from_string(model_key)
|
|
1309
|
+
|
|
1310
|
+
if model_type is ModelType.UNKNOWN:
|
|
1311
|
+
_LOGGER.debug("Unknown model type in public API message: %s", model_key)
|
|
1312
|
+
return
|
|
1313
|
+
|
|
1314
|
+
# Create proper objects from the data
|
|
1315
|
+
new_obj: ProtectModelWithId | None = None
|
|
1316
|
+
old_obj: ProtectModelWithId | None = None
|
|
1317
|
+
update_id = item.get("id", "")
|
|
1318
|
+
|
|
1319
|
+
if action_type in ("add", "update"):
|
|
1320
|
+
try:
|
|
1321
|
+
new_obj = cast(
|
|
1322
|
+
ProtectModelWithId, create_from_unifi_dict(item, api=self)
|
|
1323
|
+
)
|
|
1324
|
+
except Exception:
|
|
1325
|
+
_LOGGER.debug(
|
|
1326
|
+
"Could not create object from public API data: %s", item
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
msg_obj = WSSubscriptionMessage(
|
|
1330
|
+
action=WSAction(action_type),
|
|
1331
|
+
new_update_id=update_id,
|
|
1332
|
+
changed_data=item,
|
|
1333
|
+
new_obj=new_obj,
|
|
1334
|
+
old_obj=old_obj,
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
self.emit_events_message(msg_obj)
|
|
1338
|
+
except Exception as e:
|
|
1339
|
+
_LOGGER.exception(
|
|
1340
|
+
"Error processing public API events websocket message: %s", e
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
def _process_devices_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
1344
|
+
"""Process devices websocket message (Public API - JSON format)."""
|
|
1345
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
1346
|
+
_LOGGER.debug("Ignoring non-text websocket message: %s", msg.type)
|
|
1347
|
+
return
|
|
1348
|
+
|
|
1349
|
+
try:
|
|
1350
|
+
data = orjson.loads(msg.data)
|
|
1351
|
+
action_type = data.get("type") # "update", "add", "remove"
|
|
1352
|
+
item = data.get("item", {})
|
|
1353
|
+
model_key = item.get("modelKey")
|
|
1354
|
+
|
|
1355
|
+
if not action_type or not model_key:
|
|
1356
|
+
_LOGGER.debug("Invalid public API websocket message: %s", data)
|
|
1357
|
+
return
|
|
1358
|
+
|
|
1359
|
+
# Create a WSSubscriptionMessage similar to private WS
|
|
1360
|
+
model_type = ModelType.from_string(model_key)
|
|
1361
|
+
|
|
1362
|
+
if model_type is ModelType.UNKNOWN:
|
|
1363
|
+
_LOGGER.debug("Unknown model type in public API message: %s", model_key)
|
|
1364
|
+
return
|
|
1365
|
+
|
|
1366
|
+
# Create proper objects from the data
|
|
1367
|
+
new_obj: ProtectModelWithId | None = None
|
|
1368
|
+
old_obj: ProtectModelWithId | None = None
|
|
1369
|
+
update_id = item.get("id", "")
|
|
1370
|
+
|
|
1371
|
+
if action_type in ("add", "update"):
|
|
1372
|
+
try:
|
|
1373
|
+
new_obj = cast(
|
|
1374
|
+
ProtectModelWithId, create_from_unifi_dict(item, api=self)
|
|
1375
|
+
)
|
|
1376
|
+
except Exception:
|
|
1377
|
+
_LOGGER.debug(
|
|
1378
|
+
"Could not create object from public API data: %s", item
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
msg_obj = WSSubscriptionMessage(
|
|
1382
|
+
action=WSAction(action_type),
|
|
1383
|
+
new_update_id=update_id,
|
|
1384
|
+
changed_data=item,
|
|
1385
|
+
new_obj=new_obj,
|
|
1386
|
+
old_obj=old_obj,
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
self.emit_devices_message(msg_obj)
|
|
1390
|
+
except Exception as e:
|
|
1391
|
+
_LOGGER.exception(
|
|
1392
|
+
"Error processing public API devices websocket message: %s", e
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
async def _get_event_paginate( # noqa: PLR0912
|
|
882
1396
|
self,
|
|
883
1397
|
params: dict[str, Any],
|
|
884
1398
|
*,
|
|
@@ -936,7 +1450,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
936
1450
|
|
|
937
1451
|
return events
|
|
938
1452
|
|
|
939
|
-
async def get_events_raw(
|
|
1453
|
+
async def get_events_raw( # noqa: PLR0912
|
|
940
1454
|
self,
|
|
941
1455
|
*,
|
|
942
1456
|
start: datetime | None = None,
|
|
@@ -1123,6 +1637,34 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1123
1637
|
self._get_websocket().start()
|
|
1124
1638
|
return partial(self._unsubscribe_websocket, ws_callback)
|
|
1125
1639
|
|
|
1640
|
+
def subscribe_events_websocket(
|
|
1641
|
+
self,
|
|
1642
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1643
|
+
) -> Callable[[], None]:
|
|
1644
|
+
"""
|
|
1645
|
+
Subscribe to events websocket events.
|
|
1646
|
+
|
|
1647
|
+
Returns a callback that will unsubscribe.
|
|
1648
|
+
"""
|
|
1649
|
+
_LOGGER.debug("Adding events subscription: %s", ws_callback)
|
|
1650
|
+
self._events_ws_subscriptions.append(ws_callback)
|
|
1651
|
+
self._get_events_websocket().start()
|
|
1652
|
+
return partial(self._unsubscribe_events_websocket, ws_callback)
|
|
1653
|
+
|
|
1654
|
+
def subscribe_devices_websocket(
|
|
1655
|
+
self,
|
|
1656
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1657
|
+
) -> Callable[[], None]:
|
|
1658
|
+
"""
|
|
1659
|
+
Subscribe to devices websocket events.
|
|
1660
|
+
|
|
1661
|
+
Returns a callback that will unsubscribe.
|
|
1662
|
+
"""
|
|
1663
|
+
_LOGGER.debug("Adding devices subscription: %s", ws_callback)
|
|
1664
|
+
self._devices_ws_subscriptions.append(ws_callback)
|
|
1665
|
+
self._get_devices_websocket().start()
|
|
1666
|
+
return partial(self._unsubscribe_devices_websocket, ws_callback)
|
|
1667
|
+
|
|
1126
1668
|
def _unsubscribe_websocket(
|
|
1127
1669
|
self,
|
|
1128
1670
|
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
@@ -1133,6 +1675,26 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1133
1675
|
if not self._ws_subscriptions:
|
|
1134
1676
|
self._get_websocket().stop()
|
|
1135
1677
|
|
|
1678
|
+
def _unsubscribe_events_websocket(
|
|
1679
|
+
self,
|
|
1680
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1681
|
+
) -> None:
|
|
1682
|
+
"""Unsubscribe to events websocket events."""
|
|
1683
|
+
_LOGGER.debug("Removing events subscription: %s", ws_callback)
|
|
1684
|
+
self._events_ws_subscriptions.remove(ws_callback)
|
|
1685
|
+
if not self._events_ws_subscriptions:
|
|
1686
|
+
self._get_events_websocket().stop()
|
|
1687
|
+
|
|
1688
|
+
def _unsubscribe_devices_websocket(
|
|
1689
|
+
self,
|
|
1690
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1691
|
+
) -> None:
|
|
1692
|
+
"""Unsubscribe to devices websocket events."""
|
|
1693
|
+
_LOGGER.debug("Removing devices subscription: %s", ws_callback)
|
|
1694
|
+
self._devices_ws_subscriptions.remove(ws_callback)
|
|
1695
|
+
if not self._devices_ws_subscriptions:
|
|
1696
|
+
self._get_devices_websocket().stop()
|
|
1697
|
+
|
|
1136
1698
|
def subscribe_websocket_state(
|
|
1137
1699
|
self,
|
|
1138
1700
|
ws_callback: Callable[[WebsocketState], None],
|
|
@@ -1145,6 +1707,30 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1145
1707
|
self._ws_state_subscriptions.append(ws_callback)
|
|
1146
1708
|
return partial(self._unsubscribe_websocket_state, ws_callback)
|
|
1147
1709
|
|
|
1710
|
+
def subscribe_events_websocket_state(
|
|
1711
|
+
self,
|
|
1712
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1713
|
+
) -> Callable[[], None]:
|
|
1714
|
+
"""
|
|
1715
|
+
Subscribe to events websocket state changes.
|
|
1716
|
+
|
|
1717
|
+
Returns a callback that will unsubscribe.
|
|
1718
|
+
"""
|
|
1719
|
+
self._events_ws_state_subscriptions.append(ws_callback)
|
|
1720
|
+
return partial(self._unsubscribe_events_websocket_state, ws_callback)
|
|
1721
|
+
|
|
1722
|
+
def subscribe_devices_websocket_state(
|
|
1723
|
+
self,
|
|
1724
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1725
|
+
) -> Callable[[], None]:
|
|
1726
|
+
"""
|
|
1727
|
+
Subscribe to devices websocket state changes.
|
|
1728
|
+
|
|
1729
|
+
Returns a callback that will unsubscribe.
|
|
1730
|
+
"""
|
|
1731
|
+
self._devices_ws_state_subscriptions.append(ws_callback)
|
|
1732
|
+
return partial(self._unsubscribe_devices_websocket_state, ws_callback)
|
|
1733
|
+
|
|
1148
1734
|
def _unsubscribe_websocket_state(
|
|
1149
1735
|
self,
|
|
1150
1736
|
ws_callback: Callable[[WebsocketState], None],
|
|
@@ -1152,6 +1738,20 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1152
1738
|
"""Unsubscribe to websocket state changes."""
|
|
1153
1739
|
self._ws_state_subscriptions.remove(ws_callback)
|
|
1154
1740
|
|
|
1741
|
+
def _unsubscribe_events_websocket_state(
|
|
1742
|
+
self,
|
|
1743
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1744
|
+
) -> None:
|
|
1745
|
+
"""Unsubscribe to events websocket state changes."""
|
|
1746
|
+
self._events_ws_state_subscriptions.remove(ws_callback)
|
|
1747
|
+
|
|
1748
|
+
def _unsubscribe_devices_websocket_state(
|
|
1749
|
+
self,
|
|
1750
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1751
|
+
) -> None:
|
|
1752
|
+
"""Unsubscribe to devices websocket state changes."""
|
|
1753
|
+
self._devices_ws_state_subscriptions.remove(ws_callback)
|
|
1754
|
+
|
|
1155
1755
|
def _on_websocket_state_change(self, state: WebsocketState) -> None:
|
|
1156
1756
|
"""Websocket state changed."""
|
|
1157
1757
|
super()._on_websocket_state_change(state)
|
|
@@ -1161,6 +1761,26 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1161
1761
|
except Exception:
|
|
1162
1762
|
_LOGGER.exception("Exception while running websocket state handler")
|
|
1163
1763
|
|
|
1764
|
+
def _on_events_websocket_state_change(self, state: WebsocketState) -> None:
|
|
1765
|
+
"""Events Websocket state changed."""
|
|
1766
|
+
for sub in self._events_ws_state_subscriptions:
|
|
1767
|
+
try:
|
|
1768
|
+
sub(state)
|
|
1769
|
+
except Exception:
|
|
1770
|
+
_LOGGER.exception(
|
|
1771
|
+
"Exception while running events websocket state handler"
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
def _on_devices_websocket_state_change(self, state: WebsocketState) -> None:
|
|
1775
|
+
"""Devices Websocket state changed."""
|
|
1776
|
+
for sub in self._devices_ws_state_subscriptions:
|
|
1777
|
+
try:
|
|
1778
|
+
sub(state)
|
|
1779
|
+
except Exception:
|
|
1780
|
+
_LOGGER.exception(
|
|
1781
|
+
"Exception while running devices websocket state handler"
|
|
1782
|
+
)
|
|
1783
|
+
|
|
1164
1784
|
async def get_bootstrap(self) -> Bootstrap:
|
|
1165
1785
|
"""
|
|
1166
1786
|
Gets bootstrap object from UFP instance
|
|
@@ -1211,6 +1831,10 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1211
1831
|
Gets the list of lights straight from the NVR.
|
|
1212
1832
|
|
|
1213
1833
|
The websocket is connected and running, you likely just want to use `self.bootstrap.lights`
|
|
1834
|
+
|
|
1835
|
+
.. deprecated::
|
|
1836
|
+
Use :meth:`get_lights_public` instead. This method uses the private API
|
|
1837
|
+
and will be removed in a future version.
|
|
1214
1838
|
"""
|
|
1215
1839
|
return cast(list[Light], await self.get_devices(ModelType.LIGHT, Light))
|
|
1216
1840
|
|
|
@@ -1241,6 +1865,14 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1241
1865
|
"""
|
|
1242
1866
|
return cast(list[Chime], await self.get_devices(ModelType.CHIME, Chime))
|
|
1243
1867
|
|
|
1868
|
+
async def get_aiports(self) -> list[AiPort]:
|
|
1869
|
+
"""
|
|
1870
|
+
Gets the list of aiports straight from the NVR.
|
|
1871
|
+
|
|
1872
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.aiports`
|
|
1873
|
+
"""
|
|
1874
|
+
return cast(list[AiPort], await self.get_devices(ModelType.AIPORT, AiPort))
|
|
1875
|
+
|
|
1244
1876
|
async def get_viewers(self) -> list[Viewer]:
|
|
1245
1877
|
"""
|
|
1246
1878
|
Gets the list of viewers straight from the NVR.
|
|
@@ -1329,6 +1961,10 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1329
1961
|
Gets a light straight from the NVR.
|
|
1330
1962
|
|
|
1331
1963
|
The websocket is connected and running, you likely just want to use `self.bootstrap.lights[device_id]`
|
|
1964
|
+
|
|
1965
|
+
.. deprecated::
|
|
1966
|
+
Use :meth:`get_light_public` instead. This method uses the private API
|
|
1967
|
+
and will be removed in a future version.
|
|
1332
1968
|
"""
|
|
1333
1969
|
return cast(Light, await self.get_device(ModelType.LIGHT, device_id, Light))
|
|
1334
1970
|
|
|
@@ -1359,6 +1995,14 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1359
1995
|
"""
|
|
1360
1996
|
return cast(Chime, await self.get_device(ModelType.CHIME, device_id, Chime))
|
|
1361
1997
|
|
|
1998
|
+
async def get_aiport(self, device_id: str) -> AiPort:
|
|
1999
|
+
"""
|
|
2000
|
+
Gets a AiPort straight from the NVR.
|
|
2001
|
+
|
|
2002
|
+
The websocket is connected and running, you likely just want to use `self.bootstrap.aiport[device_id]`
|
|
2003
|
+
"""
|
|
2004
|
+
return cast(AiPort, await self.get_device(ModelType.AIPORT, device_id, AiPort))
|
|
2005
|
+
|
|
1362
2006
|
async def get_viewer(self, device_id: str) -> Viewer:
|
|
1363
2007
|
"""
|
|
1364
2008
|
Gets a viewer straight from the NVR.
|
|
@@ -1401,21 +2045,20 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1401
2045
|
|
|
1402
2046
|
Datetime of screenshot is approximate. It may be +/- a few seconds.
|
|
1403
2047
|
"""
|
|
1404
|
-
params = {
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
2048
|
+
params: dict[str, Any] = {}
|
|
2049
|
+
if dt is not None:
|
|
2050
|
+
path = "recording-snapshot"
|
|
2051
|
+
params["ts"] = to_js_time(dt)
|
|
2052
|
+
else:
|
|
2053
|
+
path = "snapshot"
|
|
2054
|
+
params["ts"] = int(time.time() * 1000)
|
|
2055
|
+
params["force"] = "true"
|
|
1408
2056
|
|
|
1409
2057
|
if width is not None:
|
|
1410
|
-
params
|
|
2058
|
+
params["w"] = width
|
|
1411
2059
|
|
|
1412
2060
|
if height is not None:
|
|
1413
|
-
params
|
|
1414
|
-
|
|
1415
|
-
path = "snapshot"
|
|
1416
|
-
if dt is not None:
|
|
1417
|
-
path = "recording-snapshot"
|
|
1418
|
-
del params["force"]
|
|
2061
|
+
params["h"] = height
|
|
1419
2062
|
|
|
1420
2063
|
return await self.api_request_raw(
|
|
1421
2064
|
f"cameras/{camera_id}/{path}",
|
|
@@ -1423,6 +2066,96 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1423
2066
|
raise_exception=False,
|
|
1424
2067
|
)
|
|
1425
2068
|
|
|
2069
|
+
async def get_public_api_camera_snapshot(
|
|
2070
|
+
self,
|
|
2071
|
+
camera_id: str,
|
|
2072
|
+
high_quality: bool = False,
|
|
2073
|
+
) -> bytes | None:
|
|
2074
|
+
"""Gets snapshot for a camera using public api."""
|
|
2075
|
+
return await self.api_request_raw(
|
|
2076
|
+
public_api=True,
|
|
2077
|
+
raise_exception=False,
|
|
2078
|
+
url=f"/v1/cameras/{camera_id}/snapshot",
|
|
2079
|
+
params={"highQuality": pybool_to_json_bool(high_quality)},
|
|
2080
|
+
)
|
|
2081
|
+
|
|
2082
|
+
async def create_camera_rtsps_streams(
|
|
2083
|
+
self,
|
|
2084
|
+
camera_id: str,
|
|
2085
|
+
qualities: list[str] | str,
|
|
2086
|
+
) -> RTSPSStreams | None:
|
|
2087
|
+
"""Creates RTSPS streams for a camera using public API."""
|
|
2088
|
+
if isinstance(qualities, str):
|
|
2089
|
+
qualities = [qualities]
|
|
2090
|
+
|
|
2091
|
+
data = {"qualities": qualities}
|
|
2092
|
+
response = await self.api_request_raw(
|
|
2093
|
+
public_api=True,
|
|
2094
|
+
url=f"/v1/cameras/{camera_id}/rtsps-stream",
|
|
2095
|
+
method="POST",
|
|
2096
|
+
json=data,
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
if response is None:
|
|
2100
|
+
return None
|
|
2101
|
+
|
|
2102
|
+
try:
|
|
2103
|
+
response_json = orjson.loads(response)
|
|
2104
|
+
return RTSPSStreams(**response_json)
|
|
2105
|
+
except (orjson.JSONDecodeError, TypeError) as ex:
|
|
2106
|
+
_LOGGER.error(
|
|
2107
|
+
"Could not decode JSON response for create RTSPS streams (camera %s): %s",
|
|
2108
|
+
camera_id,
|
|
2109
|
+
ex,
|
|
2110
|
+
)
|
|
2111
|
+
return None
|
|
2112
|
+
|
|
2113
|
+
async def get_camera_rtsps_streams(
|
|
2114
|
+
self,
|
|
2115
|
+
camera_id: str,
|
|
2116
|
+
) -> RTSPSStreams | None:
|
|
2117
|
+
"""Gets existing RTSPS streams for a camera using public API."""
|
|
2118
|
+
response = await self.api_request_raw(
|
|
2119
|
+
public_api=True,
|
|
2120
|
+
url=f"/v1/cameras/{camera_id}/rtsps-stream",
|
|
2121
|
+
method="GET",
|
|
2122
|
+
)
|
|
2123
|
+
|
|
2124
|
+
if response is None:
|
|
2125
|
+
return None
|
|
2126
|
+
|
|
2127
|
+
try:
|
|
2128
|
+
response_json = orjson.loads(response)
|
|
2129
|
+
return RTSPSStreams(**response_json)
|
|
2130
|
+
except (orjson.JSONDecodeError, TypeError) as ex:
|
|
2131
|
+
_LOGGER.error(
|
|
2132
|
+
"Could not decode JSON response for get RTSPS streams (camera %s): %s",
|
|
2133
|
+
camera_id,
|
|
2134
|
+
ex,
|
|
2135
|
+
)
|
|
2136
|
+
return None
|
|
2137
|
+
|
|
2138
|
+
async def delete_camera_rtsps_streams(
|
|
2139
|
+
self,
|
|
2140
|
+
camera_id: str,
|
|
2141
|
+
qualities: list[str] | str,
|
|
2142
|
+
) -> bool:
|
|
2143
|
+
"""Deletes RTSPS streams for a camera using public API."""
|
|
2144
|
+
if isinstance(qualities, str):
|
|
2145
|
+
qualities = [qualities]
|
|
2146
|
+
|
|
2147
|
+
# Build query parameters for qualities
|
|
2148
|
+
params = [("qualities", quality) for quality in qualities]
|
|
2149
|
+
|
|
2150
|
+
response = await self.api_request_raw(
|
|
2151
|
+
public_api=True,
|
|
2152
|
+
url=f"/v1/cameras/{camera_id}/rtsps-stream",
|
|
2153
|
+
method="DELETE",
|
|
2154
|
+
params=params,
|
|
2155
|
+
)
|
|
2156
|
+
|
|
2157
|
+
return response is not None
|
|
2158
|
+
|
|
1426
2159
|
async def get_package_camera_snapshot(
|
|
1427
2160
|
self,
|
|
1428
2161
|
camera_id: str,
|
|
@@ -1435,22 +2168,21 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1435
2168
|
|
|
1436
2169
|
Datetime of screenshot is approximate. It may be +/- a few seconds.
|
|
1437
2170
|
"""
|
|
1438
|
-
params = {
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
2171
|
+
params: dict[str, Any] = {}
|
|
2172
|
+
if dt is not None:
|
|
2173
|
+
path = "recording-snapshot"
|
|
2174
|
+
params["ts"] = to_js_time(dt)
|
|
2175
|
+
params["lens"] = 2
|
|
2176
|
+
else:
|
|
2177
|
+
path = "package-snapshot"
|
|
2178
|
+
params["ts"] = int(time.time() * 1000)
|
|
2179
|
+
params["force"] = "true"
|
|
1442
2180
|
|
|
1443
2181
|
if width is not None:
|
|
1444
|
-
params
|
|
2182
|
+
params["w"] = width
|
|
1445
2183
|
|
|
1446
2184
|
if height is not None:
|
|
1447
|
-
params
|
|
1448
|
-
|
|
1449
|
-
path = "package-snapshot"
|
|
1450
|
-
if dt is not None:
|
|
1451
|
-
path = "recording-snapshot"
|
|
1452
|
-
del params["force"]
|
|
1453
|
-
params.update({"lens": 2})
|
|
2185
|
+
params["h"] = height
|
|
1454
2186
|
|
|
1455
2187
|
return await self.api_request_raw(
|
|
1456
2188
|
f"cameras/{camera_id}/{path}",
|
|
@@ -1537,13 +2269,19 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1537
2269
|
raise_exception=False,
|
|
1538
2270
|
)
|
|
1539
2271
|
|
|
2272
|
+
_LOGGER.debug(
|
|
2273
|
+
"Requesting camera video: %s%s %s", self.private_api_path, path, params
|
|
2274
|
+
)
|
|
1540
2275
|
r = await self.request(
|
|
1541
2276
|
"get",
|
|
1542
|
-
|
|
2277
|
+
f"{self.private_api_path}{path}",
|
|
1543
2278
|
auto_close=False,
|
|
1544
2279
|
timeout=0,
|
|
1545
2280
|
params=params,
|
|
1546
2281
|
)
|
|
2282
|
+
if r.status != 200:
|
|
2283
|
+
await self._raise_for_status(r, True)
|
|
2284
|
+
|
|
1547
2285
|
if output_file is not None:
|
|
1548
2286
|
async with aiofiles.open(output_file, "wb") as output:
|
|
1549
2287
|
|
|
@@ -1765,10 +2503,12 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1765
2503
|
*,
|
|
1766
2504
|
volume: int | None = None,
|
|
1767
2505
|
repeat_times: int | None = None,
|
|
2506
|
+
ringtone_id: str | None = None,
|
|
2507
|
+
track_no: int | None = None,
|
|
1768
2508
|
) -> None:
|
|
1769
2509
|
"""Plays chime tones on a chime"""
|
|
1770
2510
|
data: dict[str, Any] | None = None
|
|
1771
|
-
if volume or repeat_times:
|
|
2511
|
+
if volume or repeat_times or ringtone_id or track_no:
|
|
1772
2512
|
chime = self.bootstrap.chimes.get(device_id)
|
|
1773
2513
|
if chime is None:
|
|
1774
2514
|
raise BadRequest("Invalid chime ID %s", device_id)
|
|
@@ -1776,8 +2516,11 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1776
2516
|
data = {
|
|
1777
2517
|
"volume": volume or chime.volume,
|
|
1778
2518
|
"repeatTimes": repeat_times or chime.repeat_times,
|
|
1779
|
-
"trackNo": chime.track_no,
|
|
2519
|
+
"trackNo": track_no or chime.track_no,
|
|
1780
2520
|
}
|
|
2521
|
+
if ringtone_id:
|
|
2522
|
+
data["ringtoneId"] = ringtone_id
|
|
2523
|
+
data.pop("trackNo", None)
|
|
1781
2524
|
|
|
1782
2525
|
await self.api_request(
|
|
1783
2526
|
f"chimes/{device_id}/play-speaker",
|
|
@@ -1789,6 +2532,22 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1789
2532
|
"""Plays chime tones on a chime"""
|
|
1790
2533
|
await self.api_request(f"chimes/{device_id}/play-buzzer", method="post")
|
|
1791
2534
|
|
|
2535
|
+
async def set_light_is_led_force_on(
|
|
2536
|
+
self, device_id: str, is_led_force_on: bool
|
|
2537
|
+
) -> None:
|
|
2538
|
+
"""
|
|
2539
|
+
Sets isLedForceOn for light.
|
|
2540
|
+
|
|
2541
|
+
.. deprecated::
|
|
2542
|
+
Use :meth:`update_light_public` instead. This method uses the private API
|
|
2543
|
+
and will be removed in a future version.
|
|
2544
|
+
"""
|
|
2545
|
+
await self.api_request(
|
|
2546
|
+
f"lights/{device_id}",
|
|
2547
|
+
method="patch",
|
|
2548
|
+
json={"lightOnSettings": {"isLedForceOn": is_led_force_on}},
|
|
2549
|
+
)
|
|
2550
|
+
|
|
1792
2551
|
async def clear_tamper_sensor(self, device_id: str) -> None:
|
|
1793
2552
|
"""Clears tamper status for sensor"""
|
|
1794
2553
|
await self.api_request(f"sensors/{device_id}/clear-tamper-flag", method="post")
|
|
@@ -1822,17 +2581,6 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1822
2581
|
|
|
1823
2582
|
return versions
|
|
1824
2583
|
|
|
1825
|
-
async def get_release_versions(self) -> set[Version]:
|
|
1826
|
-
"""Get all release versions for UniFi Protect"""
|
|
1827
|
-
versions: set[Version] = set()
|
|
1828
|
-
for url in PROTECT_APT_URLS:
|
|
1829
|
-
try:
|
|
1830
|
-
versions |= await self._get_versions_from_api(url)
|
|
1831
|
-
except NvrError:
|
|
1832
|
-
_LOGGER.warning("Failed to retrieve release versions from online.")
|
|
1833
|
-
|
|
1834
|
-
return versions
|
|
1835
|
-
|
|
1836
2584
|
async def relative_move_ptz_camera(
|
|
1837
2585
|
self,
|
|
1838
2586
|
device_id: str,
|
|
@@ -1978,3 +2726,138 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1978
2726
|
method="post",
|
|
1979
2727
|
)
|
|
1980
2728
|
return PTZPreset(**preset)
|
|
2729
|
+
|
|
2730
|
+
async def create_api_key(self, name: str) -> str:
|
|
2731
|
+
"""Create an API key with the given name and return the full API key."""
|
|
2732
|
+
if not name:
|
|
2733
|
+
raise BadRequest("API key name cannot be empty")
|
|
2734
|
+
|
|
2735
|
+
response = await self.api_request(
|
|
2736
|
+
api_path="/proxy/users/api/v2",
|
|
2737
|
+
url="/user/self/keys",
|
|
2738
|
+
method="post",
|
|
2739
|
+
json={"name": name},
|
|
2740
|
+
)
|
|
2741
|
+
|
|
2742
|
+
if (
|
|
2743
|
+
not isinstance(response, dict)
|
|
2744
|
+
or "data" not in response
|
|
2745
|
+
or not isinstance(response["data"], dict)
|
|
2746
|
+
or "full_api_key" not in response["data"]
|
|
2747
|
+
):
|
|
2748
|
+
raise BadRequest("Failed to create API key")
|
|
2749
|
+
|
|
2750
|
+
return response["data"]["full_api_key"]
|
|
2751
|
+
|
|
2752
|
+
def set_api_key(self, api_key: str) -> None:
|
|
2753
|
+
"""Set the API key for the NVR."""
|
|
2754
|
+
if not api_key:
|
|
2755
|
+
raise BadRequest("API key cannot be empty")
|
|
2756
|
+
|
|
2757
|
+
self._api_key = api_key
|
|
2758
|
+
|
|
2759
|
+
def is_api_key_set(self) -> bool:
|
|
2760
|
+
"""Check if the API key is set."""
|
|
2761
|
+
return bool(self._api_key)
|
|
2762
|
+
|
|
2763
|
+
async def get_meta_info(self) -> MetaInfo:
|
|
2764
|
+
"""Get metadata about the NVR."""
|
|
2765
|
+
data = await self.api_request(
|
|
2766
|
+
url="/v1/meta/info",
|
|
2767
|
+
public_api=True,
|
|
2768
|
+
)
|
|
2769
|
+
if not isinstance(data, dict):
|
|
2770
|
+
raise NvrError("Failed to retrieve meta info from public API")
|
|
2771
|
+
return MetaInfo(**data)
|
|
2772
|
+
|
|
2773
|
+
# Public API Methods
|
|
2774
|
+
|
|
2775
|
+
async def get_nvr_public(self) -> NVR:
|
|
2776
|
+
"""Get NVR information using public API."""
|
|
2777
|
+
data = await self.api_request_obj(url="/v1/nvrs", public_api=True)
|
|
2778
|
+
return NVR.from_unifi_dict(**data, api=self)
|
|
2779
|
+
|
|
2780
|
+
async def get_lights_public(self) -> list[Light]:
|
|
2781
|
+
"""Get all lights using public API."""
|
|
2782
|
+
data = await self.api_request_list(url="/v1/lights", public_api=True)
|
|
2783
|
+
return [Light.from_unifi_dict(**light_data, api=self) for light_data in data]
|
|
2784
|
+
|
|
2785
|
+
async def get_light_public(self, light_id: str) -> Light:
|
|
2786
|
+
"""Get a specific light using public API."""
|
|
2787
|
+
data = await self.api_request_obj(url=f"/v1/lights/{light_id}", public_api=True)
|
|
2788
|
+
return Light.from_unifi_dict(**data, api=self)
|
|
2789
|
+
|
|
2790
|
+
async def update_light_public(
|
|
2791
|
+
self,
|
|
2792
|
+
light_id: str,
|
|
2793
|
+
*,
|
|
2794
|
+
name: str | None = None,
|
|
2795
|
+
is_light_force_enabled: bool | None = None,
|
|
2796
|
+
light_mode_settings: LightModeSettings | None = None,
|
|
2797
|
+
light_device_settings: LightDeviceSettings | None = None,
|
|
2798
|
+
) -> Light:
|
|
2799
|
+
"""
|
|
2800
|
+
Update light settings using public API.
|
|
2801
|
+
|
|
2802
|
+
Args:
|
|
2803
|
+
----
|
|
2804
|
+
light_id: The light's ID
|
|
2805
|
+
name: Light name
|
|
2806
|
+
is_light_force_enabled: Force enable the light
|
|
2807
|
+
light_mode_settings: Light mode settings (mode, enable_at)
|
|
2808
|
+
light_device_settings: Light device settings (LED level, PIR settings, etc.)
|
|
2809
|
+
|
|
2810
|
+
Returns:
|
|
2811
|
+
-------
|
|
2812
|
+
Updated Light object
|
|
2813
|
+
|
|
2814
|
+
Raises:
|
|
2815
|
+
------
|
|
2816
|
+
BadRequest: If no parameters are provided
|
|
2817
|
+
|
|
2818
|
+
"""
|
|
2819
|
+
data: LightPatchRequest = {}
|
|
2820
|
+
if name is not None:
|
|
2821
|
+
data["name"] = name
|
|
2822
|
+
if is_light_force_enabled is not None:
|
|
2823
|
+
data["isLightForceEnabled"] = is_light_force_enabled
|
|
2824
|
+
if light_mode_settings is not None:
|
|
2825
|
+
data["lightModeSettings"] = light_mode_settings.unifi_dict()
|
|
2826
|
+
if light_device_settings is not None:
|
|
2827
|
+
# luxSensitivity may come from Private API but is not settable - filter it out
|
|
2828
|
+
device_dict = light_device_settings.unifi_dict()
|
|
2829
|
+
device_dict.pop("luxSensitivity", None)
|
|
2830
|
+
data["lightDeviceSettings"] = device_dict
|
|
2831
|
+
|
|
2832
|
+
if not data:
|
|
2833
|
+
raise BadRequest("At least one parameter must be provided")
|
|
2834
|
+
|
|
2835
|
+
result = await self.api_request_obj(
|
|
2836
|
+
url=f"/v1/lights/{light_id}",
|
|
2837
|
+
method="patch",
|
|
2838
|
+
json=data,
|
|
2839
|
+
public_api=True,
|
|
2840
|
+
)
|
|
2841
|
+
return Light.from_unifi_dict(**result, api=self)
|
|
2842
|
+
|
|
2843
|
+
async def get_cameras_public(self) -> list[Camera]:
|
|
2844
|
+
"""Get all cameras using public API."""
|
|
2845
|
+
data = await self.api_request_list(url="/v1/cameras", public_api=True)
|
|
2846
|
+
return [Camera.from_unifi_dict(**camera_data, api=self) for camera_data in data]
|
|
2847
|
+
|
|
2848
|
+
async def get_camera_public(self, camera_id: str) -> Camera:
|
|
2849
|
+
"""Get a specific camera using public API."""
|
|
2850
|
+
data = await self.api_request_obj(
|
|
2851
|
+
url=f"/v1/cameras/{camera_id}", public_api=True
|
|
2852
|
+
)
|
|
2853
|
+
return Camera.from_unifi_dict(**data, api=self)
|
|
2854
|
+
|
|
2855
|
+
async def get_chimes_public(self) -> list[Chime]:
|
|
2856
|
+
"""Get all chimes using public API."""
|
|
2857
|
+
data = await self.api_request_list(url="/v1/chimes", public_api=True)
|
|
2858
|
+
return [Chime.from_unifi_dict(**chime_data, api=self) for chime_data in data]
|
|
2859
|
+
|
|
2860
|
+
async def get_chime_public(self, chime_id: str) -> Chime:
|
|
2861
|
+
"""Get a specific chime using public API."""
|
|
2862
|
+
data = await self.api_request_obj(url=f"/v1/chimes/{chime_id}", public_api=True)
|
|
2863
|
+
return Chime.from_unifi_dict(**data, api=self)
|