uiprotect 1.19.3__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/__init__.py +16 -0
- uiprotect/api.py +85 -67
- uiprotect/cli/__init__.py +5 -0
- uiprotect/test_util/__init__.py +6 -2
- uiprotect/utils.py +56 -1
- uiprotect/websocket.py +116 -159
- {uiprotect-1.19.3.dist-info → uiprotect-2.0.0.dist-info}/METADATA +1 -1
- {uiprotect-1.19.3.dist-info → uiprotect-2.0.0.dist-info}/RECORD +11 -11
- {uiprotect-1.19.3.dist-info → uiprotect-2.0.0.dist-info}/LICENSE +0 -0
- {uiprotect-1.19.3.dist-info → uiprotect-2.0.0.dist-info}/WHEEL +0 -0
- {uiprotect-1.19.3.dist-info → uiprotect-2.0.0.dist-info}/entry_points.txt +0 -0
uiprotect/__init__.py
CHANGED
|
@@ -4,10 +4,26 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from .api import ProtectApiClient
|
|
6
6
|
from .exceptions import Invalid, NotAuthorized, NvrError
|
|
7
|
+
from .utils import (
|
|
8
|
+
get_nested_attr,
|
|
9
|
+
get_nested_attr_as_bool,
|
|
10
|
+
get_top_level_attr,
|
|
11
|
+
get_top_level_attr_as_bool,
|
|
12
|
+
make_enabled_getter,
|
|
13
|
+
make_required_getter,
|
|
14
|
+
make_value_getter,
|
|
15
|
+
)
|
|
7
16
|
|
|
8
17
|
__all__ = [
|
|
9
18
|
"Invalid",
|
|
10
19
|
"NotAuthorized",
|
|
11
20
|
"NvrError",
|
|
12
21
|
"ProtectApiClient",
|
|
22
|
+
"get_nested_attr",
|
|
23
|
+
"get_nested_attr_as_bool",
|
|
24
|
+
"get_top_level_attr",
|
|
25
|
+
"get_top_level_attr_as_bool",
|
|
26
|
+
"make_value_getter",
|
|
27
|
+
"make_enabled_getter",
|
|
28
|
+
"make_required_getter",
|
|
13
29
|
]
|
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
|
|
257
|
-
"""
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
417
|
-
|
|
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
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
814
|
-
|
|
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
|
-
|
|
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
|
|
uiprotect/test_util/__init__.py
CHANGED
|
@@ -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 =
|
|
107
|
-
websocket.
|
|
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/utils.py
CHANGED
|
@@ -17,11 +17,12 @@ from copy import deepcopy
|
|
|
17
17
|
from datetime import datetime, timedelta, timezone, tzinfo
|
|
18
18
|
from decimal import Decimal
|
|
19
19
|
from enum import Enum
|
|
20
|
-
from functools import cache, lru_cache
|
|
20
|
+
from functools import cache, lru_cache, partial
|
|
21
21
|
from hashlib import sha224
|
|
22
22
|
from http.cookies import Morsel
|
|
23
23
|
from inspect import isclass
|
|
24
24
|
from ipaddress import IPv4Address, IPv6Address, ip_address
|
|
25
|
+
from operator import attrgetter
|
|
25
26
|
from pathlib import Path
|
|
26
27
|
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
|
|
27
28
|
from uuid import UUID
|
|
@@ -619,3 +620,57 @@ def clamp_value(value: float, step_size: float) -> float:
|
|
|
619
620
|
def normalize_mac(mac: str) -> str:
|
|
620
621
|
"""Normalize MAC address."""
|
|
621
622
|
return mac.lower().replace(":", "").replace("-", "").replace("_", "")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
_SENTINEL = object()
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def get_nested_attr(attrs: tuple[str, ...], obj: Any) -> Any:
|
|
629
|
+
"""Fetch a nested attribute."""
|
|
630
|
+
value = obj
|
|
631
|
+
for key in attrs:
|
|
632
|
+
if (value := getattr(value, key, _SENTINEL)) is _SENTINEL:
|
|
633
|
+
return None
|
|
634
|
+
return value.value if isinstance(value, Enum) else value
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def get_nested_attr_as_bool(attrs: tuple[str, ...], obj: Any) -> bool:
|
|
638
|
+
"""Fetch a nested attribute as a bool."""
|
|
639
|
+
value = obj
|
|
640
|
+
for key in attrs:
|
|
641
|
+
if (value := getattr(value, key, _SENTINEL)) is _SENTINEL:
|
|
642
|
+
return False
|
|
643
|
+
return bool(value.value if isinstance(value, Enum) else value)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def get_top_level_attr(attr: str, obj: Any) -> Any:
|
|
647
|
+
"""Fetch a top level attribute."""
|
|
648
|
+
value = getattr(obj, attr)
|
|
649
|
+
return value.value if isinstance(value, Enum) else value
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def get_top_level_attr_as_bool(attr: str, obj: Any) -> Any:
|
|
653
|
+
"""Fetch a top level attribute as a bool."""
|
|
654
|
+
value = getattr(obj, attr)
|
|
655
|
+
return bool(value.value if isinstance(value, Enum) else value)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def make_value_getter(ufp_value: str) -> Callable[[T], Any]:
|
|
659
|
+
"""Return a function to get a value from a Protect device."""
|
|
660
|
+
if "." not in ufp_value:
|
|
661
|
+
return partial(get_top_level_attr, ufp_value)
|
|
662
|
+
return partial(get_nested_attr, tuple(ufp_value.split(".")))
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def make_enabled_getter(ufp_enabled: str) -> Callable[[T], bool]:
|
|
666
|
+
"""Return a function to get a value from a Protect device."""
|
|
667
|
+
if "." not in ufp_enabled:
|
|
668
|
+
return attrgetter(ufp_enabled)
|
|
669
|
+
return partial(get_nested_attr, tuple(ufp_enabled.split(".")))
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def make_required_getter(ufp_required_field: str) -> Callable[[T], bool]:
|
|
673
|
+
"""Return a function to get a value from a Protect device."""
|
|
674
|
+
if "." not in ufp_required_field:
|
|
675
|
+
return partial(get_top_level_attr_as_bool, ufp_required_field)
|
|
676
|
+
return partial(get_nested_attr_as_bool, tuple(ufp_required_field.split(".")))
|
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
|
|
8
|
-
from
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
40
|
+
auth_callback: AuthCallbackType,
|
|
41
|
+
update_bootstrap_callback: UpdateBootstrapCallbackType,
|
|
42
|
+
get_session: GetSessionCallbackType,
|
|
43
|
+
subscription: Callable[[WSMessage], None],
|
|
50
44
|
*,
|
|
51
|
-
timeout:
|
|
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.
|
|
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.
|
|
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
|
-
"""
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
71
|
+
while True:
|
|
80
72
|
try:
|
|
81
|
-
|
|
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.
|
|
77
|
+
_LOGGER.debug(
|
|
78
|
+
"Error in websocket reconnect loop, backoff: %s",
|
|
79
|
+
backoff,
|
|
80
|
+
exc_info=True,
|
|
81
|
+
)
|
|
84
82
|
|
|
85
|
-
|
|
83
|
+
if self._running is False:
|
|
84
|
+
break
|
|
85
|
+
await asyncio.sleep(self.backoff)
|
|
86
86
|
|
|
87
|
-
async def _websocket_loop(self
|
|
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(
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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.
|
|
156
|
+
self._websocket_reconnect_loop()
|
|
182
157
|
)
|
|
183
158
|
|
|
184
|
-
|
|
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.
|
|
162
|
+
if not self._running:
|
|
203
163
|
return
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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,7 +1,7 @@
|
|
|
1
|
-
uiprotect/__init__.py,sha256=
|
|
1
|
+
uiprotect/__init__.py,sha256=UdpRSSLSy7pdDfTKf0zRIfy6KRGt_Jv-fMzYWgibbG4,686
|
|
2
2
|
uiprotect/__main__.py,sha256=C_bHCOkv5qj6WMy-6ELoY3Y6HDhLxOa1a30CzmbZhsg,462
|
|
3
|
-
uiprotect/api.py,sha256=
|
|
4
|
-
uiprotect/cli/__init__.py,sha256=
|
|
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=
|
|
29
|
+
uiprotect/test_util/__init__.py,sha256=whiOUb5LfDLNT3AQG6ISiKtAqO2JnhCIdFavhWDK46M,18718
|
|
30
30
|
uiprotect/test_util/anonymize.py,sha256=f-8ijU-_y9r-uAbhIPn0f0I6hzJpAkvJzc8UpWihObI,8478
|
|
31
|
-
uiprotect/utils.py,sha256=
|
|
32
|
-
uiprotect/websocket.py,sha256=
|
|
33
|
-
uiprotect-
|
|
34
|
-
uiprotect-
|
|
35
|
-
uiprotect-
|
|
36
|
-
uiprotect-
|
|
37
|
-
uiprotect-
|
|
31
|
+
uiprotect/utils.py,sha256=3SJFF8qs1Jz8t3mD8qwc1hFSocolFjdXI_v4yVlC7o4,20088
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|