uiprotect 0.1.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/__init__.py +13 -0
- uiprotect/__main__.py +24 -0
- uiprotect/api.py +1936 -0
- uiprotect/cli/__init__.py +314 -0
- uiprotect/cli/backup.py +1103 -0
- uiprotect/cli/base.py +238 -0
- uiprotect/cli/cameras.py +574 -0
- uiprotect/cli/chimes.py +180 -0
- uiprotect/cli/doorlocks.py +125 -0
- uiprotect/cli/events.py +258 -0
- uiprotect/cli/lights.py +119 -0
- uiprotect/cli/liveviews.py +65 -0
- uiprotect/cli/nvr.py +154 -0
- uiprotect/cli/sensors.py +278 -0
- uiprotect/cli/viewers.py +76 -0
- uiprotect/data/__init__.py +157 -0
- uiprotect/data/base.py +1116 -0
- uiprotect/data/bootstrap.py +634 -0
- uiprotect/data/convert.py +77 -0
- uiprotect/data/devices.py +3384 -0
- uiprotect/data/nvr.py +1520 -0
- uiprotect/data/types.py +630 -0
- uiprotect/data/user.py +236 -0
- uiprotect/data/websocket.py +236 -0
- uiprotect/exceptions.py +41 -0
- uiprotect/py.typed +0 -0
- uiprotect/release_cache.json +1 -0
- uiprotect/stream.py +166 -0
- uiprotect/test_util/__init__.py +531 -0
- uiprotect/test_util/anonymize.py +257 -0
- uiprotect/utils.py +610 -0
- uiprotect/websocket.py +225 -0
- uiprotect-0.1.0.dist-info/LICENSE +23 -0
- uiprotect-0.1.0.dist-info/METADATA +245 -0
- uiprotect-0.1.0.dist-info/RECORD +37 -0
- uiprotect-0.1.0.dist-info/WHEEL +4 -0
- uiprotect-0.1.0.dist-info/entry_points.txt +3 -0
uiprotect/data/user.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""UniFi Protect User models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from functools import cache
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic.v1.fields import PrivateAttr
|
|
10
|
+
|
|
11
|
+
from uiprotect.data.base import ProtectBaseObject, ProtectModel, ProtectModelWithId
|
|
12
|
+
from uiprotect.data.types import ModelType, PermissionNode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Permission(ProtectBaseObject):
|
|
16
|
+
raw_permission: str
|
|
17
|
+
model: ModelType
|
|
18
|
+
nodes: set[PermissionNode]
|
|
19
|
+
obj_ids: set[str] | None
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
23
|
+
permission = data.get("rawPermission", "")
|
|
24
|
+
parts = permission.split(":")
|
|
25
|
+
if len(parts) < 2:
|
|
26
|
+
raise ValueError(f"Invalid permission: {permission}")
|
|
27
|
+
|
|
28
|
+
data["model"] = ModelType(parts[0])
|
|
29
|
+
if parts[1] == "*":
|
|
30
|
+
data["nodes"] = list(PermissionNode)
|
|
31
|
+
else:
|
|
32
|
+
data["nodes"] = [PermissionNode(n) for n in parts[1].split(",")]
|
|
33
|
+
|
|
34
|
+
if len(parts) == 3 and parts[2] != "*":
|
|
35
|
+
if parts[2] == "$":
|
|
36
|
+
data["obj_ids"] = ["self"]
|
|
37
|
+
else:
|
|
38
|
+
data["obj_ids"] = parts[2].split(",")
|
|
39
|
+
|
|
40
|
+
return super().unifi_dict_to_dict(data)
|
|
41
|
+
|
|
42
|
+
def unifi_dict( # type: ignore[override]
|
|
43
|
+
self,
|
|
44
|
+
data: dict[str, Any] | None = None,
|
|
45
|
+
exclude: set[str] | None = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
return self.raw_permission
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def objs(self) -> list[ProtectModelWithId] | None:
|
|
51
|
+
if self.obj_ids == {"self"} or self.obj_ids is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
devices = getattr(self.api.bootstrap, f"{self.model.value}s")
|
|
55
|
+
return [devices[oid] for oid in self.obj_ids]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Group(ProtectModelWithId):
|
|
59
|
+
name: str
|
|
60
|
+
permissions: list[Permission]
|
|
61
|
+
type: str
|
|
62
|
+
is_default: bool
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
if "permissions" in data:
|
|
67
|
+
permissions = data.pop("permissions")
|
|
68
|
+
data["permissions"] = [{"rawPermission": p} for p in permissions]
|
|
69
|
+
|
|
70
|
+
return super().unifi_dict_to_dict(data)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class UserLocation(ProtectModel):
|
|
74
|
+
is_away: bool
|
|
75
|
+
latitude: float | None
|
|
76
|
+
longitude: float | None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CloudAccount(ProtectModelWithId):
|
|
80
|
+
first_name: str
|
|
81
|
+
last_name: str
|
|
82
|
+
email: str
|
|
83
|
+
user_id: str
|
|
84
|
+
name: str
|
|
85
|
+
location: UserLocation | None
|
|
86
|
+
profile_img: str | None = None
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
@cache
|
|
90
|
+
def _get_unifi_remaps(cls) -> dict[str, str]:
|
|
91
|
+
return {**super()._get_unifi_remaps(), "user": "userId"}
|
|
92
|
+
|
|
93
|
+
def unifi_dict(
|
|
94
|
+
self,
|
|
95
|
+
data: dict[str, Any] | None = None,
|
|
96
|
+
exclude: set[str] | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
data = super().unifi_dict(data=data, exclude=exclude)
|
|
99
|
+
|
|
100
|
+
# id and cloud ID are always the same
|
|
101
|
+
if "id" in data:
|
|
102
|
+
data["cloudId"] = data["id"]
|
|
103
|
+
if "location" in data and data["location"] is None:
|
|
104
|
+
del data["location"]
|
|
105
|
+
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def user(self) -> User:
|
|
110
|
+
return self.api.bootstrap.users[self.user_id]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class UserFeatureFlags(ProtectBaseObject):
|
|
114
|
+
notifications_v2: bool
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class User(ProtectModelWithId):
|
|
118
|
+
permissions: list[Permission]
|
|
119
|
+
last_login_ip: str | None
|
|
120
|
+
last_login_time: datetime | None
|
|
121
|
+
is_owner: bool
|
|
122
|
+
enable_notifications: bool
|
|
123
|
+
has_accepted_invite: bool
|
|
124
|
+
all_permissions: list[Permission]
|
|
125
|
+
scopes: list[str] | None = None
|
|
126
|
+
location: UserLocation | None
|
|
127
|
+
name: str
|
|
128
|
+
first_name: str
|
|
129
|
+
last_name: str
|
|
130
|
+
email: str | None
|
|
131
|
+
local_username: str
|
|
132
|
+
group_ids: list[str]
|
|
133
|
+
cloud_account: CloudAccount | None
|
|
134
|
+
feature_flags: UserFeatureFlags
|
|
135
|
+
|
|
136
|
+
# TODO:
|
|
137
|
+
# settings
|
|
138
|
+
# alertRules
|
|
139
|
+
# notificationsV2
|
|
140
|
+
# notifications
|
|
141
|
+
# cloudProviders
|
|
142
|
+
|
|
143
|
+
_groups: list[Group] | None = PrivateAttr(None)
|
|
144
|
+
_perm_cache: dict[str, bool] = PrivateAttr({})
|
|
145
|
+
|
|
146
|
+
def __init__(self, **data: Any) -> None:
|
|
147
|
+
if "permissions" in data:
|
|
148
|
+
permissions = data.pop("permissions")
|
|
149
|
+
data["permissions"] = [
|
|
150
|
+
{"raw_permission": p} if isinstance(p, str) else p for p in permissions
|
|
151
|
+
]
|
|
152
|
+
if "allPermissions" in data:
|
|
153
|
+
permissions = data.pop("allPermissions")
|
|
154
|
+
data["allPermissions"] = [
|
|
155
|
+
{"raw_permission": p} if isinstance(p, str) else p for p in permissions
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
super().__init__(**data)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
if "permissions" in data:
|
|
163
|
+
permissions = data.pop("permissions")
|
|
164
|
+
data["permissions"] = [{"rawPermission": p} for p in permissions]
|
|
165
|
+
if "allPermissions" in data:
|
|
166
|
+
permissions = data.pop("allPermissions")
|
|
167
|
+
data["allPermissions"] = [{"rawPermission": p} for p in permissions]
|
|
168
|
+
|
|
169
|
+
return super().unifi_dict_to_dict(data)
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
@cache
|
|
173
|
+
def _get_unifi_remaps(cls) -> dict[str, str]:
|
|
174
|
+
return {**super()._get_unifi_remaps(), "groups": "groupIds"}
|
|
175
|
+
|
|
176
|
+
def unifi_dict(
|
|
177
|
+
self,
|
|
178
|
+
data: dict[str, Any] | None = None,
|
|
179
|
+
exclude: set[str] | None = None,
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
data = super().unifi_dict(data=data, exclude=exclude)
|
|
182
|
+
|
|
183
|
+
if "location" in data and data["location"] is None:
|
|
184
|
+
del data["location"]
|
|
185
|
+
|
|
186
|
+
return data
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def groups(self) -> list[Group]:
|
|
190
|
+
"""
|
|
191
|
+
Groups the user is in
|
|
192
|
+
|
|
193
|
+
Will always be empty if the user only has read only access.
|
|
194
|
+
"""
|
|
195
|
+
if self._groups is not None:
|
|
196
|
+
return self._groups
|
|
197
|
+
|
|
198
|
+
self._groups = [
|
|
199
|
+
self.api.bootstrap.groups[g]
|
|
200
|
+
for g in self.group_ids
|
|
201
|
+
if g in self.api.bootstrap.groups
|
|
202
|
+
]
|
|
203
|
+
return self._groups
|
|
204
|
+
|
|
205
|
+
def can(
|
|
206
|
+
self,
|
|
207
|
+
model: ModelType,
|
|
208
|
+
node: PermissionNode,
|
|
209
|
+
obj: ProtectModelWithId | None = None,
|
|
210
|
+
) -> bool:
|
|
211
|
+
"""Checks if a user can do a specific action"""
|
|
212
|
+
check_self = False
|
|
213
|
+
if model == self.model and obj is not None and obj.id == self.id:
|
|
214
|
+
perm_str = f"{model.value}:{node.value}:$"
|
|
215
|
+
check_self = True
|
|
216
|
+
else:
|
|
217
|
+
perm_str = (
|
|
218
|
+
f"{model.value}:{node.value}:{obj.id if obj is not None else '*'}"
|
|
219
|
+
)
|
|
220
|
+
if perm_str in self._perm_cache:
|
|
221
|
+
return self._perm_cache[perm_str]
|
|
222
|
+
|
|
223
|
+
for perm in self.all_permissions:
|
|
224
|
+
if model != perm.model or node not in perm.nodes:
|
|
225
|
+
continue
|
|
226
|
+
if perm.obj_ids is None:
|
|
227
|
+
self._perm_cache[perm_str] = True
|
|
228
|
+
return True
|
|
229
|
+
if check_self and perm.obj_ids == {"self"}:
|
|
230
|
+
self._perm_cache[perm_str] = True
|
|
231
|
+
return True
|
|
232
|
+
if perm.obj_ids is not None and obj is not None and obj.id in perm.obj_ids:
|
|
233
|
+
self._perm_cache[perm_str] = True
|
|
234
|
+
return True
|
|
235
|
+
self._perm_cache[perm_str] = False
|
|
236
|
+
return False
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Classes for decoding/encoding data from UniFi OS Websocket"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import enum
|
|
7
|
+
import struct
|
|
8
|
+
import zlib
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
|
|
15
|
+
from uiprotect.data.types import ProtectWSPayloadFormat
|
|
16
|
+
from uiprotect.exceptions import WSDecodeError, WSEncodeError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from uiprotect.data.base import ProtectModelWithId
|
|
20
|
+
|
|
21
|
+
WS_HEADER_SIZE = 8
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class WSPacketFrameHeader:
|
|
26
|
+
packet_type: int
|
|
27
|
+
payload_format: int
|
|
28
|
+
deflated: int
|
|
29
|
+
unknown: int
|
|
30
|
+
payload_size: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@enum.unique
|
|
34
|
+
class WSAction(str, enum.Enum):
|
|
35
|
+
ADD = "add"
|
|
36
|
+
UPDATE = "update"
|
|
37
|
+
REMOVE = "remove"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class WSSubscriptionMessage:
|
|
42
|
+
action: WSAction
|
|
43
|
+
new_update_id: UUID
|
|
44
|
+
changed_data: dict[str, Any]
|
|
45
|
+
new_obj: ProtectModelWithId | None = None
|
|
46
|
+
old_obj: ProtectModelWithId | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BaseWSPacketFrame:
|
|
50
|
+
data: Any
|
|
51
|
+
position: int = 0
|
|
52
|
+
header: WSPacketFrameHeader | None = None
|
|
53
|
+
payload_format: ProtectWSPayloadFormat = ProtectWSPayloadFormat.NodeBuffer
|
|
54
|
+
is_deflated: bool = False
|
|
55
|
+
length: int = 0
|
|
56
|
+
|
|
57
|
+
def set_data_from_binary(self, data: bytes) -> None:
|
|
58
|
+
self.data = data
|
|
59
|
+
if self.header is not None and self.header.deflated:
|
|
60
|
+
self.data = zlib.decompress(self.data)
|
|
61
|
+
|
|
62
|
+
def get_binary_from_data(self) -> bytes:
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def klass_from_format(format_raw: int) -> type[BaseWSPacketFrame]:
|
|
67
|
+
payload_format = ProtectWSPayloadFormat(format_raw)
|
|
68
|
+
|
|
69
|
+
if payload_format == ProtectWSPayloadFormat.JSON:
|
|
70
|
+
return WSJSONPacketFrame
|
|
71
|
+
|
|
72
|
+
return WSRawPacketFrame
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def from_binary(
|
|
76
|
+
data: bytes,
|
|
77
|
+
position: int = 0,
|
|
78
|
+
klass: type[WSRawPacketFrame] | None = None,
|
|
79
|
+
) -> BaseWSPacketFrame:
|
|
80
|
+
"""
|
|
81
|
+
Decode a unifi updates websocket frame.
|
|
82
|
+
|
|
83
|
+
The format of the frame is
|
|
84
|
+
b: packet_type
|
|
85
|
+
b: payload_format
|
|
86
|
+
b: deflated
|
|
87
|
+
b: unknown
|
|
88
|
+
i: payload_size
|
|
89
|
+
"""
|
|
90
|
+
header_end = position + WS_HEADER_SIZE
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
(
|
|
94
|
+
packet_type,
|
|
95
|
+
payload_format,
|
|
96
|
+
deflated,
|
|
97
|
+
unknown,
|
|
98
|
+
payload_size,
|
|
99
|
+
) = struct.unpack(
|
|
100
|
+
"!bbbbi",
|
|
101
|
+
data[position:header_end],
|
|
102
|
+
)
|
|
103
|
+
except struct.error as e:
|
|
104
|
+
raise WSDecodeError from e
|
|
105
|
+
|
|
106
|
+
if klass is None:
|
|
107
|
+
frame = WSRawPacketFrame.klass_from_format(payload_format)()
|
|
108
|
+
else:
|
|
109
|
+
frame = klass()
|
|
110
|
+
frame.payload_format = ProtectWSPayloadFormat(payload_format)
|
|
111
|
+
|
|
112
|
+
frame.header = WSPacketFrameHeader(
|
|
113
|
+
packet_type=packet_type,
|
|
114
|
+
payload_format=payload_format,
|
|
115
|
+
deflated=deflated,
|
|
116
|
+
unknown=unknown,
|
|
117
|
+
payload_size=payload_size,
|
|
118
|
+
)
|
|
119
|
+
frame.length = WS_HEADER_SIZE + frame.header.payload_size
|
|
120
|
+
frame.is_deflated = bool(frame.header.deflated)
|
|
121
|
+
frame_end = header_end + frame.header.payload_size
|
|
122
|
+
frame.set_data_from_binary(data[header_end:frame_end])
|
|
123
|
+
|
|
124
|
+
return frame
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def packed(self) -> bytes:
|
|
128
|
+
if self.header is None:
|
|
129
|
+
raise WSEncodeError("No header to encode")
|
|
130
|
+
|
|
131
|
+
data = self.get_binary_from_data()
|
|
132
|
+
header = struct.pack(
|
|
133
|
+
"!bbbbi",
|
|
134
|
+
self.header.packet_type,
|
|
135
|
+
self.header.payload_format,
|
|
136
|
+
self.header.deflated,
|
|
137
|
+
self.header.unknown,
|
|
138
|
+
len(data),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return header + data
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class WSRawPacketFrame(BaseWSPacketFrame):
|
|
145
|
+
data: bytes = b""
|
|
146
|
+
|
|
147
|
+
def get_binary_from_data(self) -> bytes:
|
|
148
|
+
data = self.data
|
|
149
|
+
if self.is_deflated:
|
|
150
|
+
data = zlib.compress(data)
|
|
151
|
+
|
|
152
|
+
return data
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class WSJSONPacketFrame(BaseWSPacketFrame):
|
|
156
|
+
data: dict[str, Any] = {}
|
|
157
|
+
payload_format: ProtectWSPayloadFormat = ProtectWSPayloadFormat.NodeBuffer
|
|
158
|
+
|
|
159
|
+
def set_data_from_binary(self, data: bytes) -> None:
|
|
160
|
+
if self.header is not None and self.header.deflated:
|
|
161
|
+
data = zlib.decompress(data)
|
|
162
|
+
|
|
163
|
+
self.data = orjson.loads(data)
|
|
164
|
+
|
|
165
|
+
def get_binary_from_data(self) -> bytes:
|
|
166
|
+
data = self.json
|
|
167
|
+
if self.is_deflated:
|
|
168
|
+
data = zlib.compress(data)
|
|
169
|
+
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def json(self) -> bytes:
|
|
174
|
+
return orjson.dumps(self.data)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class WSPacket:
|
|
178
|
+
_raw: bytes
|
|
179
|
+
_raw_encoded: str | None = None
|
|
180
|
+
|
|
181
|
+
_action_frame: BaseWSPacketFrame | None = None
|
|
182
|
+
_data_frame: BaseWSPacketFrame | None = None
|
|
183
|
+
|
|
184
|
+
def __init__(self, data: bytes):
|
|
185
|
+
self._raw = data
|
|
186
|
+
|
|
187
|
+
def decode(self) -> None:
|
|
188
|
+
self._action_frame = WSRawPacketFrame.from_binary(self._raw)
|
|
189
|
+
self._data_frame = WSRawPacketFrame.from_binary(
|
|
190
|
+
self._raw,
|
|
191
|
+
self._action_frame.length,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def action_frame(self) -> BaseWSPacketFrame:
|
|
196
|
+
if self._action_frame is None:
|
|
197
|
+
self.decode()
|
|
198
|
+
|
|
199
|
+
if self._action_frame is None:
|
|
200
|
+
raise WSDecodeError("Packet unexpectedly not decoded")
|
|
201
|
+
|
|
202
|
+
return self._action_frame
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def data_frame(self) -> BaseWSPacketFrame:
|
|
206
|
+
if self._data_frame is None:
|
|
207
|
+
self.decode()
|
|
208
|
+
|
|
209
|
+
if self._data_frame is None:
|
|
210
|
+
raise WSDecodeError("Packet unexpectedly not decoded")
|
|
211
|
+
|
|
212
|
+
return self._data_frame
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def raw(self) -> bytes:
|
|
216
|
+
return self._raw
|
|
217
|
+
|
|
218
|
+
@raw.setter
|
|
219
|
+
def raw(self, data: bytes) -> None:
|
|
220
|
+
self._raw = data
|
|
221
|
+
self._action_frame = None
|
|
222
|
+
self._data_frame = None
|
|
223
|
+
self._raw_encoded = None
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def raw_base64(self) -> str:
|
|
227
|
+
if self._raw_encoded is None:
|
|
228
|
+
self._raw_encoded = base64.b64encode(self._raw).decode("utf-8")
|
|
229
|
+
|
|
230
|
+
return self._raw_encoded
|
|
231
|
+
|
|
232
|
+
def pack_frames(self) -> bytes:
|
|
233
|
+
self._raw_encoded = None
|
|
234
|
+
self._raw = self.action_frame.packed + self.data_frame.packed
|
|
235
|
+
|
|
236
|
+
return self._raw
|
uiprotect/exceptions.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UnifiProtectError(Exception):
|
|
5
|
+
"""Base class for all other UniFi Protect errors"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StreamError(UnifiProtectError):
|
|
9
|
+
"""Expcetion raised when trying to stream content"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DataDecodeError(UnifiProtectError):
|
|
13
|
+
"""Exception raised when trying to decode a UniFi Protect object"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WSDecodeError(UnifiProtectError):
|
|
17
|
+
"""Exception raised when decoding Websocket packet"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WSEncodeError(UnifiProtectError):
|
|
21
|
+
"""Exception raised when encoding Websocket packet"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClientError(UnifiProtectError):
|
|
25
|
+
"""Base Class for all other UniFi Protect client errors"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BadRequest(ClientError):
|
|
29
|
+
"""Invalid request from API Client"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Invalid(ClientError):
|
|
33
|
+
"""Invalid return from Authorization Request."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotAuthorized(PermissionError, BadRequest):
|
|
37
|
+
"""Wrong username, password or permission error."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NvrError(ClientError):
|
|
41
|
+
"""Other error."""
|
uiprotect/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
["1.13.4","1.13.7","1.14.11","1.15.0","1.16.9","1.17.1","1.17.2","1.17.3","1.17.4","1.18.0","1.18.1","1.19.0","1.19.1","1.19.2","1.20.0","1.20.1","1.20.2","1.20.3","1.21.0","1.21.2","1.21.3","1.21.4","1.21.5","1.21.6","2.0.0","2.0.1","2.1.1","2.1.2","2.10.10","2.10.11","2.11.21","2.2.11","2.2.2","2.2.6","2.2.9","2.6.17","2.7.18","2.7.33","2.7.34","2.8.28","2.8.35","2.9.42","3.0.22"]
|
uiprotect/stream.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from asyncio.streams import StreamReader
|
|
6
|
+
from asyncio.subprocess import PIPE, Process, create_subprocess_exec
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from shlex import split
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from aioshutil import which
|
|
13
|
+
|
|
14
|
+
from uiprotect.exceptions import BadRequest, StreamError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from uiprotect.data import Camera
|
|
18
|
+
|
|
19
|
+
_LOGGER = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FfmpegCommand:
|
|
23
|
+
ffmpeg_path: Path | None
|
|
24
|
+
args: list[str]
|
|
25
|
+
process: Process | None = None
|
|
26
|
+
|
|
27
|
+
stdout: list[str] = []
|
|
28
|
+
stderr: list[str] = []
|
|
29
|
+
|
|
30
|
+
def __init__(self, cmd: str, ffmpeg_path: Path | None = None) -> None:
|
|
31
|
+
self.args = split(cmd)
|
|
32
|
+
|
|
33
|
+
if "ffmpeg" in self.args[0] and ffmpeg_path is None:
|
|
34
|
+
self.ffmpeg_path = Path(self.args.pop(0))
|
|
35
|
+
else:
|
|
36
|
+
self.ffmpeg_path = ffmpeg_path
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_started(self) -> bool:
|
|
40
|
+
return self.process is not None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_running(self) -> bool:
|
|
44
|
+
if self.process is None:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
return self.process.returncode is None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_error(self) -> bool:
|
|
51
|
+
if self.process is None:
|
|
52
|
+
raise StreamError("ffmpeg has not started")
|
|
53
|
+
|
|
54
|
+
if self.is_running:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
return self.process.returncode != 0
|
|
58
|
+
|
|
59
|
+
async def start(self) -> None:
|
|
60
|
+
if self.is_started:
|
|
61
|
+
raise StreamError("ffmpeg command already started")
|
|
62
|
+
|
|
63
|
+
if self.ffmpeg_path is None:
|
|
64
|
+
system_ffmpeg = await which("ffmpeg")
|
|
65
|
+
|
|
66
|
+
if system_ffmpeg is None:
|
|
67
|
+
raise StreamError("Could not find ffmpeg")
|
|
68
|
+
self.ffmpeg_path = Path(system_ffmpeg)
|
|
69
|
+
|
|
70
|
+
if not self.ffmpeg_path.exists():
|
|
71
|
+
raise StreamError("Could not find ffmpeg")
|
|
72
|
+
|
|
73
|
+
_LOGGER.debug("ffmpeg: %s %s", self.ffmpeg_path, " ".join(self.args))
|
|
74
|
+
self.process = await create_subprocess_exec(
|
|
75
|
+
self.ffmpeg_path,
|
|
76
|
+
*self.args,
|
|
77
|
+
stdout=PIPE,
|
|
78
|
+
stderr=PIPE,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def stop(self) -> None:
|
|
82
|
+
if self.process is None:
|
|
83
|
+
raise StreamError("ffmpeg has not started")
|
|
84
|
+
|
|
85
|
+
self.process.kill()
|
|
86
|
+
await self.process.wait()
|
|
87
|
+
|
|
88
|
+
async def _read_stream(self, stream: StreamReader | None, attr: str) -> None:
|
|
89
|
+
if stream is None:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
while True:
|
|
93
|
+
line = await stream.readline()
|
|
94
|
+
if line:
|
|
95
|
+
getattr(self, attr).append(line.decode("utf8").rstrip())
|
|
96
|
+
else:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
async def run_until_complete(self) -> None:
|
|
100
|
+
if not self.is_started:
|
|
101
|
+
await self.start()
|
|
102
|
+
|
|
103
|
+
if self.process is None:
|
|
104
|
+
raise StreamError("Could not start stream")
|
|
105
|
+
|
|
106
|
+
await asyncio.wait(
|
|
107
|
+
[
|
|
108
|
+
asyncio.create_task(self._read_stream(self.process.stdout, "stdout")),
|
|
109
|
+
asyncio.create_task(self._read_stream(self.process.stderr, "stderr")),
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
await self.process.wait()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TalkbackStream(FfmpegCommand):
|
|
116
|
+
camera: Camera
|
|
117
|
+
content_url: str
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
camera: Camera,
|
|
122
|
+
content_url: str,
|
|
123
|
+
ffmpeg_path: Path | None = None,
|
|
124
|
+
):
|
|
125
|
+
if not camera.feature_flags.has_speaker:
|
|
126
|
+
raise BadRequest("Camera does not have a speaker for talkback")
|
|
127
|
+
|
|
128
|
+
content_url = self.clean_url(content_url)
|
|
129
|
+
input_args = self.get_args_from_url(content_url)
|
|
130
|
+
if len(input_args) > 0:
|
|
131
|
+
input_args += " "
|
|
132
|
+
|
|
133
|
+
bitrate = camera.talkback_settings.bits_per_sample * 1000
|
|
134
|
+
# 8000 seems to result in best quality without overloading the camera
|
|
135
|
+
udp_bitrate = bitrate + 8000
|
|
136
|
+
|
|
137
|
+
# vn = no video
|
|
138
|
+
# acodec = audio codec to encode output in (aac)
|
|
139
|
+
# ac = number of output channels (1)
|
|
140
|
+
# ar = output sampling rate (22050)
|
|
141
|
+
# b:a = set bit rate of output audio
|
|
142
|
+
cmd = (
|
|
143
|
+
"-loglevel info -hide_banner "
|
|
144
|
+
f'{input_args}-i "{content_url}" -vn '
|
|
145
|
+
f"-acodec {camera.talkback_settings.type_fmt.value} -ac {camera.talkback_settings.channels} "
|
|
146
|
+
f"-ar {camera.talkback_settings.sampling_rate} -b:a {bitrate} -map 0:a "
|
|
147
|
+
f'-f adts "udp://{camera.host}:{camera.talkback_settings.bind_port}?bitrate={udp_bitrate}"'
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
super().__init__(cmd, ffmpeg_path)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def clean_url(cls, content_url: str) -> str:
|
|
154
|
+
parsed = urlparse(content_url)
|
|
155
|
+
if parsed.scheme in {"file", ""}:
|
|
156
|
+
path = Path(parsed.netloc + parsed.path)
|
|
157
|
+
if not path.exists():
|
|
158
|
+
raise BadRequest(f"File {path} does not exist")
|
|
159
|
+
content_url = str(path.absolute())
|
|
160
|
+
|
|
161
|
+
return content_url
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def get_args_from_url(cls, content_url: str) -> str:
|
|
165
|
+
# TODO:
|
|
166
|
+
return ""
|