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/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)