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 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
- PROTECT_APT_URLS = [
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
- _websocket: Websocket | None = None
232
+ _private_websocket: Websocket | None = None
233
+ _events_websocket: Websocket | None = None
234
+ _devices_websocket: Websocket | None = None
178
235
 
179
- api_path: str = "/proxy/protect/api/"
180
- 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"
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://{self._host}:{self._port}")
229
- 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
+ )
230
306
  else:
231
- self._url = URL(f"https://{self._host}")
232
- 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
+ )
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._websocket is None:
277
- self._websocket = Websocket(
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._websocket
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
- 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()
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 to UniFi Protect API"""
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"{self.api_path}{url}",
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
- if response.status != 200:
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
- elif status == HTTPStatus.TOO_MANY_REQUESTS.value:
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
- elif (
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
- 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)
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
- if session.get("csrf"):
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._websocket:
934
+ if self._private_websocket:
684
935
  websocket = self._get_websocket()
685
936
  websocket.stop()
686
937
  await websocket.wait_closed()
687
- 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
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 = ip_from_host(self._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
- # fallback if cannot find user supplied host
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
- 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
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.api_path}{path}",
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
- """Sets isLedForceOn for light.""" # workaround because forceOn doesnt work via websocket
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)