uiprotect 6.6.5__py3-none-any.whl → 6.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of uiprotect might be problematic. Click here for more details.

uiprotect/api.py CHANGED
@@ -27,6 +27,9 @@ from aiohttp import CookieJar, client_exceptions
27
27
  from platformdirs import user_cache_dir, user_config_dir
28
28
  from yarl import URL
29
29
 
30
+ from uiprotect.data.convert import list_from_unifi_list
31
+ from uiprotect.data.user import Keyring, Keyrings, UlpUser, UlpUsers
32
+
30
33
  from ._compat import cached_property
31
34
  from .data import (
32
35
  NVR,
@@ -90,6 +93,8 @@ If your Protect instance has a lot of events, this request will take much longer
90
93
  _LOGGER = logging.getLogger(__name__)
91
94
  _COOKIE_RE = re.compile(r"^set-cookie: ", re.IGNORECASE)
92
95
 
96
+ NFC_FINGERPRINT_SUPPORT_VERSION = Version("5.1.57")
97
+
93
98
  # TODO: Urls to still support
94
99
  # Backups
95
100
  # * GET /backups - list backends
@@ -823,6 +828,23 @@ class ProtectApiClient(BaseApiClient):
823
828
  """
824
829
  async with self._update_lock:
825
830
  bootstrap = await self.get_bootstrap()
831
+ if bootstrap.nvr.version >= NFC_FINGERPRINT_SUPPORT_VERSION:
832
+ bootstrap.keyrings = Keyrings.from_list(
833
+ cast(
834
+ list[Keyring],
835
+ list_from_unifi_list(
836
+ self, await self.api_request_list("keyrings")
837
+ ),
838
+ )
839
+ )
840
+ bootstrap.ulp_users = UlpUsers.from_list(
841
+ cast(
842
+ list[UlpUser],
843
+ list_from_unifi_list(
844
+ self, await self.api_request_list("ulp-users")
845
+ ),
846
+ )
847
+ )
826
848
  self.__dict__.pop("bootstrap", None)
827
849
  self._bootstrap = bootstrap
828
850
  return bootstrap
@@ -6,14 +6,14 @@ import asyncio
6
6
  import logging
7
7
  from dataclasses import dataclass
8
8
  from datetime import datetime
9
- from typing import TYPE_CHECKING, Any
9
+ from typing import TYPE_CHECKING, Any, cast
10
10
 
11
11
  from aiohttp.client_exceptions import ServerDisconnectedError
12
12
  from convertertools import pop_dict_set, pop_dict_tuple
13
13
  from pydantic.v1 import PrivateAttr, ValidationError
14
14
 
15
15
  from ..exceptions import ClientError
16
- from ..utils import normalize_mac, utc_now
16
+ from ..utils import normalize_mac, to_snake_case, utc_now
17
17
  from .base import (
18
18
  RECENT_EVENT_MAX,
19
19
  ProtectBaseObject,
@@ -21,7 +21,7 @@ from .base import (
21
21
  ProtectModel,
22
22
  ProtectModelWithId,
23
23
  )
24
- from .convert import create_from_unifi_dict
24
+ from .convert import MODEL_TO_CLASS, create_from_unifi_dict
25
25
  from .devices import (
26
26
  Bridge,
27
27
  Camera,
@@ -34,7 +34,7 @@ from .devices import (
34
34
  )
35
35
  from .nvr import NVR, Event, Liveview
36
36
  from .types import EventType, FixSizeOrderedDict, ModelType
37
- from .user import Group, User
37
+ from .user import Group, Keyrings, UlpUserKeyringBase, UlpUsers, User
38
38
  from .websocket import (
39
39
  WSAction,
40
40
  WSPacket,
@@ -188,6 +188,8 @@ class Bootstrap(ProtectBaseObject):
188
188
  # agreements
189
189
 
190
190
  # not directly from UniFi
191
+ keyrings: Keyrings = Keyrings()
192
+ ulp_users: UlpUsers = UlpUsers()
191
193
  events: dict[str, Event] = FixSizeOrderedDict()
192
194
  capture_ws_stats: bool = False
193
195
  mac_lookup: dict[str, ProtectDeviceRef] = {}
@@ -384,6 +386,60 @@ class Bootstrap(ProtectBaseObject):
384
386
  old_obj=device,
385
387
  )
386
388
 
389
+ def _process_ws_keyring_or_ulp_user_message(
390
+ self,
391
+ action: dict[str, Any],
392
+ data: dict[str, Any],
393
+ model_type: ModelType,
394
+ ) -> WSSubscriptionMessage | None:
395
+ action_id = action["id"]
396
+ obj_from_bootstrap: UlpUserKeyringBase[ProtectModelWithId] = getattr(
397
+ self, to_snake_case(model_type.devices_key)
398
+ )
399
+ action_type = action["action"]
400
+ if action_type == "add":
401
+ add_obj = create_from_unifi_dict(data, api=self._api, model_type=model_type)
402
+ if TYPE_CHECKING:
403
+ model_class = MODEL_TO_CLASS.get(model_type)
404
+ assert model_class is not None and isinstance(add_obj, model_class)
405
+ add_obj = cast(ProtectModelWithId, add_obj)
406
+ obj_from_bootstrap.add(add_obj)
407
+ return WSSubscriptionMessage(
408
+ action=WSAction.ADD,
409
+ new_update_id=self.last_update_id,
410
+ changed_data=add_obj.dict(),
411
+ new_obj=add_obj,
412
+ )
413
+ elif action_type == "remove":
414
+ to_remove = obj_from_bootstrap.by_id(action_id)
415
+ if to_remove is None:
416
+ return None
417
+ obj_from_bootstrap.remove(to_remove)
418
+ return WSSubscriptionMessage(
419
+ action=WSAction.REMOVE,
420
+ new_update_id=self.last_update_id,
421
+ changed_data={},
422
+ old_obj=to_remove,
423
+ )
424
+ elif action_type == "update":
425
+ updated_obj = obj_from_bootstrap.by_id(action_id)
426
+ if updated_obj is None:
427
+ return None
428
+
429
+ old_obj = updated_obj.copy()
430
+ updated_data = {to_snake_case(k): v for k, v in data.items()}
431
+ updated_obj.update_from_dict(updated_data)
432
+
433
+ return WSSubscriptionMessage(
434
+ action=WSAction.UPDATE,
435
+ new_update_id=self.last_update_id,
436
+ changed_data=updated_data,
437
+ new_obj=updated_obj,
438
+ old_obj=old_obj,
439
+ )
440
+ _LOGGER.debug("Unexpected ws action for %s: %s", model_type, action_type)
441
+ return None
442
+
387
443
  def _process_nvr_update(
388
444
  self,
389
445
  action: dict[str, Any],
@@ -540,13 +596,16 @@ class Bootstrap(ProtectBaseObject):
540
596
  return None
541
597
 
542
598
  action_action: str = action["action"]
543
- if action_action == "remove":
544
- return self._process_remove_packet(model_type, action)
545
-
546
- if not data and not is_ping_back:
547
- return None
548
599
 
549
600
  try:
601
+ if model_type in {ModelType.KEYRING, ModelType.ULP_USER}:
602
+ return self._process_ws_keyring_or_ulp_user_message(
603
+ action, data, model_type
604
+ )
605
+ if action_action == "remove":
606
+ return self._process_remove_packet(model_type, action)
607
+ if not data and not is_ping_back:
608
+ return None
550
609
  if action_action == "add":
551
610
  return self._process_add_packet(model_type, data)
552
611
  if action_action == "update":
uiprotect/data/convert.py CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import TYPE_CHECKING, Any
5
+ from typing import TYPE_CHECKING, Any, cast
6
+
7
+ from uiprotect.data.base import ProtectModelWithId
6
8
 
7
9
  from ..exceptions import DataDecodeError
8
10
  from .devices import (
@@ -16,7 +18,7 @@ from .devices import (
16
18
  )
17
19
  from .nvr import NVR, Event, Liveview
18
20
  from .types import ModelType
19
- from .user import CloudAccount, Group, User, UserLocation
21
+ from .user import CloudAccount, Group, Keyring, UlpUser, User, UserLocation
20
22
 
21
23
  if TYPE_CHECKING:
22
24
  from ..api import ProtectApiClient
@@ -38,6 +40,8 @@ MODEL_TO_CLASS: dict[str, type[ProtectModel]] = {
38
40
  ModelType.SENSOR: Sensor,
39
41
  ModelType.DOORLOCK: Doorlock,
40
42
  ModelType.CHIME: Chime,
43
+ ModelType.KEYRING: Keyring,
44
+ ModelType.ULP_USER: UlpUser,
41
45
  }
42
46
 
43
47
 
@@ -79,3 +83,12 @@ def create_from_unifi_dict(
79
83
  klass = get_klass_from_dict(data)
80
84
 
81
85
  return klass.from_unifi_dict(**data, api=api)
86
+
87
+
88
+ def list_from_unifi_list(
89
+ api: ProtectApiClient, unifi_list: list[dict[str, ProtectModelWithId]]
90
+ ) -> list[ProtectModelWithId]:
91
+ return [
92
+ cast(ProtectModelWithId, create_from_unifi_dict(obj_dict, api))
93
+ for obj_dict in unifi_list
94
+ ]
uiprotect/data/types.py CHANGED
@@ -105,6 +105,8 @@ class ModelType(str, UnknownValuesEnumMixin, enum.Enum):
105
105
  CHIME = "chime"
106
106
  DEVICE_GROUP = "deviceGroup"
107
107
  RECORDING_SCHEDULE = "recordingSchedule"
108
+ ULP_USER = "ulpUser"
109
+ KEYRING = "keyring"
108
110
  UNKNOWN = "unknown"
109
111
 
110
112
  bootstrap_model_types: tuple[ModelType, ...]
uiprotect/data/user.py CHANGED
@@ -2,15 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sys
6
+ from abc import abstractmethod
5
7
  from datetime import datetime
6
8
  from functools import cache
7
- from typing import Any
9
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
8
10
 
9
11
  from pydantic.v1.fields import PrivateAttr
10
12
 
11
13
  from .base import ProtectBaseObject, ProtectModel, ProtectModelWithId
12
14
  from .types import ModelType, PermissionNode
13
15
 
16
+ if sys.version_info >= (3, 11):
17
+ from typing import Self
18
+ else:
19
+ from typing_extensions import Self
20
+
14
21
 
15
22
  class Permission(ProtectBaseObject):
16
23
  raw_permission: str
@@ -234,3 +241,112 @@ class User(ProtectModelWithId):
234
241
  return True
235
242
  self._perm_cache[perm_str] = False
236
243
  return False
244
+
245
+
246
+ T = TypeVar("T", bound="ProtectModelWithId")
247
+
248
+
249
+ class UlpUserKeyringBase(Generic[T]):
250
+ """Base class for collections of ULP users and keyrings."""
251
+
252
+ def __init__(self) -> None:
253
+ self._id_to_item: dict[str, T] = {}
254
+
255
+ @classmethod
256
+ def from_list(cls, items: list[T]) -> Self:
257
+ instance = cls()
258
+ for item in items:
259
+ instance.add(item)
260
+ return instance
261
+
262
+ def add(self, item: T) -> None:
263
+ """Add an item to the collection."""
264
+ self._id_to_item[item.id] = item
265
+
266
+ def remove(self, item: T) -> None:
267
+ """Remove an item from the collection."""
268
+ self._id_to_item.pop(item.id, None)
269
+
270
+ def by_id(self, item_id: str) -> T | None:
271
+ """Retrieve an item by its ID."""
272
+ return self._id_to_item.get(item_id)
273
+
274
+ @abstractmethod
275
+ def by_ulp_id(self, item_id: str) -> T | None:
276
+ """Retrieve an item by its ULP ID."""
277
+
278
+ def as_list(self) -> list[T]:
279
+ return list(self._id_to_item.values())
280
+
281
+ def __eq__(self, other: Any) -> bool:
282
+ if TYPE_CHECKING:
283
+ assert isinstance(other, UlpUserKeyringBase)
284
+ return self._id_to_item == other._id_to_item
285
+
286
+
287
+ class Keyring(ProtectModelWithId):
288
+ device_type: str
289
+ device_id: str
290
+ registry_type: str
291
+ registry_id: str
292
+ last_activity: datetime | None = None
293
+ ulp_user: str
294
+
295
+
296
+ class Keyrings(UlpUserKeyringBase[Keyring]):
297
+ def __init__(self) -> None:
298
+ super().__init__()
299
+ self._keyrings_by_registry_id: dict[str, Keyring] = {}
300
+ self._keyrings_by_ulp_user: dict[str, Keyring] = {}
301
+
302
+ def add(self, keyring: Keyring) -> None:
303
+ super().add(keyring)
304
+ self._keyrings_by_registry_id[keyring.registry_id] = keyring
305
+ self._keyrings_by_ulp_user[keyring.ulp_user] = keyring
306
+
307
+ def remove(self, keyring: Keyring) -> None:
308
+ super().remove(keyring)
309
+ self._keyrings_by_registry_id.pop(keyring.registry_id, None)
310
+ self._keyrings_by_ulp_user.pop(keyring.ulp_user, None)
311
+
312
+ def by_ulp_id(self, ulp_id: str) -> Keyring | None:
313
+ return self._keyrings_by_ulp_user.get(ulp_id)
314
+
315
+ def by_registry_id(self, registry_id: str) -> Keyring | None:
316
+ return self._keyrings_by_registry_id.get(registry_id)
317
+
318
+ def __eq__(self, other: Any) -> bool:
319
+ if not isinstance(other, Keyrings):
320
+ return NotImplemented
321
+ return super().__eq__(other)
322
+
323
+
324
+ class UlpUser(ProtectModelWithId):
325
+ ulp_id: str
326
+ first_name: str
327
+ last_name: str
328
+ full_name: str
329
+ avatar: str
330
+ status: str
331
+
332
+
333
+ class UlpUsers(UlpUserKeyringBase[UlpUser]):
334
+ def __init__(self) -> None:
335
+ super().__init__()
336
+ self._users_by_ulp_id: dict[str, UlpUser] = {}
337
+
338
+ def add(self, user: UlpUser) -> None:
339
+ super().add(user)
340
+ self._users_by_ulp_id[user.ulp_id] = user
341
+
342
+ def remove(self, user: UlpUser) -> None:
343
+ super().remove(user)
344
+ self._users_by_ulp_id.pop(user.ulp_id, None)
345
+
346
+ def by_ulp_id(self, ulp_id: str) -> UlpUser | None:
347
+ return self._users_by_ulp_id.get(ulp_id)
348
+
349
+ def __eq__(self, other: Any) -> bool:
350
+ if not isinstance(other, UlpUsers):
351
+ return NotImplemented
352
+ return super().__eq__(other)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: uiprotect
3
- Version: 6.6.5
3
+ Version: 6.8.0
4
4
  Summary: Python API for Unifi Protect (Unofficial)
5
5
  Home-page: https://github.com/uilibs/uiprotect
6
6
  Author: UI Protect Maintainers
@@ -1,7 +1,7 @@
1
1
  uiprotect/__init__.py,sha256=Oz6i1tonIz4QWVnEPkbielJDJ3WQdwZVgYtjY4IwGAQ,636
2
2
  uiprotect/__main__.py,sha256=C_bHCOkv5qj6WMy-6ELoY3Y6HDhLxOa1a30CzmbZhsg,462
3
3
  uiprotect/_compat.py,sha256=HThmb1zQZCEssCxYYbQzFhJq8zYYlVaSnIEZabKc-6U,302
4
- uiprotect/api.py,sha256=Gc_x8mo-W-xtEFAhRIkiQyW99MJQgWdkkXlKxl3h-JU,67978
4
+ uiprotect/api.py,sha256=pSQ_C2cjtttnFnrVzjmHlqS7zOfMH-bwk4NxbraUCKI,68850
5
5
  uiprotect/cli/__init__.py,sha256=1MO8rJmjjAsfVx2x01gn5DJo8B64xdPGo6gRVJbWd18,8868
6
6
  uiprotect/cli/backup.py,sha256=ZiS7RZnJGKI8TJKLW2cOUzkRM8nyTvE5Ov_jZZGtvSM,36708
7
7
  uiprotect/cli/base.py,sha256=k-_qGuNT7br0iV0KE5F4wYXF75iyLLjBEckTqxC71xM,7591
@@ -16,12 +16,12 @@ uiprotect/cli/sensors.py,sha256=fQtcDJCVxs4VbAqcavgBy2ABiVxAW3GXtna6_XFBp2k,8153
16
16
  uiprotect/cli/viewers.py,sha256=2cyrp104ffIvgT0wYGIO0G35QMkEbFe7fSVqLwDXQYQ,2171
17
17
  uiprotect/data/__init__.py,sha256=OcfuJl2qXfHcj_mdnrHhzZ5tEIZrw8auziX5IE7dn-I,2938
18
18
  uiprotect/data/base.py,sha256=sn7IHKQN96uiZL6ImN1gdCHV97EpUmy-X7xWTUAtWsg,35054
19
- uiprotect/data/bootstrap.py,sha256=iROUw-pPdJpytaV8Dg5peOJotI7jidXJABsEzCQGid8,20704
20
- uiprotect/data/convert.py,sha256=8h6Il_DhMkPRDPj9F_rA2UZIlTuchS3BQD24peKpk2A,2185
19
+ uiprotect/data/bootstrap.py,sha256=j7bVRWwc3zI6gjn30LA_N5rqQbUYu6yxdy8RmrmCuSc,23224
20
+ uiprotect/data/convert.py,sha256=CDPkSMxSEhvDigmzmLFKpjrz0oa5FOvOdkNIHZrOZ4Q,2586
21
21
  uiprotect/data/devices.py,sha256=P5U47i_YMpvD0jaWiDYbFz_mZt_Wiop_ssWGWnEMySU,113489
22
22
  uiprotect/data/nvr.py,sha256=FGI0eIAyy3Zy9kaxcr67HxwaVCUU8wq3oZyWvoDq7Sg,47251
23
- uiprotect/data/types.py,sha256=PA9YOEJCmh9DAlJajHh2Y6hi4aTjlQ8LhWN8ZH5x7Fc,18522
24
- uiprotect/data/user.py,sha256=1o5gyPHafn4lHARpoSMD_NWbo5IbzGPfiSASwqqDvWs,7002
23
+ uiprotect/data/types.py,sha256=8fEf-fdm_Fqrfb3zwiJJle0ten-Rv3_0Vwk5uimf_vk,18571
24
+ uiprotect/data/user.py,sha256=4rDMUPo02LhoVGfDjwXB9NASL4RnZjP3pvqtwjudZeE,10398
25
25
  uiprotect/data/websocket.py,sha256=m4EV1Qfh08eKOihy70ycViYgEQpeNSGZQJWdtGIYJDA,6791
26
26
  uiprotect/exceptions.py,sha256=kgn0cRM6lTtgLza09SDa3ZiX6ue1QqHCOogQ4qu6KTQ,965
27
27
  uiprotect/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -31,8 +31,8 @@ uiprotect/test_util/__init__.py,sha256=Ky8mTL61nhp5II2mxTKBAsSGvNqK8U_CfKC5AGwTo
31
31
  uiprotect/test_util/anonymize.py,sha256=f-8ijU-_y9r-uAbhIPn0f0I6hzJpAkvJzc8UpWihObI,8478
32
32
  uiprotect/utils.py,sha256=jIWT7n_reL90oY91svBfQ4naRxo28qHzP5jNOL12mQE,20342
33
33
  uiprotect/websocket.py,sha256=tEyenqblNXHcjWYuf4oRP1E7buNwx6zoECMwpBr-jig,8191
34
- uiprotect-6.6.5.dist-info/LICENSE,sha256=INx18jhdbVXMEiiBANeKEbrbz57ckgzxk5uutmmcxGk,1111
35
- uiprotect-6.6.5.dist-info/METADATA,sha256=t0RgidtqlAO2hJS0N2I4M2XO2lOm7OtAf73ADVCOZlQ,11096
36
- uiprotect-6.6.5.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
37
- uiprotect-6.6.5.dist-info/entry_points.txt,sha256=J78AUTPrTTxgI3s7SVgrmGqDP7piX2wuuEORzhDdVRA,47
38
- uiprotect-6.6.5.dist-info/RECORD,,
34
+ uiprotect-6.8.0.dist-info/LICENSE,sha256=INx18jhdbVXMEiiBANeKEbrbz57ckgzxk5uutmmcxGk,1111
35
+ uiprotect-6.8.0.dist-info/METADATA,sha256=8FkmORQLIos0mSeuDePD0xTl_CfGdDiE68a1VNTpb9o,11096
36
+ uiprotect-6.8.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
37
+ uiprotect-6.8.0.dist-info/entry_points.txt,sha256=J78AUTPrTTxgI3s7SVgrmGqDP7piX2wuuEORzhDdVRA,47
38
+ uiprotect-6.8.0.dist-info/RECORD,,