uiprotect 3.8.0__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.
@@ -4,16 +4,16 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import logging
7
- from copy import deepcopy
8
7
  from dataclasses import dataclass
9
8
  from datetime import datetime
10
- from typing import TYPE_CHECKING, Any
9
+ from typing import TYPE_CHECKING, Any, cast
11
10
 
12
11
  from aiohttp.client_exceptions import ServerDisconnectedError
13
- from pydantic.v1 import PrivateAttr, ValidationError
12
+ from convertertools import pop_dict_set, pop_dict_tuple
13
+ from pydantic 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,20 +21,22 @@ 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
+ AiPort,
26
27
  Bridge,
27
28
  Camera,
28
29
  Chime,
29
30
  Doorlock,
30
31
  Light,
31
32
  ProtectAdoptableDeviceModel,
33
+ Ringtone,
32
34
  Sensor,
33
35
  Viewer,
34
36
  )
35
37
  from .nvr import NVR, Event, Liveview
36
38
  from .types import EventType, FixSizeOrderedDict, ModelType
37
- from .user import Group, User
39
+ from .user import Group, Keyrings, UlpUserKeyringBase, UlpUsers, User
38
40
  from .websocket import (
39
41
  WSAction,
40
42
  WSPacket,
@@ -98,6 +100,14 @@ CAMERA_EVENT_ATTR_MAP: dict[EventType, tuple[str, str]] = {
98
100
  "last_smart_audio_detect_event_id",
99
101
  ),
100
102
  EventType.RING: ("last_ring", "last_ring_event_id"),
103
+ EventType.NFC_CARD_SCANNED: (
104
+ "last_nfc_card_scanned",
105
+ "last_nfc_card_scanned_event_id",
106
+ ),
107
+ EventType.FINGERPRINT_IDENTIFIED: (
108
+ "last_fingerprint_identified",
109
+ "last_fingerprint_identified_event_id",
110
+ ),
101
111
  }
102
112
 
103
113
 
@@ -168,11 +178,13 @@ class Bootstrap(ProtectBaseObject):
168
178
  liveviews: dict[str, Liveview]
169
179
  nvr: NVR
170
180
  viewers: dict[str, Viewer]
171
- lights: dict[str, Light]
172
- bridges: dict[str, Bridge]
173
- sensors: dict[str, Sensor]
174
- doorlocks: dict[str, Doorlock]
175
- chimes: dict[str, Chime]
181
+ lights: dict[str, Light] = {}
182
+ bridges: dict[str, Bridge] = {}
183
+ sensors: dict[str, Sensor] = {}
184
+ doorlocks: dict[str, Doorlock] = {}
185
+ chimes: dict[str, Chime] = {}
186
+ aiports: dict[str, AiPort] = {}
187
+ ringtones: list[Ringtone]
176
188
  last_update_id: str
177
189
 
178
190
  # TODO:
@@ -180,6 +192,8 @@ class Bootstrap(ProtectBaseObject):
180
192
  # agreements
181
193
 
182
194
  # not directly from UniFi
195
+ keyrings: Keyrings = Keyrings()
196
+ ulp_users: UlpUsers = UlpUsers()
183
197
  events: dict[str, Event] = FixSizeOrderedDict()
184
198
  capture_ws_stats: bool = False
185
199
  mac_lookup: dict[str, ProtectDeviceRef] = {}
@@ -201,9 +215,20 @@ class Bootstrap(ProtectBaseObject):
201
215
  data["idLookup"] = id_lookup
202
216
  data["macLookup"] = mac_lookup
203
217
 
218
+ # Fields that are not (always?) available in newer Protect versions
219
+ optional_fields = {"doorlocks"}
220
+
204
221
  for model_type in ModelType.bootstrap_models_types_set:
205
222
  key = model_type.devices_key # type: ignore[attr-defined]
206
223
  items: dict[str, ProtectModel] = {}
