uiprotect 2.3.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 +49 -45
- uiprotect/websocket.py +39 -11
- {uiprotect-2.3.0.dist-info → uiprotect-3.1.0.dist-info}/METADATA +1 -1
- {uiprotect-2.3.0.dist-info → uiprotect-3.1.0.dist-info}/RECORD +7 -7
- {uiprotect-2.3.0.dist-info → uiprotect-3.1.0.dist-info}/LICENSE +0 -0
- {uiprotect-2.3.0.dist-info → uiprotect-3.1.0.dist-info}/WHEEL +0 -0
- {uiprotect-2.3.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
|
|
@@ -75,7 +75,6 @@ if sys.version_info[:2] < (3, 13):
|
|
|
75
75
|
|
|
76
76
|
TOKEN_COOKIE_MAX_EXP_SECONDS = 60
|
|
77
77
|
|
|
78
|
-
NEVER_RAN = -1000
|
|
79
78
|
# how many seconds before the bootstrap is refreshed from Protect
|
|
80
79
|
DEVICE_UPDATE_INTERVAL = 900
|
|
81
80
|
# retry timeout for thumbnails/heatmaps
|
|
@@ -164,8 +163,6 @@ class BaseApiClient:
|
|
|
164
163
|
_ws_timeout: int
|
|
165
164
|
|
|
166
165
|
_is_authenticated: bool = False
|
|
167
|
-
_last_update: float = NEVER_RAN
|
|
168
|
-
_last_ws_status: bool = False
|
|
169
166
|
_last_token_cookie: Morsel[str] | None = None
|
|
170
167
|
_last_token_cookie_decode: dict[str, Any] | None = None
|
|
171
168
|
_session: aiohttp.ClientSession | None = None
|
|
@@ -277,6 +274,7 @@ class BaseApiClient:
|
|
|
277
274
|
self._update_bootstrap_soon,
|
|
278
275
|
self.get_session,
|
|
279
276
|
self._process_ws_message,
|
|
277
|
+
self._on_websocket_state_change,
|
|
280
278
|
verify=self._verify_ssl,
|
|
281
279
|
timeout=self._ws_timeout,
|
|
282
280
|
)
|
|
@@ -289,7 +287,7 @@ class BaseApiClient:
|
|
|
289
287
|
# since the lastUpdateId is not valid anymore
|
|
290
288
|
if self._update_task and not self._update_task.done():
|
|
291
289
|
return
|
|
292
|
-
self._update_task = asyncio.create_task(self.update(
|
|
290
|
+
self._update_task = asyncio.create_task(self.update())
|
|
293
291
|
|
|
294
292
|
async def close_session(self) -> None:
|
|
295
293
|
"""Closing and deletes client session"""
|
|
@@ -677,31 +675,19 @@ class BaseApiClient:
|
|
|
677
675
|
await websocket.wait_closed()
|
|
678
676
|
self._websocket = None
|
|
679
677
|
|
|
680
|
-
def check_ws(self) -> bool:
|
|
681
|
-
"""Checks current state of Websocket."""
|
|
682
|
-
if self._websocket is None:
|
|
683
|
-
return False
|
|
684
|
-
|
|
685
|
-
if not self._websocket.is_connected:
|
|
686
|
-
log = _LOGGER.debug
|
|
687
|
-
if self._last_ws_status:
|
|
688
|
-
log = _LOGGER.warning
|
|
689
|
-
log("Websocket connection not active, failing back to polling")
|
|
690
|
-
elif not self._last_ws_status:
|
|
691
|
-
_LOGGER.info("Websocket re-connected successfully")
|
|
692
|
-
|
|
693
|
-
self._last_ws_status = self._websocket.is_connected
|
|
694
|
-
return self._last_ws_status
|
|
695
|
-
|
|
696
678
|
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
697
679
|
raise NotImplementedError
|
|
698
680
|
|
|
699
681
|
def _get_last_update_id(self) -> str | None:
|
|
700
682
|
raise NotImplementedError
|
|
701
683
|
|
|
702
|
-
async def update(self
|
|
684
|
+
async def update(self) -> Bootstrap:
|
|
703
685
|
raise NotImplementedError
|
|
704
686
|
|
|
687
|
+
def _on_websocket_state_change(self, state: WebsocketState) -> None:
|
|
688
|
+
"""Websocket state changed."""
|
|
689
|
+
_LOGGER.debug("Websocket state changed: %s", state)
|
|
690
|
+
|
|
705
691
|
|
|
706
692
|
class ProtectApiClient(BaseApiClient):
|
|
707
693
|
"""
|
|
@@ -710,8 +696,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
710
696
|
UniFi Protect is a full async application. "normal" use of interacting with it is
|
|
711
697
|
to call `.update()` which will initialize the `.bootstrap` and create a Websocket
|
|
712
698
|
connection to UFP. This Websocket connection will emit messages that will automatically
|
|
713
|
-
update the `.bootstrap` over time.
|
|
714
|
-
verify the integry of the Websocket connection.
|
|
699
|
+
update the `.bootstrap` over time.
|
|
715
700
|
|
|
716
701
|
You can use the `.get_` methods to one off pull devices from the UFP API, but should
|
|
717
702
|
not be used for building an aplication on top of.
|
|
@@ -739,6 +724,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
739
724
|
_subscribed_models: set[ModelType]
|
|
740
725
|
_ignore_stats: bool
|
|
741
726
|
_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
727
|
+
_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
|
|
742
728
|
_bootstrap: Bootstrap | None = None
|
|
743
729
|
_last_update_dt: datetime | None = None
|
|
744
730
|
_connection_host: IPv4Address | IPv6Address | str | None = None
|
|
@@ -781,6 +767,7 @@ class ProtectApiClient(BaseApiClient):
|
|
|
781
767
|
self._subscribed_models = subscribed_models or set()
|
|
782
768
|
self._ignore_stats = ignore_stats
|
|
783
769
|
self._ws_subscriptions = []
|
|
770
|
+
self._ws_state_subscriptions = []
|
|
784
771
|
self.ignore_unadopted = ignore_unadopted
|
|
785
772
|
self._update_lock = asyncio.Lock()
|
|
786
773
|
|
|
@@ -816,32 +803,21 @@ class ProtectApiClient(BaseApiClient):
|
|
|
816
803
|
|
|
817
804
|
return self._connection_host
|
|
818
805
|
|
|
819
|
-
async def update(self
|
|
806
|
+
async def update(self) -> Bootstrap:
|
|
820
807
|
"""
|
|
821
|
-
Updates the state of devices,
|
|
822
|
-
|
|
808
|
+
Updates the state of devices, initializes `.bootstrap`
|
|
809
|
+
|
|
810
|
+
The websocket is auto connected once there are any
|
|
811
|
+
subscriptions to it. update must be called at least
|
|
812
|
+
once before subscribing to the websocket.
|
|
823
813
|
|
|
824
814
|
You can use the various other `get_` methods if you need one off data from UFP
|
|
825
815
|
"""
|
|
826
816
|
async with self._update_lock:
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
bootstrap_updated = False
|
|
832
|
-
if (
|
|
833
|
-
self._bootstrap is None
|
|
834
|
-
or now - self._last_update > DEVICE_UPDATE_INTERVAL
|
|
835
|
-
):
|
|
836
|
-
bootstrap_updated = True
|
|
837
|
-
self._bootstrap = await self.get_bootstrap()
|
|
838
|
-
self.__dict__.pop("bootstrap", None)
|
|
839
|
-
self._last_update = now
|
|
840
|
-
|
|
841
|
-
if bootstrap_updated:
|
|
842
|
-
return None
|
|
843
|
-
self._last_update = now
|
|
844
|
-
return self._bootstrap
|
|
817
|
+
bootstrap = await self.get_bootstrap()
|
|
818
|
+
self.__dict__.pop("bootstrap", None)
|
|
819
|
+
self._bootstrap = bootstrap
|
|
820
|
+
return bootstrap
|
|
845
821
|
|
|
846
822
|
async def poll_events(self) -> None:
|
|
847
823
|
"""Poll for events."""
|
|
@@ -1154,6 +1130,34 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1154
1130
|
if not self._ws_subscriptions:
|
|
1155
1131
|
self._get_websocket().stop()
|
|
1156
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
|
+
|
|
1157
1161
|
async def get_bootstrap(self) -> Bootstrap:
|
|
1158
1162
|
"""
|
|
1159
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-
|
|
34
|
-
uiprotect-
|
|
35
|
-
uiprotect-
|
|
36
|
-
uiprotect-
|
|
37
|
-
uiprotect-
|
|
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
|