uiprotect 2.0.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of uiprotect might be problematic. Click here for more details.

Files changed (36) hide show
  1. {uiprotect-2.0.0 → uiprotect-2.2.0}/PKG-INFO +1 -1
  2. {uiprotect-2.0.0 → uiprotect-2.2.0}/pyproject.toml +1 -1
  3. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/api.py +41 -21
  4. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/websocket.py +41 -42
  5. {uiprotect-2.0.0 → uiprotect-2.2.0}/LICENSE +0 -0
  6. {uiprotect-2.0.0 → uiprotect-2.2.0}/README.md +0 -0
  7. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/__init__.py +0 -0
  8. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/__main__.py +0 -0
  9. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/__init__.py +0 -0
  10. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/backup.py +0 -0
  11. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/base.py +0 -0
  12. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/cameras.py +0 -0
  13. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/chimes.py +0 -0
  14. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/doorlocks.py +0 -0
  15. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/events.py +0 -0
  16. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/lights.py +0 -0
  17. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/liveviews.py +0 -0
  18. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/nvr.py +0 -0
  19. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/sensors.py +0 -0
  20. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/cli/viewers.py +0 -0
  21. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/__init__.py +0 -0
  22. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/base.py +0 -0
  23. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/bootstrap.py +0 -0
  24. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/convert.py +0 -0
  25. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/devices.py +0 -0
  26. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/nvr.py +0 -0
  27. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/types.py +0 -0
  28. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/user.py +0 -0
  29. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/data/websocket.py +0 -0
  30. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/exceptions.py +0 -0
  31. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/py.typed +0 -0
  32. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/release_cache.json +0 -0
  33. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/stream.py +0 -0
  34. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/test_util/__init__.py +0 -0
  35. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/test_util/anonymize.py +0 -0
  36. {uiprotect-2.0.0 → uiprotect-2.2.0}/src/uiprotect/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: uiprotect
3
- Version: 2.0.0
3
+ Version: 2.2.0
4
4
  Summary: Python API for Unifi Protect (Unofficial)
5
5
  Home-page: https://github.com/uilibs/uiprotect
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "uiprotect"
3
- version = "2.0.0"
3
+ version = "2.2.0"
4
4
  description = "Python API for Unifi Protect (Unofficial)"
5
5
  authors = ["UI Protect Maintainers <ui@koston.org>"]
6
6
  license = "MIT"
@@ -12,6 +12,7 @@ import time
12
12
  from collections.abc import Callable
13
13
  from datetime import datetime, timedelta
14
14
  from functools import cached_property, partial
15
+ from http import HTTPStatus
15
16
  from http.cookies import Morsel, SimpleCookie
16
17
  from ipaddress import IPv4Address, IPv6Address
17
18
  from pathlib import Path
@@ -222,23 +223,24 @@ class BaseApiClient:
222
223
  """Updates the url after changing _host or _port."""
223
224
  if self._port != 443:
224
225
  self._url = URL(f"https://{self._host}:{self._port}")
226
+ self._ws_url = URL(f"wss://{self._host}:{self._port}{self.ws_path}")
225
227
  else:
226
228
  self._url = URL(f"https://{self._host}")
229
+ self._ws_url = URL(f"wss://{self._host}{self.ws_path}")
227
230
 
228
231
  self.base_url = str(self._url)
229
232
 
230
233
  @property
231
- def ws_url(self) -> str:
234
+ def _ws_url_object(self) -> URL:
232
235
  """Get Websocket URL."""
233
- url = f"wss://{self._host}"
234
- if self._port != 443:
235
- url += f":{self._port}"
236
+ if last_update_id := self._get_last_update_id():
237
+ return self._ws_url.with_query(lastUpdateId=last_update_id)
238
+ return self._ws_url
236
239
 
237
- url += self.ws_path
238
- last_update_id = self._get_last_update_id()
239
- if last_update_id is None:
240
- return url
241
- return f"{url}?lastUpdateId={last_update_id}"
240
+ @property
241
+ def ws_url(self) -> str:
242
+ """Get Websocket URL."""
243
+ return str(self._ws_url_object)
242
244
 
243
245
  @property
244
246
  def config_file(self) -> Path:
@@ -270,7 +272,7 @@ class BaseApiClient:
270
272
  """Gets or creates current Websocket."""
271
273
  if self._websocket is None:
272
274
  self._websocket = Websocket(
273
- self.get_websocket_url,
275
+ self._get_websocket_url,
274
276
  self._auth_websocket,
275
277
  self._update_bootstrap_soon,
276
278
  self.get_session,
@@ -395,15 +397,7 @@ class BaseApiClient:
395
397
 
396
398
  try:
397
399
  if response.status != 200:
398
- reason = await get_response_reason(response)
399
- msg = "Request failed: %s - Status: %s - Reason: %s"
400
- if raise_exception:
401
- if response.status in {401, 403}:
402
- raise NotAuthorized(msg % (url, response.status, reason))
403
- if response.status >= 400 and response.status < 500:
404
- raise BadRequest(msg % (url, response.status, reason))
405
- raise NvrError(msg % (url, response.status, reason))
406
- _LOGGER.debug(msg, url, response.status, reason)
400
+ await self._raise_for_status(response, raise_exception)
407
401
  return None
408
402
 
409
403
  data: bytes | None = await response.read()
@@ -416,6 +410,30 @@ class BaseApiClient:
416
410
  # re-raise exception
417
411
  raise
418
412
 
413
+ async def _raise_for_status(
414
+ self, response: aiohttp.ClientResponse, raise_exception: bool = True
415
+ ) -> None:
416
+ url = response.url
417
+ reason = await get_response_reason(response)
418
+ msg = "Request failed: %s - Status: %s - Reason: %s"
419
+ if raise_exception:
420
+ status = response.status
421
+ if status in {
422
+ HTTPStatus.UNAUTHORIZED.value,
423
+ HTTPStatus.FORBIDDEN.value,
424
+ }:
425
+ raise NotAuthorized(msg % (url, status, reason))
426
+ elif status == HTTPStatus.TOO_MANY_REQUESTS.value:
427
+ _LOGGER.debug("Too many requests - Login is rate limited: %s", response)
428
+ raise NvrError(msg % (url, status, reason))
429
+ elif (
430
+ status >= HTTPStatus.BAD_REQUEST.value
431
+ and status < HTTPStatus.INTERNAL_SERVER_ERROR.value
432
+ ):
433
+ raise BadRequest(msg % (url, status, reason))
434
+ raise NvrError(msg % (url, status, reason))
435
+ _LOGGER.debug(msg, url, status, reason)
436
+
419
437
  async def api_request(
420
438
  self,
421
439
  url: str,
@@ -512,6 +530,8 @@ class BaseApiClient:
512
530
  }
513
531
 
514
532
  response = await self.request("post", url=url, json=auth)
533
+ if response.status != 200:
534
+ await self._raise_for_status(response, True)
515
535
  self.set_header("cookie", response.headers.get("set-cookie", ""))
516
536
  self._is_authenticated = True
517
537
  _LOGGER.debug("Authenticated successfully!")
@@ -645,9 +665,9 @@ class BaseApiClient:
645
665
 
646
666
  return token_expires_at >= max_expire_time
647
667
 
648
- def get_websocket_url(self) -> str:
668
+ def _get_websocket_url(self) -> URL:
649
669
  """Get Websocket URL."""
650
- return self.ws_url
670
+ return self._ws_url_object
651
671
 
652
672
  async def async_disconnect_ws(self) -> None:
653
673
  """Disconnect from Websocket."""
@@ -17,6 +17,7 @@ from aiohttp import (
17
17
  WSMsgType,
18
18
  WSServerHandshakeError,
19
19
  )
20
+ from yarl import URL
20
21
 
21
22
  _LOGGER = logging.getLogger(__name__)
22
23
  AuthCallbackType = Callable[..., Coroutine[Any, Any, Optional[dict[str, str]]]]
@@ -36,9 +37,9 @@ class Websocket:
36
37
 
37
38
  def __init__(
38
39
  self,
39
- get_url: Callable[[], str],
40
+ get_url: Callable[[], URL],
40
41
  auth_callback: AuthCallbackType,
41
- update_bootstrap_callback: UpdateBootstrapCallbackType,
42
+ update_bootstrap: UpdateBootstrapCallbackType,
42
43
  get_session: GetSessionCallbackType,
43
44
  subscription: Callable[[WSMessage], None],
44
45
  *,
@@ -53,51 +54,59 @@ class Websocket:
53
54
  self.verify = verify
54
55
  self._get_session = get_session
55
56
  self._auth = auth_callback
56
- self._update_bootstrap_callback = update_bootstrap_callback
57
- self._connect_lock = asyncio.Lock()
57
+ self._update_bootstrap = update_bootstrap
58
58
  self._subscription = subscription
59
- self._last_ws_connect_ok = False
59
+ self._seen_non_close_message = False
60
60
 
61
61
  @property
62
62
  def is_connected(self) -> bool:
63
63
  """Return if the websocket is connected."""
64
64
  return self._ws_connection is not None and not self._ws_connection.closed
65
65
 
66
- async def _websocket_reconnect_loop(self) -> None:
67
- """Reconnect loop for websocket."""
66
+ async def _websocket_loop(self) -> None:
67
+ """Running loop for websocket."""
68
68
  await self.wait_closed()
69
69
  backoff = self.backoff
70
70
 
71
71
  while True:
72
+ url = self.get_url()
72
73
  try:
73
- await self._websocket_loop()
74
- except ClientError:
75
- _LOGGER.debug("Error in websocket reconnect loop, backoff: %s", backoff)
74
+ await self._websocket_inner_loop(url)
75
+ except ClientError as ex:
76
+ level = logging.ERROR if self._seen_non_close_message else logging.DEBUG
77
+ if isinstance(ex, WSServerHandshakeError):
78
+ if ex.status == HTTPStatus.UNAUTHORIZED.value:
79
+ _LOGGER.log(
80
+ level, "Websocket authentication error: %s: %s", url, ex
81
+ )
82
+ await self._attempt_reauth()
83
+ else:
84
+ _LOGGER.log(level, "Websocket handshake error: %s: %s", url, ex)
85
+ else:
86
+ _LOGGER.log(level, "Websocket disconnect error: %s: %s", url, ex)
87
+ except asyncio.TimeoutError:
88
+ level = logging.ERROR if self._seen_non_close_message else logging.DEBUG
89
+ _LOGGER.log(level, "Websocket timeout: %s", url)
76
90
  except Exception:
77
- _LOGGER.debug(
78
- "Error in websocket reconnect loop, backoff: %s",
79
- backoff,
80
- exc_info=True,
81
- )
91
+ _LOGGER.exception("Unexpected error in websocket loop")
82
92
 
83
93
  if self._running is False:
84
94
  break
95
+ _LOGGER.debug("Reconnecting websocket in %s seconds", backoff)
85
96
  await asyncio.sleep(self.backoff)
86
97
 
87
- async def _websocket_loop(self) -> None:
88
- url = self.get_url()
98
+ async def _websocket_inner_loop(self, url: URL) -> None:
89
99
  _LOGGER.debug("Connecting WS to %s", url)
90
100
  self._headers = await self._auth(False)
91
101
  ssl = None if self.verify else False
92
102
  msg: WSMessage | None = None
93
- seen_non_close_message = False
103
+ self._seen_non_close_message = False
104
+ session = await self._get_session()
94
105
  # catch any and all errors for Websocket so we can clean up correctly
95
106
  try:
96
- session = await self._get_session()
97
107
  self._ws_connection = await session.ws_connect(
98
108
  url, ssl=ssl, headers=self._headers, timeout=self.timeout
99
109
  )
100
- self._last_ws_connect_ok = True
101
110
  while True:
102
111
  msg = await self._ws_connection.receive(self.timeout)
103
112
  msg_type = msg.type
@@ -108,27 +117,11 @@ class Websocket:
108
117
  _LOGGER.debug("Websocket closed: %s", msg)
109
118
  break
110
119
 
111
- seen_non_close_message = True
120
+ self._seen_non_close_message = True
112
121
  try:
113
122
  self._subscription(msg)
114
123
  except Exception:
115
124
  _LOGGER.exception("Error processing websocket message")
116
- except asyncio.TimeoutError:
117
- _LOGGER.debug("Websocket timeout: %s", url)
118
- except WSServerHandshakeError as ex:
119
- level = logging.ERROR if self._last_ws_connect_ok else logging.DEBUG
120
- self._last_ws_connect_ok = False
121
- if ex.status == HTTPStatus.UNAUTHORIZED.value:
122
- _LOGGER.log(level, "Websocket authentication error: %s", url)
123
- self._headers = await self._auth(True)
124
- else:
125
- _LOGGER.log(level, "Websocket handshake error: %s", url, exc_info=True)
126
- raise
127
- except ClientError:
128
- level = logging.ERROR if self._last_ws_connect_ok else logging.DEBUG
129
- self._last_ws_connect_ok = False
130
- _LOGGER.log(level, "Websocket disconnect error: %s", url, exc_info=True)
131
- raise
132
125
  finally:
133
126
  if (
134
127
  msg is not None
@@ -137,24 +130,29 @@ class Websocket:
137
130
  # its an indication that we should update the bootstrap
138
131
  # since lastUpdateId is invalid
139
132
  and (
140
- not seen_non_close_message
133
+ not self._seen_non_close_message
141
134
  or (msg.extra and "lastUpdateId" in msg.extra)
142
135
  )
143
136
  ):
144
- self._update_bootstrap_callback()
137
+ self._update_bootstrap()
145
138
  _LOGGER.debug("Websocket disconnected: last message: %s", msg)
146
139
  if self._ws_connection is not None and not self._ws_connection.closed:
147
140
  await self._ws_connection.close()
148
141
  self._ws_connection = None
149
142
 
143
+ async def _attempt_reauth(self) -> None:
144
+ """Attempt to re-authenticate."""
145
+ try:
146
+ self._headers = await self._auth(True)
147
+ except Exception:
148
+ _LOGGER.exception("Error reauthenticating websocket")
149
+
150
150
  def start(self) -> None:
151
151
  """Start the websocket."""
152
152
  if self._running:
153
153
  return
154
154
  self._running = True
155
- self._websocket_loop_task = asyncio.create_task(
156
- self._websocket_reconnect_loop()
157
- )
155
+ self._websocket_loop_task = asyncio.create_task(self._websocket_loop())
158
156
 
159
157
  def stop(self) -> None:
160
158
  """Disconnect the websocket."""
@@ -171,6 +169,7 @@ class Websocket:
171
169
  if self._stop_task:
172
170
  with contextlib.suppress(asyncio.CancelledError):
173
171
  await self._stop_task
172
+ self._stop_task = None
174
173
 
175
174
  async def _stop(self) -> None:
176
175
  """Stop the websocket."""
File without changes
File without changes