uiprotect 1.20.0__py3-none-any.whl → 2.0.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 CHANGED
@@ -11,7 +11,7 @@ import sys
11
11
  import time
12
12
  from collections.abc import Callable
13
13
  from datetime import datetime, timedelta
14
- from functools import cached_property
14
+ from functools import cached_property, partial
15
15
  from http.cookies import Morsel, SimpleCookie
16
16
  from ipaddress import IPv4Address, IPv6Address
17
17
  from pathlib import Path
@@ -203,6 +203,7 @@ class BaseApiClient:
203
203
  self._verify_ssl = verify_ssl
204
204
  self._ws_timeout = ws_timeout
205
205
  self._loaded_session = False
206
+ self._update_task: asyncio.Task[Bootstrap | None] | None = None
206
207
 
207
208
  self.config_dir = config_dir or (Path(user_config_dir()) / "ufp")
208
209
  self.cache_dir = cache_dir or (Path(user_cache_dir()) / "ufp_cache")
@@ -253,37 +254,56 @@ class BaseApiClient:
253
254
 
254
255
  return self._session
255
256
 
256
- async def get_websocket(self) -> Websocket:
257
- """Gets or creates current Websocket."""
258
-
259
- async def _auth(force: bool) -> dict[str, str] | None:
260
- if force:
261
- if self._session is not None:
262
- self._session.cookie_jar.clear()
263
- self.set_header("cookie", None)
264
- self.set_header("x-csrf-token", None)
257
+ async def _auth_websocket(self, force: bool) -> dict[str, str] | None:
258
+ """Authenticate for Websocket."""
259
+ if force:
260
+ if self._session is not None:
261
+ self._session.cookie_jar.clear()
262
+ self.set_header("cookie", None)
263
+ self.set_header("x-csrf-token", None)
264
+ self._is_authenticated = False
265
265
 
266
- await self.ensure_authenticated()
267
- return self.headers
266
+ await self.ensure_authenticated()
267
+ return self.headers
268
268
 
269
+ def _get_websocket(self) -> Websocket:
270
+ """Gets or creates current Websocket."""
269
271
  if self._websocket is None:
270
272
  self._websocket = Websocket(
271
273
  self.get_websocket_url,
272
- _auth,
274
+ self._auth_websocket,
275
+ self._update_bootstrap_soon,
276
+ self.get_session,
277
+ self._process_ws_message,
273
278
  verify=self._verify_ssl,
274
279
  timeout=self._ws_timeout,
275
280
  )
276
- self._websocket.subscribe(self._process_ws_message)
277
-
278
281
  return self._websocket
279
282
 
283
+ def _update_bootstrap_soon(self) -> None:
284
+ """Update bootstrap soon."""
285
+ _LOGGER.debug("Updating bootstrap soon")
286
+ # Force the next bootstrap update
287
+ # since the lastUpdateId is not valid anymore
288
+ if self._update_task and not self._update_task.done():
289
+ return
290
+ self._update_task = asyncio.create_task(self.update(force=True))
291
+
280
292
  async def close_session(self) -> None:
281
- """Closing and delets client session"""
293
+ """Closing and deletes client session"""
294
+ await self._cancel_update_task()
282
295
  if self._session is not None:
283
296
  await self._session.close()
284
297
  self._session = None
285
298
  self._loaded_session = False
286
299
 
300
+ async def _cancel_update_task(self) -> None:
301
+ if self._update_task:
302
+ self._update_task.cancel()
303
+ with contextlib.suppress(asyncio.CancelledError):
304
+ await self._update_task
305
+ self._update_task = None
306
+
287
307
  def set_header(self, key: str, value: str | None) -> None:
288
308
  """Set header."""
289
309
  self.headers = self.headers or {}
@@ -413,8 +433,13 @@ class BaseApiClient:
413
433
  )
414
434
 
415
435
  if data is not None:
