uiprotect 7.5.2__py3-none-any.whl → 7.32.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.
- uiprotect/api.py +886 -59
- uiprotect/cli/__init__.py +109 -24
- uiprotect/cli/aiports.py +1 -2
- uiprotect/cli/backup.py +5 -4
- uiprotect/cli/base.py +4 -4
- uiprotect/cli/cameras.py +152 -13
- uiprotect/cli/chimes.py +5 -6
- uiprotect/cli/doorlocks.py +2 -3
- uiprotect/cli/events.py +7 -8
- uiprotect/cli/lights.py +11 -3
- uiprotect/cli/liveviews.py +1 -2
- uiprotect/cli/sensors.py +2 -3
- uiprotect/cli/viewers.py +2 -3
- uiprotect/data/base.py +32 -32
- uiprotect/data/bootstrap.py +20 -15
- uiprotect/data/devices.py +183 -16
- uiprotect/data/nvr.py +139 -38
- uiprotect/data/types.py +32 -19
- uiprotect/stream.py +13 -2
- uiprotect/test_util/__init__.py +30 -7
- uiprotect/test_util/anonymize.py +4 -5
- uiprotect/utils.py +56 -24
- uiprotect/websocket.py +3 -3
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/METADATA +70 -17
- uiprotect-7.32.0.dist-info/RECORD +39 -0
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/WHEEL +1 -1
- uiprotect-7.5.2.dist-info/RECORD +0 -39
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info}/entry_points.txt +0 -0
- {uiprotect-7.5.2.dist-info → uiprotect-7.32.0.dist-info/licenses}/LICENSE +0 -0
uiprotect/data/types.py
CHANGED
|
@@ -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,
|
|
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"
|
|
@@ -50,7 +60,7 @@ EventCategories = Literal[
|
|
|
50
60
|
]
|
|
51
61
|
|
|
52
62
|
ProgressCallback = Callable[[int, int, int], Coroutine[Any, Any, None]]
|
|
53
|
-
IteratorCallback = Callable[[int,
|
|
63
|
+
IteratorCallback = Callable[[int, bytes | None], Coroutine[Any, Any, None]]
|
|
54
64
|
|
|
55
65
|
|
|
56
66
|
class FixSizeOrderedDict(dict[KT, VT]):
|
|
@@ -435,6 +445,8 @@ class VideoMode(str, ValuesEnumMixin, enum.Enum):
|
|
|
435
445
|
HOMEKIT = "homekit"
|
|
436
446
|
SPORT = "sport"
|
|
437
447
|
SLOW_SHUTTER = "slowShutter"
|
|
448
|
+
LPR_NONE_REFLEX = "lprNoneReflex"
|
|
449
|
+
LPR_REFLEX = "lprReflex"
|
|
438
450
|
# should only be for unadopted devices
|
|
439
451
|
UNKNOWN = "unknown"
|
|
440
452
|
|
|
@@ -447,6 +459,7 @@ class AudioStyle(str, UnknownValuesEnumMixin, enum.Enum):
|
|
|
447
459
|
|
|
448
460
|
@enum.unique
|
|
449
461
|
class RecordingMode(str, ValuesEnumMixin, enum.Enum):
|
|
462
|
+
ADAPTIVE = "adaptive"
|
|
450
463
|
ALWAYS = "always"
|
|
451
464
|
NEVER = "never"
|
|
452
465
|
SCHEDULE = "schedule"
|
uiprotect/stream.py
CHANGED
|
@@ -18,6 +18,12 @@ if TYPE_CHECKING:
|
|
|
18
18
|
|
|
19
19
|
_LOGGER = logging.getLogger(__name__)
|
|
20
20
|
|
|
21
|
+
CODEC_TO_ENCODER = {
|
|
22
|
+
"aac": {"encoder": "aac", "format": "adts"},
|
|
23
|
+
"opus": {"encoder": "libopus", "format": "rtp"},
|
|
24
|
+
"vorbis": {"encoder": "libvorbis", "format": "ogg"},
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
|
|
22
28
|
class FfmpegCommand:
|
|
23
29
|
ffmpeg_path: Path | None
|
|
@@ -130,6 +136,11 @@ class TalkbackStream(FfmpegCommand):
|
|
|
130
136
|
if len(input_args) > 0:
|
|
131
137
|
input_args += " "
|
|
132
138
|
|
|
139
|
+
codec = camera.talkback_settings.type_fmt.value
|
|
140
|
+
encoder = CODEC_TO_ENCODER.get(codec)
|
|
141
|
+
if encoder is None:
|
|
142
|
+
raise ValueError(f"Unsupported codec: {codec}")
|
|
143
|
+
|
|
133
144
|
# vn = no video
|
|
134
145
|
# acodec = audio codec to encode output in (aac)
|
|
135
146
|
# ac = number of output channels (1)
|
|
@@ -138,9 +149,9 @@ class TalkbackStream(FfmpegCommand):
|
|
|
138
149
|
cmd = (
|
|
139
150
|
"-loglevel info -hide_banner "
|
|
140
151
|
f'{input_args}-i "{content_url}" -vn '
|
|
141
|
-
f"-acodec {
|
|
152
|
+
f"-acodec {encoder['encoder']} -ac {camera.talkback_settings.channels} "
|
|
142
153
|
f"-ar {camera.talkback_settings.sampling_rate} -b:a {camera.talkback_settings.sampling_rate} -map 0:a "
|
|
143
|
-
f'-f
|
|
154
|
+
f'-f {encoder["format"]} "udp://{camera.host}:{camera.talkback_settings.bind_port}?bitrate={camera.talkback_settings.sampling_rate}"'
|
|
144
155
|
)
|
|
145
156
|
|
|
146
157
|
super().__init__(cmd, ffmpeg_path)
|
uiprotect/test_util/__init__.py
CHANGED
|
@@ -126,12 +126,12 @@ class SampleDataGenerator:
|
|
|
126
126
|
"group": len(bootstrap["groups"]),
|
|
127
127
|
"liveview": len(bootstrap["liveviews"]),
|
|
128
128
|
"viewer": len(bootstrap["viewers"]),
|
|
129
|
-
"light": len(bootstrap
|
|
130
|
-
"bridge": len(bootstrap
|
|
131
|
-
"sensor": len(bootstrap
|
|
132
|
-
"doorlock": len(bootstrap
|
|
133
|
-
"chime": len(bootstrap
|
|
134
|
-
"aiport": len(bootstrap
|
|
129
|
+
"light": len(bootstrap.get("lights", [])),
|
|
130
|
+
"bridge": len(bootstrap.get("bridges", [])),
|
|
131
|
+
"sensor": len(bootstrap.get("sensors", [])),
|
|
132
|
+
"doorlock": len(bootstrap.get("doorlocks", [])),
|
|
133
|
+
"chime": len(bootstrap.get("chimes", [])),
|
|
134
|
+
"aiport": len(bootstrap.get("aiports", [])),
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
self.log("Generating event data...")
|
|
@@ -142,6 +142,7 @@ class SampleDataGenerator:
|
|
|
142
142
|
|
|
143
143
|
if close_session:
|
|
144
144
|
await self.client.close_session()
|
|
145
|
+
await self.client.close_public_api_session()
|
|
145
146
|
|
|
146
147
|
await self.write_json_file("sample_constants", self.constants, anonymize=False)
|
|
147
148
|
|
|
@@ -308,10 +309,18 @@ class SampleDataGenerator:
|
|
|
308
309
|
await self.write_json_file("sample_camera", deepcopy(obj))
|
|
309
310
|
self.constants["camera_online"] = camera_is_online
|
|
310
311
|
|
|
312
|
+
# Check if camera has channels
|
|
313
|
+
if not obj.get("channels") or len(obj["channels"]) == 0:
|
|
314
|
+
self.log(
|
|
315
|
+
"Camera has no channels, skipping snapshot, thumbnail and heatmap generation",
|
|
316
|
+
)
|
|
317
|
+
return
|
|
318
|
+
|
|
311
319
|
if not camera_is_online:
|
|
312
320
|
self.log(
|
|
313
321
|
"Camera is not online, skipping snapshot, thumbnail and heatmap generation",
|
|
314
322
|
)
|
|
323
|
+
return
|
|
315
324
|
|
|
316
325
|
# snapshot
|
|
317
326
|
width = obj["channels"][0]["width"]
|
|
@@ -324,6 +333,15 @@ class SampleDataGenerator:
|
|
|
324
333
|
snapshot = await self.client.get_camera_snapshot(obj["id"], width, height)
|
|
325
334
|
await self.write_image_file(filename, snapshot)
|
|
326
335
|
|
|
336
|
+
# public api snapshot
|
|
337
|
+
pub_filename = "sample_camera_public_api_snapshot"
|
|
338
|
+
if self.anonymize:
|
|
339
|
+
self.log(f"Writing {pub_filename}...")
|
|
340
|
+
placeholder_image(self.output_folder / f"{pub_filename}.png", width, height)
|
|
341
|
+
else:
|
|
342
|
+
pub_snapshot = await self.client.get_public_api_camera_snapshot(obj["id"])
|
|
343
|
+
await self.write_image_file(pub_filename, pub_snapshot)
|
|
344
|
+
|
|
327
345
|
async def generate_motion_data(
|
|
328
346
|
self,
|
|
329
347
|
motion_event: dict[str, Any] | None,
|
|
@@ -442,7 +460,12 @@ class SampleDataGenerator:
|
|
|
442
460
|
await self.write_json_file("sample_sensor", obj)
|
|
443
461
|
|
|
444
462
|
async def generate_lock_data(self) -> None:
|
|
445
|
-
|
|
463
|
+
try:
|
|
464
|
+
objs = await self.client.api_request_list("doorlocks")
|
|
465
|
+
except BadRequest:
|
|
466
|
+
self.log("No doorlock endpoint available. Skipping doorlock endpoints...")
|
|
467
|
+
return
|
|
468
|
+
|
|
446
469
|
device_id: str | None = None
|
|
447
470
|
for obj_dict in objs:
|
|
448
471
|
device_id = obj_dict["id"]
|
uiprotect/test_util/anonymize.py
CHANGED
|
@@ -3,11 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import secrets
|
|
4
4
|
import string
|
|
5
5
|
import uuid
|
|
6
|
+
import warnings
|
|
6
7
|
from typing import Any
|
|
7
8
|
from urllib.parse import urlparse
|
|
8
9
|
|
|
9
|
-
import typer
|
|
10
|
-
|
|
11
10
|
from ..data import ModelType
|
|
12
11
|
|
|
13
12
|
object_id_mapping: dict[str, str] = {}
|
|
@@ -72,7 +71,7 @@ def anonymize_user(user_dict: dict[str, Any]) -> dict[str, Any]:
|
|
|
72
71
|
return user_dict
|
|
73
72
|
|
|
74
73
|
|
|
75
|
-
def anonymize_value(value: Any, name: str | None = None) -> Any:
|
|
74
|
+
def anonymize_value(value: Any, name: str | None = None) -> Any: # noqa: PLR0912
|
|
76
75
|
if isinstance(value, str):
|
|
77
76
|
if name == "accessKey":
|
|
78
77
|
value = f"{random_number(13)}:{random_hex(24)}:{random_hex(128)}"
|
|
@@ -105,13 +104,13 @@ def anonymize_value(value: Any, name: str | None = None) -> Any:
|
|
|
105
104
|
return value
|
|
106
105
|
|
|
107
106
|
|
|
108
|
-
def anonymize_dict(obj: dict[str, Any], name: str | None = None) -> dict[str, Any]:
|
|
107
|
+
def anonymize_dict(obj: dict[str, Any], name: str | None = None) -> dict[str, Any]: # noqa: PLR0912
|
|
109
108
|
obj_type = None
|
|
110
109
|
if "modelKey" in obj:
|
|
111
110
|
if obj["modelKey"] in [m.value for m in ModelType]:
|
|
112
111
|
obj_type = ModelType(obj["modelKey"])
|
|
113
112
|
else:
|
|
114
|
-
|
|
113
|
+
warnings.warn(f"Unknown modelKey: {obj['modelKey']}", stacklevel=2)
|
|
115
114
|
|
|
116
115
|
if obj_type == ModelType.USER:
|
|
117
116
|
return anonymize_user(obj)
|
uiprotect/utils.py
CHANGED
|
@@ -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
|
|
|
@@ -74,8 +71,6 @@ SNAKE_CASE_MATCH_3 = re.compile("([a-z0-9])([A-Z])")
|
|
|
74
71
|
|
|
75
72
|
_LOGGER = logging.getLogger(__name__)
|
|
76
73
|
|
|
77
|
-
RELEASE_CACHE = Path(__file__).parent / "release_cache.json"
|
|
78
|
-
|
|
79
74
|
_CREATE_TYPES = {IPv6Address, IPv4Address, UUID, Color, Decimal, Path, Version}
|
|
80
75
|
_BAD_UUID = "00000000-0000-00 0- 000-000000000000"
|
|
81
76
|
|
|
@@ -210,22 +205,21 @@ def to_camel_case(name: str) -> str:
|
|
|
210
205
|
|
|
211
206
|
|
|
212
207
|
_EMPTY_UUID = UUID("0" * 32)
|
|
213
|
-
_SHAPE_TYPES = {SHAPE_DICT_V1, SHAPE_SET_V1, SHAPE_LIST_V1}
|
|
214
208
|
|
|
215
209
|
|
|
216
|
-
def convert_unifi_data(value: Any, field: FieldInfo) -> Any:
|
|
210
|
+
def convert_unifi_data(value: Any, field: FieldInfo) -> Any: # noqa: PLR0911, PLR0912
|
|
217
211
|
"""Converts value from UFP data into pydantic field class"""
|
|
218
|
-
|
|
212
|
+
origin, type_ = get_field_type(field.annotation) # type: ignore[arg-type]
|
|
219
213
|
|
|
220
214
|
if type_ is Any:
|
|
221
215
|
return value
|
|
222
216
|
|
|
223
|
-
if
|
|
224
|
-
if
|
|
217
|
+
if origin is not None:
|
|
218
|
+
if origin is list and isinstance(value, list):
|
|
225
219
|
return [convert_unifi_data(v, field) for v in value]
|
|
226
|
-
if
|
|
220
|
+
if origin is set and isinstance(value, list):
|
|
227
221
|
return {convert_unifi_data(v, field) for v in value}
|
|
228
|
-
if
|
|
222
|
+
if origin is dict and isinstance(value, dict):
|
|
229
223
|
return {k: convert_unifi_data(v, field) for k, v in value.items()}
|
|
230
224
|
|
|
231
225
|
if value is not None:
|
|
@@ -237,10 +231,13 @@ def convert_unifi_data(value: Any, field: FieldInfo) -> Any:
|
|
|
237
231
|
# cannot do this check too soon because some types cannot be used in isinstance
|
|
238
232
|
if isinstance(value, type_):
|
|
239
233
|
return value
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
234
|
+
if type_ is UUID:
|
|
235
|
+
if not value:
|
|
236
|
+
return None
|
|
237
|
+
# handle edge case for improperly formatted UUIDs
|
|
238
|
+
# 00000000-0000-00 0- 000-000000000000
|
|
239
|
+
if value == _BAD_UUID:
|
|
240
|
+
return _EMPTY_UUID
|
|
244
241
|
if (type_ is IPv4Address) and value == "":
|
|
245
242
|
return None
|
|
246
243
|
return type_(value)
|
|
@@ -272,7 +269,7 @@ def _is_from_string_enum(type_: Any) -> bool:
|
|
|
272
269
|
return hasattr(type_, "from_string")
|
|
273
270
|
|
|
274
271
|
|
|
275
|
-
def serialize_unifi_obj(value: Any, levels: int = -1) -> Any:
|
|
272
|
+
def serialize_unifi_obj(value: Any, levels: int = -1) -> Any: # noqa: PLR0911
|
|
276
273
|
"""Serializes UFP data"""
|
|
277
274
|
if unifi_dict := getattr(value, "unifi_dict", None):
|
|
278
275
|
value = unifi_dict()
|
|
@@ -362,13 +359,41 @@ def convert_video_modes(items: Iterable[str]) -> list[VideoMode]:
|
|
|
362
359
|
return types
|
|
363
360
|
|
|
364
361
|
|
|
365
|
-
def ip_from_host(host: str) -> IPv4Address | IPv6Address:
|
|
362
|
+
async def ip_from_host(host: str) -> IPv4Address | IPv6Address:
|
|
363
|
+
"""
|
|
364
|
+
Resolve hostname to IP address (IPv4 or IPv6).
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
ValueError: If host cannot be resolved to IP address
|
|
368
|
+
|
|
369
|
+
"""
|
|
366
370
|
try:
|
|
367
371
|
return ip_address(host)
|
|
368
372
|
except ValueError:
|
|
369
373
|
pass
|
|
370
374
|
|
|
371
|
-
|
|
375
|
+
try:
|
|
376
|
+
loop = asyncio.get_running_loop()
|
|
377
|
+
addr_info = await loop.getaddrinfo(host, None)
|
|
378
|
+
ip_str = addr_info[0][4][0]
|
|
379
|
+
except (socket.gaierror, OSError) as err:
|
|
380
|
+
raise ValueError(f"Cannot resolve hostname '{host}' to IP address") from err
|
|
381
|
+
|
|
382
|
+
return ip_address(ip_str)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def format_host_for_url(host: IPv4Address | IPv6Address | str) -> str:
|
|
386
|
+
"""Format host for URLs. IPv6 addresses are wrapped in brackets."""
|
|
387
|
+
if isinstance(host, str):
|
|
388
|
+
try:
|
|
389
|
+
parsed_host = ip_address(host)
|
|
390
|
+
except ValueError:
|
|
391
|
+
return host
|
|
392
|
+
host = parsed_host
|
|
393
|
+
|
|
394
|
+
if isinstance(host, IPv6Address):
|
|
395
|
+
return f"[{host}]"
|
|
396
|
+
return str(host)
|
|
372
397
|
|
|
373
398
|
|
|
374
399
|
def dict_diff(orig: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -424,7 +449,7 @@ def print_ws_stat_summary(
|
|
|
424
449
|
) -> None:
|
|
425
450
|
# typer<0.4.1 is incompatible with click>=8.1.0
|
|
426
451
|
# allows only the CLI interface to break if both are installed
|
|
427
|
-
import typer
|
|
452
|
+
import typer # noqa: PLC0415
|
|
428
453
|
|
|
429
454
|
if output is None:
|
|
430
455
|
output = typer.echo if typer is not None else print
|
|
@@ -519,7 +544,7 @@ def format_duration(duration: timedelta) -> str:
|
|
|
519
544
|
|
|
520
545
|
|
|
521
546
|
def _set_timezone(tz: tzinfo | str) -> tzinfo:
|
|
522
|
-
global TIMEZONE_GLOBAL
|
|
547
|
+
global TIMEZONE_GLOBAL # noqa: PLW0603
|
|
523
548
|
|
|
524
549
|
if isinstance(tz, str):
|
|
525
550
|
tz = zoneinfo.ZoneInfo(tz)
|
|
@@ -535,7 +560,9 @@ def get_local_timezone() -> tzinfo:
|
|
|
535
560
|
return TIMEZONE_GLOBAL
|
|
536
561
|
|
|
537
562
|
try:
|
|
538
|
-
from homeassistant.util import
|
|
563
|
+
from homeassistant.util import ( # noqa: PLC0415
|
|
564
|
+
dt as dt_util, # type: ignore[import-not-found]
|
|
565
|
+
)
|
|
539
566
|
|
|
540
567
|
return _set_timezone(dt_util.DEFAULT_TIME_ZONE)
|
|
541
568
|
except ImportError:
|
|
@@ -572,7 +599,7 @@ def local_datetime(dt: datetime | None = None) -> datetime:
|
|
|
572
599
|
|
|
573
600
|
|
|
574
601
|
def log_event(event: Event) -> None:
|
|
575
|
-
from uiprotect.data import EventType
|
|
602
|
+
from uiprotect.data import EventType # noqa: PLC0415
|
|
576
603
|
|
|
577
604
|
_LOGGER.debug("event WS msg: %s", event.model_dump())
|
|
578
605
|
if "smart" not in event.type.value:
|
|
@@ -699,3 +726,8 @@ def make_required_getter(ufp_required_field: str) -> Callable[[T], bool]:
|
|
|
699
726
|
@lru_cache
|
|
700
727
|
def timedelta_total_seconds(td: timedelta) -> float:
|
|
701
728
|
return td.total_seconds()
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def pybool_to_json_bool(value: bool) -> str:
|
|
732
|
+
"""Convert a Python bool to a JSON boolean string ('true'/'false')."""
|
|
733
|
+
return "true" if value else "false"
|
uiprotect/websocket.py
CHANGED
|
@@ -8,7 +8,7 @@ import logging
|
|
|
8
8
|
from collections.abc import Awaitable, Callable, Coroutine
|
|
9
9
|
from enum import Enum
|
|
10
10
|
from http import HTTPStatus
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any
|
|
12
12
|
|
|
13
13
|
import aiohttp
|
|
14
14
|
from aiohttp import (
|
|
@@ -24,7 +24,7 @@ from yarl import URL
|
|
|
24
24
|
from .exceptions import NotAuthorized, NvrError
|
|
25
25
|
|
|
26
26
|
_LOGGER = logging.getLogger(__name__)
|
|
27
|
-
AuthCallbackType = Callable[..., Coroutine[Any, Any,
|
|
27
|
+
AuthCallbackType = Callable[..., Coroutine[Any, Any, dict[str, str] | None]]
|
|
28
28
|
GetSessionCallbackType = Callable[[], Awaitable[ClientSession]]
|
|
29
29
|
UpdateBootstrapCallbackType = Callable[[], None]
|
|
30
30
|
_CLOSE_MESSAGE_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED}
|
|
@@ -137,7 +137,7 @@ class Websocket:
|
|
|
137
137
|
if msg_type is WSMsgType.ERROR:
|
|
138
138
|
_LOGGER.exception("Error from Websocket: %s", msg.data)
|
|
139
139
|
break
|
|
140
|
-
|
|
140
|
+
if msg_type in _CLOSE_MESSAGE_TYPES:
|
|
141
141
|
_LOGGER.debug("Websocket closed: %s", msg)
|
|
142
142
|
break
|
|
143
143
|
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: uiprotect
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.32.0
|
|
4
4
|
Summary: Python API for Unifi Protect (Unofficial)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
5
7
|
Author: UI Protect Maintainers
|
|
6
8
|
Author-email: ui@koston.org
|
|
7
9
|
Requires-Python: >=3.10
|
|
8
10
|
Classifier: Development Status :: 5 - Production/Stable
|
|
9
11
|
Classifier: Intended Audience :: Developers
|
|
10
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
11
12
|
Classifier: Natural Language :: English
|
|
12
13
|
Classifier: Operating System :: OS Independent
|
|
13
14
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -15,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
15
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
20
|
Classifier: Topic :: Software Development :: Build Tools
|
|
19
21
|
Classifier: Topic :: Software Development :: Libraries
|
|
20
22
|
Requires-Dist: aiofiles (>=24)
|
|
@@ -92,6 +94,31 @@ Install this via pip (or your favorite package manager):
|
|
|
92
94
|
|
|
93
95
|
`pip install uiprotect`
|
|
94
96
|
|
|
97
|
+
## Developer Setup
|
|
98
|
+
|
|
99
|
+
The recommended way to develop is using the provided **devcontainer** with VS Code:
|
|
100
|
+
|
|
101
|
+
1. Install [VS Code](https://code.visualstudio.com/) and the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
|
102
|
+
2. Open the project in VS Code
|
|
103
|
+
3. When prompted, click "Reopen in Container" (or use Command Palette: "Dev Containers: Reopen in Container")
|
|
104
|
+
4. The devcontainer will automatically set up Python, Poetry, pre-commit hooks, and all dependencies
|
|
105
|
+
|
|
106
|
+
Alternatively, if you want to develop natively without devcontainer:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Install dependencies
|
|
110
|
+
poetry install --with dev
|
|
111
|
+
|
|
112
|
+
# Install pre-commit hooks
|
|
113
|
+
poetry run pre-commit install --install-hooks
|
|
114
|
+
|
|
115
|
+
# Run tests
|
|
116
|
+
poetry run pytest
|
|
117
|
+
|
|
118
|
+
# Run pre-commit checks manually
|
|
119
|
+
poetry run pre-commit run --all-files
|
|
120
|
+
```
|
|
121
|
+
|
|
95
122
|
## History
|
|
96
123
|
|
|
97
124
|
This project was split off from `pyunifiprotect` because that project changed its license to one that would not be accepted in Home Assistant. This project is committed to keeping the MIT license.
|
|
@@ -122,14 +149,6 @@ The API is not documented by Ubiquiti, so there might be misses and/or frequent
|
|
|
122
149
|
|
|
123
150
|
The module is primarily written for the purpose of being used in Home Assistant core [integration for UniFi Protect](https://www.home-assistant.io/integrations/unifiprotect) but might be used for other purposes also.
|
|
124
151
|
|
|
125
|
-
## Smart Detections now Require Remote Access to enable
|
|
126
|
-
|
|
127
|
-
Smart Detections (person, vehicle, animal, face), a feature that previously could be used with local only console, [now requires you to enable remote access to enable](https://community.ui.com/questions/Cannot-enable-Smart-Detections/e3d50641-5c00-4607-9723-453cda557e35#answer/1d146426-89aa-4022-a0ae-fd5000846028).
|
|
128
|
-
|
|
129
|
-
Enabling Remote Access may grant other users access to your console [due to the fact Ubiquiti can reconfigure access controls at any time](https://community.ui.com/questions/Bug-Fix-Cloud-Access-Misconfiguration/fe8d4479-e187-4471-bf95-b2799183ceb7).
|
|
130
|
-
|
|
131
|
-
If you are not okay with the feature being locked behind Remote Access, [let Ubiquiti know](https://community.ui.com/questions/Cannot-enable-Smart-Detections/e3d50641-5c00-4607-9723-453cda557e35).
|
|
132
|
-
|
|
133
152
|
## Documentation
|
|
134
153
|
|
|
135
154
|
[Full documentation for the project](https://uiprotect.readthedocs.io/).
|
|
@@ -138,8 +157,8 @@ If you are not okay with the feature being locked behind Remote Access, [let Ubi
|
|
|
138
157
|
|
|
139
158
|
If you want to install `uiprotect` natively, the below are the requirements:
|
|
140
159
|
|
|
141
|
-
- [UniFi Protect](https://ui.com/camera-security) version
|
|
142
|
-
-
|
|
160
|
+
- [UniFi Protect](https://ui.com/camera-security) version 6.0+
|
|
161
|
+
- Only UniFi Protect version 6 and newer are supported. The library is generally tested against the latest stable version and the latest EA version.
|
|
143
162
|
- [Python](https://www.python.org/) 3.10+
|
|
144
163
|
- POSIX compatible system
|
|
145
164
|
- Library is only tested on Linux, specifically the latest Debian version available for the official Python Docker images, but there is no reason the library should not work on any Linux distro or macOS.
|
|
@@ -177,7 +196,7 @@ function uiprotect() {
|
|
|
177
196
|
-e UFP_PASSWORD=YOUR_PASSWORD_HERE \
|
|
178
197
|
-e UFP_ADDRESS=YOUR_IP_ADDRESS \
|
|
179
198
|
-e UFP_PORT=443 \
|
|
180
|
-
-e UFP_SSL_VERIFY=
|
|
199
|
+
-e UFP_SSL_VERIFY=false \
|
|
181
200
|
-e TZ=America/New_York \
|
|
182
201
|
-v $PWD:/data ghcr.io/uilibs/uiprotect:latest "$@"
|
|
183
202
|
}
|
|
@@ -203,13 +222,42 @@ export UFP_USERNAME=YOUR_USERNAME_HERE
|
|
|
203
222
|
export UFP_PASSWORD=YOUR_PASSWORD_HERE
|
|
204
223
|
export UFP_ADDRESS=YOUR_IP_ADDRESS
|
|
205
224
|
export UFP_PORT=443
|
|
206
|
-
#
|
|
207
|
-
export UFP_SSL_VERIFY=
|
|
225
|
+
# set to true if you have a valid HTTPS certificate for your instance
|
|
226
|
+
export UFP_SSL_VERIFY=false
|
|
227
|
+
|
|
228
|
+
# Alternatively, use an API key for authentication (required for public API operations)
|
|
229
|
+
export UFP_API_KEY=YOUR_API_KEY_HERE
|
|
208
230
|
|
|
209
231
|
uiprotect --help
|
|
210
232
|
uiprotect nvr
|
|
211
233
|
```
|
|
212
234
|
|
|
235
|
+
#### Available CLI Commands
|
|
236
|
+
|
|
237
|
+
**Top-level commands:**
|
|
238
|
+
|
|
239
|
+
- `uiprotect shell` - Start an interactive Python shell with the API client
|
|
240
|
+
- `uiprotect create-api-key <name>` - Create a new API key for authentication
|
|
241
|
+
- `uiprotect get-meta-info` - Get metadata information
|
|
242
|
+
- `uiprotect generate-sample-data` - Generate sample data for testing
|
|
243
|
+
- `uiprotect profile-ws` - Profile WebSocket performance
|
|
244
|
+
- `uiprotect decode-ws-msg` - Decode WebSocket messages
|
|
245
|
+
|
|
246
|
+
**Device management commands:**
|
|
247
|
+
|
|
248
|
+
- `uiprotect nvr` - NVR information and settings
|
|
249
|
+
- `uiprotect events` - Event management and export
|
|
250
|
+
- `uiprotect cameras` - Camera management
|
|
251
|
+
- `uiprotect lights` - Light device management
|
|
252
|
+
- `uiprotect sensors` - Sensor management
|
|
253
|
+
- `uiprotect viewers` - Viewer management
|
|
254
|
+
- `uiprotect liveviews` - Live view configuration
|
|
255
|
+
- `uiprotect chimes` - Chime management
|
|
256
|
+
- `uiprotect doorlocks` - Door lock management
|
|
257
|
+
- `uiprotect aiports` - AI port management
|
|
258
|
+
|
|
259
|
+
For more details on any command, use `uiprotect <command> --help`.
|
|
260
|
+
|
|
213
261
|
### Python
|
|
214
262
|
|
|
215
263
|
UniFi Protect itself is 100% async, so as such this library is primarily designed to be used in an async context.
|
|
@@ -219,8 +267,12 @@ The main interface for the library is the `uiprotect.ProtectApiClient`:
|
|
|
219
267
|
```python
|
|
220
268
|
from uiprotect import ProtectApiClient
|
|
221
269
|
|
|
270
|
+
# Initialize with username/password
|
|
222
271
|
protect = ProtectApiClient(host, port, username, password, verify_ssl=True)
|
|
223
272
|
|
|
273
|
+
# Or with API key (required for public API operations)
|
|
274
|
+
protect = ProtectApiClient(host, port, username, password, api_key=api_key, verify_ssl=True)
|
|
275
|
+
|
|
224
276
|
await protect.update() # this will initialize the protect .bootstrap and open a Websocket connection for updates
|
|
225
277
|
|
|
226
278
|
# get names of your cameras
|
|
@@ -240,6 +292,8 @@ unsub()
|
|
|
240
292
|
|
|
241
293
|
## TODO / Planned / Not Implemented
|
|
242
294
|
|
|
295
|
+
Switching from Protect Private API to the New Public API
|
|
296
|
+
|
|
243
297
|
Generally any feature missing from the library is planned to be done eventually / nice to have with the following exceptions
|
|
244
298
|
|
|
245
299
|
### UniFi OS Features
|
|
@@ -254,5 +308,4 @@ Anything that is strictly a UniFi OS feature. If it is ever done, it will be in
|
|
|
254
308
|
Some features that require an Ubiquiti Account or "Remote Access" to be enabled are currently not implemented. Examples include:
|
|
255
309
|
|
|
256
310
|
- Stream sharing
|
|
257
|
-
- Face detection
|
|
258
311
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
uiprotect/__init__.py,sha256=Oz6i1tonIz4QWVnEPkbielJDJ3WQdwZVgYtjY4IwGAQ,636
|
|
2
|
+
uiprotect/__main__.py,sha256=C_bHCOkv5qj6WMy-6ELoY3Y6HDhLxOa1a30CzmbZhsg,462
|
|
3
|
+
uiprotect/_compat.py,sha256=HThmb1zQZCEssCxYYbQzFhJq8zYYlVaSnIEZabKc-6U,302
|
|
4
|
+
uiprotect/api.py,sha256=5wOjGvi4NfevJWooHD12X-hmjI9l2BW1yrbIyT_Cocg,101631
|
|
5
|
+
uiprotect/cli/__init__.py,sha256=BvmuccQA16q4__YXnig4vhwMqfeXwEH1V3kNiCmroRU,11905
|
|
6
|
+
uiprotect/cli/aiports.py,sha256=22sC-OVkUFfBGJR2oID8QzWsk4GQfLEmam06-LBP2Z0,1545
|
|
7
|
+
uiprotect/cli/backup.py,sha256=lC44FujSYgVBUs32CbsY8pBejO4qScy6U94UzO-l2fc,36742
|
|
8
|
+
uiprotect/cli/base.py,sha256=FojWPuLHlZ3kpn7WZTQjCbWwweWtP-xDpmtT88xAcds,7565
|
|
9
|
+
uiprotect/cli/cameras.py,sha256=sdlhrQj2o63Se9hgZeiZs7HIMJrtaPH702ExIGYcTEU,21328
|
|
10
|
+
uiprotect/cli/chimes.py,sha256=5-ARR0hVsmg8EvtCQMllCvIsHHjB81hfVdgG8Y6ZEOw,5283
|
|
11
|
+
uiprotect/cli/doorlocks.py,sha256=zoRYE0IsnEI0x7t8aBj08GFZeDNnDGe5IrUFpP6Mxqk,3489
|
|
12
|
+
uiprotect/cli/events.py,sha256=x2a9-18Bt-SPqz1xwNH4CjKAtvbIrpeOiUwSQj065BA,7137
|
|
13
|
+
uiprotect/cli/lights.py,sha256=U7K-YHg2nnsfZfcpjJr5RB0UUVbd3Vn5nlDIA6ng6yo,3530
|
|
14
|
+
uiprotect/cli/liveviews.py,sha256=wJLJh33UVqSOB6UpQhR3tO--CXxGTtJz_WBhPLZLPkc,1832
|
|
15
|
+
uiprotect/cli/nvr.py,sha256=TwxEg2XT8jXAbOqv6gc7KFXELKadeItEDYweSL4_-e8,4260
|
|
16
|
+
uiprotect/cli/sensors.py,sha256=crz_R52X7EFKQtBgL2QzacThExOOoN5NubERQuw5jpk,8119
|
|
17
|
+
uiprotect/cli/viewers.py,sha256=IgJpOzwdo9HVs55Osf3uC5d0raeU19WFIW-RfrnnOug,2137
|
|
18
|
+
uiprotect/data/__init__.py,sha256=audwJBjxRiYdNPeYlP6iofFIOq3gyQzh6VpDsOCM2dQ,2964
|
|
19
|
+
uiprotect/data/base.py,sha256=_xFBNq6Uwd8u1gRy8Z3K4-qnhDTWy0L8F6m7qSBzvPw,35533
|
|
20
|
+
uiprotect/data/bootstrap.py,sha256=ZZD8f1uz2nOWogedFQJWvLuFllcdaoYAYbL4uWEDdG4,23853
|
|
21
|
+
uiprotect/data/convert.py,sha256=xEN878_hm0HZZCVYGwJSxcSp2as9zpkvsemVIibReOA,2628
|
|
22
|
+
uiprotect/data/devices.py,sha256=8ntKqhtRNSE6eL9HYWyqHrAnlBt4yNTY2DUMf3au1z4,120633
|
|
23
|
+
uiprotect/data/nvr.py,sha256=53PGBaduKyLp1mFFiBaBpzlX0IMGrgsmTa8r-hFsX2k,51060
|
|
24
|
+
uiprotect/data/types.py,sha256=szB5vOzLaiJm0o3Lhdtaoawq54kNtiOFMWKjfSVuy4o,19869
|
|
25
|
+
uiprotect/data/user.py,sha256=Del5LUmt5uCfAQMI9-kl_GaKm085oTLjxmcCrlEKXxc,10526
|
|
26
|
+
uiprotect/data/websocket.py,sha256=m4EV1Qfh08eKOihy70ycViYgEQpeNSGZQJWdtGIYJDA,6791
|
|
27
|
+
uiprotect/exceptions.py,sha256=kgn0cRM6lTtgLza09SDa3ZiX6ue1QqHCOogQ4qu6KTQ,965
|
|
28
|
+
uiprotect/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
uiprotect/release_cache.json,sha256=NamnSFy78hOWY0DPO87J9ELFCAN6NnVquv8gQO75ZG4,386
|
|
30
|
+
uiprotect/stream.py,sha256=ls65vMOXF4IlJ5axewFITfhcaTh_ihaFeCkCTfhy0Nk,5168
|
|
31
|
+
uiprotect/test_util/__init__.py,sha256=W57cVs3F6lv1F7wZd7In4UMo66l1JU68J3CeHr2fieY,20276
|
|
32
|
+
uiprotect/test_util/anonymize.py,sha256=GTtl-SSFS0gjhWK9Jlrk70RB78w6_spYKa-VM0jhAD4,8517
|
|
33
|
+
uiprotect/utils.py,sha256=id5_3jbseiJfULH0g0bCkU-9wHEyM5I-TgqyPuOUlHU,21533
|
|
34
|
+
uiprotect/websocket.py,sha256=BedfEVLWhiTP5Il4ULerw2cUNGlr2OLyb_91QOh3iSs,8176
|
|
35
|
+
uiprotect-7.32.0.dist-info/METADATA,sha256=eDJdE_Xq85TZ5xuhiF8dFKc9jYw6HEPC8tMcve3KGkY,12409
|
|
36
|
+
uiprotect-7.32.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
37
|
+
uiprotect-7.32.0.dist-info/entry_points.txt,sha256=J78AUTPrTTxgI3s7SVgrmGqDP7piX2wuuEORzhDdVRA,47
|
|
38
|
+
uiprotect-7.32.0.dist-info/licenses/LICENSE,sha256=INx18jhdbVXMEiiBANeKEbrbz57ckgzxk5uutmmcxGk,1111
|
|
39
|
+
uiprotect-7.32.0.dist-info/RECORD,,
|