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/data/types.py CHANGED
@@ -1,18 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import enum
4
- from collections.abc import Callable, Coroutine
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, Optional, 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 extract_type_shape(annotation: type[Any] | None) -> tuple[Any, int]:
31
- """Extract the type from a type hint."""
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
- v1_field = ModelFieldV1(
35
- name="", type_=annotation, class_validators=None, model_config=_BaseConfigV1
36
- )
37
- return v1_field.type_, v1_field.shape
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, Optional[bytes]], Coroutine[Any, Any, None]]
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 {camera.talkback_settings.type_fmt.value} -ac {camera.talkback_settings.channels} "
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 adts "udp://{camera.host}:{camera.talkback_settings.bind_port}?bitrate={camera.talkback_settings.sampling_rate}"'
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)
@@ -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["lights"]),
130
- "bridge": len(bootstrap["bridges"]),
131
- "sensor": len(bootstrap["sensors"]),
132
- "doorlock": len(bootstrap["doorlocks"]),
133
- "chime": len(bootstrap["chimes"]),
134
- "aiport": len(bootstrap["aiports"]),
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
- objs = await self.client.api_request_list("doorlocks")
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"]
@@ -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
- typer.secho(f"Unknown modelKey: {obj['modelKey']}", fg="yellow")
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
- extract_type_shape,
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
- type_, shape = extract_type_shape(field.annotation) # type: ignore[arg-type]
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 shape in _SHAPE_TYPES:
224
- if shape == SHAPE_LIST_V1 and isinstance(value, list):
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 shape == SHAPE_SET_V1 and isinstance(value, list):
220
+ if origin is set and isinstance(value, list):
227
221
  return {convert_unifi_data(v, field) for v in value}
228
- if shape == SHAPE_DICT_V1 and isinstance(value, dict):
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
- # handle edge case for improperly formatted UUIDs
241
- # 00000000-0000-00 0- 000-000000000000
242
- if type_ is UUID and value == _BAD_UUID:
243
- return _EMPTY_UUID
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
- return ip_address(socket.gethostbyname(host))
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 dt as dt_util # type: ignore[import-not-found]
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, Optional
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, Optional[dict[str, str]]]]
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
- elif msg_type in _CLOSE_MESSAGE_TYPES:
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.3
1
+ Metadata-Version: 2.4
2
2
  Name: uiprotect
3
- Version: 7.5.2
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 1.20+
142
- - Latest version of library is generally only tested against the two latest minor version. This is either two latest stable versions (such as 1.21.x and 2.0.x) or the latest EA version and stable version (such as 2.2.x EA and 2.1.x).
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=True \
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
- # change to false if you do not have a valid HTTPS certificate for your instance
207
- export UFP_SSL_VERIFY=True
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.2
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any