416
- json_data: list[Any] | dict[str, Any] = orjson.loads(data)
417
- return json_data
436
+ json_data: list[Any] | dict[str, Any]
437
+ try:
438
+ json_data = orjson.loads(data)
439
+ return json_data
440
+ except orjson.JSONDecodeError as ex:
441
+ _LOGGER.error("Could not decode JSON from %s", url)
442
+ raise NvrError(f"Could not decode JSON from {url}") from ex
418
443
  return None
419
444
 
420
445
  async def api_request_obj(
@@ -620,31 +645,17 @@ class BaseApiClient:
620
645
 
621
646
  return token_expires_at >= max_expire_time
622
647
 
623
- async def async_connect_ws(self, force: bool) -> None:
624
- """Connect to Websocket."""
625
- if force and self._websocket is not None:
626
- await self._websocket.disconnect()
627
- self._websocket = None
628
-
629
- websocket = await self.get_websocket()
630
- if not websocket.is_connected:
631
- self._last_ws_status = False
632
- with contextlib.suppress(
633
- TimeoutError,
634
- asyncio.TimeoutError,
635
- asyncio.CancelledError,
636
- ):
637
- await websocket.connect()
638
-
639
648
  def get_websocket_url(self) -> str:
640
649
  """Get Websocket URL."""
641
650
  return self.ws_url
642
651
 
643
652
  async def async_disconnect_ws(self) -> None:
644
653
  """Disconnect from Websocket."""
645
- if self._websocket is None:
646
- return
647
- await self._websocket.disconnect()
654
+ if self._websocket:
655
+ websocket = self._get_websocket()
656
+ websocket.stop()
657
+ await websocket.wait_closed()
658
+ self._websocket = None
648
659
 
649
660
  def check_ws(self) -> bool:
650
661
  """Checks current state of Websocket."""
@@ -668,6 +679,9 @@ class BaseApiClient:
668
679
  def _get_last_update_id(self) -> str | None:
669
680
  raise NotImplementedError
670
681
 
682
+ async def update(self, force: bool = False) -> Bootstrap | None:
683
+ raise NotImplementedError
684
+
671
685
 
672
686
  class ProtectApiClient(BaseApiClient):
673
687
  """
@@ -748,6 +762,7 @@ class ProtectApiClient(BaseApiClient):
748
762
  self._ignore_stats = ignore_stats
749
763
  self._ws_subscriptions = []
750
764
  self.ignore_unadopted = ignore_unadopted
765
+ self._update_lock = asyncio.Lock()
751
766
 
752
767
  if override_connection_host:
753
768
  self._connection_host = ip_from_host(self._host)
@@ -788,41 +803,37 @@ class ProtectApiClient(BaseApiClient):
788
803
 
789
804
  You can use the various other `get_` methods if you need one off data from UFP
790
805
  """
791
- now = time.monotonic()
792
- now_dt = utc_now()
793
- max_event_dt = now_dt - timedelta(hours=1)
794
- if force:
795
- self._last_update = NEVER_RAN
796
- self._last_update_dt = max_event_dt
797
-
798
- bootstrap_updated = False
799
- if self._bootstrap is None or now - self._last_update > DEVICE_UPDATE_INTERVAL:
800
- bootstrap_updated = True
801
- self._bootstrap = await self.get_bootstrap()
802
- self.__dict__.pop("bootstrap", None)
803
- self._last_update = now
804
- self._last_update_dt = now_dt
806
+ async with self._update_lock:
807
+ now = time.monotonic()
808
+ if force:
809
+ self._last_update = NEVER_RAN
805
810
 
806
- await self.async_connect_ws(force)
807
- if self.check_ws():
808
- # If the websocket is connected/connecting
809
- # we do not need to get events
810
- _LOGGER.debug("Skipping update since websocket is active")
811
- return None
811
+ bootstrap_updated = False
812
+ if (
813
+ self._bootstrap is None
814
+ or now - self._last_update > DEVICE_UPDATE_INTERVAL
815
+ ):
816
+ bootstrap_updated = True
817
+ self._bootstrap = await self.get_bootstrap()
818
+ self.__dict__.pop("bootstrap", None)
819
+ self._last_update = now
812
820
 
813
- if bootstrap_updated:
814
- return None
821
+ if bootstrap_updated:
822
+ return None
823
+ self._last_update = now
824
+ return self._bootstrap
815
825
 
826
+ async def poll_events(self) -> None:
827
+ """Poll for events."""
828
+ now_dt = utc_now()
829
+ max_event_dt = now_dt - timedelta(hours=1)
816
830
  events = await self.get_events(
817
831
  start=self._last_update_dt or max_event_dt,
818
832
  end=now_dt,
819
833
  )
820
834
  for event in events:
821
835
  self.bootstrap.process_event(event)
822
-
823
- self._last_update = now
824
836
  self._last_update_dt = now_dt
825
- return self._bootstrap
826
837
 
827
838
  def emit_message(self, msg: WSSubscriptionMessage) -> None:
828
839
  """Emit message to all subscriptions."""
@@ -1108,13 +1119,20 @@ class ProtectApiClient(BaseApiClient):
1108
1119
 
1109
1120
  Returns a callback that will unsubscribe.
1110
1121
  """
1111
-
1112
- def _unsub_ws_callback() -> None:
1113
- self._ws_subscriptions.remove(ws_callback)
1114
-
1115
1122
  _LOGGER.debug("Adding subscription: %s", ws_callback)
1116
1123
  self._ws_subscriptions.append(ws_callback)
1117
- return _unsub_ws_callback
1124
+ self._get_websocket().start()
1125
+ return partial(self._unsubscribe_websocket, ws_callback)
1126
+
1127
+ def _unsubscribe_websocket(
1128
+ self,
1129
+ ws_callback: Callable[[WSSubscriptionMessage], None],
1130
+ ) -> None:
1131
+ """Unsubscribe to websocket events."""
1132
+ _LOGGER.debug("Removing subscription: %s", ws_callback)
1133
+ self._ws_subscriptions.remove(ws_callback)
1134
+ if not self._ws_subscriptions:
1135
+ self._get_websocket().stop()
1118
1136
 
1119
1137
  async def get_bootstrap(self) -> Bootstrap:
1120
1138
  """
uiprotect/cli/__init__.py CHANGED
@@ -201,6 +201,7 @@ def shell(ctx: typer.Context) -> None:
201
201
 
202
202
  async def wait_forever() -> None:
203
203
  await protect.update()
204
+ protect.subscribe_websocket(lambda _: None)
204
205
  while True:
205
206
  await asyncio.sleep(10)
206
207
  await protect.update()
@@ -262,12 +263,16 @@ def profile_ws(
262
263
 
263
264
  async def callback() -> None:
264
265
  await protect.update()
266
+ unsub = protect.subscribe_websocket(lambda _: None)
265
267
  await profile_ws_job(
266
268
  protect,
267
269
  wait_time,
268
270
  output_path=output_path,
269
271
  ws_progress=_progress_bar,
270
272
  )
273
+ unsub()
274
+ await protect.async_disconnect_ws()
275
+ await protect.close_session()
271
276
 
272
277
  _setup_logger()
273
278
 
@@ -103,8 +103,10 @@ class SampleDataGenerator:
103
103
  async def async_generate(self, close_session: bool = True) -> None:
104
104
  self.log(f"Output folder: {self.output_folder}")
105
105
  self.output_folder.mkdir(parents=True, exist_ok=True)
106
- websocket = await self.client.get_websocket()
107
- websocket.subscribe(self._handle_ws_message)
106
+ websocket = self.client._get_websocket()
107
+ websocket.start()
108
+ self.log("Websocket started...")
109
+ websocket._subscription = self._handle_ws_message
108
110
 
109
111
  self.log("Updating devices...")
110
112
  await self.client.update()
@@ -131,8 +133,10 @@ class SampleDataGenerator:
131
133
  "chime": len(bootstrap["chimes"]),
132
134
  }
133
135
 
136
+ self.log("Generating event data...")
134
137
  motion_event, smart_detection = await self.generate_event_data()
135
138
  await self.generate_device_data(motion_event, smart_detection)
139
+ self.log("Recording websocket events...")
136
140
  await self.record_ws_events()
137
141
 
138
142
  if close_session:
uiprotect/websocket.py CHANGED
@@ -3,9 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextlib
6
7
  import logging
7
- import time
8
- from collections.abc import Callable, Coroutine
8
+ from collections.abc import Awaitable, Callable, Coroutine
9
+ from http import HTTPStatus
9
10
  from typing import Any, Optional
10
11
 
11
12
  from aiohttp import (
@@ -14,213 +15,169 @@ from aiohttp import (
14
15
  ClientWebSocketResponse,
15
16
  WSMessage,
16
17
  WSMsgType,
18
+ WSServerHandshakeError,
17
19
  )
18
20
 
19
- from .utils import asyncio_timeout
20
-
21
21
  _LOGGER = logging.getLogger(__name__)
22
- CALLBACK_TYPE = Callable[..., Coroutine[Any, Any, Optional[dict[str, str]]]]
23
- RECENT_FAILURE_CUT_OFF = 30
24
- RECENT_FAILURE_THRESHOLD = 2
22
+ AuthCallbackType = Callable[..., Coroutine[Any, Any, Optional[dict[str, str]]]]
23
+ GetSessionCallbackType = Callable[[], Awaitable[ClientSession]]
24
+ UpdateBootstrapCallbackType = Callable[[], None]
25
+ _CLOSE_MESSAGE_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED}
25
26
 
26
27
 
27
28
  class Websocket:
28
29
  """UniFi Protect Websocket manager."""
29
30
 
30
- url: str
31
- verify: bool
32
- timeout_interval: int
33
- backoff: int
34
- _auth: CALLBACK_TYPE
35
- _timeout: float
36
- _ws_subscriptions: list[Callable[[WSMessage], None]]
37
- _connect_lock: asyncio.Lock
38
-
31
+ _running = False
39
32
  _headers: dict[str, str] | None = None
40
33
  _websocket_loop_task: asyncio.Task[None] | None = None
41
- _timer_task: asyncio.Task[None] | None = None
34
+ _stop_task: asyncio.Task[None] | None = None
42
35
  _ws_connection: ClientWebSocketResponse | None = None
43
- _last_connect: float = -1000
44
- _recent_failures: int = 0
45
36
 
46
37
  def __init__(
47
38
  self,
48
39
  get_url: Callable[[], str],
49
- auth_callback: CALLBACK_TYPE,
40
+ auth_callback: AuthCallbackType,
41
+ update_bootstrap_callback: UpdateBootstrapCallbackType,
42
+ get_session: GetSessionCallbackType,
43
+ subscription: Callable[[WSMessage], None],
50
44
  *,
51
- timeout: int = 30,
45
+ timeout: float = 30.0,
52
46
  backoff: int = 10,
53
47
  verify: bool = True,
54
48
  ) -> None:
55
49
  """Init Websocket."""
56
50
  self.get_url = get_url
57
- self.timeout_interval = timeout
51
+ self.timeout = timeout
58
52
  self.backoff = backoff
59
53
  self.verify = verify
54
+ self._get_session = get_session
60
55
  self._auth = auth_callback
61
- self._timeout = time.monotonic()
62
- self._ws_subscriptions = []
56
+ self._update_bootstrap_callback = update_bootstrap_callback
63
57
  self._connect_lock = asyncio.Lock()
58
+ self._subscription = subscription
59
+ self._last_ws_connect_ok = False
64
60
 
65
61
  @property
66
62
  def is_connected(self) -> bool:
67
- """Check if Websocket connected."""
68
- return self._ws_connection is not None
69
-
70
- def _get_session(self) -> ClientSession:
71
- # for testing, to make easier to mock
72
- return ClientSession()
63
+ """Return if the websocket is connected."""
64
+ return self._ws_connection is not None and not self._ws_connection.closed
73
65
 
74
- def _process_message(self, msg: WSMessage) -> bool:
75
- if msg.type == WSMsgType.ERROR:
76
- _LOGGER.exception("Error from Websocket: %s", msg.data)
77
- return False
66
+ async def _websocket_reconnect_loop(self) -> None:
67
+ """Reconnect loop for websocket."""
68
+ await self.wait_closed()
69
+ backoff = self.backoff
78
70
 
79
- for sub in self._ws_subscriptions:
71
+ while True:
80
72
  try:
81
- sub(msg)
73
+ await self._websocket_loop()
74
+ except ClientError:
75
+ _LOGGER.debug("Error in websocket reconnect loop, backoff: %s", backoff)
82
76
  except Exception:
83
- _LOGGER.exception("Error processing websocket message")
77
+ _LOGGER.debug(
78
+ "Error in websocket reconnect loop, backoff: %s",
79
+ backoff,
80
+ exc_info=True,
81
+ )
84
82
 
85
- return True
83
+ if self._running is False:
84
+ break
85
+ await asyncio.sleep(self.backoff)
86
86
 
87
- async def _websocket_loop(self, start_event: asyncio.Event) -> None:
87
+ async def _websocket_loop(self) -> None:
88
88
  url = self.get_url()
89
89
  _LOGGER.debug("Connecting WS to %s", url)
90
- self._headers = await self._auth(self._should_reset_auth)
91
-
92
- session = self._get_session()
90
+ self._headers = await self._auth(False)
91
+ ssl = None if self.verify else False
92
+ msg: WSMessage | None = None
93
+ seen_non_close_message = False
93
94
  # catch any and all errors for Websocket so we can clean up correctly
94
95
  try:
96
+ session = await self._get_session()
95
97
  self._ws_connection = await session.ws_connect(
96
- url,
97
- ssl=None if self.verify else False,
98
- headers=self._headers,
98
+ url, ssl=ssl, headers=self._headers, timeout=self.timeout
99
99
  )
100
- start_event.set()
101
-
102
- self._reset_timeout()
103
- async for msg in self._ws_connection:
104
- if not self._process_message(msg):
100
+ self._last_ws_connect_ok = True
101
+ while True:
102
+ msg = await self._ws_connection.receive(self.timeout)
103
+ msg_type = msg.type
104
+ if msg_type is WSMsgType.ERROR:
105
+ _LOGGER.exception("Error from Websocket: %s", msg.data)
105
106
  break
106
- self._reset_timeout()
107
+ elif msg_type in _CLOSE_MESSAGE_TYPES:
108
+ _LOGGER.debug("Websocket closed: %s", msg)
109
+ break
110
+
111
+ seen_non_close_message = True
112
+ try:
113
+ self._subscription(msg)
114
+ except Exception:
115
+ _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
107
127
  except ClientError:
108
- _LOGGER.exception("Websocket disconnect error: %s", url)
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
109
132
  finally:
110
- _LOGGER.debug("Websocket disconnected")
111
- self._increase_failure()
112
- self._cancel_timeout()
133
+ if (
134
+ msg is not None
135
+ and msg.type is WSMsgType.CLOSE
136
+ # If it closes right away or lastUpdateId is in the extra
137
+ # its an indication that we should update the bootstrap
138
+ # since lastUpdateId is invalid
139
+ and (
140
+ not seen_non_close_message
141
+ or (msg.extra and "lastUpdateId" in msg.extra)
142
+ )
143
+ ):
144
+ self._update_bootstrap_callback()
145
+ _LOGGER.debug("Websocket disconnected: last message: %s", msg)
113
146
  if self._ws_connection is not None and not self._ws_connection.closed:
114
147
  await self._ws_connection.close()
115
- if not session.closed:
116
- await session.close()
117
148
  self._ws_connection = None
118
- # make sure event does not timeout
119
- start_event.set()
120
-
121
- @property
122
- def has_recent_connect(self) -> bool:
123
- """Check if Websocket has recent connection."""
124
- return time.monotonic() - RECENT_FAILURE_CUT_OFF <= self._last_connect
125
-
126
- @property
127
- def _should_reset_auth(self) -> bool:
128
- if self.has_recent_connect:
129
- if self._recent_failures > RECENT_FAILURE_THRESHOLD:
130
- return True
131
- else:
132
- self._recent_failures = 0
133
- return False
134
-
135
- def _increase_failure(self) -> None:
136
- if self.has_recent_connect:
137
- self._recent_failures += 1
138
- else:
139
- self._recent_failures = 1
140
-
141
- async def _do_timeout(self) -> bool:
142
- _LOGGER.debug("WS timed out")
143
- return await self.reconnect()
144
-
145
- async def _timeout_loop(self) -> None:
146
- while True:
147
- now = time.monotonic()
148
- if now > self._timeout:
149
- _LOGGER.debug("WS timed out")
150
- if not await self.reconnect():
151
- _LOGGER.debug("WS could not reconnect")
152
- continue
153
- sleep_time = self._timeout - now
154
- _LOGGER.debug("WS Timeout loop sleep %s", sleep_time)
155
- await asyncio.sleep(sleep_time)
156
-
157
- def _reset_timeout(self) -> None:
158
- self._timeout = time.monotonic() + self.timeout_interval
159
-
160
- if self._timer_task is None:
161
- self._timer_task = asyncio.create_task(self._timeout_loop())
162
-
163
- def _cancel_timeout(self) -> None:
164
- if self._timer_task:
165
- self._timer_task.cancel()
166
-
167
- async def connect(self) -> bool:
168
- """Connect the websocket."""
169
- if self._connect_lock.locked():
170
- _LOGGER.debug("Another connect is already happening")
171
- return False
172
- try:
173
- async with asyncio_timeout(0.1):
174
- await self._connect_lock.acquire()
175
- except (TimeoutError, asyncio.TimeoutError, asyncio.CancelledError):
176
- _LOGGER.debug("Failed to get connection lock")
177
149
 
178
- start_event = asyncio.Event()
179
- _LOGGER.debug("Scheduling WS connect...")
150
+ def start(self) -> None:
151
+ """Start the websocket."""
152
+ if self._running:
153
+ return
154
+ self._running = True
180
155
  self._websocket_loop_task = asyncio.create_task(
181
- self._websocket_loop(start_event),
156
+ self._websocket_reconnect_loop()
182
157
  )
183
158
 
184
- try:
185
- async with asyncio_timeout(self.timeout_interval):
186
- await start_event.wait()
187
- except (TimeoutError, asyncio.TimeoutError, asyncio.CancelledError):
188
- _LOGGER.warning("Timed out while waiting for Websocket to connect")
189
- await self.disconnect()
190
-
191
- self._connect_lock.release()
192
- if self._ws_connection is None:
193
- _LOGGER.debug("Failed to connect to Websocket")
194
- return False
195
- _LOGGER.debug("Connected to Websocket successfully")
196
- self._last_connect = time.monotonic()
197
- return True
198
-
199
- async def disconnect(self) -> None:
159
+ def stop(self) -> None:
200
160
  """Disconnect the websocket."""
201
161
  _LOGGER.debug("Disconnecting websocket...")
202
- if self._ws_connection is None:
162
+ if not self._running:
203
163
  return
204
- await self._ws_connection.close()
205
- self._ws_connection = None
206
-
207
- async def reconnect(self) -> bool:
208
- """Reconnect the websocket."""
209
- _LOGGER.debug("Reconnecting websocket...")
210
- await self.disconnect()
211
- await asyncio.sleep(self.backoff)
212
- return await self.connect()
213
-
214
- def subscribe(self, ws_callback: Callable[[WSMessage], None]) -> Callable[[], None]:
215
- """
216
- Subscribe to raw websocket messages.
217
-
218
- Returns a callback that will unsubscribe.
219
- """
220
-
221
- def _unsub_ws_callback() -> None:
222
- self._ws_subscriptions.remove(ws_callback)
223
-
224
- _LOGGER.debug("Adding subscription: %s", ws_callback)
225
- self._ws_subscriptions.append(ws_callback)
226
- return _unsub_ws_callback
164
+ if self._websocket_loop_task:
165
+ self._websocket_loop_task.cancel()
166
+ self._running = False
167
+ self._stop_task = asyncio.create_task(self._stop())
168
+
169
+ async def wait_closed(self) -> None:
170
+ """Wait for the websocket to close."""
171
+ if self._stop_task:
172
+ with contextlib.suppress(asyncio.CancelledError):
173
+ await self._stop_task
174
+
175
+ async def _stop(self) -> None:
176
+ """Stop the websocket."""
177
+ if self._ws_connection:
178
+ await self._ws_connection.close()
179
+ self._ws_connection = None
180
+ if self._websocket_loop_task:
181
+ with contextlib.suppress(asyncio.CancelledError):
182
+ await self._websocket_loop_task
183
+ self._websocket_loop_task = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: uiprotect
3
- Version: 1.20.0
3
+ Version: 2.0.0
4
4
  Summary: Python API for Unifi Protect (Unofficial)
5
5
  Home-page: https://github.com/uilibs/uiprotect
6
6
  License: MIT
@@ -1,7 +1,7 @@
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=SvAni2arXGtP8HzvTljKNLclSfzpRL03RaEF8o1o6K4,65561
4
- uiprotect/cli/__init__.py,sha256=sSLW9keVQOkgFcMW18HTDjRrt9sJ0KWjn9DJDA6f9Pc,8658
3
+ uiprotect/api.py,sha256=gNTK6fU3Z7PQBfs6YNTXENFxXA6RL-ayaj7XiBTDeFg,66560
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
7
7
  uiprotect/cli/cameras.py,sha256=YvvMccQEYG3Wih0Ix8tan1R1vfaJ6cogg6YKWLzMUV8,16973
@@ -26,12 +26,12 @@ uiprotect/exceptions.py,sha256=kgn0cRM6lTtgLza09SDa3ZiX6ue1QqHCOogQ4qu6KTQ,965
26
26
  uiprotect/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
27
  uiprotect/release_cache.json,sha256=NamnSFy78hOWY0DPO87J9ELFCAN6NnVquv8gQO75ZG4,386
28
28
  uiprotect/stream.py,sha256=McV3XymKyjn-1uV5jdQHcpaDjqLS4zWyMASQ8ubcyb4,4924
29
- uiprotect/test_util/__init__.py,sha256=d2g7afa0LSdixQ0kjEDYwafDFME_UlW2LzxpamZ2BC0,18556
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=JHI_2EZeRPqPyQopsBZS0dr3tu0HaTiqeLazfBXhW_8,7339
33
- uiprotect-1.20.0.dist-info/LICENSE,sha256=INx18jhdbVXMEiiBANeKEbrbz57ckgzxk5uutmmcxGk,1111
34
- uiprotect-1.20.0.dist-info/METADATA,sha256=2tR-i6s0-c1E1PzpEfVbFq9V9jA2RjilNdhywYM9NW0,10983
35
- uiprotect-1.20.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
36
- uiprotect-1.20.0.dist-info/entry_points.txt,sha256=J78AUTPrTTxgI3s7SVgrmGqDP7piX2wuuEORzhDdVRA,47
37
- uiprotect-1.20.0.dist-info/RECORD,,
32
+ uiprotect/websocket.py,sha256=enGQoS-Li9iIxcYK3KrMYtCQ4kcR1lQugiSYwROIBkU,6742
33
+ uiprotect-2.0.0.dist-info/LICENSE,sha256=INx18jhdbVXMEiiBANeKEbrbz57ckgzxk5uutmmcxGk,1111
34
+ uiprotect-2.0.0.dist-info/METADATA,sha256=6yuY6tDONbYf7jA3k0DDDzy2pj1LqewxXEcGGi_0mX0,10982
35
+ uiprotect-2.0.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
36
+ uiprotect-2.0.0.dist-info/entry_points.txt,sha256=J78AUTPrTTxgI3s7SVgrmGqDP7piX2wuuEORzhDdVRA,47
37
+ uiprotect-2.0.0.dist-info/RECORD,,