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/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
@@ -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 ""