224
+ if key not in data:
225
+ # Optional fields with defaults don't need logging or setting
226
+ if key not in optional_fields:
227
+ data[key] = {}
228
+ _LOGGER.error(
229
+ f"Missing key in bootstrap: {key}. This may be fixed by updating Protect."
230
+ )
231
+ continue
207
232
  for item in data[key]:
208
233
  if (
209
234
  api is not None
@@ -230,9 +255,7 @@ class Bootstrap(ProtectBaseObject):
230
255
  ) -> dict[str, Any]:
231
256
  data = super().unifi_dict(data=data, exclude=exclude)
232
257
 
233
- for key in ("events", "captureWsStats", "macLookup", "idLookup"):
234
- if key in data:
235
- del data[key]
258
+ pop_dict_tuple(data, ("events", "captureWsStats", "macLookup", "idLookup"))
236
259
  for model_type in ModelType.bootstrap_models_types_set:
237
260
  attr = model_type.devices_key # type: ignore[attr-defined]
238
261
  if attr in data and isinstance(data[attr], dict):
@@ -354,7 +377,7 @@ class Bootstrap(ProtectBaseObject):
354
377
  return WSSubscriptionMessage(
355
378
  action=WSAction.ADD,
356
379
  new_update_id=self.last_update_id,
357
- changed_data=obj.dict(),
380
+ changed_data=obj.model_dump(),
358
381
  new_obj=obj,
359
382
  )
360
383
 
@@ -378,6 +401,60 @@ class Bootstrap(ProtectBaseObject):
378
401
  old_obj=device,
379
402
  )
380
403
 
404
+ def _process_ws_keyring_or_ulp_user_message(
405
+ self,
406
+ action: dict[str, Any],
407
+ data: dict[str, Any],
408
+ model_type: ModelType,
409
+ ) -> WSSubscriptionMessage | None:
410
+ action_id = action["id"]
411
+ obj_from_bootstrap: UlpUserKeyringBase[ProtectModelWithId] = getattr(
412
+ self, to_snake_case(model_type.devices_key)
413
+ )
414
+ action_type = action["action"]
415
+ if action_type == "add":
416
+ add_obj = create_from_unifi_dict(data, api=self._api, model_type=model_type)
417
+ if TYPE_CHECKING:
418
+ model_class = MODEL_TO_CLASS.get(model_type)
419
+ assert model_class is not None and isinstance(add_obj, model_class)
420
+ add_obj = cast(ProtectModelWithId, add_obj)
421
+ obj_from_bootstrap.add(add_obj)
422
+ return WSSubscriptionMessage(
423
+ action=WSAction.ADD,
424
+ new_update_id=self.last_update_id,
425
+ changed_data=add_obj.model_dump(),
426
+ new_obj=add_obj,
427
+ )
428
+ if action_type == "remove":
429
+ to_remove = obj_from_bootstrap.by_id(action_id)
430
+ if to_remove is None:
431
+ return None
432
+ obj_from_bootstrap.remove(to_remove)
433
+ return WSSubscriptionMessage(
434
+ action=WSAction.REMOVE,
435
+ new_update_id=self.last_update_id,
436
+ changed_data={},
437
+ old_obj=to_remove,
438
+ )
439
+ if action_type == "update":
440
+ updated_obj = obj_from_bootstrap.by_id(action_id)
441
+ if updated_obj is None:
442
+ return None
443
+
444
+ old_obj = updated_obj.model_copy()
445
+ updated_data = {to_snake_case(k): v for k, v in data.items()}
446
+ updated_obj.update_from_dict(updated_data)
447
+
448
+ return WSSubscriptionMessage(
449
+ action=WSAction.UPDATE,
450
+ new_update_id=self.last_update_id,
451
+ changed_data=updated_data,
452
+ new_obj=updated_obj,
453
+ old_obj=old_obj,
454
+ )
455
+ _LOGGER.debug("Unexpected ws action for %s: %s", model_type, action_type)
456
+ return None
457
+
381
458
  def _process_nvr_update(
382
459
  self,
383
460
  action: dict[str, Any],
@@ -385,8 +462,7 @@ class Bootstrap(ProtectBaseObject):
385
462
  ignore_stats: bool,
386
463
  ) -> WSSubscriptionMessage | None:
