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/types.py
CHANGED
|
@@ -1,19 +1,52 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import enum
|
|
4
|
-
|
|
5
|
-
from
|
|
6
|
-
from
|
|
4
|
+
import types
|
|
5
|
+
from collections.abc import Callable, Coroutine, Sequence
|
|
6
|
+
from functools import cache, lru_cache
|
|
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
|
-
from pydantic
|
|
10
|
-
from pydantic.
|
|
11
|
-
from
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from pydantic.types import StringConstraints
|
|
12
|
+
from pydantic_extra_types.color import Color # noqa: F401
|
|
13
|
+
|
|
14
|
+
from .._compat import cached_property
|
|
12
15
|
|
|
13
16
|
KT = TypeVar("KT")
|
|
14
17
|
VT = TypeVar("VT")
|
|
15
18
|
|
|
16
19
|
|
|
20
|
+
@lru_cache(maxsize=512)
|
|
21
|
+
def get_field_type(annotation: type[Any] | None) -> tuple[type | None, Any]:
|
|
22
|
+
"""Extract the origin and type from an annotation."""
|
|
23
|
+
if annotation is None:
|
|
24
|
+
raise ValueError("Type annotation cannot be None")
|
|
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
|
|
48
|
+
|
|
49
|
+
|
|
17
50
|
DEFAULT = "DEFAULT_VALUE"
|
|
18
51
|
DEFAULT_TYPE = Literal["DEFAULT_VALUE"]
|
|
19
52
|
EventCategories = Literal[
|
|
@@ -27,7 +60,7 @@ EventCategories = Literal[
|
|
|
27
60
|
]
|
|
28
61
|
|
|
29
62
|
ProgressCallback = Callable[[int, int, int], Coroutine[Any, Any, None]]
|
|
30
|
-
IteratorCallback = Callable[[int,
|
|
63
|
+
IteratorCallback = Callable[[int, bytes | None], Coroutine[Any, Any, None]]
|
|
31
64
|
|
|
32
65
|
|
|
33
66
|
class FixSizeOrderedDict(dict[KT, VT]):
|
|
@@ -49,6 +82,11 @@ class ValuesEnumMixin:
|
|
|
49
82
|
_values: list[str] | None = None
|
|
50
83
|
_values_normalized: dict[str, str] | None = None
|
|
51
84
|
|
|
85
|
+
@classmethod
|
|
86
|
+
@cache
|
|
87
|
+
def from_string(cls, value: str) -> Any:
|
|
88
|
+
return cls(value) # type: ignore[call-arg]
|
|
89
|
+
|
|
52
90
|
@classmethod
|
|
53
91
|
@cache
|
|
54
92
|
def values(cls) -> list[str]:
|
|
@@ -96,8 +134,12 @@ class ModelType(str, UnknownValuesEnumMixin, enum.Enum):
|
|
|
96
134
|
DOORLOCK = "doorlock"
|
|
97
135
|
SCHEDULE = "schedule"
|
|
98
136
|
CHIME = "chime"
|
|
137
|
+
AIPORT = "aiport"
|
|
99
138
|
DEVICE_GROUP = "deviceGroup"
|
|
100
139
|
RECORDING_SCHEDULE = "recordingSchedule"
|
|
140
|
+
ULP_USER = "ulpUser"
|
|
141
|
+
RINGTONE = "ringtone"
|
|
142
|
+
KEYRING = "keyring"
|
|
101
143
|
UNKNOWN = "unknown"
|
|
102
144
|
|
|
103
145
|
bootstrap_model_types: tuple[ModelType, ...]
|
|
@@ -111,6 +153,16 @@ class ModelType(str, UnknownValuesEnumMixin, enum.Enum):
|
|
|
111
153
|
"""Return the devices key."""
|
|
112
154
|
return f"{self.value}s"
|
|
113
155
|
|
|
156
|
+
@cached_property
|
|
157
|
+
def name(self) -> str:
|
|
158
|
+
"""Return the name."""
|
|
159
|
+
return self._name_
|
|
160
|
+
|
|
161
|
+
@cached_property
|
|
162
|
+
def value(self) -> str:
|
|
163
|
+
"""Return the value."""
|
|
164
|
+
return self._value_
|
|
165
|
+
|
|
114
166
|
@classmethod
|
|
115
167
|
@cache
|
|
116
168
|
def from_string(cls, value: str) -> ModelType:
|
|
@@ -133,6 +185,7 @@ class ModelType(str, UnknownValuesEnumMixin, enum.Enum):
|
|
|
133
185
|
ModelType.SENSOR,
|
|
134
186
|
ModelType.DOORLOCK,
|
|
135
187
|
ModelType.CHIME,
|
|
188
|
+
ModelType.AIPORT,
|
|
136
189
|
)
|
|
137
190
|
|
|
138
191
|
@classmethod
|
|
@@ -184,6 +237,8 @@ class EventType(str, ValuesEnumMixin, enum.Enum):
|
|
|
184
237
|
POOR_CONNECTION = "poorConnection"
|
|
185
238
|
STREAM_RECOVERY = "streamRecovery"
|
|
186
239
|
MOTION = "motion"
|
|
240
|
+
NFC_CARD_SCANNED = "nfcCardScanned"
|
|
241
|
+
FINGERPRINT_IDENTIFIED = "fingerprintIdentified"
|
|
187
242
|
RECORDING_DELETED = "recordingDeleted"
|
|
188
243
|
SMART_AUDIO_DETECT = "smartAudioDetect"
|
|
189
244
|
SMART_DETECT = "smartDetectZone"
|
|
@@ -257,8 +312,12 @@ class EventType(str, ValuesEnumMixin, enum.Enum):
|
|
|
257
312
|
def device_events() -> list[str]:
|
|
258
313
|
return [
|
|
259
314
|
EventType.MOTION.value,
|
|
315
|
+
EventType.NFC_CARD_SCANNED.value,
|
|
316
|
+
EventType.FINGERPRINT_IDENTIFIED.value,
|
|
260
317
|
EventType.RING.value,
|
|
261
318
|
EventType.SMART_DETECT.value,
|
|
319
|
+
EventType.SMART_AUDIO_DETECT.value,
|
|
320
|
+
EventType.SMART_DETECT_LINE.value,
|
|
262
321
|
]
|
|
263
322
|
|
|
264
323
|
@staticmethod
|
|
@@ -386,6 +445,8 @@ class VideoMode(str, ValuesEnumMixin, enum.Enum):
|
|
|
386
445
|
HOMEKIT = "homekit"
|
|
387
446
|
SPORT = "sport"
|
|
388
447
|
SLOW_SHUTTER = "slowShutter"
|
|
448
|
+
LPR_NONE_REFLEX = "lprNoneReflex"
|
|
449
|
+
LPR_REFLEX = "lprReflex"
|
|
389
450
|
# should only be for unadopted devices
|
|
390
451
|
UNKNOWN = "unknown"
|
|
391
452
|
|
|
@@ -398,6 +459,7 @@ class AudioStyle(str, UnknownValuesEnumMixin, enum.Enum):
|
|
|
398
459
|
|
|
399
460
|
@enum.unique
|
|
400
461
|
class RecordingMode(str, ValuesEnumMixin, enum.Enum):
|
|
462
|
+
ADAPTIVE = "adaptive"
|
|
401
463
|
ALWAYS = "always"
|
|
402
464
|
NEVER = "never"
|
|
403
465
|
SCHEDULE = "schedule"
|
|
@@ -475,6 +537,7 @@ class SleepStateType(str, ValuesEnumMixin, enum.Enum):
|
|
|
475
537
|
class AutoExposureMode(str, ValuesEnumMixin, enum.Enum):
|
|
476
538
|
MANUAL = "manual"
|
|
477
539
|
AUTO = "auto"
|
|
540
|
+
NONE = "none"
|
|
478
541
|
SHUTTER = "shutter"
|
|
479
542
|
FLICK50 = "flick50"
|
|
480
543
|
FLICK60 = "flick60"
|
|
@@ -484,6 +547,7 @@ class AutoExposureMode(str, ValuesEnumMixin, enum.Enum):
|
|
|
484
547
|
class FocusMode(str, ValuesEnumMixin, enum.Enum):
|
|
485
548
|
MANUAL = "manual"
|
|
486
549
|
AUTO = "auto"
|
|
550
|
+
NONE = "none"
|
|
487
551
|
ZTRIG = "ztrig"
|
|
488
552
|
TOUCH = "touch"
|
|
489
553
|
|
|
@@ -575,9 +639,20 @@ class PermissionNode(str, UnknownValuesEnumMixin, enum.Enum):
|
|
|
575
639
|
READ_LIVE = "readlive"
|
|
576
640
|
UNKNOWN = "unknown"
|
|
577
641
|
|
|
642
|
+
@cached_property
|
|
643
|
+
def name(self) -> str:
|
|
644
|
+
"""Return the name."""
|
|
645
|
+
return self._name_
|
|
646
|
+
|
|
647
|
+
@cached_property
|
|
648
|
+
def value(self) -> str:
|
|
649
|
+
"""Return the value."""
|
|
650
|
+
return self._value_
|
|
651
|
+
|
|
578
652
|
|
|
579
653
|
@enum.unique
|
|
580
654
|
class HDRMode(str, UnknownValuesEnumMixin, enum.Enum):
|
|
655
|
+
NONE = "none"
|
|
581
656
|
NORMAL = "normal"
|
|
582
657
|
ALWAYS_ON = "superHdr"
|
|
583
658
|
|
|
@@ -591,64 +666,33 @@ class LensType(str, enum.Enum):
|
|
|
591
666
|
DLSR_17 = "m43"
|
|
592
667
|
|
|
593
668
|
|
|
594
|
-
|
|
595
|
-
max_length = 30
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
class ICRCustomValue(ConstrainedInt):
|
|
599
|
-
ge = 0
|
|
600
|
-
le = 10
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
class ICRLuxValue(ConstrainedInt):
|
|
604
|
-
ge = 1
|
|
605
|
-
le = 30
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
class LEDLevel(ConstrainedInt):
|
|
609
|
-
ge = 0
|
|
610
|
-
le = 6
|
|
611
|
-
|
|
669
|
+
DoorbellText = Annotated[str, StringConstraints(max_length=30)]
|
|
612
670
|
|
|
613
|
-
|
|
614
|
-
ge = 0
|
|
615
|
-
le = 100
|
|
671
|
+
ICRCustomValue = Annotated[int, Field(ge=0, le=11)]
|
|
616
672
|
|
|
673
|
+
ICRLuxValue = Annotated[int, Field(ge=1, le=30)]
|
|
617
674
|
|
|
618
|
-
|
|
619
|
-
ge = 1
|
|
620
|
-
le = 255
|
|
675
|
+
LEDLevel = Annotated[int, Field(ge=0, le=6)]
|
|
621
676
|
|
|
677
|
+
PercentInt = Annotated[int, Field(ge=0, le=101)]
|
|
622
678
|
|
|
623
|
-
|
|
624
|
-
ge = 0
|
|
625
|
-
le = 100
|
|
679
|
+
TwoByteInt = Annotated[int, Field(ge=1, le=256)]
|
|
626
680
|
|
|
681
|
+
PercentFloat = Annotated[float, Field(ge=0, le=100)]
|
|
627
682
|
|
|
628
|
-
|
|
629
|
-
ge = 0
|
|
630
|
-
le = 3
|
|
683
|
+
WDRLevel = Annotated[int, Field(ge=0, le=4)]
|
|
631
684
|
|
|
685
|
+
ICRSensitivity = Annotated[int, Field(ge=0, le=4)]
|
|
632
686
|
|
|
633
|
-
|
|
634
|
-
ge = 0
|
|
635
|
-
le = 3
|
|
687
|
+
Percent = Annotated[float, Field(ge=0, le=1)]
|
|
636
688
|
|
|
637
|
-
|
|
638
|
-
class Percent(ConstrainedFloat):
|
|
639
|
-
ge = 0
|
|
640
|
-
le = 1
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
class RepeatTimes(ConstrainedInt):
|
|
644
|
-
ge = 1
|
|
645
|
-
le = 6
|
|
689
|
+
RepeatTimes = Annotated[int, Field(ge=1, le=6)]
|
|
646
690
|
|
|
647
691
|
|
|
648
692
|
class PTZPositionDegree(BaseModel):
|
|
649
693
|
pan: float
|
|
650
694
|
tilt: float
|
|
651
|
-
zoom:
|
|
695
|
+
zoom: float
|
|
652
696
|
|
|
653
697
|
|
|
654
698
|
class PTZPositionSteps(BaseModel):
|
|
@@ -679,15 +723,6 @@ class PTZPreset(BaseModel):
|
|
|
679
723
|
CoordType = Union[Percent, int, float]
|
|
680
724
|
|
|
681
725
|
|
|
682
|
-
# TODO: fix when upgrading to pydantic v2
|
|
683
|
-
class Color(BaseColor):
|
|
684
|
-
def __eq__(self, o: object) -> bool:
|
|
685
|
-
if isinstance(o, Color):
|
|
686
|
-
return self.as_hex() == o.as_hex()
|
|
687
|
-
|
|
688
|
-
return super().__eq__(o)
|
|
689
|
-
|
|
690
|
-
|
|
691
726
|
class Version(BaseVersion):
|
|
692
727
|
def __str__(self) -> str:
|
|
693
728
|
super_str = super().__str__()
|
uiprotect/data/user.py
CHANGED
|
@@ -2,21 +2,28 @@
|
|
|
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
|
-
from pydantic.
|
|
11
|
+
from pydantic.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
|
|
17
24
|
model: ModelType
|
|
18
25
|
nodes: set[PermissionNode]
|
|
19
|
-
obj_ids: set[str] | None
|
|
26
|
+
obj_ids: set[str] | None = None
|
|
20
27
|
|
|
21
28
|
@classmethod
|
|
22
29
|
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -72,8 +79,8 @@ class Group(ProtectModelWithId):
|
|
|
72
79
|
|
|
73
80
|
class UserLocation(ProtectModel):
|
|
74
81
|
is_away: bool
|
|
75
|
-
latitude: float | None
|
|
76
|
-
longitude: float | None
|
|
82
|
+
latitude: float | None = None
|
|
83
|
+
longitude: float | None = None
|
|
77
84
|
|
|
78
85
|
|
|
79
86
|
class CloudAccount(ProtectModelWithId):
|
|
@@ -82,7 +89,7 @@ class CloudAccount(ProtectModelWithId):
|
|
|
82
89
|
email: str
|
|
83
90
|
user_id: str
|
|
84
91
|
name: str
|
|
85
|
-
location: UserLocation | None
|
|
92
|
+
location: UserLocation | None = None
|
|
86
93
|
profile_img: str | None = None
|
|
87
94
|
|
|
88
95
|
@classmethod
|
|
@@ -116,21 +123,21 @@ class UserFeatureFlags(ProtectBaseObject):
|
|
|
116
123
|
|
|
117
124
|
class User(ProtectModelWithId):
|
|
118
125
|
permissions: list[Permission]
|
|
119
|
-
last_login_ip: str | None
|
|
120
|
-
last_login_time: datetime | None
|
|
126
|
+
last_login_ip: str | None = None
|
|
127
|
+
last_login_time: datetime | None = None
|
|
121
128
|
is_owner: bool
|
|
122
129
|
enable_notifications: bool
|
|
123
130
|
has_accepted_invite: bool
|
|
124
131
|
all_permissions: list[Permission]
|
|
125
132
|
scopes: list[str] | None = None
|
|
126
|
-
location: UserLocation | None
|
|
133
|
+
location: UserLocation | None = None
|
|
127
134
|
name: str
|
|
128
135
|
first_name: str
|
|
129
136
|
last_name: str
|
|
130
|
-
email: str | None
|
|
137
|
+
email: str | None = None
|
|
131
138
|
local_username: str
|
|
132
139
|
group_ids: list[str]
|
|
133
|
-
cloud_account: CloudAccount | None
|
|
140
|
+
cloud_account: CloudAccount | None = None
|
|
134
141
|
feature_flags: UserFeatureFlags
|
|
135
142
|
|
|
136
143
|
# TODO:
|
|
@@ -210,7 +217,7 @@ class User(ProtectModelWithId):
|
|
|
210
217
|
) -> bool:
|
|
211
218
|
"""Checks if a user can do a specific action"""
|
|
212
219
|
check_self = False
|
|
213
|
-
if model
|
|
220
|
+
if model is self.model and obj is not None and obj.id == self.id:
|
|
214
221
|
perm_str = f"{model.value}:{node.value}:$"
|
|
215
222
|
check_self = True
|
|
216
223
|
else:
|
|
@@ -221,7 +228,7 @@ class User(ProtectModelWithId):
|
|
|
221
228
|
return self._perm_cache[perm_str]
|
|
222
229
|
|
|
223
230
|
for perm in self.all_permissions:
|
|
224
|
-
if model
|
|
231
|
+
if model is not perm.model or node not in perm.nodes:
|
|
225
232
|
continue
|
|
226
233
|
if perm.obj_ids is None:
|
|
227
234
|
self._perm_cache[perm_str] = True
|
|
@@ -234,3 +241,115 @@ 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
|
+
def __len__(self) -> int:
|
|
256
|
+
return len(self._id_to_item)
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def from_list(cls, items: list[T]) -> Self:
|
|
260
|
+
instance = cls()
|
|
261
|
+
for item in items:
|
|
262
|
+
instance.add(item)
|
|
263
|
+
return instance
|
|
264
|
+
|
|
265
|
+
def add(self, item: T) -> None:
|
|
266
|
+
"""Add an item to the collection."""
|
|
267
|
+
self._id_to_item[item.id] = item
|
|
268
|
+
|
|
269
|
+
def remove(self, item: T) -> None:
|
|
270
|
+
"""Remove an item from the collection."""
|
|
271
|
+
self._id_to_item.pop(item.id, None)
|
|
272
|
+
|
|
273
|
+
def by_id(self, item_id: str) -> T | None:
|
|
274
|
+
"""Retrieve an item by its ID."""
|
|
275
|
+
return self._id_to_item.get(item_id)
|
|
276
|
+
|
|
277
|
+
@abstractmethod
|
|
278
|
+
def by_ulp_id(self, item_id: str) -> T | None:
|
|
279
|
+
"""Retrieve an item by its ULP ID."""
|
|
280
|
+
|
|
281
|
+
def as_list(self) -> list[T]:
|
|
282
|
+
return list(self._id_to_item.values())
|
|
283
|
+
|
|
284
|
+
def __eq__(self, other: Any) -> bool:
|
|
285
|
+
if TYPE_CHECKING:
|
|
286
|
+
assert isinstance(other, UlpUserKeyringBase)
|
|
287
|
+
return self._id_to_item == other._id_to_item
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class Keyring(ProtectModelWithId):
|
|
291
|
+
device_type: str
|
|
292
|
+
device_id: str
|
|
293
|
+
registry_type: str
|
|
294
|
+
registry_id: str
|
|
295
|
+
last_activity: datetime | None = None
|
|
296
|
+
ulp_user: str
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class Keyrings(UlpUserKeyringBase[Keyring]):
|
|
300
|
+
def __init__(self) -> None:
|
|
301
|
+
super().__init__()
|
|
302
|
+
self._keyrings_by_registry_id: dict[str, Keyring] = {}
|
|
303
|
+
self._keyrings_by_ulp_user: dict[str, Keyring] = {}
|
|
304
|
+
|
|
305
|
+
def add(self, keyring: Keyring) -> None:
|
|
306
|
+
super().add(keyring)
|
|
307
|
+
self._keyrings_by_registry_id[keyring.registry_id] = keyring
|
|
308
|
+
self._keyrings_by_ulp_user[keyring.ulp_user] = keyring
|
|
309
|
+
|
|
310
|
+
def remove(self, keyring: Keyring) -> None:
|
|
311
|
+
super().remove(keyring)
|
|
312
|
+
self._keyrings_by_registry_id.pop(keyring.registry_id, None)
|
|
313
|
+
self._keyrings_by_ulp_user.pop(keyring.ulp_user, None)
|
|
314
|
+
|
|
315
|
+
def by_ulp_id(self, ulp_id: str) -> Keyring | None:
|
|
316
|
+
return self._keyrings_by_ulp_user.get(ulp_id)
|
|
317
|
+
|
|
318
|
+
def by_registry_id(self, registry_id: str) -> Keyring | None:
|
|
319
|
+
return self._keyrings_by_registry_id.get(registry_id)
|
|
320
|
+
|
|
321
|
+
def __eq__(self, other: Any) -> bool:
|
|
322
|
+
if not isinstance(other, Keyrings):
|
|
323
|
+
return NotImplemented
|
|
324
|
+
return super().__eq__(other)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class UlpUser(ProtectModelWithId):
|
|
328
|
+
ulp_id: str
|
|
329
|
+
first_name: str
|
|
330
|
+
last_name: str
|
|
331
|
+
full_name: str
|
|
332
|
+
avatar: str
|
|
333
|
+
status: str
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class UlpUsers(UlpUserKeyringBase[UlpUser]):
|
|
337
|
+
def __init__(self) -> None:
|
|
338
|
+
super().__init__()
|
|
339
|
+
self._users_by_ulp_id: dict[str, UlpUser] = {}
|
|
340
|
+
|
|
341
|
+
def add(self, user: UlpUser) -> None:
|
|
342
|
+
super().add(user)
|
|
343
|
+
self._users_by_ulp_id[user.ulp_id] = user
|
|
344
|
+
|
|
345
|
+
def remove(self, user: UlpUser) -> None:
|
|
346
|
+
super().remove(user)
|
|
347
|
+
self._users_by_ulp_id.pop(user.ulp_id, None)
|
|
348
|
+
|
|
349
|
+
def by_ulp_id(self, ulp_id: str) -> UlpUser | None:
|
|
350
|
+
return self._users_by_ulp_id.get(ulp_id)
|
|
351
|
+
|
|
352
|
+
def __eq__(self, other: Any) -> bool:
|
|
353
|
+
if not isinstance(other, UlpUsers):
|
|
354
|
+
return NotImplemented
|
|
355
|
+
return super().__eq__(other)
|
uiprotect/data/websocket.py
CHANGED
|
@@ -7,11 +7,12 @@ import enum
|
|
|
7
7
|
import struct
|
|
8
8
|
import zlib
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
-
from functools import cache
|
|
10
|
+
from functools import cache
|
|
11
11
|
from typing import TYPE_CHECKING, Any
|
|
12
12
|
|
|
13
13
|
import orjson
|
|
14
14
|
|
|
15
|
+
from .._compat import cached_property
|
|
15
16
|
from ..exceptions import WSDecodeError, WSEncodeError
|
|
16
17
|
from .types import ProtectWSPayloadFormat
|
|
17
18
|
|
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,9 +136,10 @@ class TalkbackStream(FfmpegCommand):
|
|
|
130
136
|
if len(input_args) > 0:
|
|
131
137
|
input_args += " "
|
|
132
138
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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}")
|
|
136
143
|
|
|
137
144
|
# vn = no video
|
|
138
145
|
# acodec = audio codec to encode output in (aac)
|
|
@@ -142,9 +149,9 @@ class TalkbackStream(FfmpegCommand):
|
|
|
142
149
|
cmd = (
|
|
143
150
|
"-loglevel info -hide_banner "
|
|
144
151
|
f'{input_args}-i "{content_url}" -vn '
|
|
145
|
-
f"-acodec {
|
|
146
|
-
f"-ar {camera.talkback_settings.sampling_rate} -b:a {
|
|
147
|
-
f'-f
|
|
152
|
+
f"-acodec {encoder['encoder']} -ac {camera.talkback_settings.channels} "
|
|
153
|
+
f"-ar {camera.talkback_settings.sampling_rate} -b:a {camera.talkback_settings.sampling_rate} -map 0:a "
|
|
154
|
+
f'-f {encoder["format"]} "udp://{camera.host}:{camera.talkback_settings.bind_port}?bitrate={camera.talkback_settings.sampling_rate}"'
|
|
148
155
|
)
|
|
149
156
|
|
|
150
157
|
super().__init__(cmd, ffmpeg_path)
|
uiprotect/test_util/__init__.py
CHANGED
|
@@ -126,11 +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
|
|
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", [])),
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
self.log("Generating event data...")
|
|
@@ -141,6 +142,7 @@ class SampleDataGenerator:
|
|
|
141
142
|
|
|
142
143
|
if close_session:
|
|
143
144
|
await self.client.close_session()
|
|
145
|
+
await self.client.close_public_api_session()
|
|
144
146
|
|
|
145
147
|
await self.write_json_file("sample_constants", self.constants, anonymize=False)
|
|
146
148
|
|
|
@@ -283,6 +285,7 @@ class SampleDataGenerator:
|
|
|
283
285
|
self.generate_sensor_data(),
|
|
284
286
|
self.generate_lock_data(),
|
|
285
287
|
self.generate_chime_data(),
|
|
288
|
+
self.generate_aiport_data(),
|
|
286
289
|
self.generate_bridge_data(),
|
|
287
290
|
self.generate_liveview_data(),
|
|
288
291
|
)
|
|
@@ -306,10 +309,18 @@ class SampleDataGenerator:
|
|
|
306
309
|
await self.write_json_file("sample_camera", deepcopy(obj))
|
|
307
310
|
self.constants["camera_online"] = camera_is_online
|
|
308
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
|
+
|
|
309
319
|
if not camera_is_online:
|
|
310
320
|
self.log(
|
|
311
321
|
"Camera is not online, skipping snapshot, thumbnail and heatmap generation",
|
|
312
322
|
)
|
|
323
|
+
return
|
|
313
324
|
|
|
314
325
|
# snapshot
|
|
315
326
|
width = obj["channels"][0]["width"]
|
|
@@ -322,6 +333,15 @@ class SampleDataGenerator:
|
|
|
322
333
|
snapshot = await self.client.get_camera_snapshot(obj["id"], width, height)
|
|
323
334
|
await self.write_image_file(filename, snapshot)
|
|
324
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
|
+
|
|
325
345
|
async def generate_motion_data(
|
|
326
346
|
self,
|
|
327
347
|
motion_event: dict[str, Any] | None,
|
|
@@ -359,7 +379,7 @@ class SampleDataGenerator:
|
|
|
359
379
|
length = int((motion_event["end"] - motion_event["start"]) / 1000)
|
|
360
380
|
if self.anonymize:
|
|
361
381
|
run(
|
|
362
|
-
split(
|
|
382
|
+
split(
|
|
363
383
|
BLANK_VIDEO_CMD.format(
|
|
364
384
|
length=length,
|
|
365
385
|
filename=self.output_folder / f"{filename}.mp4",
|
|
@@ -440,7 +460,12 @@ class SampleDataGenerator:
|
|
|
440
460
|
await self.write_json_file("sample_sensor", obj)
|
|
441
461
|
|
|
442
462
|
async def generate_lock_data(self) -> None:
|
|
443
|
-
|
|
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
|
+
|
|
444
469
|
device_id: str | None = None
|
|
445
470
|
for obj_dict in objs:
|
|
446
471
|
device_id = obj_dict["id"]
|
|
@@ -469,6 +494,21 @@ class SampleDataGenerator:
|
|
|
469
494
|
obj = await self.client.api_request_obj(f"chimes/{device_id}")
|
|
470
495
|
await self.write_json_file("sample_chime", obj)
|
|
471
496
|
|
|
497
|
+
async def generate_aiport_data(self) -> None:
|
|
498
|
+
objs = await self.client.api_request_list("aiports")
|
|
499
|
+
device_id: str | None = None
|
|
500
|
+
for obj_dict in objs:
|
|
501
|
+
device_id = obj_dict["id"]
|
|
502
|
+
if is_online(obj_dict):
|
|
503
|
+
break
|
|
504
|
+
|
|
505
|
+
if device_id is None:
|
|
506
|
+
self.log("No aiport found. Skipping aiport endpoints...")
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
obj = await self.client.api_request_obj(f"aiports/{device_id}")
|
|
510
|
+
await self.write_json_file("sample_aiport", obj)
|
|
511
|
+
|
|
472
512
|
async def generate_bridge_data(self) -> None:
|
|
473
513
|
objs = await self.client.api_request_list("bridges")
|
|
474
514
|
device_id: str | None = None
|
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)
|