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.
- uiprotect/__init__.py +1 -3
- uiprotect/_compat.py +13 -0
- uiprotect/api.py +975 -92
- uiprotect/cli/__init__.py +111 -24
- uiprotect/cli/aiports.py +58 -0
- uiprotect/cli/backup.py +5 -4
- uiprotect/cli/base.py +5 -5
- 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/__init__.py +2 -0
- uiprotect/data/base.py +96 -97
- uiprotect/data/bootstrap.py +116 -45
- uiprotect/data/convert.py +17 -2
- uiprotect/data/devices.py +409 -164
- uiprotect/data/nvr.py +236 -118
- uiprotect/data/types.py +94 -59
- uiprotect/data/user.py +132 -13
- uiprotect/data/websocket.py +2 -1
- uiprotect/stream.py +13 -6
- uiprotect/test_util/__init__.py +47 -7
- uiprotect/test_util/anonymize.py +4 -5
- uiprotect/utils.py +99 -45
- uiprotect/websocket.py +11 -6
- {uiprotect-3.8.0.dist-info → uiprotect-7.32.0.dist-info}/METADATA +77 -21
- uiprotect-7.32.0.dist-info/RECORD +39 -0
- {uiprotect-3.8.0.dist-info → uiprotect-7.32.0.dist-info}/WHEEL +1 -1
- uiprotect-3.8.0.dist-info/RECORD +0 -37
- {uiprotect-3.8.0.dist-info → uiprotect-7.32.0.dist-info}/entry_points.txt +0 -0
- {uiprotect-3.8.0.dist-info → uiprotect-7.32.0.dist-info/licenses}/LICENSE +0 -0
uiprotect/data/bootstrap.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
404
|
-
self.nvr = self.nvr.update_from_dict(
|
|
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
|
-
|
|
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",
|
|
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.
|
|
461
|
-
obj = obj.update_from_dict(
|
|
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=
|
|
518
|
-
action=
|
|
519
|
-
keys=
|
|
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
|
-
"""
|
|
618
|
-
return
|
|
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
|
+
]
|