387
464
  if ignore_stats:
388
- for key in STATS_KEYS.intersection(data):
389
- del data[key]
465
+ pop_dict_set(data, STATS_KEYS)
390
466
  # nothing left to process
391
467
  if not data:
392
468
  return None
@@ -400,8 +476,8 @@ class Bootstrap(ProtectBaseObject):
400
476
  if not (data := self.nvr.unifi_dict_to_dict(data)):
401
477
  return None
402
478
 
403
- old_nvr = self.nvr.copy()
404
- self.nvr = self.nvr.update_from_dict(deepcopy(data))
479
+ old_nvr = self.nvr.model_copy()
480
+ self.nvr = self.nvr.update_from_dict(data)
405
481
 
406
482
  return WSSubscriptionMessage(
407
483
  action=WSAction.UPDATE,
@@ -435,8 +511,7 @@ class Bootstrap(ProtectBaseObject):
435
511
  model_type, IGNORE_DEVICE_KEYS
436
512
  )
437
513
 
438
- for key in remove_keys.intersection(data):
439
- del data[key]
514
+ pop_dict_set(data, remove_keys)
440
515
 
441
516
  # nothing left to process
442
517
  if not data and not is_ping_back:
@@ -447,7 +522,7 @@ class Bootstrap(ProtectBaseObject):
447
522
  if action_id not in devices:
448
523
  # ignore updates to events that phase out
449
524
  if model_type is not ModelType.EVENT:
450
- _LOGGER.debug("Unexpected %s: %s", key, action_id)
525
+ _LOGGER.debug("Unexpected %s: %s", model_type, action_id)
451
526
  return None
452
527
 
453
528
  obj = devices[action_id]
@@ -457,20 +532,13 @@ class Bootstrap(ProtectBaseObject):
457
532
  # nothing left to process
458
533
  return None
459
534
 
460
- old_obj = obj.copy()
461
- obj = obj.update_from_dict(deepcopy(data))
535
+ old_obj = obj.model_copy()
536
+ obj = obj.update_from_dict(data)
462
537
 
463
538
  if model_type is ModelType.EVENT:
464
539
  if TYPE_CHECKING:
465
540
  assert isinstance(obj, Event)
466
541
  self.process_event(obj)
467
- elif model_type is ModelType.CAMERA:
468
- if TYPE_CHECKING:
469
- assert isinstance(obj, Camera)
470
- if "last_ring" in data and (last_ring := obj.last_ring):
471
- if is_recent := last_ring + RECENT_EVENT_MAX >= utc_now():
472
- obj.set_ring_timeout()
473
- _LOGGER.debug("last_ring for %s (%s)", obj.id, is_recent)
474
542
  elif model_type is ModelType.SENSOR:
475
543
  if TYPE_CHECKING:
476
544
  assert isinstance(obj, Sensor)
@@ -499,9 +567,7 @@ class Bootstrap(ProtectBaseObject):
499
567
  capture_ws_stats = self.capture_ws_stats
500
568
  action = packet.action_frame.data
501
569
  data = packet.data_frame.data
502
- if capture_ws_stats:
503
- action = deepcopy(action)
504
- data = deepcopy(data)
570
+ keys = list(data) if capture_ws_stats else None
505
571
 
506
572
  new_update_id: str | None = action["newUpdateId"]
507
573
  if new_update_id is not None:
@@ -512,11 +578,13 @@ class Bootstrap(ProtectBaseObject):
512
578
  )
513
579
 
514
580
  if capture_ws_stats:
