uiprotect 2.3.0__tar.gz → 3.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.3.0 → uiprotect-3.1.0}/PKG-INFO +1 -1
  2. {uiprotect-2.3.0 → uiprotect-3.1.0}/pyproject.toml +1 -1
  3. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/api.py +49 -45
  4. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/websocket.py +39 -11
  5. {uiprotect-2.3.0 → uiprotect-3.1.0}/LICENSE +0 -0
  6. {uiprotect-2.3.0 → uiprotect-3.1.0}/README.md +0 -0
  7. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/__init__.py +0 -0
  8. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/__main__.py +0 -0
  9. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/__init__.py +0 -0
  10. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/backup.py +0 -0
  11. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/base.py +0 -0
  12. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/cameras.py +0 -0
  13. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/chimes.py +0 -0
  14. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/doorlocks.py +0 -0
  15. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/events.py +0 -0
  16. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/lights.py +0 -0
  17. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/liveviews.py +0 -0
  18. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/nvr.py +0 -0
  19. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/sensors.py +0 -0
  20. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/cli/viewers.py +0 -0
  21. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/__init__.py +0 -0
  22. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/base.py +0 -0
  23. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/bootstrap.py +0 -0
  24. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/convert.py +0 -0
  25. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/devices.py +0 -0
  26. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/nvr.py +0 -0
  27. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/types.py +0 -0
  28. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/user.py +0 -0
  29. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/data/websocket.py +0 -0
  30. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/exceptions.py +0 -0
  31. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/py.typed +0 -0
  32. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/release_cache.json +0 -0
  33. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/stream.py +0 -0
  34. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/test_util/__init__.py +0 -0
  35. {uiprotect-2.3.0 → uiprotect-3.1.0}/src/uiprotect/test_util/anonymize.py +0 -0
  36. {uiprotect-2.3.0 → uiprotect-3.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.3.0
3
+ Version: 3.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.3.0"
3
+ version = "3.1.0"
4
4
  description = "Python API for Unifi Protect (Unofficial)"
5
5
  authors = ["UI Protect Maintainers <ui@koston.org>"]
6
6
  license = "MIT"
@@ -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(force=True))
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, force: bool = False) -> Bootstrap | None:
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. Caling `.udpate` again (without `force`) will
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, force: bool = False) -> Bootstrap | None:
806
+ async def update(self) -> Bootstrap:
820
807
  """
821
- Updates the state of devices, initalizes `.bootstrap` and
822
- connects to UFP Websocket for real time updates
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
- now = time.monotonic()
828
- if force:
829
- self._last_update = NEVER_RAN
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
@@ -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 = True
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
- self._stop_task = asyncio.create_task(self._stop())
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(self) -> None:
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 self._ws_connection:
181
- await self._ws_connection.close()
182
- self._ws_connection = None
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 self._websocket_loop_task
186
- self._websocket_loop_task = None
214
+ await websocket_loop_task
File without changes
File without changes