uiprotect 2.0.0__py3-none-any.whl → 2.1.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.
Potentially problematic release.
This version of uiprotect might be problematic. Click here for more details.
- uiprotect/api.py +41 -21
- uiprotect/websocket.py +42 -38
- {uiprotect-2.0.0.dist-info → uiprotect-2.1.0.dist-info}/METADATA +1 -1
- {uiprotect-2.0.0.dist-info → uiprotect-2.1.0.dist-info}/RECORD +7 -7
- {uiprotect-2.0.0.dist-info → uiprotect-2.1.0.dist-info}/LICENSE +0 -0
- {uiprotect-2.0.0.dist-info → uiprotect-2.1.0.dist-info}/WHEEL +0 -0
- {uiprotect-2.0.0.dist-info → uiprotect-2.1.0.dist-info}/entry_points.txt +0 -0
uiprotect/api.py
CHANGED
|
@@ -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
|
|
234
|
+
def _ws_url_object(self) -> URL:
|
|
232
235
|
"""Get Websocket URL."""
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
668
|
+
def _get_websocket_url(self) -> URL:
|
|
649
669
|
"""Get Websocket URL."""
|
|
650
|
-
return self.
|
|
670
|
+
return self._ws_url_object
|
|
651
671
|
|
|
652
672
|
async def async_disconnect_ws(self) -> None:
|
|
653
673
|
"""Disconnect from Websocket."""
|
uiprotect/websocket.py
CHANGED
|
@@ -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[[],
|
|
40
|
+
get_url: Callable[[], URL],
|
|
40
41
|
auth_callback: AuthCallbackType,
|
|
41
|
-
|
|
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.
|
|
57
|
-
self._connect_lock = asyncio.Lock()
|
|
57
|
+
self._update_bootstrap = update_bootstrap
|
|
58
58
|
self._subscription = subscription
|
|
59
|
-
self.
|
|
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
|
|
67
|
-
"""
|
|
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.
|
|
74
|
-
except ClientError:
|
|
75
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
138
|
+
not self._seen_non_close_message
|
|
141
139
|
or (msg.extra and "lastUpdateId" in msg.extra)
|
|
142
140
|
)
|
|
143
141
|
):
|
|
144
|
-
self.
|
|
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."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
uiprotect/__init__.py,sha256=UdpRSSLSy7pdDfTKf0zRIfy6KRGt_Jv-fMzYWgibbG4,686
|
|
2
2
|
uiprotect/__main__.py,sha256=C_bHCOkv5qj6WMy-6ELoY3Y6HDhLxOa1a30CzmbZhsg,462
|
|
3
|
-
uiprotect/api.py,sha256=
|
|
3
|
+
uiprotect/api.py,sha256=P_iS5nIxc4sAYp4ISBS_X7wUGIPV2c96xhUkj6kGleg,67379
|
|
4
4
|
uiprotect/cli/__init__.py,sha256=1MO8rJmjjAsfVx2x01gn5DJo8B64xdPGo6gRVJbWd18,8868
|
|
5
5
|
uiprotect/cli/backup.py,sha256=ZiS7RZnJGKI8TJKLW2cOUzkRM8nyTvE5Ov_jZZGtvSM,36708
|
|
6
6
|
uiprotect/cli/base.py,sha256=k-_qGuNT7br0iV0KE5F4wYXF75iyLLjBEckTqxC71xM,7591
|
|
@@ -29,9 +29,9 @@ uiprotect/stream.py,sha256=McV3XymKyjn-1uV5jdQHcpaDjqLS4zWyMASQ8ubcyb4,4924
|
|
|
29
29
|
uiprotect/test_util/__init__.py,sha256=whiOUb5LfDLNT3AQG6ISiKtAqO2JnhCIdFavhWDK46M,18718
|
|
30
30
|
uiprotect/test_util/anonymize.py,sha256=f-8ijU-_y9r-uAbhIPn0f0I6hzJpAkvJzc8UpWihObI,8478
|
|
31
31
|
uiprotect/utils.py,sha256=3SJFF8qs1Jz8t3mD8qwc1hFSocolFjdXI_v4yVlC7o4,20088
|
|
32
|
-
uiprotect/websocket.py,sha256=
|
|
33
|
-
uiprotect-2.
|
|
34
|
-
uiprotect-2.
|
|
35
|
-
uiprotect-2.
|
|
36
|
-
uiprotect-2.
|
|
37
|
-
uiprotect-2.
|
|
32
|
+
uiprotect/websocket.py,sha256=qVx2ZXOacnvyipiPy76i19Rpu596hFxGHhYWuMJVOtg,6898
|
|
33
|
+
uiprotect-2.1.0.dist-info/LICENSE,sha256=INx18jhdbVXMEiiBANeKEbrbz57ckgzxk5uutmmcxGk,1111
|
|
34
|
+
uiprotect-2.1.0.dist-info/METADATA,sha256=2ObhpC8nRihfVKuDZlBYh84HQX93a9FkHPRswCTcn44,10982
|
|
35
|
+
uiprotect-2.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
36
|
+
uiprotect-2.1.0.dist-info/entry_points.txt,sha256=J78AUTPrTTxgI3s7SVgrmGqDP7piX2wuuEORzhDdVRA,47
|
|
37
|
+
uiprotect-2.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|