581
+ if TYPE_CHECKING:
582
+ assert keys is not None
515
583
  self._ws_stats.append(
516
584
  WSStat(
517
- model=packet.action_frame.data["modelKey"],
518
- action=packet.action_frame.data["action"],
519
- keys=list(packet.data_frame.data),
585
+ model=action["modelKey"],
586
+ action=action["action"],
587
+ keys=keys,
520
588
  keys_set=[] if message is None else list(message.changed_data),
521
589
  size=len(packet.raw),
522
590
  filtered=message is None,
@@ -525,7 +593,7 @@ class Bootstrap(ProtectBaseObject):
525
593
 
526
594
  return message
527
595
 
528
- def _make_ws_packet_message(
596
+ def _make_ws_packet_message( # noqa: PLR0911
529
597
  self,
530
598
  action: dict[str, Any],
531
599
  data: dict[str, Any],
@@ -543,13 +611,16 @@ class Bootstrap(ProtectBaseObject):
543
611
  return None
544
612
 
545
613
  action_action: str = action["action"]
546
- if action_action == "remove":
547
- return self._process_remove_packet(model_type, action)
548
-
549
- if not data and not is_ping_back:
550
- return None
551
614
 
552
615
  try:
616
+ if model_type in {ModelType.KEYRING, ModelType.ULP_USER}:
617
+ return self._process_ws_keyring_or_ulp_user_message(
618
+ action, data, model_type
619
+ )
620
+ if action_action == "remove":
621
+ return self._process_remove_packet(model_type, action)
622
+ if not data and not is_ping_back:
623
+ return None
553
624
  if action_action == "add":
554
625
  return self._process_add_packet(model_type, data)
555
626
  if action_action == "update":
@@ -614,5 +685,5 @@ class Bootstrap(ProtectBaseObject):
614
685
  _LOGGER.debug("Successfully refresh model: %s %s", model_type, device_id)
615
686
 
616
687
  async def get_is_prerelease(self) -> bool:
617
- """Get if current version of Protect is a prerelease version."""
618
- return await self.nvr.get_is_prerelease()
688
+ """[DEPRECATED] Always returns False. Will be removed after HA 2025.8.0."""
689
+ return False
uiprotect/data/convert.py CHANGED
@@ -2,10 +2,13 @@
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 (
11
+ AiPort,
9
12
  Bridge,
10
13
  Camera,
11
14
  Chime,
@@ -16,7 +19,7 @@ from .devices import (
16
19
  )
17
20
  from .nvr import NVR, Event, Liveview
18
21
  from .types import ModelType
19
- from .user import CloudAccount, Group, User, UserLocation
22
+ from .user import CloudAccount, Group, Keyring, UlpUser, User, UserLocation
20
23
 
21
24
  if TYPE_CHECKING:
22
25
  from ..api import ProtectApiClient
@@ -38,6 +41,9 @@ MODEL_TO_CLASS: dict[str, type[ProtectModel]] = {
38
41
  ModelType.SENSOR: Sensor,
39
42
  ModelType.DOORLOCK: Doorlock,
40
43
  ModelType.CHIME: Chime,
44
+ ModelType.AIPORT: AiPort,
45
+ ModelType.KEYRING: Keyring,
46
+ ModelType.ULP_USER: UlpUser,
41
47
  }
42
48
 
43
49
 
@@ -79,3 +85,12 @@ def create_from_unifi_dict(
79
85
  klass = get_klass_from_dict(data)
80
86
 
81
87
  return klass.from_unifi_dict(**data, api=api)
88
+
89
+
90
+ def list_from_unifi_list(
91
+ api: ProtectApiClient, unifi_list: list[dict[str, ProtectModelWithId]]
92
+ ) -> list[ProtectModelWithId]:
93
+ return [
94
+ cast(ProtectModelWithId, create_from_unifi_dict(obj_dict, api))
95
+ for obj_dict in unifi_list
96
+ ]