uiprotect 2.0.0__tar.gz → 2.1.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.1.0}/PKG-INFO +1 -1
  2. {uiprotect-2.0.0 → uiprotect-2.1.0}/pyproject.toml +1 -1
  3. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/api.py +41 -21
  4. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/websocket.py +42 -38
  5. {uiprotect-2.0.0 → uiprotect-2.1.0}/LICENSE +0 -0
  6. {uiprotect-2.0.0 → uiprotect-2.1.0}/README.md +0 -0
  7. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/__init__.py +0 -0
  8. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/__main__.py +0 -0
  9. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/__init__.py +0 -0
  10. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/backup.py +0 -0
  11. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/base.py +0 -0
  12. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/cameras.py +0 -0
  13. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/chimes.py +0 -0
  14. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/doorlocks.py +0 -0
  15. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/events.py +0 -0
  16. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/lights.py +0 -0
  17. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/liveviews.py +0 -0
  18. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/nvr.py +0 -0
  19. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/sensors.py +0 -0
  20. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/cli/viewers.py +0 -0
  21. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/__init__.py +0 -0
  22. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/base.py +0 -0
  23. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/bootstrap.py +0 -0
  24. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/convert.py +0 -0
  25. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/devices.py +0 -0
  26. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/nvr.py +0 -0
  27. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/types.py +0 -0
  28. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/user.py +0 -0
  29. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/data/websocket.py +0 -0
  30. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/exceptions.py +0 -0
  31. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/py.typed +0 -0
  32. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/release_cache.json +0 -0
  33. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/stream.py +0 -0
  34. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/test_util/__init__.py +0 -0
  35. {uiprotect-2.0.0 → uiprotect-2.1.0}/src/uiprotect/test_util/anonymize.py +0 -0
  36. {uiprotect-2.0.0 → uiprotect-2.1.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.1.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.1.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,29 +54,44 @@ 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(level, "Websocket authentication error: %s", url)
80
+ await self._attempt_reauth()
81
+ else:
82
+ _LOGGER.log(
83
+ level, "Websocket handshake error: %s", url, exc_info=True
84
+ )
85
+ else:
86
+ _LOGGER.log(
87
+ level, "Websocket disconnect error: %s", url, exc_info=True
88
+ )
89
+ except asyncio.TimeoutError:
90
+ level = logging.ERROR if self._seen_non_close_message else logging.DEBUG
91
+ _LOGGER.log(level, "Websocket timeout: %s", url)
76
92
  except Exception:
77
93
  _LOGGER.debug(
78
- "Error in websocket reconnect loop, backoff: %s",
94
+ "Unexpected error in websocket reconnect loop, backoff: %s",
79
95
  backoff,
80
96
  exc_info=True,
81
97
  )
@@ -84,20 +100,18 @@ class Websocket:
84
100
  break
85
101
  await asyncio.sleep(self.backoff)
86
102
 
87
- async def _websocket_loop(self) -> None:
88
- url = self.get_url()
103
+ async def _websocket_inner_loop(self, url: URL) -> None:
89
104
  _LOGGER.debug("Connecting WS to %s", url)
90
105
  self._headers = await self._auth(False)
91
106
  ssl = None if self.verify else False
92
107
  msg: WSMessage | None = None
93
- seen_non_close_message = False
108
+ self._seen_non_close_message = False
109
+ session = await self._get_session()
94
110
  # catch any and all errors for Websocket so we can clean up correctly
95
111
  try:
96
- session = await self._get_session()
97
112
  self._ws_connection = await session.ws_connect(
98
113
  url, ssl=ssl, headers=self._headers, timeout=self.timeout
99
114
  )
100
- self._last_ws_connect_ok = True
101
115
  while True:
102
116
  msg = await self._ws_connection.receive(self.timeout)
103
117
  msg_type = msg.type
@@ -108,27 +122,11 @@ class Websocket:
108
122
  _LOGGER.debug("Websocket closed: %s", msg)
109
123
  break
110
124
 
111
- seen_non_close_message = True
125
+ self._seen_non_close_message = True
112
126
  try:
113
127
  self._subscription(msg)
114
128
  except Exception:
115
129
  _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
130
  finally:
133
131
  if (
134
132
  msg is not None
@@ -137,24 +135,29 @@ class Websocket:
137
135
  # its an indication that we should update the bootstrap
138
136
  # since lastUpdateId is invalid
139
137
  and (
140
- not seen_non_close_message
138
+ not self._seen_non_close_message
141
139
  or (msg.extra and "lastUpdateId" in msg.extra)
142
140
  )
143
141
  ):
144
- self._update_bootstrap_callback()
142
+ self._update_bootstrap()
145
143
  _LOGGER.debug("Websocket disconnected: last message: %s", msg)
146
144
  if self._ws_connection is not None and not self._ws_connection.closed:
147
145
  await self._ws_connection.close()
148
146
  self._ws_connection = None
149
147
 
148
+ async def _attempt_reauth(self) -> None:
149
+ """Attempt to re-authenticate."""
150
+ try:
151
+ self._headers = await self._auth(True)
152
+ except Exception:
153
+ _LOGGER.exception("Error reauthenticating websocket")
154
+
150
155
  def start(self) -> None:
151
156
  """Start the websocket."""
152
157
  if self._running:
153
158
  return
154
159
  self._running = True
155
- self._websocket_loop_task = asyncio.create_task(
156
- self._websocket_reconnect_loop()
157
- )
160
+ self._websocket_loop_task = asyncio.create_task(self._websocket_loop())
158
161
 
159
162
  def stop(self) -> None:
160
163
  """Disconnect the websocket."""
@@ -171,6 +174,7 @@ class Websocket:
171
174
  if self._stop_task:
172
175
  with contextlib.suppress(asyncio.CancelledError):
173
176
  await self._stop_task
177
+ self._stop_task = None
174
178
 
175
179
  async def _stop(self) -> None:
176
180
  """Stop the websocket."""
File without changes
File without changes