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/base.py
ADDED
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
"""UniFi Protect Data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from functools import cache
|
|
10
|
+
from ipaddress import IPv4Address
|
|
11
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from pydantic.v1 import BaseModel
|
|
15
|
+
from pydantic.v1.fields import SHAPE_DICT, SHAPE_LIST, PrivateAttr
|
|
16
|
+
|
|
17
|
+
from uiprotect.data.types import (
|
|
18
|
+
ModelType,
|
|
19
|
+
PercentFloat,
|
|
20
|
+
PermissionNode,
|
|
21
|
+
ProtectWSPayloadFormat,
|
|
22
|
+
StateType,
|
|
23
|
+
)
|
|
24
|
+
from uiprotect.data.websocket import (
|
|
25
|
+
WSJSONPacketFrame,
|
|
26
|
+
WSPacket,
|
|
27
|
+
WSPacketFrameHeader,
|
|
28
|
+
)
|
|
29
|
+
from uiprotect.exceptions import BadRequest, ClientError, NotAuthorized
|
|
30
|
+
from uiprotect.utils import (
|
|
31
|
+
asyncio_timeout,
|
|
32
|
+
convert_unifi_data,
|
|
33
|
+
dict_diff,
|
|
34
|
+
is_debug,
|
|
35
|
+
process_datetime,
|
|
36
|
+
serialize_unifi_obj,
|
|
37
|
+
to_snake_case,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from asyncio.events import TimerHandle
|
|
42
|
+
|
|
43
|
+
from pydantic.v1.typing import DictStrAny, SetStr
|
|
44
|
+
from typing_extensions import Self # requires Python 3.11+
|
|
45
|
+
|
|
46
|
+
from uiprotect.api import ProtectApiClient
|
|
47
|
+
from uiprotect.data.devices import Bridge
|
|
48
|
+
from uiprotect.data.nvr import Event
|
|
49
|
+
from uiprotect.data.user import User
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
ProtectObject = TypeVar("ProtectObject", bound="ProtectBaseObject")
|
|
53
|
+
RECENT_EVENT_MAX = timedelta(seconds=30)
|
|
54
|
+
EVENT_PING_INTERVAL = timedelta(seconds=3)
|
|
55
|
+
_LOGGER = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cache
|
|
59
|
+
def _is_protect_base_object(cls: type) -> bool:
|
|
60
|
+
"""A cached version of `issubclass(cls, ProtectBaseObject)` to speed up the check."""
|
|
61
|
+
return issubclass(cls, ProtectBaseObject)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProtectBaseObject(BaseModel):
|
|
65
|
+
"""
|
|
66
|
+
Base class for building Python objects from UniFi Protect JSON.
|
|
67
|
+
|
|
68
|
+
* Provides `.unifi_dict_to_dict` to convert UFP JSON to a more Pythonic formatted dict (camel case to snake case)
|
|
69
|
+
* Add attrs with matching Pyhonic name and they will automatically be populated from the UFP JSON if passed in to the constructer
|
|
70
|
+
* Provides `.unifi_dict` to convert object back into UFP JSON
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
_api: ProtectApiClient | None = PrivateAttr(None)
|
|
74
|
+
|
|
75
|
+
_protect_objs: ClassVar[dict[str, type[ProtectBaseObject]] | None] = None
|
|
76
|
+
_protect_objs_set: ClassVar[SetStr | None] = None
|
|
77
|
+
_protect_lists: ClassVar[dict[str, type[ProtectBaseObject]] | None] = None
|
|
78
|
+
_protect_lists_set: ClassVar[SetStr | None] = None
|
|
79
|
+
_protect_dicts: ClassVar[dict[str, type[ProtectBaseObject]] | None] = None
|
|
80
|
+
_protect_dicts_set: ClassVar[SetStr | None] = None
|
|
81
|
+
_to_unifi_remaps: ClassVar[DictStrAny | None] = None
|
|
82
|
+
|
|
83
|
+
class Config:
|
|
84
|
+
arbitrary_types_allowed = True
|
|
85
|
+
validate_assignment = True
|
|
86
|
+
copy_on_model_validation = "shallow"
|
|
87
|
+
|
|
88
|
+
def __init__(self, api: ProtectApiClient | None = None, **data: Any) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Base class for creating Python objects from UFP JSON data.
|
|
91
|
+
|
|
92
|
+
Use the static method `.from_unifi_dict()` to create objects from UFP JSON data from then the main class constructor.
|
|
93
|
+
"""
|
|
94
|
+
super().__init__(**data)
|
|
95
|
+
self._api = api
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_unifi_dict(
|
|
99
|
+
cls,
|
|
100
|
+
api: ProtectApiClient | None = None,
|
|
101
|
+
**data: Any,
|
|
102
|
+
) -> Self:
|
|
103
|
+
"""
|
|
104
|
+
Main constructor for `ProtectBaseObject`
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
----
|
|
108
|
+
api: Optional reference to the ProtectAPIClient that created generated the UFP JSON
|
|
109
|
+
**data: decoded UFP JSON
|
|
110
|
+
|
|
111
|
+
`api` is is expected as a `@property`. If it is `None` and accessed, a `BadRequest` will be raised.
|
|
112
|
+
|
|
113
|
+
API can be used for saving updates for the Protect object or fetching references to other objects
|
|
114
|
+
(cameras, users, etc.)
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
data["api"] = api
|
|
118
|
+
data = cls.unifi_dict_to_dict(data)
|
|
119
|
+
|
|
120
|
+
if is_debug():
|
|
121
|
+
data.pop("api", None)
|
|
122
|
+
return cls(api=api, **data)
|
|
123
|
+
|
|
124
|
+
return cls.construct(**data)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def construct(cls, _fields_set: set[str] | None = None, **values: Any) -> Self:
|
|
128
|
+
api = values.pop("api", None)
|
|
129
|
+
values_set = set(values)
|
|
130
|
+
|
|
131
|
+
unifi_objs = cls._get_protect_objs()
|
|
132
|
+
for key in cls._get_protect_objs_set().intersection(values_set):
|
|
133
|
+
if isinstance(values[key], dict):
|
|
134
|
+
values[key] = unifi_objs[key].construct(**values[key])
|
|
135
|
+
|
|
136
|
+
unifi_lists = cls._get_protect_lists()
|
|
137
|
+
for key in cls._get_protect_lists_set().intersection(values_set):
|
|
138
|
+
if isinstance(values[key], list):
|
|
139
|
+
values[key] = [
|
|
140
|
+
unifi_lists[key].construct(**v) if isinstance(v, dict) else v
|
|
141
|
+
for v in values[key]
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
unifi_dicts = cls._get_protect_dicts()
|
|
145
|
+
for key in cls._get_protect_dicts_set().intersection(values_set):
|
|
146
|
+
if isinstance(values[key], dict):
|
|
147
|
+
values[key] = {
|
|
148
|
+
k: unifi_dicts[key].construct(**v) if isinstance(v, dict) else v
|
|
149
|
+
for k, v in values[key].items()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
obj = super().construct(_fields_set=_fields_set, **values)
|
|
153
|
+
obj._api = api
|
|
154
|
+
|
|
155
|
+
return obj
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
@cache
|
|
159
|
+
def _get_excluded_changed_fields(cls) -> set[str]:
|
|
160
|
+
"""
|
|
161
|
+
Helper method for override in child classes for fields that excluded from calculating "changed" state for a
|
|
162
|
+
model (`.get_changed()`)
|
|
163
|
+
"""
|
|
164
|
+
return set()
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
@cache
|
|
168
|
+
def _get_unifi_remaps(cls) -> dict[str, str]:
|
|
169
|
+
"""
|
|
170
|
+
Helper method for overriding in child classes for remapping UFP JSON keys to Python ones that do not fit the
|
|
171
|
+
simple camel case to snake case formula.
|
|
172
|
+
|
|
173
|
+
Return format is
|
|
174
|
+
{
|
|
175
|
+
"ufpJsonName": "python_name"
|
|
176
|
+
}
|
|
177
|
+
"""
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def _get_to_unifi_remaps(cls) -> dict[str, str]:
|
|
182
|
+
"""
|
|
183
|
+
Helper method for overriding in child classes for reversing remap UFP
|
|
184
|
+
JSON keys to Python ones that do not fit the simple camel case to
|
|
185
|
+
snake case formula.
|
|
186
|
+
|
|
187
|
+
Return format is
|
|
188
|
+
{
|
|
189
|
+
"python_name": "ufpJsonName"
|
|
190
|
+
}
|
|
191
|
+
"""
|
|
192
|
+
if cls._to_unifi_remaps is None:
|
|
193
|
+
cls._to_unifi_remaps = {
|
|
194
|
+
to_key: from_key for from_key, to_key in cls._get_unifi_remaps().items()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return cls._to_unifi_remaps
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def _set_protect_subtypes(cls) -> None:
|
|
201
|
+
"""Helper method to detect attrs of current class that are UFP Objects themselves"""
|
|
202
|
+
cls._protect_objs = {}
|
|
203
|
+
cls._protect_lists = {}
|
|
204
|
+
cls._protect_dicts = {}
|
|
205
|
+
|
|
206
|
+
for name, field in cls.__fields__.items():
|
|
207
|
+
try:
|
|
208
|
+
if _is_protect_base_object(field.type_):
|
|
209
|
+
if field.shape == SHAPE_LIST:
|
|
210
|
+
cls._protect_lists[name] = field.type_
|
|
211
|
+
elif field.shape == SHAPE_DICT:
|
|
212
|
+
cls._protect_dicts[name] = field.type_
|
|
213
|
+
else:
|
|
214
|
+
cls._protect_objs[name] = field.type_
|
|
215
|
+
except TypeError:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def _get_protect_objs(cls) -> dict[str, type[ProtectBaseObject]]:
|
|
220
|
+
"""Helper method to get all child UFP objects"""
|
|
221
|
+
if cls._protect_objs is not None:
|
|
222
|
+
return cls._protect_objs
|
|
223
|
+
|
|
224
|
+
cls._set_protect_subtypes()
|
|
225
|
+
return cls._protect_objs # type: ignore[return-value]
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def _get_protect_objs_set(cls) -> set[str]:
|
|
229
|
+
"""Helper method to get all child UFP objects"""
|
|
230
|
+
if cls._protect_objs_set is None:
|
|
231
|
+
cls._protect_objs_set = set(cls._get_protect_objs().keys())
|
|
232
|
+
|
|
233
|
+
return cls._protect_objs_set
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def _get_protect_lists(cls) -> dict[str, type[ProtectBaseObject]]:
|
|
237
|
+
"""Helper method to get all child of UFP objects (lists)"""
|
|
238
|
+
if cls._protect_lists is not None:
|
|
239
|
+
return cls._protect_lists
|
|
240
|
+
|
|
241
|
+
cls._set_protect_subtypes()
|
|
242
|
+
return cls._protect_lists # type: ignore[return-value]
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def _get_protect_lists_set(cls) -> set[str]:
|
|
246
|
+
"""Helper method to get all child UFP objects"""
|
|
247
|
+
if cls._protect_lists_set is None:
|
|
248
|
+
cls._protect_lists_set = set(cls._get_protect_lists().keys())
|
|
249
|
+
|
|
250
|
+
return cls._protect_lists_set
|
|
251
|
+
|
|
252
|
+
@classmethod
|
|
253
|
+
def _get_protect_dicts(cls) -> dict[str, type[ProtectBaseObject]]:
|
|
254
|
+
"""Helper method to get all child of UFP objects (dicts)"""
|
|
255
|
+
if cls._protect_dicts is not None:
|
|
256
|
+
return cls._protect_dicts
|
|
257
|
+
|
|
258
|
+
cls._set_protect_subtypes()
|
|
259
|
+
return cls._protect_dicts # type: ignore[return-value]
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def _get_protect_dicts_set(cls) -> set[str]:
|
|
263
|
+
"""Helper method to get all child UFP objects"""
|
|
264
|
+
if cls._protect_dicts_set is None:
|
|
265
|
+
cls._protect_dicts_set = set(cls._get_protect_dicts().keys())
|
|
266
|
+
|
|
267
|
+
return cls._protect_dicts_set
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def _get_api(cls, api: ProtectApiClient | None) -> ProtectApiClient | None:
|
|
271
|
+
"""Helper method to try to find and the current ProjtectAPIClient instance from given data"""
|
|
272
|
+
if api is None and isinstance(cls, ProtectBaseObject) and hasattr(cls, "_api"): # type: ignore[unreachable]
|
|
273
|
+
api = cls._api # type: ignore[unreachable]
|
|
274
|
+
|
|
275
|
+
return api
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def _clean_protect_obj(
|
|
279
|
+
cls,
|
|
280
|
+
data: Any,
|
|
281
|
+
klass: type[ProtectBaseObject],
|
|
282
|
+
api: ProtectApiClient | None,
|
|
283
|
+
) -> Any:
|
|
284
|
+
if isinstance(data, dict):
|
|
285
|
+
if api is not None:
|
|
286
|
+
data["api"] = api
|
|
287
|
+
return klass.unifi_dict_to_dict(data=data)
|
|
288
|
+
return data
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def _clean_protect_obj_list(
|
|
292
|
+
cls,
|
|
293
|
+
items: list[Any],
|
|
294
|
+
klass: type[ProtectBaseObject],
|
|
295
|
+
api: ProtectApiClient | None,
|
|
296
|
+
) -> list[Any]:
|
|
297
|
+
for index, item in enumerate(items):
|
|
298
|
+
items[index] = cls._clean_protect_obj(item, klass, api)
|
|
299
|
+
return items
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def _clean_protect_obj_dict(
|
|
303
|
+
cls,
|
|
304
|
+
items: dict[Any, Any],
|
|
305
|
+
klass: type[ProtectBaseObject],
|
|
306
|
+
api: ProtectApiClient | None,
|
|
307
|
+
) -> dict[Any, Any]:
|
|
308
|
+
for key, value in items.items():
|
|
309
|
+
items[key] = cls._clean_protect_obj(value, klass, api)
|
|
310
|
+
return items
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
314
|
+
"""
|
|
315
|
+
Takes a decoded UFP JSON dict and converts it into a Python dict
|
|
316
|
+
|
|
317
|
+
* Remaps items from `._get_unifi_remaps()`
|
|
318
|
+
* Converts camelCase keys to snake_case keys
|
|
319
|
+
* Injects ProtectAPIClient into any child UFP object Dicts
|
|
320
|
+
* Runs `.unifi_dict_to_dict` for any child UFP objects
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
----
|
|
324
|
+
data: decoded UFP JSON dict
|
|
325
|
+
|
|
326
|
+
"""
|
|
327
|
+
# get the API client instance
|
|
328
|
+
api = cls._get_api(data.get("api"))
|
|
329
|
+
|
|
330
|
+
# remap keys that will not be converted correctly by snake_case convert
|
|
331
|
+
remaps = cls._get_unifi_remaps()
|
|
332
|
+
for from_key in set(remaps).intersection(data):
|
|
333
|
+
data[remaps[from_key]] = data.pop(from_key)
|
|
334
|
+
|
|
335
|
+
# convert to snake_case and remove extra fields
|
|
336
|
+
for key in list(data.keys()):
|
|
337
|
+
new_key = to_snake_case(key)
|
|
338
|
+
data[new_key] = data.pop(key)
|
|
339
|
+
key = new_key
|
|
340
|
+
|
|
341
|
+
if key == "api":
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
if key not in cls.__fields__:
|
|
345
|
+
del data[key]
|
|
346
|
+
continue
|
|
347
|
+
data[key] = convert_unifi_data(data[key], cls.__fields__[key])
|
|
348
|
+
|
|
349
|
+
# clean child UFP objs
|
|
350
|
+
data_set = set(data)
|
|
351
|
+
|
|
352
|
+
unifi_objs = cls._get_protect_objs()
|
|
353
|
+
for key in cls._get_protect_objs_set().intersection(data_set):
|
|
354
|
+
data[key] = cls._clean_protect_obj(data[key], unifi_objs[key], api)
|
|
355
|
+
|
|
356
|
+
unifi_lists = cls._get_protect_lists()
|
|
357
|
+
for key in cls._get_protect_lists_set().intersection(data_set):
|
|
358
|
+
if isinstance(data[key], list):
|
|
359
|
+
data[key] = cls._clean_protect_obj_list(
|
|
360
|
+
data[key],
|
|
361
|
+
unifi_lists[key],
|
|
362
|
+
api,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
unifi_dicts = cls._get_protect_dicts()
|
|
366
|
+
for key in cls._get_protect_dicts_set().intersection(data_set):
|
|
367
|
+
if isinstance(data[key], dict):
|
|
368
|
+
data[key] = cls._clean_protect_obj_dict(
|
|
369
|
+
data[key],
|
|
370
|
+
unifi_dicts[key],
|
|
371
|
+
api,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return data
|
|
375
|
+
|
|
376
|
+
def _unifi_dict_protect_obj(
|
|
377
|
+
self,
|
|
378
|
+
data: dict[str, Any],
|
|
379
|
+
key: str,
|
|
380
|
+
use_obj: bool,
|
|
381
|
+
klass: type[ProtectBaseObject],
|
|
382
|
+
) -> Any:
|
|
383
|
+
value: Any | None = data.get(key)
|
|
384
|
+
if use_obj:
|
|
385
|
+
value = getattr(self, key)
|
|
386
|
+
|
|
387
|
+
if isinstance(value, ProtectBaseObject):
|
|
388
|
+
value = value.unifi_dict()
|
|
389
|
+
elif isinstance(value, dict):
|
|
390
|
+
value = klass.construct({}).unifi_dict(data=value) # type: ignore[arg-type]
|
|
391
|
+
|
|
392
|
+
return value
|
|
393
|
+
|
|
394
|
+
def _unifi_dict_protect_obj_list(
|
|
395
|
+
self,
|
|
396
|
+
data: dict[str, Any],
|
|
397
|
+
key: str,
|
|
398
|
+
use_obj: bool,
|
|
399
|
+
klass: type[ProtectBaseObject],
|
|
400
|
+
) -> Any:
|
|
401
|
+
value: Any | None = data.get(key)
|
|
402
|
+
if use_obj:
|
|
403
|
+
value = getattr(self, key)
|
|
404
|
+
|
|
405
|
+
if not isinstance(value, list):
|
|
406
|
+
return value
|
|
407
|
+
|
|
408
|
+
items: list[Any] = []
|
|
409
|
+
for item in value:
|
|
410
|
+
if isinstance(item, ProtectBaseObject):
|
|
411
|
+
new_item = item.unifi_dict()
|
|
412
|
+
else:
|
|
413
|
+
new_item = klass.construct({}).unifi_dict(data=item) # type: ignore[arg-type]
|
|
414
|
+
items.append(new_item)
|
|
415
|
+
|
|
416
|
+
return items
|
|
417
|
+
|
|
418
|
+
def _unifi_dict_protect_obj_dict(
|
|
419
|
+
self,
|
|
420
|
+
data: dict[str, Any],
|
|
421
|
+
key: str,
|
|
422
|
+
use_obj: bool,
|
|
423
|
+
) -> Any:
|
|
424
|
+
value: Any | None = data.get(key)
|
|
425
|
+
if use_obj:
|
|
426
|
+
value = getattr(self, key)
|
|
427
|
+
|
|
428
|
+
if not isinstance(value, dict):
|
|
429
|
+
return value
|
|
430
|
+
|
|
431
|
+
items: dict[Any, Any] = {}
|
|
432
|
+
for obj_key, obj in value.items():
|
|
433
|
+
if isinstance(obj, ProtectBaseObject):
|
|
434
|
+
obj = obj.unifi_dict()
|
|
435
|
+
items[obj_key] = obj
|
|
436
|
+
|
|
437
|
+
return items
|
|
438
|
+
|
|
439
|
+
def unifi_dict(
|
|
440
|
+
self,
|
|
441
|
+
data: dict[str, Any] | None = None,
|
|
442
|
+
exclude: set[str] | None = None,
|
|
443
|
+
) -> dict[str, Any]:
|
|
444
|
+
"""
|
|
445
|
+
Can either convert current Python object into UFP JSON dict or take the output of a `.dict()` call and convert it.
|
|
446
|
+
|
|
447
|
+
* Remaps items from `._get_unifi_remaps()` in reverse
|
|
448
|
+
* Converts snake_case to camelCase
|
|
449
|
+
* Automatically removes any ProtectApiClient instances that might still be in the data
|
|
450
|
+
* Automatically calls `.unifi_dict()` for any UFP Python objects that are detected
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
----
|
|
454
|
+
data: Optional output of `.dict()` for the Python object. If `None`, will call `.dict` first
|
|
455
|
+
exclude: Optional set of fields to exclude from convert. Useful for subclassing and having custom
|
|
456
|
+
processing for dumping to UFP JSON data.
|
|
457
|
+
|
|
458
|
+
"""
|
|
459
|
+
use_obj = False
|
|
460
|
+
if data is None:
|
|
461
|
+
excluded_fields = (
|
|
462
|
+
self._get_protect_objs_set() | self._get_protect_lists_set()
|
|
463
|
+
)
|
|
464
|
+
if exclude is not None:
|
|
465
|
+
excluded_fields |= exclude
|
|
466
|
+
data = self.dict(exclude=excluded_fields)
|
|
467
|
+
use_obj = True
|
|
468
|
+
|
|
469
|
+
for key, klass in self._get_protect_objs().items():
|
|
470
|
+
if use_obj or key in data:
|
|
471
|
+
data[key] = self._unifi_dict_protect_obj(data, key, use_obj, klass)
|
|
472
|
+
|
|
473
|
+
for key, klass in self._get_protect_lists().items():
|
|
474
|
+
if use_obj or key in data:
|
|
475
|
+
data[key] = self._unifi_dict_protect_obj_list(data, key, use_obj, klass)
|
|
476
|
+
|
|
477
|
+
for key in self._get_protect_dicts():
|
|
478
|
+
if use_obj or key in data:
|
|
479
|
+
data[key] = self._unifi_dict_protect_obj_dict(data, key, use_obj)
|
|
480
|
+
|
|
481
|
+
# all child objects have been serialized correctly do not do it twice
|
|
482
|
+
new_data: dict[str, Any] = serialize_unifi_obj(data, levels=2)
|
|
483
|
+
remaps = self._get_to_unifi_remaps()
|
|
484
|
+
for to_key in set(new_data).intersection(remaps):
|
|
485
|
+
new_data[remaps[to_key]] = new_data.pop(to_key)
|
|
486
|
+
|
|
487
|
+
if "api" in new_data:
|
|
488
|
+
del new_data["api"]
|
|
489
|
+
|
|
490
|
+
return new_data
|
|
491
|
+
|
|
492
|
+
def _inject_api(
|
|
493
|
+
self,
|
|
494
|
+
data: dict[str, Any],
|
|
495
|
+
api: ProtectApiClient | None,
|
|
496
|
+
) -> dict[str, Any]:
|
|
497
|
+
data["api"] = api
|
|
498
|
+
data_set = set(data)
|
|
499
|
+
|
|
500
|
+
for key in self._get_protect_objs_set().intersection(data_set):
|
|
501
|
+
unifi_obj: Any | None = getattr(self, key)
|
|
502
|
+
if unifi_obj is not None and isinstance(unifi_obj, dict):
|
|
503
|
+
unifi_obj["api"] = api
|
|
504
|
+
|
|
505
|
+
for key in self._get_protect_lists_set().intersection(data_set):
|
|
506
|
+
new_items = []
|
|
507
|
+
for item in data[key]:
|
|
508
|
+
if isinstance(item, dict):
|
|
509
|
+
item["api"] = api
|
|
510
|
+
new_items.append(item)
|
|
511
|
+
data[key] = new_items
|
|
512
|
+
|
|
513
|
+
for key in self._get_protect_dicts_set().intersection(data_set):
|
|
514
|
+
for item_key, item in data[key].items():
|
|
515
|
+
if isinstance(item, dict):
|
|
516
|
+
item["api"] = api
|
|
517
|
+
data[key][item_key] = item
|
|
518
|
+
|
|
519
|
+
return data
|
|
520
|
+
|
|
521
|
+
def update_from_dict(self: ProtectObject, data: dict[str, Any]) -> ProtectObject:
|
|
522
|
+
"""Updates current object from a cleaned UFP JSON dict"""
|
|
523
|
+
data_set = set(data)
|
|
524
|
+
for key in self._get_protect_objs_set().intersection(data_set):
|
|
525
|
+
unifi_obj: Any | None = getattr(self, key)
|
|
526
|
+
if unifi_obj is not None and isinstance(unifi_obj, ProtectBaseObject):
|
|
527
|
+
item = data.pop(key)
|
|
528
|
+
if item is not None:
|
|
529
|
+
item = unifi_obj.update_from_dict(item)
|
|
530
|
+
setattr(self, key, item)
|
|
531
|
+
|
|
532
|
+
data = self._inject_api(data, self._api)
|
|
533
|
+
unifi_lists = self._get_protect_lists()
|
|
534
|
+
for key in self._get_protect_lists_set().intersection(data_set):
|
|
535
|
+
if not isinstance(data[key], list):
|
|
536
|
+
continue
|
|
537
|
+
klass = unifi_lists[key]
|
|
538
|
+
new_items = []
|
|
539
|
+
for item in data.pop(key):
|
|
540
|
+
if item is not None and isinstance(item, ProtectBaseObject):
|
|
541
|
+
new_items.append(item)
|
|
542
|
+
elif isinstance(item, dict):
|
|
543
|
+
new_items.append(klass(**item))
|
|
544
|
+
setattr(self, key, new_items)
|
|
545
|
+
|
|
546
|
+
# Always injected above
|
|
547
|
+
del data["api"]
|
|
548
|
+
|
|
549
|
+
for key in data:
|
|
550
|
+
setattr(self, key, convert_unifi_data(data[key], self.__fields__[key]))
|
|
551
|
+
|
|
552
|
+
return self
|
|
553
|
+
|
|
554
|
+
def dict_with_excludes(self) -> dict[str, Any]:
|
|
555
|
+
"""Returns a dict of the current object without any UFP objects converted to dicts."""
|
|
556
|
+
excludes = self.__class__._get_excluded_changed_fields()
|
|
557
|
+
return self.dict(exclude=excludes)
|
|
558
|
+
|
|
559
|
+
def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]:
|
|
560
|
+
return dict_diff(data_before_changes, self.dict())
|
|
561
|
+
|
|
562
|
+
@property
|
|
563
|
+
def api(self) -> ProtectApiClient:
|
|
564
|
+
"""
|
|
565
|
+
ProtectApiClient that the UFP object was created with. If no API Client was passed in time of
|
|
566
|
+
creation, will raise `BadRequest`
|
|
567
|
+
"""
|
|
568
|
+
if self._api is None:
|
|
569
|
+
raise BadRequest("API Client not initialized")
|
|
570
|
+
|
|
571
|
+
return self._api
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class ProtectModel(ProtectBaseObject):
|
|
575
|
+
"""
|
|
576
|
+
Base class for UFP objects with a `modelKey` attr. Provides `.from_unifi_dict()` static helper method for
|
|
577
|
+
automatically decoding a `modelKey` object into the correct UFP object and type
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
model: ModelType | None
|
|
581
|
+
|
|
582
|
+
@classmethod
|
|
583
|
+
@cache
|
|
584
|
+
def _get_unifi_remaps(cls) -> dict[str, str]:
|
|
585
|
+
return {**super()._get_unifi_remaps(), "modelKey": "model"}
|
|
586
|
+
|
|
587
|
+
def unifi_dict(
|
|
588
|
+
self,
|
|
589
|
+
data: dict[str, Any] | None = None,
|
|
590
|
+
exclude: set[str] | None = None,
|
|
591
|
+
) -> dict[str, Any]:
|
|
592
|
+
data = super().unifi_dict(data=data, exclude=exclude)
|
|
593
|
+
|
|
594
|
+
if "modelKey" in data and data["modelKey"] is None:
|
|
595
|
+
del data["modelKey"]
|
|
596
|
+
|
|
597
|
+
return data
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
class ProtectModelWithId(ProtectModel):
|
|
601
|
+
id: str
|
|
602
|
+
|
|
603
|
+
_update_lock: asyncio.Lock = PrivateAttr(...)
|
|
604
|
+
_update_queue: asyncio.Queue[Callable[[], None]] = PrivateAttr(...)
|
|
605
|
+
_update_event: asyncio.Event = PrivateAttr(...)
|
|
606
|
+
|
|
607
|
+
def __init__(self, **data: Any) -> None:
|
|
608
|
+
update_lock = data.pop("update_lock", None)
|
|
609
|
+
update_queue = data.pop("update_queue", None)
|
|
610
|
+
update_event = data.pop("update_event", None)
|
|
611
|
+
super().__init__(**data)
|
|
612
|
+
self._update_lock = update_lock or asyncio.Lock()
|
|
613
|
+
self._update_queue = update_queue or asyncio.Queue()
|
|
614
|
+
self._update_event = update_event or asyncio.Event()
|
|
615
|
+
|
|
616
|
+
@classmethod
|
|
617
|
+
def construct(cls, _fields_set: set[str] | None = None, **values: Any) -> Self:
|
|
618
|
+
update_lock = values.pop("update_lock", None)
|
|
619
|
+
update_queue = values.pop("update_queue", None)
|
|
620
|
+
update_event = values.pop("update_event", None)
|
|
621
|
+
obj = super().construct(_fields_set=_fields_set, **values)
|
|
622
|
+
obj._update_lock = update_lock or asyncio.Lock()
|
|
623
|
+
obj._update_queue = update_queue or asyncio.Queue()
|
|
624
|
+
obj._update_event = update_event or asyncio.Event()
|
|
625
|
+
|
|
626
|
+
return obj
|
|
627
|
+
|
|
628
|
+
@classmethod
|
|
629
|
+
def _get_read_only_fields(cls) -> set[str]:
|
|
630
|
+
return set()
|
|
631
|
+
|
|
632
|
+
async def _api_update(self, data: dict[str, Any]) -> None:
|
|
633
|
+
raise NotImplementedError
|
|
634
|
+
|
|
635
|
+
def revert_changes(self, data_before_changes: dict[str, Any]) -> None:
|
|
636
|
+
"""Reverts current changes to device and resets it back to initial state"""
|
|
637
|
+
changed = self.get_changed(data_before_changes)
|
|
638
|
+
for key in changed:
|
|
639
|
+
setattr(self, key, data_before_changes[key])
|
|
640
|
+
|
|
641
|
+
def can_create(self, user: User) -> bool:
|
|
642
|
+
if self.model is None:
|
|
643
|
+
return True
|
|
644
|
+
|
|
645
|
+
return user.can(self.model, PermissionNode.CREATE, self)
|
|
646
|
+
|
|
647
|
+
def can_read(self, user: User) -> bool:
|
|
648
|
+
if self.model is None:
|
|
649
|
+
return True
|
|
650
|
+
|
|
651
|
+
return user.can(self.model, PermissionNode.READ, self)
|
|
652
|
+
|
|
653
|
+
def can_write(self, user: User) -> bool:
|
|
654
|
+
if self.model is None:
|
|
655
|
+
return True
|
|
656
|
+
|
|
657
|
+
return user.can(self.model, PermissionNode.WRITE, self)
|
|
658
|
+
|
|
659
|
+
def can_delete(self, user: User) -> bool:
|
|
660
|
+
if self.model is None:
|
|
661
|
+
return True
|
|
662
|
+
|
|
663
|
+
return user.can(self.model, PermissionNode.DELETE, self)
|
|
664
|
+
|
|
665
|
+
async def queue_update(self, callback: Callable[[], None]) -> None:
|
|
666
|
+
"""
|
|
667
|
+
Queues a device update.
|
|
668
|
+
|
|
669
|
+
This allows aggregating devices updates so if multiple ones come in all at once,
|
|
670
|
+
they can be combined in a single PATCH.
|
|
671
|
+
"""
|
|
672
|
+
self._update_queue.put_nowait(callback)
|
|
673
|
+
|
|
674
|
+
self._update_event.set()
|
|
675
|
+
await asyncio.sleep(
|
|
676
|
+
0.001,
|
|
677
|
+
) # release execution so other `queue_update` calls can abort
|
|
678
|
+
self._update_event.clear()
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
async with asyncio_timeout(0.05):
|
|
682
|
+
await self._update_event.wait()
|
|
683
|
+
self._update_event.clear()
|
|
684
|
+
return
|
|
685
|
+
except (TimeoutError, asyncio.TimeoutError, asyncio.CancelledError):
|
|
686
|
+
async with self._update_lock:
|
|
687
|
+
# Important! Now that we have the lock, we yield to the event loop so any
|
|
688
|
+
# updates from the websocket are processed before we generate the diff
|
|
689
|
+
await asyncio.sleep(0)
|
|
690
|
+
# Save the initial data before we generate the diff
|
|
691
|
+
data_before_changes = self.dict_with_excludes()
|
|
692
|
+
while not self._update_queue.empty():
|
|
693
|
+
callback = self._update_queue.get_nowait()
|
|
694
|
+
callback()
|
|
695
|
+
# Important, do not yield to the event loop before generating the diff
|
|
696
|
+
# otherwise we may miss updates from the websocket
|
|
697
|
+
await self._save_device_changes(
|
|
698
|
+
data_before_changes,
|
|
699
|
+
self.unifi_dict(data=self.get_changed(data_before_changes)),
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
async def save_device(
|
|
703
|
+
self,
|
|
704
|
+
data_before_changes: dict[str, Any],
|
|
705
|
+
force_emit: bool = False,
|
|
706
|
+
revert_on_fail: bool = True,
|
|
707
|
+
) -> None:
|
|
708
|
+
"""
|
|
709
|
+
Generates a diff for unsaved changed on the device and sends them back to UFP
|
|
710
|
+
|
|
711
|
+
USE WITH CAUTION, updates _all_ fields for the current object that have been changed.
|
|
712
|
+
May have unexpected side effects.
|
|
713
|
+
|
|
714
|
+
Tested updates have been added a methods on applicable devices.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
----
|
|
718
|
+
force_emit: Emit a fake UFP WS message. Should only be use for when UFP does not properly emit a WS message
|
|
719
|
+
|
|
720
|
+
"""
|
|
721
|
+
# do not allow multiple save_device calls at once
|
|
722
|
+
release_lock = False
|
|
723
|
+
if not self._update_lock.locked():
|
|
724
|
+
await self._update_lock.acquire()
|
|
725
|
+
release_lock = True
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
await self._save_device_changes(
|
|
729
|
+
data_before_changes,
|
|
730
|
+
self.unifi_dict(data=self.get_changed(data_before_changes)),
|
|
731
|
+
force_emit=force_emit,
|
|
732
|
+
revert_on_fail=revert_on_fail,
|
|
733
|
+
)
|
|
734
|
+
finally:
|
|
735
|
+
if release_lock:
|
|
736
|
+
self._update_lock.release()
|
|
737
|
+
|
|
738
|
+
async def _save_device_changes(
|
|
739
|
+
self,
|
|
740
|
+
data_before_changes: dict[str, Any],
|
|
741
|
+
updated: dict[str, Any],
|
|
742
|
+
force_emit: bool = False,
|
|
743
|
+
revert_on_fail: bool = True,
|
|
744
|
+
) -> None:
|
|
745
|
+
"""Saves the current device changes to UFP."""
|
|
746
|
+
assert (
|
|
747
|
+
self._update_lock.locked()
|
|
748
|
+
), "save_device_changes should only be called when the update lock is held"
|
|
749
|
+
read_only_fields = self.__class__._get_read_only_fields()
|
|
750
|
+
|
|
751
|
+
if self.model is None:
|
|
752
|
+
raise BadRequest("Unknown model type")
|
|
753
|
+
|
|
754
|
+
if not self.api.bootstrap.auth_user.can(self.model, PermissionNode.WRITE, self):
|
|
755
|
+
if revert_on_fail:
|
|
756
|
+
self.revert_changes(data_before_changes)
|
|
757
|
+
raise NotAuthorized(f"Do not have write permission for obj: {self.id}")
|
|
758
|
+
|
|
759
|
+
# do not patch when there are no updates
|
|
760
|
+
if updated == {}:
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
read_only_keys = read_only_fields.intersection(updated.keys())
|
|
764
|
+
if len(read_only_keys) > 0:
|
|
765
|
+
self.revert_changes(data_before_changes)
|
|
766
|
+
raise BadRequest(
|
|
767
|
+
f"{type(self)} The following key(s) are read only: {read_only_keys}, updated: {updated}",
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
try:
|
|
771
|
+
await self._api_update(updated)
|
|
772
|
+
except ClientError:
|
|
773
|
+
if revert_on_fail:
|
|
774
|
+
self.revert_changes(data_before_changes)
|
|
775
|
+
raise
|
|
776
|
+
|
|
777
|
+
if force_emit:
|
|
778
|
+
await self.emit_message(updated)
|
|
779
|
+
|
|
780
|
+
async def emit_message(self, updated: dict[str, Any]) -> None:
|
|
781
|
+
"""Emites fake WS message for ProtectApiClient to process."""
|
|
782
|
+
if updated == {}:
|
|
783
|
+
_LOGGER.debug("Event ping callback started for %s", self.id)
|
|
784
|
+
|
|
785
|
+
if self.model is None:
|
|
786
|
+
raise BadRequest("Unknown model type")
|
|
787
|
+
|
|
788
|
+
header = WSPacketFrameHeader(
|
|
789
|
+
packet_type=1,
|
|
790
|
+
payload_format=ProtectWSPayloadFormat.JSON.value,
|
|
791
|
+
deflated=0,
|
|
792
|
+
unknown=1,
|
|
793
|
+
payload_size=1,
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
action_frame = WSJSONPacketFrame()
|
|
797
|
+
action_frame.header = header
|
|
798
|
+
action_frame.data = {
|
|
799
|
+
"action": "update",
|
|
800
|
+
"newUpdateId": None,
|
|
801
|
+
"modelKey": self.model.value,
|
|
802
|
+
"id": self.id,
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
data_frame = WSJSONPacketFrame()
|
|
806
|
+
data_frame.header = header
|
|
807
|
+
data_frame.data = updated
|
|
808
|
+
|
|
809
|
+
message = self.api.bootstrap.process_ws_packet(
|
|
810
|
+
WSPacket(action_frame.packed + data_frame.packed),
|
|
811
|
+
)
|
|
812
|
+
if message is not None:
|
|
813
|
+
self.api.emit_message(message)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
class ProtectDeviceModel(ProtectModelWithId):
|
|
817
|
+
name: str | None
|
|
818
|
+
type: str
|
|
819
|
+
mac: str
|
|
820
|
+
host: IPv4Address | str | None
|
|
821
|
+
up_since: datetime | None
|
|
822
|
+
uptime: timedelta | None
|
|
823
|
+
last_seen: datetime | None
|
|
824
|
+
hardware_revision: str | None
|
|
825
|
+
firmware_version: str | None
|
|
826
|
+
is_updating: bool
|
|
827
|
+
is_ssh_enabled: bool
|
|
828
|
+
|
|
829
|
+
_callback_ping: TimerHandle | None = PrivateAttr(None)
|
|
830
|
+
|
|
831
|
+
@classmethod
|
|
832
|
+
@cache
|
|
833
|
+
def _get_read_only_fields(cls) -> set[str]:
|
|
834
|
+
return super()._get_read_only_fields() | {
|
|
835
|
+
"mac",
|
|
836
|
+
"host",
|
|
837
|
+
"type",
|
|
838
|
+
"upSince",
|
|
839
|
+
"uptime",
|
|
840
|
+
"lastSeen",
|
|
841
|
+
"hardwareRevision",
|
|
842
|
+
"isUpdating",
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
@classmethod
|
|
846
|
+
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
847
|
+
if "lastSeen" in data:
|
|
848
|
+
data["lastSeen"] = process_datetime(data, "lastSeen")
|
|
849
|
+
if "upSince" in data and data["upSince"] is not None:
|
|
850
|
+
data["upSince"] = process_datetime(data, "upSince")
|
|
851
|
+
if (
|
|
852
|
+
"uptime" in data
|
|
853
|
+
and data["uptime"] is not None
|
|
854
|
+
and not isinstance(data["uptime"], timedelta)
|
|
855
|
+
):
|
|
856
|
+
data["uptime"] = timedelta(milliseconds=int(data["uptime"]))
|
|
857
|
+
# hardware revisions for all devices are not simple numbers
|
|
858
|
+
# so cast them all to str to be consistent
|
|
859
|
+
if "hardwareRevision" in data and data["hardwareRevision"] is not None:
|
|
860
|
+
data["hardwareRevision"] = str(data["hardwareRevision"])
|
|
861
|
+
|
|
862
|
+
return super().unifi_dict_to_dict(data)
|
|
863
|
+
|
|
864
|
+
def _event_callback_ping(self) -> None:
|
|
865
|
+
_LOGGER.debug("Event ping timer started for %s", self.id)
|
|
866
|
+
loop = asyncio.get_event_loop()
|
|
867
|
+
self._callback_ping = loop.call_later(
|
|
868
|
+
EVENT_PING_INTERVAL.total_seconds(),
|
|
869
|
+
asyncio.create_task,
|
|
870
|
+
self.emit_message({}),
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
async def set_name(self, name: str | None) -> None:
|
|
874
|
+
"""Sets name for the device"""
|
|
875
|
+
|
|
876
|
+
def callback() -> None:
|
|
877
|
+
self.name = name
|
|
878
|
+
|
|
879
|
+
await self.queue_update(callback)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
class WiredConnectionState(ProtectBaseObject):
|
|
883
|
+
phy_rate: float | None
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
class WirelessConnectionState(ProtectBaseObject):
|
|
887
|
+
signal_quality: int | None
|
|
888
|
+
signal_strength: int | None
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
class BluetoothConnectionState(WirelessConnectionState):
|
|
892
|
+
experience_score: PercentFloat | None = None
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
class WifiConnectionState(WirelessConnectionState):
|
|
896
|
+
phy_rate: float | None
|
|
897
|
+
channel: int | None
|
|
898
|
+
frequency: int | None
|
|
899
|
+
ssid: str | None
|
|
900
|
+
bssid: str | None = None
|
|
901
|
+
tx_rate: float | None = None
|
|
902
|
+
# requires 2.7.5+
|
|
903
|
+
ap_name: str | None = None
|
|
904
|
+
experience: str | None = None
|
|
905
|
+
# requires 2.7.15+
|
|
906
|
+
connectivity: str | None = None
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
class ProtectAdoptableDeviceModel(ProtectDeviceModel):
|
|
910
|
+
state: StateType
|
|
911
|
+
connection_host: IPv4Address | str | None
|
|
912
|
+
connected_since: datetime | None
|
|
913
|
+
latest_firmware_version: str | None
|
|
914
|
+
firmware_build: str | None
|
|
915
|
+
is_adopting: bool
|
|
916
|
+
is_adopted: bool
|
|
917
|
+
is_adopted_by_other: bool
|
|
918
|
+
is_provisioned: bool
|
|
919
|
+
is_rebooting: bool
|
|
920
|
+
can_adopt: bool
|
|
921
|
+
is_attempting_to_connect: bool
|
|
922
|
+
is_connected: bool
|
|
923
|
+
# requires 1.21+
|
|
924
|
+
market_name: str | None
|
|
925
|
+
# requires 2.7.5+
|
|
926
|
+
fw_update_state: str | None = None
|
|
927
|
+
# requires 2.8.14+
|
|
928
|
+
nvr_mac: str | None = None
|
|
929
|
+
# requires 2.8.22+
|
|
930
|
+
guid: UUID | None = None
|
|
931
|
+
# requires 2.9.20+
|
|
932
|
+
is_restoring: bool | None = None
|
|
933
|
+
last_disconnect: datetime | None = None
|
|
934
|
+
anonymous_device_id: UUID | None = None
|
|
935
|
+
|
|
936
|
+
wired_connection_state: WiredConnectionState | None = None
|
|
937
|
+
wifi_connection_state: WifiConnectionState | None = None
|
|
938
|
+
bluetooth_connection_state: BluetoothConnectionState | None = None
|
|
939
|
+
bridge_id: str | None
|
|
940
|
+
is_downloading_firmware: bool | None
|
|
941
|
+
|
|
942
|
+
# TODO:
|
|
943
|
+
# bridgeCandidates
|
|
944
|
+
|
|
945
|
+
@classmethod
|
|
946
|
+
@cache
|
|
947
|
+
def _get_read_only_fields(cls) -> set[str]:
|
|
948
|
+
return super()._get_read_only_fields() | {
|
|
949
|
+
"connectionHost",
|
|
950
|
+
"connectedSince",
|
|
951
|
+
"state",
|
|
952
|
+
"latestFirmwareVersion",
|
|
953
|
+
"firmwareBuild",
|
|
954
|
+
"isAdopting",
|
|
955
|
+
"isProvisioned",
|
|
956
|
+
"isRebooting",
|
|
957
|
+
"canAdopt",
|
|
958
|
+
"isAttemptingToConnect",
|
|
959
|
+
"bluetoothConnectionState",
|
|
960
|
+
"isDownloadingFirmware",
|
|
961
|
+
"anonymousDeviceId",
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
@classmethod
|
|
965
|
+
@cache
|
|
966
|
+
def _get_unifi_remaps(cls) -> dict[str, str]:
|
|
967
|
+
return {
|
|
968
|
+
**super()._get_unifi_remaps(),
|
|
969
|
+
"bridge": "bridgeId",
|
|
970
|
+
"isDownloadingFW": "isDownloadingFirmware",
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async def _api_update(self, data: dict[str, Any]) -> None:
|
|
974
|
+
if self.model is not None:
|
|
975
|
+
return await self.api.update_device(self.model, self.id, data)
|
|
976
|
+
return None
|
|
977
|
+
|
|
978
|
+
def unifi_dict(
|
|
979
|
+
self,
|
|
980
|
+
data: dict[str, Any] | None = None,
|
|
981
|
+
exclude: set[str] | None = None,
|
|
982
|
+
) -> dict[str, Any]:
|
|
983
|
+
data = super().unifi_dict(data=data, exclude=exclude)
|
|
984
|
+
|
|
985
|
+
if "wiredConnectionState" in data and data["wiredConnectionState"] is None:
|
|
986
|
+
del data["wiredConnectionState"]
|
|
987
|
+
if "wifiConnectionState" in data and data["wifiConnectionState"] is None:
|
|
988
|
+
del data["wifiConnectionState"]
|
|
989
|
+
if (
|
|
990
|
+
"bluetoothConnectionState" in data
|
|
991
|
+
and data["bluetoothConnectionState"] is None
|
|
992
|
+
):
|
|
993
|
+
del data["bluetoothConnectionState"]
|
|
994
|
+
return data
|
|
995
|
+
|
|
996
|
+
@classmethod
|
|
997
|
+
def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
|
|
998
|
+
if "lastDisconnect" in data and data["lastDisconnect"] is not None:
|
|
999
|
+
data["lastDisconnect"] = process_datetime(data, "lastDisconnect")
|
|
1000
|
+
|
|
1001
|
+
return super().unifi_dict_to_dict(data)
|
|
1002
|
+
|
|
1003
|
+
@property
|
|
1004
|
+
def display_name(self) -> str:
|
|
1005
|
+
return self.name or self.market_name or self.type
|
|
1006
|
+
|
|
1007
|
+
@property
|
|
1008
|
+
def is_wired(self) -> bool:
|
|
1009
|
+
return self.wired_connection_state is not None
|
|
1010
|
+
|
|
1011
|
+
@property
|
|
1012
|
+
def is_wifi(self) -> bool:
|
|
1013
|
+
return self.wifi_connection_state is not None
|
|
1014
|
+
|
|
1015
|
+
@property
|
|
1016
|
+
def is_bluetooth(self) -> bool:
|
|
1017
|
+
return self.bluetooth_connection_state is not None
|
|
1018
|
+
|
|
1019
|
+
@property
|
|
1020
|
+
def bridge(self) -> Bridge | None:
|
|
1021
|
+
if self.bridge_id is None:
|
|
1022
|
+
return None
|
|
1023
|
+
|
|
1024
|
+
return self.api.bootstrap.bridges[self.bridge_id]
|
|
1025
|
+
|
|
1026
|
+
@property
|
|
1027
|
+
def protect_url(self) -> str:
|
|
1028
|
+
"""UFP Web app URL for this device"""
|
|
1029
|
+
return f"{self.api.base_url}/protect/devices/{self.id}"
|
|
1030
|
+
|
|
1031
|
+
@property
|
|
1032
|
+
def is_adopted_by_us(self) -> bool:
|
|
1033
|
+
"""Verifies device is adopted and controlled by this NVR."""
|
|
1034
|
+
return self.is_adopted and not self.is_adopted_by_other
|
|
1035
|
+
|
|
1036
|
+
def get_changed(self, data_before_changes: dict[str, Any]) -> dict[str, Any]:
|
|
1037
|
+
"""Gets dictionary of all changed fields"""
|
|
1038
|
+
return dict_diff(data_before_changes, self.dict_with_excludes())
|
|
1039
|
+
|
|
1040
|
+
async def set_ssh(self, enabled: bool) -> None:
|
|
1041
|
+
"""Sets ssh status for protect device"""
|
|
1042
|
+
|
|
1043
|
+
def callback() -> None:
|
|
1044
|
+
self.is_ssh_enabled = enabled
|
|
1045
|
+
|
|
1046
|
+
await self.queue_update(callback)
|
|
1047
|
+
|
|
1048
|
+
async def reboot(self) -> None:
|
|
1049
|
+
"""Reboots an adopted device"""
|
|
1050
|
+
if self.model is not None:
|
|
1051
|
+
if not self.api.bootstrap.auth_user.can(
|
|
1052
|
+
self.model,
|
|
1053
|
+
PermissionNode.WRITE,
|
|
1054
|
+
self,
|
|
1055
|
+
):
|
|
1056
|
+
raise NotAuthorized("Do not have permission to reboot device")
|
|
1057
|
+
await self.api.reboot_device(self.model, self.id)
|
|
1058
|
+
|
|
1059
|
+
async def unadopt(self) -> None:
|
|
1060
|
+
"""Unadopt/Unmanage adopted device"""
|
|
1061
|
+
if not self.is_adopted_by_us:
|
|
1062
|
+
raise BadRequest("Device is not adopted")
|
|
1063
|
+
|
|
1064
|
+
if self.model is not None:
|
|
1065
|
+
if not self.api.bootstrap.auth_user.can(
|
|
1066
|
+
self.model,
|
|
1067
|
+
PermissionNode.DELETE,
|
|
1068
|
+
self,
|
|
1069
|
+
):
|
|
1070
|
+
raise NotAuthorized("Do not have permission to unadopt devices")
|
|
1071
|
+
await self.api.unadopt_device(self.model, self.id)
|
|
1072
|
+
|
|
1073
|
+
async def adopt(self, name: str | None = None) -> None:
|
|
1074
|
+
"""Adopts a device"""
|
|
1075
|
+
if not self.can_adopt:
|
|
1076
|
+
raise BadRequest("Device cannot be adopted")
|
|
1077
|
+
|
|
1078
|
+
if self.model is not None:
|
|
1079
|
+
if not self.api.bootstrap.auth_user.can(self.model, PermissionNode.CREATE):
|
|
1080
|
+
raise NotAuthorized("Do not have permission to adopt devices")
|
|
1081
|
+
|
|
1082
|
+
await self.api.adopt_device(self.model, self.id)
|
|
1083
|
+
if name is not None:
|
|
1084
|
+
await self.set_name(name)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
class ProtectMotionDeviceModel(ProtectAdoptableDeviceModel):
|
|
1088
|
+
last_motion: datetime | None
|
|
1089
|
+
is_dark: bool
|
|
1090
|
+
|
|
1091
|
+
# not directly from UniFi
|
|
1092
|
+
last_motion_event_id: str | None = None
|
|
1093
|
+
|
|
1094
|
+
@classmethod
|
|
1095
|
+
@cache
|
|
1096
|
+
def _get_read_only_fields(cls) -> set[str]:
|
|
1097
|
+
return super()._get_read_only_fields() | {"lastMotion", "isDark"}
|
|
1098
|
+
|
|
1099
|
+
def unifi_dict(
|
|
1100
|
+
self,
|
|
1101
|
+
data: dict[str, Any] | None = None,
|
|
1102
|
+
exclude: set[str] | None = None,
|
|
1103
|
+
) -> dict[str, Any]:
|
|
1104
|
+
data = super().unifi_dict(data=data, exclude=exclude)
|
|
1105
|
+
|
|
1106
|
+
if "lastMotionEventId" in data:
|
|
1107
|
+
del data["lastMotionEventId"]
|
|
1108
|
+
|
|
1109
|
+
return data
|
|
1110
|
+
|
|
1111
|
+
@property
|
|
1112
|
+
def last_motion_event(self) -> Event | None:
|
|
1113
|
+
if self.last_motion_event_id is None:
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
return self.api.bootstrap.events.get(self.last_motion_event_id)
|