uiprotect 3.0.0__tar.gz → 3.1.1__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-3.0.0 → uiprotect-3.1.1}/PKG-INFO +1 -1
  2. {uiprotect-3.0.0 → uiprotect-3.1.1}/pyproject.toml +1 -1
  3. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/api.py +40 -19
  4. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/websocket.py +39 -11
  5. {uiprotect-3.0.0 → uiprotect-3.1.1}/LICENSE +0 -0
  6. {uiprotect-3.0.0 → uiprotect-3.1.1}/README.md +0 -0
  7. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/__init__.py +0 -0
  8. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/__main__.py +0 -0
  9. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/__init__.py +0 -0
  10. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/backup.py +0 -0
  11. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/base.py +0 -0
  12. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/cameras.py +0 -0
  13. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/chimes.py +0 -0
  14. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/doorlocks.py +0 -0
  15. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/events.py +0 -0
  16. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/lights.py +0 -0
  17. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/liveviews.py +0 -0
  18. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/nvr.py +0 -0
  19. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/sensors.py +0 -0
  20. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/cli/viewers.py +0 -0
  21. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/__init__.py +0 -0
  22. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/base.py +0 -0
  23. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/bootstrap.py +0 -0
  24. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/convert.py +0 -0
  25. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/devices.py +0 -0
  26. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/nvr.py +0 -0
  27. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/types.py +0 -0
  28. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/user.py +0 -0
  29. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/data/websocket.py +0 -0
  30. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/exceptions.py +0 -0
  31. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/py.typed +0 -0
  32. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/release_cache.json +0 -0
  33. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/stream.py +0 -0
  34. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/test_util/__init__.py +0 -0
  35. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/test_util/anonymize.py +0 -0
  36. {uiprotect-3.0.0 → uiprotect-3.1.1}/src/uiprotect/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: uiprotect
3
- Version: 3.0.0
3
+ Version: 3.1.1
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 = "3.0.0"
3
+ version = "3.1.1"
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
@@ -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
  )
@@ -411,11 +411,13 @@ class BaseApiClient:
411
411
  async def _raise_for_status(
412
412
  self, response: aiohttp.ClientResponse, raise_exception: bool = True
413
413
  ) -> None:
414
+ """Raise an exception based on the response status."""
414
415
  url = response.url
415
416
  reason = await get_response_reason(response)
416
417
  msg = "Request failed: %s - Status: %s - Reason: %s"
418
+ status = response.status
419
+
417
420
  if raise_exception:
418
- status = response.status
419
421
  if status in {
420
422
  HTTPStatus.UNAUTHORIZED.value,
421
423
  HTTPStatus.FORBIDDEN.value,
@@ -430,6 +432,7 @@ class BaseApiClient:
430
432
  ):
431
433
  raise BadRequest(msg % (url, status, reason))
432
434
  raise NvrError(msg % (url, status, reason))
435
+
433
436
  _LOGGER.debug(msg, url, status, reason)
434
437
 
435
438
  async def api_request(
@@ -675,22 +678,6 @@ class BaseApiClient:
675
678
  await websocket.wait_closed()
676
679
  self._websocket = None
677
680
 
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
681
  def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
695
682
  raise NotImplementedError
696
683
 
@@ -700,6 +687,10 @@ class BaseApiClient:
700
687
  async def update(self) -> Bootstrap:
701
688
  raise NotImplementedError
702
689
 
690
+ def _on_websocket_state_change(self, state: WebsocketState) -> None:
691
+ """Websocket state changed."""
692
+ _LOGGER.debug("Websocket state changed: %s", state)
693
+
703
694
 
704
695
  class ProtectApiClient(BaseApiClient):
705
696
  """
@@ -736,6 +727,7 @@ class ProtectApiClient(BaseApiClient):
736
727
  _subscribed_models: set[ModelType]
737
728
  _ignore_stats: bool
738
729
  _ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
730
+ _ws_state_subscriptions: list[Callable[[WebsocketState], None]]
739
731
  _bootstrap: Bootstrap | None = None
740
732
  _last_update_dt: datetime | None = None
741
733
  _connection_host: IPv4Address | IPv6Address | str | None = None
@@ -778,6 +770,7 @@ class ProtectApiClient(BaseApiClient):
778
770
  self._subscribed_models = subscribed_models or set()
779
771
  self._ignore_stats = ignore_stats
780
772
  self._ws_subscriptions = []
773
+ self._ws_state_subscriptions = []
781
774
  self.ignore_unadopted = ignore_unadopted
782
775
  self._update_lock = asyncio.Lock()
783
776
 
@@ -1140,6 +1133,34 @@ class ProtectApiClient(BaseApiClient):
1140
1133
  if not self._ws_subscriptions:
1141
1134
  self._get_websocket().stop()
1142
1135
 
1136
+ def subscribe_websocket_state(
1137
+ self,
1138
+ ws_callback: Callable[[WebsocketState], None],
1139
+ ) -> Callable[[], None]:
1140
+ """
1141
+ Subscribe to websocket state changes.
1142
+
1143
+ Returns a callback that will unsubscribe.
1144
+ """
1145
+ self._ws_state_subscriptions.append(ws_callback)
1146
+ return partial(self._unsubscribe_websocket_state, ws_callback)
1147
+
1148
+ def _unsubscribe_websocket_state(
1149
+ self,
1150
+ ws_callback: Callable[[WebsocketState], None],
1151
+ ) -> None:
1152
+ """Unsubscribe to websocket state changes."""
1153
+ self._ws_state_subscriptions.remove(ws_callback)
1154
+
1155
+ def _on_websocket_state_change(self, state: WebsocketState) -> None:
1156
+ """Websocket state changed."""
1157
+ super()._on_websocket_state_change(state)
1158
+ for sub in self._ws_state_subscriptions:
1159
+ try:
1160
+ sub(state)
1161
+ except Exception:
1162
+ _LOGGER.exception("Exception while running websocket state handler")
1163
+
1143
1164
  async def get_bootstrap(self) -> Bootstrap:
1144
1165
  """
1145
1166
  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