uiprotect 7.5.2__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/api.py +886 -59
- uiprotect/cli/__init__.py +109 -24
- uiprotect/cli/aiports.py +1 -2
- uiprotect/cli/backup.py +5 -4
- uiprotect/cli/base.py +4 -4
- 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/base.py +32 -32
- uiprotect/data/bootstrap.py +20 -15
- uiprotect/data/devices.py +183 -16
- uiprotect/data/nvr.py +139 -38
- uiprotect/data/types.py +32 -19
- uiprotect/stream.py +13 -2
- uiprotect/test_util/__init__.py +30 -7
- uiprotect/test_util/anonymize.py +4 -5
- uiprotect/utils.py +56 -24
- uiprotect/websocket.py +3 -3
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/METADATA +70 -17
- uiprotect-7.32.0.dist-info/RECORD +39 -0
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/WHEEL +1 -1
- uiprotect-7.5.2.dist-info/RECORD +0 -39
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/entry_points.txt +0 -0
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info/licenses}/LICENSE +0 -0
uiprotect/api.py
CHANGED
|
@@ -14,9 +14,9 @@ from datetime import datetime, timedelta
|
|
|
14
14
|
from functools import partial
|
|
15
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
|
|
19
|
+
from typing import Any, Literal, TypedDict, cast
|
|
20
20
|
from urllib.parse import SplitResult
|
|
21
21
|
|
|
22
22
|
import aiofiles
|
|
@@ -27,7 +27,10 @@ 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
|
|
30
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
|
|
31
34
|
from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers
|
|
32
35
|
|
|
33
36
|
from ._compat import cached_property
|
|
@@ -52,6 +55,7 @@ from .data import (
|
|
|
52
55
|
SmartDetectTrack,
|
|
53
56
|
Version,
|
|
54
57
|
Viewer,
|
|
58
|
+
WSAction,
|
|
55
59
|
WSPacket,
|
|
56
60
|
WSSubscriptionMessage,
|
|
57
61
|
create_from_unifi_dict,
|
|
@@ -62,8 +66,10 @@ from .data.types import IteratorCallback, ProgressCallback
|
|
|
62
66
|
from .exceptions import BadRequest, NotAuthorized, NvrError
|
|
63
67
|
from .utils import (
|
|
64
68
|
decode_token_cookie,
|
|
69
|
+
format_host_for_url,
|
|
65
70
|
get_response_reason,
|
|
66
71
|
ip_from_host,
|
|
72
|
+
pybool_to_json_bool,
|
|
67
73
|
set_debug,
|
|
68
74
|
to_js_time,
|
|
69
75
|
utc_now,
|
|
@@ -75,16 +81,23 @@ if "partitioned" not in cookies.Morsel._reserved: # type: ignore[attr-defined]
|
|
|
75
81
|
cookies.Morsel._reserved["partitioned"] = "partitioned" # type: ignore[attr-defined]
|
|
76
82
|
cookies.Morsel._flags.add("partitioned") # type: ignore[attr-defined]
|
|
77
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
|
+
|
|
78
94
|
TOKEN_COOKIE_MAX_EXP_SECONDS = 60
|
|
79
95
|
|
|
80
96
|
# how many seconds before the bootstrap is refreshed from Protect
|
|
81
97
|
DEVICE_UPDATE_INTERVAL = 900
|
|
82
98
|
# retry timeout for thumbnails/heatmaps
|
|
83
99
|
RETRY_TIMEOUT = 10
|
|
84
|
-
|
|
85
|
-
"https://apt.artifacts.ui.com/dists/stretch/release/binary-arm64/Packages",
|
|
86
|
-
"https://apt.artifacts.ui.com/dists/bullseye/release/binary-arm64/Packages",
|
|
87
|
-
]
|
|
100
|
+
|
|
88
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.
|
|
89
102
|
|
|
90
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."""
|
|
@@ -158,11 +171,52 @@ def get_user_hash(host: str, username: str) -> str:
|
|
|
158
171
|
return session.hexdigest()
|
|
159
172
|
|
|
160
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
|
+
|
|
161
214
|
class BaseApiClient:
|
|
162
215
|
_host: str
|
|
163
216
|
_port: int
|
|
164
217
|
_username: str
|
|
165
218
|
_password: str
|
|
219
|
+
_api_key: str | None = None
|
|
166
220
|
_verify_ssl: bool
|
|
167
221
|
_ws_timeout: int
|
|
168
222
|
|
|
@@ -170,14 +224,20 @@ class BaseApiClient:
|
|
|
170
224
|
_last_token_cookie: Morsel[str] | None = None
|
|
171
225
|
_last_token_cookie_decode: dict[str, Any] | None = None
|
|
172
226
|
_session: aiohttp.ClientSession | None = None
|
|
227
|
+
_public_api_session: aiohttp.ClientSession | None = None
|
|
173
228
|
_loaded_session: bool = False
|
|
174
229
|
_cookiename = "TOKEN"
|
|
175
230
|
|
|
176
231
|
headers: dict[str, str] | None = None
|
|
177
|
-
|
|
232
|
+
_private_websocket: Websocket | None = None
|
|
233
|
+
_events_websocket: Websocket | None = None
|
|
234
|
+
_devices_websocket: Websocket | None = None
|
|
178
235
|
|
|
179
|
-
|
|
180
|
-
|
|
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"
|
|
181
241
|
|
|
182
242
|
cache_dir: Path
|
|
183
243
|
config_dir: Path
|
|
@@ -189,8 +249,10 @@ class BaseApiClient:
|
|
|
189
249
|
port: int,
|
|
190
250
|
username: str,
|
|
191
251
|
password: str,
|
|
252
|
+
api_key: str | None = None,
|
|
192
253
|
verify_ssl: bool = True,
|
|
193
254
|
session: aiohttp.ClientSession | None = None,
|
|
255
|
+
public_api_session: aiohttp.ClientSession | None = None,
|
|
194
256
|
ws_timeout: int = 30,
|
|
195
257
|
cache_dir: Path | None = None,
|
|
196
258
|
config_dir: Path | None = None,
|
|
@@ -203,6 +265,7 @@ class BaseApiClient:
|
|
|
203
265
|
|
|
204
266
|
self._username = username
|
|
205
267
|
self._password = password
|
|
268
|
+
self._api_key = api_key
|
|
206
269
|
self._verify_ssl = verify_ssl
|
|
207
270
|
self._ws_timeout = ws_timeout
|
|
208
271
|
self._ws_receive_timeout = ws_receive_timeout
|
|
@@ -216,6 +279,9 @@ class BaseApiClient:
|
|
|
216
279
|
if session is not None:
|
|
217
280
|
self._session = session
|
|
218
281
|
|
|
282
|
+
if public_api_session is not None:
|
|
283
|
+
self._public_api_session = public_api_session
|
|
284
|
+
|
|
219
285
|
self._update_url()
|
|
220
286
|
|
|
221
287
|
def _update_cookiename(self, cookie: SimpleCookie) -> None:
|
|
@@ -224,12 +290,26 @@ class BaseApiClient:
|
|
|
224
290
|
|
|
225
291
|
def _update_url(self) -> None:
|
|
226
292
|
"""Updates the url after changing _host or _port."""
|
|
293
|
+
formatted_host = format_host_for_url(self._host)
|
|
294
|
+
|
|
227
295
|
if self._port != 443:
|
|
228
|
-
self._url = URL(f"https://{
|
|
229
|
-
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
|
+
)
|
|
230
306
|
else:
|
|
231
|
-
self._url = URL(f"https://{
|
|
232
|
-
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
|
+
)
|
|
233
313
|
|
|
234
314
|
self.base_url = str(self._url)
|
|
235
315
|
|
|
@@ -245,6 +325,16 @@ class BaseApiClient:
|
|
|
245
325
|
"""Get Websocket URL."""
|
|
246
326
|
return str(self._ws_url_object)
|
|
247
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
|
+
|
|
248
338
|
@property
|
|
249
339
|
def config_file(self) -> Path:
|
|
250
340
|
return self.config_dir / "unifi_protect.json"
|
|
@@ -259,6 +349,15 @@ class BaseApiClient:
|
|
|
259
349
|
|
|
260
350
|
return self._session
|
|
261
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
|
+
|
|
262
361
|
async def _auth_websocket(self, force: bool) -> dict[str, str] | None:
|
|
263
362
|
"""Authenticate for Websocket."""
|
|
264
363
|
if force:
|
|
@@ -271,10 +370,19 @@ class BaseApiClient:
|
|
|
271
370
|
await self.ensure_authenticated()
|
|
272
371
|
return self.headers
|
|
273
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
|
+
|
|
274
382
|
def _get_websocket(self) -> Websocket:
|
|
275
383
|
"""Gets or creates current Websocket."""
|
|
276
|
-
if self.
|
|
277
|
-
self.
|
|
384
|
+
if self._private_websocket is None:
|
|
385
|
+
self._private_websocket = Websocket(
|
|
278
386
|
self._get_websocket_url,
|
|
279
387
|
self._auth_websocket,
|
|
280
388
|
self._update_bootstrap_soon,
|
|
@@ -285,7 +393,39 @@ class BaseApiClient:
|
|
|
285
393
|
timeout=self._ws_timeout,
|
|
286
394
|
receive_timeout=self._ws_receive_timeout,
|
|
287
395
|
)
|
|
288
|
-
return self.
|
|
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,
|
|
411
|
+
)
|
|
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
|
|
289
429
|
|
|
290
430
|
def _update_bootstrap_soon(self) -> None:
|
|
291
431
|
"""Update bootstrap soon."""
|
|
@@ -304,6 +444,12 @@ class BaseApiClient:
|
|
|
304
444
|
self._session = None
|
|
305
445
|
self._loaded_session = False
|
|
306
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
|
+
|
|
307
453
|
async def _cancel_update_task(self) -> None:
|
|
308
454
|
if self._update_task:
|
|
309
455
|
self._update_task.cancel()
|
|
@@ -325,20 +471,29 @@ class BaseApiClient:
|
|
|
325
471
|
url: str,
|
|
326
472
|
require_auth: bool = False,
|
|
327
473
|
auto_close: bool = True,
|
|
474
|
+
public_api: bool = False,
|
|
328
475
|
**kwargs: Any,
|
|
329
476
|
) -> aiohttp.ClientResponse:
|
|
330
477
|
"""Make a request to UniFi Protect"""
|
|
331
|
-
if require_auth:
|
|
478
|
+
if require_auth and not public_api:
|
|
332
479
|
await self.ensure_authenticated()
|
|
333
480
|
|
|
334
481
|
request_url = self._url.join(
|
|
335
482
|
URL(SplitResult("", "", url, "", ""), encoded=True)
|
|
336
483
|
)
|
|
337
|
-
headers = kwargs.get("headers") or self.headers
|
|
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}
|
|
338
489
|
_LOGGER.debug("Request url: %s", request_url)
|
|
339
490
|
if not self._verify_ssl:
|
|
340
491
|
kwargs["ssl"] = False
|
|
341
|
-
|
|
492
|
+
|
|
493
|
+
if public_api:
|
|
494
|
+
session = await self.get_public_api_session()
|
|
495
|
+
else:
|
|
496
|
+
session = await self.get_session()
|
|
342
497
|
|
|
343
498
|
for attempt in range(2):
|
|
344
499
|
try:
|
|
@@ -390,19 +545,29 @@ class BaseApiClient:
|
|
|
390
545
|
method: str = "get",
|
|
391
546
|
require_auth: bool = True,
|
|
392
547
|
raise_exception: bool = True,
|
|
548
|
+
api_path: str | None = None,
|
|
549
|
+
public_api: bool = False,
|
|
393
550
|
**kwargs: Any,
|
|
394
551
|
) -> bytes | None:
|
|
395
|
-
"""Make a request
|
|
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
|
+
|
|
396
559
|
response = await self.request(
|
|
397
560
|
method,
|
|
398
|
-
f"{
|
|
561
|
+
f"{path}{url}",
|
|
399
562
|
require_auth=require_auth,
|
|
400
563
|
auto_close=False,
|
|
564
|
+
public_api=public_api,
|
|
401
565
|
**kwargs,
|
|
402
566
|
)
|
|
403
567
|
|
|
404
568
|
try:
|
|
405
|
-
|
|
569
|
+
# Check for successful status codes (2xx range)
|
|
570
|
+
if not (200 <= response.status < 300):
|
|
406
571
|
await self._raise_for_status(response, raise_exception)
|
|
407
572
|
return None
|
|
408
573
|
|
|
@@ -425,16 +590,20 @@ class BaseApiClient:
|
|
|
425
590
|
msg = "Request failed: %s - Status: %s - Reason: %s"
|
|
426
591
|
status = response.status
|
|
427
592
|
|
|
593
|
+
# Success status codes (2xx) should not raise exceptions
|
|
594
|
+
if 200 <= status < 300:
|
|
595
|
+
return
|
|
596
|
+
|
|
428
597
|
if raise_exception:
|
|
429
598
|
if status in {
|
|
430
599
|
HTTPStatus.UNAUTHORIZED.value,
|
|
431
600
|
HTTPStatus.FORBIDDEN.value,
|
|
432
601
|
}:
|
|
433
602
|
raise NotAuthorized(msg % (url, status, reason))
|
|
434
|
-
|
|
603
|
+
if status == HTTPStatus.TOO_MANY_REQUESTS.value:
|
|
435
604
|
_LOGGER.debug("Too many requests - Login is rate limited: %s", response)
|
|
436
605
|
raise NvrError(msg % (url, status, reason))
|
|
437
|
-
|
|
606
|
+
if (
|
|
438
607
|
status >= HTTPStatus.BAD_REQUEST.value
|
|
439
608
|
and status < HTTPStatus.INTERNAL_SERVER_ERROR.value
|
|
440
609
|
):
|
|
@@ -449,6 +618,8 @@ class BaseApiClient:
|
|
|
449
618
|
method: str = "get",
|
|
450
619
|
require_auth: bool = True,
|
|
451
620
|
raise_exception: bool = True,
|
|
621
|
+
api_path: str | None = None,
|
|
622
|
+
public_api: bool = False,
|
|
452
623
|
**kwargs: Any,
|
|
453
624
|
) -> list[Any] | dict[str, Any] | None:
|
|
454
625
|
data = await self.api_request_raw(
|
|
@@ -456,6 +627,8 @@ class BaseApiClient:
|
|
|
456
627
|
method=method,
|
|
457
628
|
require_auth=require_auth,
|
|
458
629
|
raise_exception=raise_exception,
|
|
630
|
+
api_path=api_path,
|
|
631
|
+
public_api=public_api,
|
|
459
632
|
**kwargs,
|
|
460
633
|
)
|
|
461
634
|
|
|
@@ -475,6 +648,7 @@ class BaseApiClient:
|
|
|
475
648
|
method: str = "get",
|
|
476
649
|
require_auth: bool = True,
|
|
477
650
|
raise_exception: bool = True,
|
|
651
|
+
public_api: bool = False,
|
|
478
652
|
**kwargs: Any,
|
|
479
653
|
) -> dict[str, Any]:
|
|
480
654
|
data = await self.api_request(
|
|
@@ -482,6 +656,7 @@ class BaseApiClient:
|
|
|
482
656
|
method=method,
|
|
483
657
|
require_auth=require_auth,
|
|
484
658
|
raise_exception=raise_exception,
|
|
659
|
+
public_api=public_api,
|
|
485
660
|
**kwargs,
|
|
486
661
|
)
|
|
487
662
|
|
|
@@ -496,6 +671,7 @@ class BaseApiClient:
|
|
|
496
671
|
method: str = "get",
|
|
497
672
|
require_auth: bool = True,
|
|
498
673
|
raise_exception: bool = True,
|
|
674
|
+
public_api: bool = False,
|
|
499
675
|
**kwargs: Any,
|
|
500
676
|
) -> list[Any]:
|
|
501
677
|
data = await self.api_request(
|
|
@@ -503,6 +679,7 @@ class BaseApiClient:
|
|
|
503
679
|
method=method,
|
|
504
680
|
require_auth=require_auth,
|
|
505
681
|
raise_exception=raise_exception,
|
|
682
|
+
public_api=public_api,
|
|
506
683
|
**kwargs,
|
|
507
684
|
)
|
|
508
685
|
|
|
@@ -541,8 +718,25 @@ class BaseApiClient:
|
|
|
541
718
|
response = await self.request("post", url=url, json=auth)
|
|
542
719
|
if response.status != 200:
|
|
543
720
|
await self._raise_for_status(response, True)
|
|
544
|
-
|
|
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)
|
|
545
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
|
+
|
|
546
740
|
_LOGGER.debug("Authenticated successfully!")
|
|
547
741
|
|
|
548
742
|
async def _update_last_token_cookie(self, response: aiohttp.ClientResponse) -> None:
|
|
@@ -554,7 +748,6 @@ class BaseApiClient:
|
|
|
554
748
|
and csrf_token != self.headers.get("x-csrf-token")
|
|
555
749
|
):
|
|
556
750
|
self.set_header("x-csrf-token", csrf_token)
|
|
557
|
-
await self._update_last_token_cookie(response)
|
|
558
751
|
self._update_cookiename(response.cookies)
|
|
559
752
|
|
|
560
753
|
if (
|
|
@@ -610,6 +803,7 @@ class BaseApiClient:
|
|
|
610
803
|
|
|
611
804
|
async def _read_auth_config(self) -> SimpleCookie | None:
|
|
612
805
|
"""Read auth cookie from config."""
|
|
806
|
+
config: dict[str, Any] = {}
|
|
613
807
|
try:
|
|
614
808
|
async with aiofiles.open(self.config_file, "rb") as f:
|
|
615
809
|
config_data = await f.read()
|
|
@@ -640,10 +834,16 @@ class BaseApiClient:
|
|
|
640
834
|
cookie_value = _COOKIE_RE.sub("", str(cookie[cookie_name]))
|
|
641
835
|
self._last_token_cookie = cookie[cookie_name]
|
|
642
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
|
+
|
|
643
844
|
self._is_authenticated = True
|
|
644
845
|
self.set_header("cookie", cookie_value)
|
|
645
|
-
|
|
646
|
-
self.set_header("x-csrf-token", session["csrf"])
|
|
846
|
+
self.set_header("x-csrf-token", csrf_token)
|
|
647
847
|
return cookie
|
|
648
848
|
|
|
649
849
|
def is_authenticated(self) -> bool:
|
|
@@ -674,21 +874,90 @@ class BaseApiClient:
|
|
|
674
874
|
|
|
675
875
|
return token_expires_at >= max_expire_time
|
|
676
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
|
+
|
|
677
928
|
def _get_websocket_url(self) -> URL:
|
|
678
929
|
"""Get Websocket URL."""
|
|
679
930
|
return self._ws_url_object
|
|
680
931
|
|
|
681
932
|
async def async_disconnect_ws(self) -> None:
|
|
682
933
|
"""Disconnect from Websocket."""
|
|
683
|
-
if self.
|
|
934
|
+
if self._private_websocket:
|
|
684
935
|
websocket = self._get_websocket()
|
|
685
936
|
websocket.stop()
|
|
686
937
|
await websocket.wait_closed()
|
|
687
|
-
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
|
|
688
949
|
|
|
689
950
|
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
690
951
|
raise NotImplementedError
|
|
691
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
|
+
|
|
692
961
|
def _get_last_update_id(self) -> str | None:
|
|
693
962
|
raise NotImplementedError
|
|
694
963
|
|
|
@@ -699,6 +968,14 @@ class BaseApiClient:
|
|
|
699
968
|
"""Websocket state changed."""
|
|
700
969
|
_LOGGER.debug("Websocket state changed: %s", state)
|
|
701
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
|
+
|
|
702
979
|
|
|
703
980
|
class ProtectApiClient(BaseApiClient):
|
|
704
981
|
"""
|
|
@@ -721,6 +998,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
721
998
|
port: UFP HTTPS port
|
|
722
999
|
username: UFP username
|
|
723
1000
|
password: UFP password
|
|
1001
|
+
api_key: API key for UFP
|
|
724
1002
|
verify_ssl: Verify HTTPS certificate (default: `True`)
|
|
725
1003
|
session: Optional aiohttp session to use (default: generate one)
|
|
726
1004
|
override_connection_host: Use `host` as your `connection_host` for RTSP stream instead of using the one provided by UniFi Protect.
|
|
@@ -735,7 +1013,11 @@ class ProtectApiClient(BaseApiClient):
|
|
|
735
1013
|
_subscribed_models: set[ModelType]
|
|
736
1014
|
_ignore_stats: bool
|
|
737
1015
|
_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
1016
|
+
_events_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
1017
|
+
_devices_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
738
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]]
|
|
739
1021
|
_bootstrap: Bootstrap | None = None
|
|
740
1022
|
_last_update_dt: datetime | None = None
|
|
741
1023
|
_connection_host: IPv4Address | IPv6Address | str | None = None
|
|
@@ -748,8 +1030,10 @@ class ProtectApiClient(BaseApiClient):
|
|
|
748
1030
|
port: int,
|
|
749
1031
|
username: str,
|
|
750
1032
|
password: str,
|
|
1033
|
+
api_key: str | None = None,
|
|
751
1034
|
verify_ssl: bool = True,
|
|
752
1035
|
session: aiohttp.ClientSession | None = None,
|
|
1036
|
+
public_api_session: aiohttp.ClientSession | None = None,
|
|
753
1037
|
ws_timeout: int = 30,
|
|
754
1038
|
cache_dir: Path | None = None,
|
|
755
1039
|
config_dir: Path | None = None,
|
|
@@ -767,8 +1051,10 @@ class ProtectApiClient(BaseApiClient):
|
|
|
767
1051
|
port=port,
|
|
768
1052
|
username=username,
|
|
769
1053
|
password=password,
|
|
1054
|
+
api_key=api_key,
|
|
770
1055
|
verify_ssl=verify_ssl,
|
|
771
1056
|
session=session,
|
|
1057
|
+
public_api_session=public_api_session,
|
|
772
1058
|
ws_timeout=ws_timeout,
|
|
773
1059
|
ws_receive_timeout=ws_receive_timeout,
|
|
774
1060
|
cache_dir=cache_dir,
|
|
@@ -780,16 +1066,62 @@ class ProtectApiClient(BaseApiClient):
|
|
|
780
1066
|
self._subscribed_models = subscribed_models or set()
|
|
781
1067
|
self._ignore_stats = ignore_stats
|
|
782
1068
|
self._ws_subscriptions = []
|
|
1069
|
+
self._events_ws_subscriptions = []
|
|
1070
|
+
self._devices_ws_subscriptions = []
|
|
783
1071
|
self._ws_state_subscriptions = []
|
|
1072
|
+
self._events_ws_state_subscriptions = []
|
|
1073
|
+
self._devices_ws_state_subscriptions = []
|
|
784
1074
|
self.ignore_unadopted = ignore_unadopted
|
|
785
1075
|
self._update_lock = asyncio.Lock()
|
|
786
1076
|
|
|
787
1077
|
if override_connection_host:
|
|
788
|
-
self._connection_host =
|
|
1078
|
+
self._connection_host = ip_address(self._host)
|
|
789
1079
|
|
|
790
1080
|
if debug:
|
|
791
1081
|
set_debug()
|
|
792
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
|
+
|
|
793
1125
|
@cached_property
|
|
794
1126
|
def bootstrap(self) -> Bootstrap:
|
|
795
1127
|
if self._bootstrap is None:
|
|
@@ -799,21 +1131,11 @@ class ProtectApiClient(BaseApiClient):
|
|
|
799
1131
|
|
|
800
1132
|
@property
|
|
801
1133
|
def connection_host(self) -> IPv4Address | IPv6Address | str:
|
|
802
|
-
"""Connection host to use for generating RTSP URLs"""
|
|
1134
|
+
"""Connection host to use for generating RTSP URLs."""
|
|
803
1135
|
if self._connection_host is None:
|
|
804
|
-
|
|
805
|
-
index = 0
|
|
806
|
-
try:
|
|
807
|
-
# check if user supplied host is avaiable
|
|
808
|
-
index = self.bootstrap.nvr.hosts.index(self._host)
|
|
809
|
-
except ValueError:
|
|
810
|
-
# check if IP of user supplied host is avaiable
|
|
811
|
-
host = ip_from_host(self._host)
|
|
812
|
-
with contextlib.suppress(ValueError):
|
|
813
|
-
index = self.bootstrap.nvr.hosts.index(host)
|
|
814
|
-
|
|
815
|
-
self._connection_host = self.bootstrap.nvr.hosts[index]
|
|
1136
|
+
self._set_connection_host_from_bootstrap()
|
|
816
1137
|
|
|
1138
|
+
assert self._connection_host is not None
|
|
817
1139
|
return self._connection_host
|
|
818
1140
|
|
|
819
1141
|
async def update(self) -> Bootstrap:
|
|
@@ -847,6 +1169,11 @@ class ProtectApiClient(BaseApiClient):
|
|
|
847
1169
|
)
|
|
848
1170
|
self.__dict__.pop("bootstrap", None)
|
|
849
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
|
+
|
|
850
1177
|
return bootstrap
|
|
851
1178
|
|
|
852
1179
|
async def poll_events(self) -> None:
|
|
@@ -888,6 +1215,62 @@ class ProtectApiClient(BaseApiClient):
|
|
|
888
1215
|
except Exception:
|
|
889
1216
|
_LOGGER.exception("Exception while running subscription handler")
|
|
890
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
|
+
|
|
891
1274
|
def _get_last_update_id(self) -> str | None:
|
|
892
1275
|
if self._bootstrap is None:
|
|
893
1276
|
return None
|
|
@@ -905,7 +1288,111 @@ class ProtectApiClient(BaseApiClient):
|
|
|
905
1288
|
|
|
906
1289
|
self.emit_message(processed_message)
|
|
907
1290
|
|
|
908
|
-
|
|
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
|
|
909
1396
|
self,
|
|
910
1397
|
params: dict[str, Any],
|
|
911
1398
|
*,
|
|
@@ -963,7 +1450,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
963
1450
|
|
|
964
1451
|
return events
|
|
965
1452
|
|
|
966
|
-
async def get_events_raw(
|
|
1453
|
+
async def get_events_raw( # noqa: PLR0912
|
|
967
1454
|
self,
|
|
968
1455
|
*,
|
|
969
1456
|
start: datetime | None = None,
|
|
@@ -1150,6 +1637,34 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1150
1637
|
self._get_websocket().start()
|
|
1151
1638
|
return partial(self._unsubscribe_websocket, ws_callback)
|
|
1152
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
|
+
|
|
1153
1668
|
def _unsubscribe_websocket(
|
|
1154
1669
|
self,
|
|
1155
1670
|
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
@@ -1160,6 +1675,26 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1160
1675
|
if not self._ws_subscriptions:
|
|
1161
1676
|
self._get_websocket().stop()
|
|
1162
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
|
+
|
|
1163
1698
|
def subscribe_websocket_state(
|
|
1164
1699
|
self,
|
|
1165
1700
|
ws_callback: Callable[[WebsocketState], None],
|
|
@@ -1172,6 +1707,30 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1172
1707
|
self._ws_state_subscriptions.append(ws_callback)
|
|
1173
1708
|
return partial(self._unsubscribe_websocket_state, ws_callback)
|
|
1174
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
|
+
|
|
1175
1734
|
def _unsubscribe_websocket_state(
|
|
1176
1735
|
self,
|
|
1177
1736
|
ws_callback: Callable[[WebsocketState], None],
|
|
@@ -1179,6 +1738,20 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1179
1738
|
"""Unsubscribe to websocket state changes."""
|
|
1180
1739
|
self._ws_state_subscriptions.remove(ws_callback)
|
|
1181
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
|
+
|
|
1182
1755
|
def _on_websocket_state_change(self, state: WebsocketState) -> None:
|
|
1183
1756
|
"""Websocket state changed."""
|
|
1184
1757
|
super()._on_websocket_state_change(state)
|
|
@@ -1188,6 +1761,26 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1188
1761
|
except Exception:
|
|
1189
1762
|
_LOGGER.exception("Exception while running websocket state handler")
|
|
1190
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
|
+
|
|
1191
1784
|
async def get_bootstrap(self) -> Bootstrap:
|
|
1192
1785
|
"""
|
|
1193
1786
|
Gets bootstrap object from UFP instance
|
|
@@ -1238,6 +1831,10 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1238
1831
|
Gets the list of lights straight from the NVR.
|
|
1239
1832
|
|
|
1240
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.
|
|
1241
1838
|
"""
|
|
1242
1839
|
return cast(list[Light], await self.get_devices(ModelType.LIGHT, Light))
|
|
1243
1840
|
|
|
@@ -1364,6 +1961,10 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1364
1961
|
Gets a light straight from the NVR.
|
|
1365
1962
|
|
|
1366
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.
|
|
1367
1968
|
"""
|
|
1368
1969
|
return cast(Light, await self.get_device(ModelType.LIGHT, device_id, Light))
|
|
1369
1970
|
|
|
@@ -1465,6 +2066,96 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1465
2066
|
raise_exception=False,
|
|
1466
2067
|
)
|
|
1467
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
|
+
|
|
1468
2159
|
async def get_package_camera_snapshot(
|
|
1469
2160
|
self,
|
|
1470
2161
|
camera_id: str,
|
|
@@ -1578,13 +2269,19 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1578
2269
|
raise_exception=False,
|
|
1579
2270
|
)
|
|
1580
2271
|
|
|
2272
|
+
_LOGGER.debug(
|
|
2273
|
+
"Requesting camera video: %s%s %s", self.private_api_path, path, params
|
|
2274
|
+
)
|
|
1581
2275
|
r = await self.request(
|
|
1582
2276
|
"get",
|
|
1583
|
-
f"{self.
|
|
2277
|
+
f"{self.private_api_path}{path}",
|
|
1584
2278
|
auto_close=False,
|
|
1585
2279
|
timeout=0,
|
|
1586
2280
|
params=params,
|
|
1587
2281
|
)
|
|
2282
|
+
if r.status != 200:
|
|
2283
|
+
await self._raise_for_status(r, True)
|
|
2284
|
+
|
|
1588
2285
|
if output_file is not None:
|
|
1589
2286
|
async with aiofiles.open(output_file, "wb") as output:
|
|
1590
2287
|
|
|
@@ -1838,7 +2535,13 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1838
2535
|
async def set_light_is_led_force_on(
|
|
1839
2536
|
self, device_id: str, is_led_force_on: bool
|
|
1840
2537
|
) -> None:
|
|
1841
|
-
"""
|
|
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
|
+
"""
|
|
1842
2545
|
await self.api_request(
|
|
1843
2546
|
f"lights/{device_id}",
|
|
1844
2547
|
method="patch",
|
|
@@ -1878,17 +2581,6 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1878
2581
|
|
|
1879
2582
|
return versions
|
|
1880
2583
|
|
|
1881
|
-
async def get_release_versions(self) -> set[Version]:
|
|
1882
|
-
"""Get all release versions for UniFi Protect"""
|
|
1883
|
-
versions: set[Version] = set()
|
|
1884
|
-
for url in PROTECT_APT_URLS:
|
|
1885
|
-
try:
|
|
1886
|
-
versions |= await self._get_versions_from_api(url)
|
|
1887
|
-
except NvrError:
|
|
1888
|
-
_LOGGER.warning("Failed to retrieve release versions from online.")
|
|
1889
|
-
|
|
1890
|
-
return versions
|
|
1891
|
-
|
|
1892
2584
|
async def relative_move_ptz_camera(
|
|
1893
2585
|
self,
|
|
1894
2586
|
device_id: str,
|
|
@@ -2034,3 +2726,138 @@ class ProtectApiClient(BaseApiClient):
|
|
|
2034
2726
|
method="post",
|
|
2035
2727
|
)
|
|
2036
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)
|