uiprotect 7.21.1__tar.gz → 7.23.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.
- {uiprotect-7.21.1 → uiprotect-7.23.0}/PKG-INFO +5 -4
- {uiprotect-7.21.1 → uiprotect-7.23.0}/pyproject.toml +8 -9
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/api.py +364 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/base.py +6 -8
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/types.py +28 -18
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/utils.py +6 -10
- {uiprotect-7.21.1 → uiprotect-7.23.0}/LICENSE +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/README.md +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/__init__.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/__main__.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/_compat.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/__init__.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/aiports.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/backup.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/base.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/cameras.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/chimes.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/doorlocks.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/events.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/lights.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/liveviews.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/nvr.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/sensors.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/cli/viewers.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/__init__.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/bootstrap.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/convert.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/devices.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/nvr.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/user.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/data/websocket.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/exceptions.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/py.typed +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/release_cache.json +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/stream.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/test_util/__init__.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/test_util/anonymize.py +0 -0
- {uiprotect-7.21.1 → uiprotect-7.23.0}/src/uiprotect/websocket.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: uiprotect
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.23.0
|
|
4
4
|
Summary: Python API for Unifi Protect (Unofficial)
|
|
5
|
-
License: MIT
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
6
7
|
Author: UI Protect Maintainers
|
|
7
8
|
Author-email: ui@koston.org
|
|
8
9
|
Requires-Python: >=3.10
|
|
9
10
|
Classifier: Development Status :: 5 - Production/Stable
|
|
10
11
|
Classifier: Intended Audience :: Developers
|
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
12
12
|
Classifier: Natural Language :: English
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
20
|
Classifier: Topic :: Software Development :: Build Tools
|
|
20
21
|
Classifier: Topic :: Software Development :: Libraries
|
|
21
22
|
Requires-Dist: aiofiles (>=24)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "uiprotect"
|
|
3
|
-
version = "7.
|
|
3
|
+
version = "7.23.0"
|
|
4
4
|
license = "MIT"
|
|
5
5
|
description = "Python API for Unifi Protect (Unofficial)"
|
|
6
6
|
authors = [{ name = "UI Protect Maintainers", email = "ui@koston.org" }]
|
|
@@ -52,17 +52,17 @@ pydantic-extra-types = ">=2.10.1"
|
|
|
52
52
|
|
|
53
53
|
[tool.poetry.group.dev.dependencies]
|
|
54
54
|
pytest = ">=7,<9"
|
|
55
|
-
pytest-cov = ">=3,<
|
|
55
|
+
pytest-cov = ">=3,<8"
|
|
56
56
|
aiosqlite = ">=0.20.0"
|
|
57
57
|
asttokens = ">=2.4.1,<4.0.0"
|
|
58
|
-
pytest-asyncio = ">=0.23.7,<1.
|
|
58
|
+
pytest-asyncio = ">=0.23.7,<1.3.0"
|
|
59
59
|
pytest-benchmark = ">=4,<6"
|
|
60
|
-
pytest-sugar = "^1.
|
|
60
|
+
pytest-sugar = "^1.1.1"
|
|
61
61
|
pytest-timeout = "^2.4.0"
|
|
62
62
|
pytest-xdist = "^3.7.0"
|
|
63
|
-
types-aiofiles = ">=23.2.0.20240403,<
|
|
63
|
+
types-aiofiles = ">=23.2.0.20240403,<26.0.0.0"
|
|
64
64
|
types-dateparser = "^1.2.2.20250809"
|
|
65
|
-
mypy = "^1.
|
|
65
|
+
mypy = "^1.18.2"
|
|
66
66
|
|
|
67
67
|
[tool.poetry.group.docs]
|
|
68
68
|
optional = true
|
|
@@ -73,13 +73,13 @@ sphinx = { version = ">=4.0", python = ">=3.11"}
|
|
|
73
73
|
furo = { version = ">=2023.5.20", python = ">=3.11"}
|
|
74
74
|
sphinx-autobuild = { version = ">=2024.0.0", python = ">=3.11"}
|
|
75
75
|
mike = "^2.1.1"
|
|
76
|
-
mkdocs-material = "^9.6.
|
|
76
|
+
mkdocs-material = "^9.6.21"
|
|
77
77
|
mkdocs-material-extensions = "^1.3.1"
|
|
78
78
|
pymdown-extensions = "^10.16.1"
|
|
79
79
|
mkdocs-git-revision-date-localized-plugin = "^1.4.7"
|
|
80
80
|
mkdocs-include-markdown-plugin = ">=6.1.1,<8.0.0"
|
|
81
81
|
mkdocstrings = ">=0.25.1,<0.31.0"
|
|
82
|
-
mkdocstrings-python = "^1.
|
|
82
|
+
mkdocstrings-python = "^1.18.2"
|
|
83
83
|
|
|
84
84
|
[tool.semantic_release]
|
|
85
85
|
version_toml = ["pyproject.toml:project.version"]
|
|
@@ -148,7 +148,6 @@ ignore = [
|
|
|
148
148
|
"S110", # `try`-`except`-`pass` detected, consider logging the exception
|
|
149
149
|
"D106", # Missing docstring in public nested class
|
|
150
150
|
"UP007", # typer needs Optional syntax
|
|
151
|
-
"UP038", # Use `X | Y` in `isinstance` is slower
|
|
152
151
|
"S603", # check for execution of untrusted input
|
|
153
152
|
"PERF203", # too many to fix right now
|
|
154
153
|
]
|
|
@@ -54,6 +54,7 @@ from .data import (
|
|
|
54
54
|
SmartDetectTrack,
|
|
55
55
|
Version,
|
|
56
56
|
Viewer,
|
|
57
|
+
WSAction,
|
|
57
58
|
WSPacket,
|
|
58
59
|
WSSubscriptionMessage,
|
|
59
60
|
create_from_unifi_dict,
|
|
@@ -217,10 +218,14 @@ class BaseApiClient:
|
|
|
217
218
|
|
|
218
219
|
headers: dict[str, str] | None = None
|
|
219
220
|
_private_websocket: Websocket | None = None
|
|
221
|
+
_events_websocket: Websocket | None = None
|
|
222
|
+
_devices_websocket: Websocket | None = None
|
|
220
223
|
|
|
221
224
|
private_api_path: str = "/proxy/protect/api/"
|
|
222
225
|
public_api_path: str = "/proxy/protect/integration"
|
|
223
226
|
private_ws_path: str = "/proxy/protect/ws/updates"
|
|
227
|
+
events_ws_path: str = "/proxy/protect/integration/v1/subscribe/events"
|
|
228
|
+
devices_ws_path: str = "/proxy/protect/integration/v1/subscribe/devices"
|
|
224
229
|
|
|
225
230
|
cache_dir: Path
|
|
226
231
|
config_dir: Path
|
|
@@ -276,9 +281,17 @@ class BaseApiClient:
|
|
|
276
281
|
if self._port != 443:
|
|
277
282
|
self._url = URL(f"https://{self._host}:{self._port}")
|
|
278
283
|
self._ws_url = URL(f"wss://{self._host}:{self._port}{self.private_ws_path}")
|
|
284
|
+
self._events_ws_url = URL(
|
|
285
|
+
f"https://{self._host}:{self._port}{self.events_ws_path}"
|
|
286
|
+
)
|
|
287
|
+
self._devices_ws_url = URL(
|
|
288
|
+
f"https://{self._host}:{self._port}{self.devices_ws_path}"
|
|
289
|
+
)
|
|
279
290
|
else:
|
|
280
291
|
self._url = URL(f"https://{self._host}")
|
|
281
292
|
self._ws_url = URL(f"wss://{self._host}{self.private_ws_path}")
|
|
293
|
+
self._events_ws_url = URL(f"https://{self._host}{self.events_ws_path}")
|
|
294
|
+
self._devices_ws_url = URL(f"https://{self._host}{self.devices_ws_path}")
|
|
282
295
|
|
|
283
296
|
self.base_url = str(self._url)
|
|
284
297
|
|
|
@@ -294,6 +307,16 @@ class BaseApiClient:
|
|
|
294
307
|
"""Get Websocket URL."""
|
|
295
308
|
return str(self._ws_url_object)
|
|
296
309
|
|
|
310
|
+
@property
|
|
311
|
+
def events_ws_url(self) -> str:
|
|
312
|
+
"""Get Events Websocket URL."""
|
|
313
|
+
return str(self._events_ws_url)
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def devices_ws_url(self) -> str:
|
|
317
|
+
"""Get Devices Websocket URL."""
|
|
318
|
+
return str(self._devices_ws_url)
|
|
319
|
+
|
|
297
320
|
@property
|
|
298
321
|
def config_file(self) -> Path:
|
|
299
322
|
return self.config_dir / "unifi_protect.json"
|
|
@@ -329,6 +352,15 @@ class BaseApiClient:
|
|
|
329
352
|
await self.ensure_authenticated()
|
|
330
353
|
return self.headers
|
|
331
354
|
|
|
355
|
+
async def _auth_public_api_websocket(
|
|
356
|
+
self, force: bool = False
|
|
357
|
+
) -> dict[str, str] | None:
|
|
358
|
+
"""Authenticate for Public API Websocket."""
|
|
359
|
+
if self._api_key is None:
|
|
360
|
+
raise NotAuthorized("API key is required for public API WebSocket")
|
|
361
|
+
|
|
362
|
+
return {"X-API-KEY": self._api_key}
|
|
363
|
+
|
|
332
364
|
def _get_websocket(self) -> Websocket:
|
|
333
365
|
"""Gets or creates current Websocket."""
|
|
334
366
|
if self._private_websocket is None:
|
|
@@ -345,6 +377,38 @@ class BaseApiClient:
|
|
|
345
377
|
)
|
|
346
378
|
return self._private_websocket
|
|
347
379
|
|
|
380
|
+
def _get_events_websocket(self) -> Websocket:
|
|
381
|
+
"""Gets or creates current Events Websocket."""
|
|
382
|
+
if self._events_websocket is None:
|
|
383
|
+
self._events_websocket = Websocket(
|
|
384
|
+
lambda: self._events_ws_url,
|
|
385
|
+
self._auth_public_api_websocket,
|
|
386
|
+
lambda: None,
|
|
387
|
+
self.get_public_api_session,
|
|
388
|
+
self._process_events_ws_message,
|
|
389
|
+
self._on_events_websocket_state_change,
|
|
390
|
+
verify=self._verify_ssl,
|
|
391
|
+
timeout=self._ws_timeout,
|
|
392
|
+
receive_timeout=self._ws_receive_timeout,
|
|
393
|
+
)
|
|
394
|
+
return self._events_websocket
|
|
395
|
+
|
|
396
|
+
def _get_devices_websocket(self) -> Websocket:
|
|
397
|
+
"""Gets or creates current Devices Websocket."""
|
|
398
|
+
if self._devices_websocket is None:
|
|
399
|
+
self._devices_websocket = Websocket(
|
|
400
|
+
lambda: self._devices_ws_url,
|
|
401
|
+
self._auth_public_api_websocket,
|
|
402
|
+
lambda: None,
|
|
403
|
+
self.get_public_api_session,
|
|
404
|
+
self._process_devices_ws_message,
|
|
405
|
+
self._on_devices_websocket_state_change,
|
|
406
|
+
verify=self._verify_ssl,
|
|
407
|
+
timeout=self._ws_timeout,
|
|
408
|
+
receive_timeout=self._ws_receive_timeout,
|
|
409
|
+
)
|
|
410
|
+
return self._devices_websocket
|
|
411
|
+
|
|
348
412
|
def _update_bootstrap_soon(self) -> None:
|
|
349
413
|
"""Update bootstrap soon."""
|
|
350
414
|
_LOGGER.debug("Updating bootstrap soon")
|
|
@@ -781,10 +845,28 @@ class BaseApiClient:
|
|
|
781
845
|
websocket.stop()
|
|
782
846
|
await websocket.wait_closed()
|
|
783
847
|
self._private_websocket = None
|
|
848
|
+
if self._events_websocket:
|
|
849
|
+
events_websocket = self._get_events_websocket()
|
|
850
|
+
events_websocket.stop()
|
|
851
|
+
await events_websocket.wait_closed()
|
|
852
|
+
self._events_websocket = None
|
|
853
|
+
if self._devices_websocket:
|
|
854
|
+
devices_websocket = self._get_devices_websocket()
|
|
855
|
+
devices_websocket.stop()
|
|
856
|
+
await devices_websocket.wait_closed()
|
|
857
|
+
self._devices_websocket = None
|
|
784
858
|
|
|
785
859
|
def _process_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
786
860
|
raise NotImplementedError
|
|
787
861
|
|
|
862
|
+
def _process_events_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
863
|
+
"""Process events websocket message - to be implemented by subclass."""
|
|
864
|
+
raise NotImplementedError
|
|
865
|
+
|
|
866
|
+
def _process_devices_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
867
|
+
"""Process devices websocket message - to be implemented by subclass."""
|
|
868
|
+
raise NotImplementedError
|
|
869
|
+
|
|
788
870
|
def _get_last_update_id(self) -> str | None:
|
|
789
871
|
raise NotImplementedError
|
|
790
872
|
|
|
@@ -795,6 +877,14 @@ class BaseApiClient:
|
|
|
795
877
|
"""Websocket state changed."""
|
|
796
878
|
_LOGGER.debug("Websocket state changed: %s", state)
|
|
797
879
|
|
|
880
|
+
def _on_events_websocket_state_change(self, state: WebsocketState) -> None:
|
|
881
|
+
"""Events websocket state changed."""
|
|
882
|
+
_LOGGER.debug("Events websocket state changed: %s", state)
|
|
883
|
+
|
|
884
|
+
def _on_devices_websocket_state_change(self, state: WebsocketState) -> None:
|
|
885
|
+
"""Devices websocket state changed."""
|
|
886
|
+
_LOGGER.debug("Devices websocket state changed: %s", state)
|
|
887
|
+
|
|
798
888
|
|
|
799
889
|
class ProtectApiClient(BaseApiClient):
|
|
800
890
|
"""
|
|
@@ -832,7 +922,11 @@ class ProtectApiClient(BaseApiClient):
|
|
|
832
922
|
_subscribed_models: set[ModelType]
|
|
833
923
|
_ignore_stats: bool
|
|
834
924
|
_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
925
|
+
_events_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
926
|
+
_devices_ws_subscriptions: list[Callable[[WSSubscriptionMessage], None]]
|
|
835
927
|
_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
|
|
928
|
+
_events_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
|
|
929
|
+
_devices_ws_state_subscriptions: list[Callable[[WebsocketState], None]]
|
|
836
930
|
_bootstrap: Bootstrap | None = None
|
|
837
931
|
_last_update_dt: datetime | None = None
|
|
838
932
|
_connection_host: IPv4Address | IPv6Address | str | None = None
|
|
@@ -881,7 +975,11 @@ class ProtectApiClient(BaseApiClient):
|
|
|
881
975
|
self._subscribed_models = subscribed_models or set()
|
|
882
976
|
self._ignore_stats = ignore_stats
|
|
883
977
|
self._ws_subscriptions = []
|
|
978
|
+
self._events_ws_subscriptions = []
|
|
979
|
+
self._devices_ws_subscriptions = []
|
|
884
980
|
self._ws_state_subscriptions = []
|
|
981
|
+
self._events_ws_state_subscriptions = []
|
|
982
|
+
self._devices_ws_state_subscriptions = []
|
|
885
983
|
self.ignore_unadopted = ignore_unadopted
|
|
886
984
|
self._update_lock = asyncio.Lock()
|
|
887
985
|
|
|
@@ -989,6 +1087,62 @@ class ProtectApiClient(BaseApiClient):
|
|
|
989
1087
|
except Exception:
|
|
990
1088
|
_LOGGER.exception("Exception while running subscription handler")
|
|
991
1089
|
|
|
1090
|
+
def emit_events_message(self, msg: WSSubscriptionMessage) -> None:
|
|
1091
|
+
"""Emit message to all events subscriptions."""
|
|
1092
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
1093
|
+
if msg.new_obj is not None:
|
|
1094
|
+
_LOGGER.debug(
|
|
1095
|
+
"emitting events message: %s:%s:%s:%s",
|
|
1096
|
+
msg.action,
|
|
1097
|
+
msg.new_obj.model,
|
|
1098
|
+
msg.new_obj.id,
|
|
1099
|
+
list(msg.changed_data),
|
|
1100
|
+
)
|
|
1101
|
+
elif msg.old_obj is not None:
|
|
1102
|
+
_LOGGER.debug(
|
|
1103
|
+
"emitting events message: %s:%s:%s",
|
|
1104
|
+
msg.action,
|
|
1105
|
+
msg.old_obj.model,
|
|
1106
|
+
msg.old_obj.id,
|
|
1107
|
+
)
|
|
1108
|
+
else:
|
|
1109
|
+
_LOGGER.debug("emitting events message: %s", msg.action)
|
|
1110
|
+
|
|
1111
|
+
for sub in self._events_ws_subscriptions:
|
|
1112
|
+
try:
|
|
1113
|
+
sub(msg)
|
|
1114
|
+
except Exception:
|
|
1115
|
+
_LOGGER.exception("Exception while running events subscription handler")
|
|
1116
|
+
|
|
1117
|
+
def emit_devices_message(self, msg: WSSubscriptionMessage) -> None:
|
|
1118
|
+
"""Emit message to all devices subscriptions."""
|
|
1119
|
+
if _LOGGER.isEnabledFor(logging.DEBUG):
|
|
1120
|
+
if msg.new_obj is not None:
|
|
1121
|
+
_LOGGER.debug(
|
|
1122
|
+
"emitting devices message: %s:%s:%s:%s",
|
|
1123
|
+
msg.action,
|
|
1124
|
+
msg.new_obj.model,
|
|
1125
|
+
msg.new_obj.id,
|
|
1126
|
+
list(msg.changed_data),
|
|
1127
|
+
)
|
|
1128
|
+
elif msg.old_obj is not None:
|
|
1129
|
+
_LOGGER.debug(
|
|
1130
|
+
"emitting devices message: %s:%s:%s",
|
|
1131
|
+
msg.action,
|
|
1132
|
+
msg.old_obj.model,
|
|
1133
|
+
msg.old_obj.id,
|
|
1134
|
+
)
|
|
1135
|
+
else:
|
|
1136
|
+
_LOGGER.debug("emitting devices message: %s", msg.action)
|
|
1137
|
+
|
|
1138
|
+
for sub in self._devices_ws_subscriptions:
|
|
1139
|
+
try:
|
|
1140
|
+
sub(msg)
|
|
1141
|
+
except Exception:
|
|
1142
|
+
_LOGGER.exception(
|
|
1143
|
+
"Exception while running devices subscription handler"
|
|
1144
|
+
)
|
|
1145
|
+
|
|
992
1146
|
def _get_last_update_id(self) -> str | None:
|
|
993
1147
|
if self._bootstrap is None:
|
|
994
1148
|
return None
|
|
@@ -1006,6 +1160,110 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1006
1160
|
|
|
1007
1161
|
self.emit_message(processed_message)
|
|
1008
1162
|
|
|
1163
|
+
def _process_events_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
1164
|
+
"""Process events websocket message (Public API - JSON format)."""
|
|
1165
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
1166
|
+
_LOGGER.debug("Ignoring non-text websocket message: %s", msg.type)
|
|
1167
|
+
return
|
|
1168
|
+
|
|
1169
|
+
try:
|
|
1170
|
+
data = orjson.loads(msg.data)
|
|
1171
|
+
action_type = data.get("type") # "update", "add", "remove"
|
|
1172
|
+
item = data.get("item", {})
|
|
1173
|
+
model_key = item.get("modelKey")
|
|
1174
|
+
|
|
1175
|
+
if not action_type or not model_key:
|
|
1176
|
+
_LOGGER.debug("Invalid public API websocket message: %s", data)
|
|
1177
|
+
return
|
|
1178
|
+
|
|
1179
|
+
# Create a WSSubscriptionMessage similar to private WS
|
|
1180
|
+
model_type = ModelType.from_string(model_key)
|
|
1181
|
+
|
|
1182
|
+
if model_type is ModelType.UNKNOWN:
|
|
1183
|
+
_LOGGER.debug("Unknown model type in public API message: %s", model_key)
|
|
1184
|
+
return
|
|
1185
|
+
|
|
1186
|
+
# Create proper objects from the data
|
|
1187
|
+
new_obj: ProtectModelWithId | None = None
|
|
1188
|
+
old_obj: ProtectModelWithId | None = None
|
|
1189
|
+
update_id = item.get("id", "")
|
|
1190
|
+
|
|
1191
|
+
if action_type in ("add", "update"):
|
|
1192
|
+
try:
|
|
1193
|
+
new_obj = cast(
|
|
1194
|
+
ProtectModelWithId, create_from_unifi_dict(item, api=self)
|
|
1195
|
+
)
|
|
1196
|
+
except Exception:
|
|
1197
|
+
_LOGGER.debug(
|
|
1198
|
+
"Could not create object from public API data: %s", item
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
msg_obj = WSSubscriptionMessage(
|
|
1202
|
+
action=WSAction(action_type),
|
|
1203
|
+
new_update_id=update_id,
|
|
1204
|
+
changed_data=item,
|
|
1205
|
+
new_obj=new_obj,
|
|
1206
|
+
old_obj=old_obj,
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
self.emit_events_message(msg_obj)
|
|
1210
|
+
except Exception as e:
|
|
1211
|
+
_LOGGER.exception(
|
|
1212
|
+
"Error processing public API events websocket message: %s", e
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
def _process_devices_ws_message(self, msg: aiohttp.WSMessage) -> None:
|
|
1216
|
+
"""Process devices websocket message (Public API - JSON format)."""
|
|
1217
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
1218
|
+
_LOGGER.debug("Ignoring non-text websocket message: %s", msg.type)
|
|
1219
|
+
return
|
|
1220
|
+
|
|
1221
|
+
try:
|
|
1222
|
+
data = orjson.loads(msg.data)
|
|
1223
|
+
action_type = data.get("type") # "update", "add", "remove"
|
|
1224
|
+
item = data.get("item", {})
|
|
1225
|
+
model_key = item.get("modelKey")
|
|
1226
|
+
|
|
1227
|
+
if not action_type or not model_key:
|
|
1228
|
+
_LOGGER.debug("Invalid public API websocket message: %s", data)
|
|
1229
|
+
return
|
|
1230
|
+
|
|
1231
|
+
# Create a WSSubscriptionMessage similar to private WS
|
|
1232
|
+
model_type = ModelType.from_string(model_key)
|
|
1233
|
+
|
|
1234
|
+
if model_type is ModelType.UNKNOWN:
|
|
1235
|
+
_LOGGER.debug("Unknown model type in public API message: %s", model_key)
|
|
1236
|
+
return
|
|
1237
|
+
|
|
1238
|
+
# Create proper objects from the data
|
|
1239
|
+
new_obj: ProtectModelWithId | None = None
|
|
1240
|
+
old_obj: ProtectModelWithId | None = None
|
|
1241
|
+
update_id = item.get("id", "")
|
|
1242
|
+
|
|
1243
|
+
if action_type in ("add", "update"):
|
|
1244
|
+
try:
|
|
1245
|
+
new_obj = cast(
|
|
1246
|
+
ProtectModelWithId, create_from_unifi_dict(item, api=self)
|
|
1247
|
+
)
|
|
1248
|
+
except Exception:
|
|
1249
|
+
_LOGGER.debug(
|
|
1250
|
+
"Could not create object from public API data: %s", item
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
msg_obj = WSSubscriptionMessage(
|
|
1254
|
+
action=WSAction(action_type),
|
|
1255
|
+
new_update_id=update_id,
|
|
1256
|
+
changed_data=item,
|
|
1257
|
+
new_obj=new_obj,
|
|
1258
|
+
old_obj=old_obj,
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
self.emit_devices_message(msg_obj)
|
|
1262
|
+
except Exception as e:
|
|
1263
|
+
_LOGGER.exception(
|
|
1264
|
+
"Error processing public API devices websocket message: %s", e
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1009
1267
|
async def _get_event_paginate(
|
|
1010
1268
|
self,
|
|
1011
1269
|
params: dict[str, Any],
|
|
@@ -1251,6 +1509,34 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1251
1509
|
self._get_websocket().start()
|
|
1252
1510
|
return partial(self._unsubscribe_websocket, ws_callback)
|
|
1253
1511
|
|
|
1512
|
+
def subscribe_events_websocket(
|
|
1513
|
+
self,
|
|
1514
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1515
|
+
) -> Callable[[], None]:
|
|
1516
|
+
"""
|
|
1517
|
+
Subscribe to events websocket events.
|
|
1518
|
+
|
|
1519
|
+
Returns a callback that will unsubscribe.
|
|
1520
|
+
"""
|
|
1521
|
+
_LOGGER.debug("Adding events subscription: %s", ws_callback)
|
|
1522
|
+
self._events_ws_subscriptions.append(ws_callback)
|
|
1523
|
+
self._get_events_websocket().start()
|
|
1524
|
+
return partial(self._unsubscribe_events_websocket, ws_callback)
|
|
1525
|
+
|
|
1526
|
+
def subscribe_devices_websocket(
|
|
1527
|
+
self,
|
|
1528
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1529
|
+
) -> Callable[[], None]:
|
|
1530
|
+
"""
|
|
1531
|
+
Subscribe to devices websocket events.
|
|
1532
|
+
|
|
1533
|
+
Returns a callback that will unsubscribe.
|
|
1534
|
+
"""
|
|
1535
|
+
_LOGGER.debug("Adding devices subscription: %s", ws_callback)
|
|
1536
|
+
self._devices_ws_subscriptions.append(ws_callback)
|
|
1537
|
+
self._get_devices_websocket().start()
|
|
1538
|
+
return partial(self._unsubscribe_devices_websocket, ws_callback)
|
|
1539
|
+
|
|
1254
1540
|
def _unsubscribe_websocket(
|
|
1255
1541
|
self,
|
|
1256
1542
|
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
@@ -1261,6 +1547,26 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1261
1547
|
if not self._ws_subscriptions:
|
|
1262
1548
|
self._get_websocket().stop()
|
|
1263
1549
|
|
|
1550
|
+
def _unsubscribe_events_websocket(
|
|
1551
|
+
self,
|
|
1552
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1553
|
+
) -> None:
|
|
1554
|
+
"""Unsubscribe to events websocket events."""
|
|
1555
|
+
_LOGGER.debug("Removing events subscription: %s", ws_callback)
|
|
1556
|
+
self._events_ws_subscriptions.remove(ws_callback)
|
|
1557
|
+
if not self._events_ws_subscriptions:
|
|
1558
|
+
self._get_events_websocket().stop()
|
|
1559
|
+
|
|
1560
|
+
def _unsubscribe_devices_websocket(
|
|
1561
|
+
self,
|
|
1562
|
+
ws_callback: Callable[[WSSubscriptionMessage], None],
|
|
1563
|
+
) -> None:
|
|
1564
|
+
"""Unsubscribe to devices websocket events."""
|
|
1565
|
+
_LOGGER.debug("Removing devices subscription: %s", ws_callback)
|
|
1566
|
+
self._devices_ws_subscriptions.remove(ws_callback)
|
|
1567
|
+
if not self._devices_ws_subscriptions:
|
|
1568
|
+
self._get_devices_websocket().stop()
|
|
1569
|
+
|
|
1264
1570
|
def subscribe_websocket_state(
|
|
1265
1571
|
self,
|
|
1266
1572
|
ws_callback: Callable[[WebsocketState], None],
|
|
@@ -1273,6 +1579,30 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1273
1579
|
self._ws_state_subscriptions.append(ws_callback)
|
|
1274
1580
|
return partial(self._unsubscribe_websocket_state, ws_callback)
|
|
1275
1581
|
|
|
1582
|
+
def subscribe_events_websocket_state(
|
|
1583
|
+
self,
|
|
1584
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1585
|
+
) -> Callable[[], None]:
|
|
1586
|
+
"""
|
|
1587
|
+
Subscribe to events websocket state changes.
|
|
1588
|
+
|
|
1589
|
+
Returns a callback that will unsubscribe.
|
|
1590
|
+
"""
|
|
1591
|
+
self._events_ws_state_subscriptions.append(ws_callback)
|
|
1592
|
+
return partial(self._unsubscribe_events_websocket_state, ws_callback)
|
|
1593
|
+
|
|
1594
|
+
def subscribe_devices_websocket_state(
|
|
1595
|
+
self,
|
|
1596
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1597
|
+
) -> Callable[[], None]:
|
|
1598
|
+
"""
|
|
1599
|
+
Subscribe to devices websocket state changes.
|
|
1600
|
+
|
|
1601
|
+
Returns a callback that will unsubscribe.
|
|
1602
|
+
"""
|
|
1603
|
+
self._devices_ws_state_subscriptions.append(ws_callback)
|
|
1604
|
+
return partial(self._unsubscribe_devices_websocket_state, ws_callback)
|
|
1605
|
+
|
|
1276
1606
|
def _unsubscribe_websocket_state(
|
|
1277
1607
|
self,
|
|
1278
1608
|
ws_callback: Callable[[WebsocketState], None],
|
|
@@ -1280,6 +1610,20 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1280
1610
|
"""Unsubscribe to websocket state changes."""
|
|
1281
1611
|
self._ws_state_subscriptions.remove(ws_callback)
|
|
1282
1612
|
|
|
1613
|
+
def _unsubscribe_events_websocket_state(
|
|
1614
|
+
self,
|
|
1615
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1616
|
+
) -> None:
|
|
1617
|
+
"""Unsubscribe to events websocket state changes."""
|
|
1618
|
+
self._events_ws_state_subscriptions.remove(ws_callback)
|
|
1619
|
+
|
|
1620
|
+
def _unsubscribe_devices_websocket_state(
|
|
1621
|
+
self,
|
|
1622
|
+
ws_callback: Callable[[WebsocketState], None],
|
|
1623
|
+
) -> None:
|
|
1624
|
+
"""Unsubscribe to devices websocket state changes."""
|
|
1625
|
+
self._devices_ws_state_subscriptions.remove(ws_callback)
|
|
1626
|
+
|
|
1283
1627
|
def _on_websocket_state_change(self, state: WebsocketState) -> None:
|
|
1284
1628
|
"""Websocket state changed."""
|
|
1285
1629
|
super()._on_websocket_state_change(state)
|
|
@@ -1289,6 +1633,26 @@ class ProtectApiClient(BaseApiClient):
|
|
|
1289
1633
|
except Exception:
|
|
1290
1634
|
_LOGGER.exception("Exception while running websocket state handler")
|
|
1291
1635
|
|
|
1636
|
+
def _on_events_websocket_state_change(self, state: WebsocketState) -> None:
|
|
1637
|
+
"""Events Websocket state changed."""
|
|
1638
|
+
for sub in self._events_ws_state_subscriptions:
|
|
1639
|
+
try:
|
|
1640
|
+
sub(state)
|
|
1641
|
+
except Exception:
|
|
1642
|
+
_LOGGER.exception(
|
|
1643
|
+
"Exception while running events websocket state handler"
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
def _on_devices_websocket_state_change(self, state: WebsocketState) -> None:
|
|
1647
|
+
"""Devices Websocket state changed."""
|
|
1648
|
+
for sub in self._devices_ws_state_subscriptions:
|
|
1649
|
+
try:
|
|
1650
|
+
sub(state)
|
|
1651
|
+
except Exception:
|
|
1652
|
+
_LOGGER.exception(
|
|
1653
|
+
"Exception while running devices websocket state handler"
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1292
1656
|
async def get_bootstrap(self) -> Bootstrap:
|
|
1293
1657
|
"""
|
|
1294
1658
|
Gets bootstrap object from UFP instance
|
|
@@ -27,14 +27,12 @@ from ..utils import (
|
|
|
27
27
|
to_snake_case,
|
|
28
28
|
)
|
|
29
29
|
from .types import (
|
|
30
|
-
SHAPE_DICT_V1,
|
|
31
|
-
SHAPE_LIST_V1,
|
|
32
30
|
ModelType,
|
|
33
31
|
PercentFloat,
|
|
34
32
|
PermissionNode,
|
|
35
33
|
ProtectWSPayloadFormat,
|
|
36
34
|
StateType,
|
|
37
|
-
|
|
35
|
+
get_field_type,
|
|
38
36
|
)
|
|
39
37
|
from .websocket import (
|
|
40
38
|
WSJSONPacketFrame,
|
|
@@ -223,11 +221,11 @@ class ProtectBaseObject(BaseModel):
|
|
|
223
221
|
|
|
224
222
|
for name, field in cls.model_fields.items():
|
|
225
223
|
try:
|
|
226
|
-
|
|
224
|
+
origin, type_ = get_field_type(field.annotation) # type: ignore[arg-type]
|
|
227
225
|
if _is_protect_base_object(type_):
|
|
228
|
-
if
|
|
226
|
+
if origin is list:
|
|
229
227
|
lists[name] = type_
|
|
230
|
-
elif
|
|
228
|
+
elif origin is dict:
|
|
231
229
|
dicts[name] = type_
|
|
232
230
|
else:
|
|
233
231
|
objs[name] = type_
|
|
@@ -489,8 +487,8 @@ class ProtectBaseObject(BaseModel):
|
|
|
489
487
|
has_unifi_objs,
|
|
490
488
|
unifi_lists,
|
|
491
489
|
has_unifi_lists,
|
|
492
|
-
|
|
493
|
-
|
|
490
|
+
_unifi_dicts,
|
|
491
|
+
_has_unifi_dicts,
|
|
494
492
|
) = self._get_protect_model()
|
|
495
493
|
api = self._api
|
|
496
494
|
_fields = self.__class__.model_fields
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import enum
|
|
4
|
-
|
|
4
|
+
import types
|
|
5
|
+
from collections.abc import Callable, Coroutine, Sequence
|
|
5
6
|
from functools import cache, lru_cache
|
|
6
|
-
from typing import Annotated, Any, Literal, TypeVar, Union
|
|
7
|
+
from typing import Annotated, Any, Literal, TypeVar, Union, get_args, get_origin
|
|
7
8
|
|
|
8
9
|
from packaging.version import Version as BaseVersion
|
|
9
10
|
from pydantic import BaseModel, Field
|
|
10
11
|
from pydantic.types import StringConstraints
|
|
11
|
-
from pydantic.v1.config import BaseConfig as BaseConfigV1
|
|
12
|
-
from pydantic.v1.fields import SHAPE_DICT as SHAPE_DICT_V1 # noqa: F401
|
|
13
|
-
from pydantic.v1.fields import SHAPE_LIST as SHAPE_LIST_V1 # noqa: F401
|
|
14
|
-
from pydantic.v1.fields import SHAPE_SET as SHAPE_SET_V1 # noqa: F401
|
|
15
|
-
from pydantic.v1.fields import ModelField as ModelFieldV1
|
|
16
12
|
from pydantic_extra_types.color import Color # noqa: F401
|
|
17
13
|
|
|
18
14
|
from .._compat import cached_property
|
|
@@ -21,20 +17,34 @@ KT = TypeVar("KT")
|
|
|
21
17
|
VT = TypeVar("VT")
|
|
22
18
|
|
|
23
19
|
|
|
24
|
-
class _BaseConfigV1(BaseConfigV1):
|
|
25
|
-
arbitrary_types_allowed = True
|
|
26
|
-
validate_assignment = True
|
|
27
|
-
|
|
28
|
-
|
|
29
20
|
@lru_cache(maxsize=512)
|
|
30
|
-
def
|
|
31
|
-
"""Extract the type from
|
|
21
|
+
def get_field_type(annotation: type[Any] | None) -> tuple[type | None, Any]:
|
|
22
|
+
"""Extract the origin and type from an annotation."""
|
|
32
23
|
if annotation is None:
|
|
33
24
|
raise ValueError("Type annotation cannot be None")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
|
|
25
|
+
origin = get_origin(annotation)
|
|
26
|
+
args: Sequence[Any]
|
|
27
|
+
if origin in (list, set):
|
|
28
|
+
if not (args := get_args(annotation)):
|
|
29
|
+
raise ValueError(f"Unable to determine args of type: {annotation}")
|
|
30
|
+
return origin, args[0]
|
|
31
|
+
if origin is dict:
|
|
32
|
+
if not (args := get_args(annotation)):
|
|
33
|
+
raise ValueError(f"Unable to determine args of type: {annotation}")
|
|
34
|
+
return origin, args[1]
|
|
35
|
+
if origin is Annotated:
|
|
36
|
+
if not (args := get_args(annotation)):
|
|
37
|
+
raise ValueError(f"Unable to determine args of type: {annotation}")
|
|
38
|
+
return None, args[0]
|
|
39
|
+
if origin is Union or origin is types.UnionType:
|
|
40
|
+
if not (args := get_args(annotation)):
|
|
41
|
+
raise ValueError(f"Unable to determine args of type: {annotation}")
|
|
42
|
+
args = [get_field_type(arg) for arg in args]
|
|
43
|
+
if len(args) == 2 and type(None) in list(zip(*args, strict=False))[1]:
|
|
44
|
+
# Strip '| None' type from Union
|
|
45
|
+
return next(arg for arg in args if arg[1] is not type(None))
|
|
46
|
+
return None, annotation
|
|
47
|
+
return origin, annotation
|
|
38
48
|
|
|
39
49
|
|
|
40
50
|
DEFAULT = "DEFAULT_VALUE"
|
|
@@ -32,15 +32,12 @@ from aiohttp import ClientResponse
|
|
|
32
32
|
from pydantic.fields import FieldInfo
|
|
33
33
|
|
|
34
34
|
from .data.types import (
|
|
35
|
-
SHAPE_DICT_V1,
|
|
36
|
-
SHAPE_LIST_V1,
|
|
37
|
-
SHAPE_SET_V1,
|
|
38
35
|
Color,
|
|
39
36
|
SmartDetectAudioType,
|
|
40
37
|
SmartDetectObjectType,
|
|
41
38
|
Version,
|
|
42
39
|
VideoMode,
|
|
43
|
-
|
|
40
|
+
get_field_type,
|
|
44
41
|
)
|
|
45
42
|
from .exceptions import NvrError
|
|
46
43
|
|
|
@@ -208,22 +205,21 @@ def to_camel_case(name: str) -> str:
|
|
|
208
205
|
|
|
209
206
|
|
|
210
207
|
_EMPTY_UUID = UUID("0" * 32)
|
|
211
|
-
_SHAPE_TYPES = {SHAPE_DICT_V1, SHAPE_SET_V1, SHAPE_LIST_V1}
|
|
212
208
|
|
|
213
209
|
|
|
214
210
|
def convert_unifi_data(value: Any, field: FieldInfo) -> Any:
|
|
215
211
|
"""Converts value from UFP data into pydantic field class"""
|
|
216
|
-
|
|
212
|
+
origin, type_ = get_field_type(field.annotation) # type: ignore[arg-type]
|
|
217
213
|
|
|
218
214
|
if type_ is Any:
|
|
219
215
|
return value
|
|
220
216
|
|
|
221
|
-
if
|
|
222
|
-
if
|
|
217
|
+
if origin is not None:
|
|
218
|
+
if origin is list and isinstance(value, list):
|
|
223
219
|
return [convert_unifi_data(v, field) for v in value]
|
|
224
|
-
if
|
|
220
|
+
if origin is set and isinstance(value, list):
|
|
225
221
|
return {convert_unifi_data(v, field) for v in value}
|
|
226
|
-
if
|
|
222
|
+
if origin is dict and isinstance(value, dict):
|
|
227
223
|
return {k: convert_unifi_data(v, field) for k, v in value.items()}
|
|
228
224
|
|
|
229
225
|
if value is not None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|