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/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 cached_property, partial
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 urljoin
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 sys.version_info[:2] < (3, 13):
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
- PROTECT_APT_URLS = [
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
- _websocket: Websocket | None = None
232
+ _private_websocket: Websocket | None = None
233
+ _events_websocket: Websocket | None = None
234
+ _devices_websocket: Websocket | None = None
174
235
 
175
- api_path: str = "/proxy/protect/api/"
176
- ws_path: str = "/proxy/protect/ws/updates"
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://{self._host}:{self._port}")
223
- self._ws_url = URL(f"wss://{self._host}:{self._port}{self.ws_path}")
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://{self._host}")
226
- self._ws_url = URL(f"wss://{self._host}{self.ws_path}")
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._websocket is None:
271
- self._websocket = Websocket(
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._websocket
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.joinpath(url[1:])
328
- headers = kwargs.get("headers") or self.headers
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
- session = await self.get_session()
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 to UniFi Protect API"""
387
- url = urljoin(self.api_path, url)
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
- if response.status != 200:
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
- elif status == HTTPStatus.TOO_MANY_REQUESTS.value:
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
- elif (
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
- self.set_header("cookie", response.headers.get("set-cookie", ""))
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
- if session.get("csrf"):
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._websocket:
934
+ if self._private_websocket:
676
935
  websocket = self._get_websocket()
677
936
  websocket.stop()
678
937
  await websocket.wait_closed()
679
- self._websocket = None
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 = ip_from_host(self._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
- # fallback if cannot find user supplied host
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
- async def _get_event_paginate(
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
- "ts": to_js_time(dt or utc_now()),
1406
- "force": "true",
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.update({"w": width})
2058
+ params["w"] = width
1411
2059
 
1412
2060
  if height is not None:
1413
- params.update({"h": height})
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
- "ts": to_js_time(dt or utc_now()),
1440
- "force": "true",
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.update({"w": width})
2182
+ params["w"] = width
1445
2183
 
1446
2184
  if height is not None:
1447
- params.update({"h": height})
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
- urljoin(self.api_path, path),
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)