uiprotect 3.0.0__py3-none-any.whl → 3.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 +36 -18
- uiprotect/websocket.py +39 -11
- {uiprotect-3.0.0.dist-info → uiprotect-3.1.0.dist-info}/METADATA +1 -1
- {uiprotect-3.0.0.dist-info → uiprotect-3.1.0.dist-info}/RECORD +7 -7
- {uiprotect-3.0.0.dist-info → uiprotect-3.1.0.dist-info}/LICENSE +0 -0
- {uiprotect-3.0.0.dist-info → uiprotect-3.1.0.dist-info}/WHEEL +0 -0
- {uiprotect-3.0.0.dist-info → uiprotect-3.1.0.dist-info}/entry_points.txt +0 -0
uiprotect/api.py
CHANGED
|
@@ -64,7 +64,7 @@ from .utils import (
|
|
|
64
64
|
to_js_time,
|
|
65
65
|
utc_now,
|
|
66
66
|
)
|
|
67
|
-
from .websocket import Websocket
|
|
67
|
+
from .websocket import Websocket, WebsocketState
|
|
68
68
|
|
|
69
69
|
if sys.version_info[:2] < (3, 13):
|
|
70
70
|
from http import cookies
|
|
@@ -163,7 +163,6 @@ class BaseApiClient:
|
|
|
163
163
|
_ws_timeout: int
|
|
164
164
|
|
|
165
165
|
_is_authenticated: bool = False
|
|
166
|
-
_last_ws_status: bool = False
|
|
167
166
|
_last_token_cookie: Morsel[str] | None = None
|
|
168
167
|
_last_token_cookie_decode: dict[str, Any] | None = None
|
|
169
168
|
_session: aiohttp.ClientSession | None = None
|
|
@@ -275,6 +274,7 @@ class BaseApiClient:
|
|
|
275
274
|
self._update_bootstrap_soon,
|
|
276
275
|
self.get_session,
|
|
277
276
|
self._process_ws_message,
|
|
277
|
+
self._on_websocket_state_change,
|
|
278
278
|
verify=self._verify_ssl,
|
|
279
279
|
timeout=self._ws_timeout,
|
|
280
280
|
)
|
|
@@ -675,22 +675,6 @@ class BaseApiClient:
|
|
|
675
675
|
await websocket.wait_closed()
|
|
676
676
|
self._websocket = None
|
|
677
677
|
|
|
678
|
-
def check_ws(self) -> bool:
|
|
679
|
-
"""Checks current state of Websocket."""
|
|
680
|
-
if self._websocket is None:
|
|
681
|
-
return False
|
|
682
|
-
|
|
683
|
-
if not self._websocket.is_connected:
|
|
684
|
-
log = _LOGGER.debug
|
|
685
|
-
if self._last_ws_status:
|
|
686
|
-
log = _LOGGER.warning
|
|
687
|
-
log("Websocket connection not active, failing back to polling")
|
|
688
|
-
elif not self._last_ws_status:
|
|
689
|
-
_LOGGER.info("Websocket re-connected successfully")
|
|
690
|
-
|
|
691
|
-
self._last_ws_status = self._websocket.is_connected
|
|
692
|
-
return self._last_ws_status
|
|
693
|
-
|
|
694
678
|
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
695
679
|
raise NotImplementedError
|
|
696
680
|
|
|
@@ -700,6 +684,10 @@ class BaseApiClient:
|
|
|
700
684
|
async def update(self) -> Bootstrap:
|
|
701
685
|
raise NotImplementedError
|
|
702
686
|
|
|
687
|
+
def _on_websocket_state_change(self, state: WebsocketState) -> None:
|
|
688
|
+
"""Websocket state changed."""
|
|
689
|
+
_LOGGER.debug("Websocket state changed: %s", state)
|
|
690
|
+
|
|
703
691
|
|
|
704
692
|
class ProtectApiClient(BaseApiClient):
|
|
705
693
|
"""
|
|
@@ -736,6 +724,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
736
724
|
_subscribed_models: set[ModelType]
|
|
737
725
|
_ignore_stats: bool
|
|
738
726
|
_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
727
|
+
_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
|
|
739
728
|
_bootstrap: Bootstrap | None = None
|
|
740
729
|
_last_update_dt: datetime | None = None
|
|
741
730
|
_connection_host: IPv4Address | IPv6Address | str | None = None
|
|
@@ -778,6 +767,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
778
767
|
self._subscribed_models = subscribed_models or set()
|
|
779
768
|
self._ignore_stats = ignore_stats
|
|
780
769
|
self._ws_subscriptions = []
|
|
770
|
+
self._ws_state_subscriptions = []
|
|
781
771
|
self.ignore_unadopted = ignore_unadopted
|
|
782
772
|
self._update_lock = asyncio.Lock()
|
|
783
773
|
|
|
@@ -1140,6 +1130,34 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1140
1130
|
if not self._ws_subscriptions:
|
|
1141
1131
|
self._get_websocket().stop()
|
|
1142
1132
|
|
|
1133
|
+
def subscribe_websocket_state(
|
|
1134
|
+
self,
|
|
1135
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1136
|
+
) -> Callable[[], None]:
|
|
1137
|
+
"""
|
|
1138
|
+
Subscribe to websocket state changes.
|
|
1139
|
+
|
|
1140
|
+
Returns a callback that will unsubscribe.
|
|
1141
|
+
"""
|
|
1142
|
+
self._ws_state_subscriptions.append(ws_callback)
|
|
1143
|
+
return partial(self._unsubscribe_websocket_state, ws_callback)
|
|
1144
|
+
|
|
1145
|
+
def _unsubscribe_websocket_state(
|
|
1146
|
+
self,
|
|
1147
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1148
|
+
) -> None:
|
|
1149
|
+
"""Unsubscribe to websocket state changes."""
|
|
1150
|
+
self._ws_state_subscriptions.remove(ws_callback)
|
|
1151
|
+
|
|
1152
|
+
def _on_websocket_state_change(self, state: WebsocketState) -> None:
|
|
1153
|
+
"""Websocket state changed."""
|
|
1154
|
+
super()._on_websocket_state_change(state)
|
|
1155
|
+
for sub in self._ws_state_subscriptions:
|
|
1156
|
+
try:
|
|
1157
|
+
sub(state)
|
|
1158
|
+
except Exception:
|
|
1159
|
+
_LOGGER.exception("Exception while running websocket state handler")
|
|
1160
|
+
|
|
1143
1161
|
async def get_bootstrap(self) -> Bootstrap:
|
|
1144
1162
|
"""
|
|
1145
1163
|
Gets bootstrap object from UFP instance
|
uiprotect/websocket.py
CHANGED
|
@@ -6,6 +6,7 @@ import asyncio
|
|
|
6
6
|
import contextlib
|
|
7
7
|
import logging
|
|
8
8
|
from collections.abc import Awaitable, Callable, Coroutine
|
|
9
|
+
from enum import Enum
|
|
9
10
|
from http import HTTPStatus
|
|
10
11
|
from typing import Any, Optional
|
|
11
12
|
|
|
@@ -28,6 +29,11 @@ UpdateBootstrapCallbackType = Callable[[], None]
|
|
|
28
29
|
_CLOSE_MESSAGE_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED}
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
class WebsocketState(Enum):
|
|
33
|
+
CONNECTED = True
|
|
34
|
+
DISCONNECTED = False
|
|
35
|
+
|
|
36
|
+
|
|
31
37
|
class Websocket:
|
|
32
38
|
"""UniFi Protect Websocket manager."""
|
|
33
39
|
|
|
@@ -44,6 +50,7 @@ class Websocket:
|
|
|
44
50
|
update_bootstrap: UpdateBootstrapCallbackType,
|
|
45
51
|
get_session: GetSessionCallbackType,
|
|
46
52
|
subscription: Callable[[WSMessage], None],
|
|
53
|
+
state_callback: Callable[[WebsocketState], None],
|
|
47
54
|
*,
|
|
48
55
|
timeout: float = 30.0,
|
|
49
56
|
backoff: int = 10,
|
|
@@ -59,10 +66,12 @@ class Websocket:
|
|
|
59
66
|
self._update_bootstrap = update_bootstrap
|
|
60
67
|
self._subscription = subscription
|
|
61
68
|
self._seen_non_close_message = False
|
|
69
|
+
self._websocket_state = state_callback
|
|
70
|
+
self._current_state: WebsocketState = WebsocketState.DISCONNECTED
|
|
62
71
|
|
|
63
72
|
@property
|
|
64
73
|
def is_connected(self) -> bool:
|
|
65
|
-
"""Return if the websocket is connected."""
|
|
74
|
+
"""Return if the websocket is connected and has received a valid message."""
|
|
66
75
|
return self._ws_connection is not None and not self._ws_connection.closed
|
|
67
76
|
|
|
68
77
|
async def _websocket_loop(self) -> None:
|
|
@@ -92,11 +101,19 @@ class Websocket:
|
|
|
92
101
|
except Exception:
|
|
93
102
|
_LOGGER.exception("Unexpected error in websocket loop")
|
|
94
103
|
|
|
104
|
+
self._state_changed(WebsocketState.DISCONNECTED)
|
|
95
105
|
if self._running is False:
|
|
96
106
|
break
|
|
97
107
|
_LOGGER.debug("Reconnecting websocket in %s seconds", backoff)
|
|
98
108
|
await asyncio.sleep(self.backoff)
|
|
99
109
|
|
|
110
|
+
def _state_changed(self, state: WebsocketState) -> None:
|
|
111
|
+
"""State changed."""
|
|
112
|
+
if self._current_state is state:
|
|
113
|
+
return
|
|
114
|
+
self._current_state = state
|
|
115
|
+
self._websocket_state(state)
|
|
116
|
+
|
|
100
117
|
async def _websocket_inner_loop(self, url: URL) -> None:
|
|
101
118
|
_LOGGER.debug("Connecting WS to %s", url)
|
|
102
119
|
await self._attempt_auth(False)
|
|
@@ -119,7 +136,9 @@ class Websocket:
|
|
|
119
136
|
_LOGGER.debug("Websocket closed: %s", msg)
|
|
120
137
|
break
|
|
121
138
|
|
|
122
|
-
self._seen_non_close_message
|
|
139
|
+
if not self._seen_non_close_message:
|
|
140
|
+
self._seen_non_close_message = True
|
|
141
|
+
self._state_changed(WebsocketState.CONNECTED)
|
|
123
142
|
try:
|
|
124
143
|
self._subscription(msg)
|
|
125
144
|
except Exception:
|
|
@@ -166,21 +185,30 @@ class Websocket:
|
|
|
166
185
|
if self._websocket_loop_task:
|
|
167
186
|
self._websocket_loop_task.cancel()
|
|
168
187
|
self._running = False
|
|
169
|
-
|
|
188
|
+
ws_connection = self._ws_connection
|
|
189
|
+
websocket_loop_task = self._websocket_loop_task
|
|
190
|
+
self._ws_connection = None
|
|
191
|
+
self._websocket_loop_task = None
|
|
192
|
+
self._stop_task = asyncio.create_task(
|
|
193
|
+
self._stop(ws_connection, websocket_loop_task)
|
|
194
|
+
)
|
|
195
|
+
self._state_changed(WebsocketState.DISCONNECTED)
|
|
170
196
|
|
|
171
197
|
async def wait_closed(self) -> None:
|
|
172
198
|
"""Wait for the websocket to close."""
|
|
173
|
-
if self._stop_task:
|
|
199
|
+
if self._stop_task and not self._stop_task.done():
|
|
174
200
|
with contextlib.suppress(asyncio.CancelledError):
|
|
175
201
|
await self._stop_task
|
|
176
202
|
self._stop_task = None
|
|
177
203
|
|
|
178
|
-
async def _stop(
|
|
204
|
+
async def _stop(
|
|
205
|
+
self,
|
|
206
|
+
ws_connection: ClientWebSocketResponse | None,
|
|
207
|
+
websocket_loop_task: asyncio.Task[None] | None,
|
|
208
|
+
) -> None:
|
|
179
209
|
"""Stop the websocket."""
|
|
180
|
-
if
|
|
181
|
-
await
|
|
182
|
-
|
|
183
|
-
if self._websocket_loop_task:
|
|
210
|
+
if ws_connection:
|
|
211
|
+
await ws_connection.close()
|
|
212
|
+
if websocket_loop_task:
|
|
184
213
|
with contextlib.suppress(asyncio.CancelledError):
|
|
185
|
-
await
|
|
186
|
-
self._websocket_loop_task = None
|
|
214
|
+
await websocket_loop_task
|
|
@@ -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=JQxgQ8Tr6r3_slT0Lm0jGL5YwCE9MFjzRe3-A9aM-Qg,67560
|
|
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-3.
|
|
34
|
-
uiprotect-3.
|
|
35
|
-
uiprotect-3.
|
|
36
|
-
uiprotect-3.
|
|
37
|
-
uiprotect-3.
|
|
32
|
+
uiprotect/websocket.py,sha256=D5DZrMzo434ecp8toNxOB5HM193kVwYw42yEcg99yMw,8029
|
|
33
|
+
uiprotect-3.1.0.dist-info/LICENSE,sha256=INx18jhdbVXMEiiBANeKEbrbz57ckgzxk5uutmmcxGk,1111
|
|
34
|
+
uiprotect-3.1.0.dist-info/METADATA,sha256=3ocMBHOygKE3ejOejbnoVE46o-kaY5XR8dZMVEKDafk,10982
|
|
35
|
+
uiprotect-3.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
36
|
+
uiprotect-3.1.0.dist-info/entry_points.txt,sha256=J78AUTPrTTxgI3s7SVgrmGqDP7piX2wuuEORzhDdVRA,47
|
|
37
|
+
uiprotect-3.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|