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.

@@ -0,0 +1,634 @@
1
+ """UniFi Protect Bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from copy import deepcopy
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from typing import Any, cast
11
+ from uuid import UUID
12
+
13
+ from aiohttp.client_exceptions import ServerDisconnectedError
14
+ from pydantic.v1 import PrivateAttr, ValidationError
15
+
16
+ from uiprotect.data.base import (
17
+ RECENT_EVENT_MAX,
18
+ ProtectBaseObject,
19
+ ProtectModel,
20
+ ProtectModelWithId,
21
+ )
22
+ from uiprotect.data.convert import create_from_unifi_dict
23
+ from uiprotect.data.devices import (
24
+ Bridge,
25
+ Camera,
26
+ Chime,
27
+ Doorlock,
28
+ Light,
29
+ ProtectAdoptableDeviceModel,
30
+ Sensor,
31
+ Viewer,
32
+ )
33
+ from uiprotect.data.nvr import NVR, Event, Liveview
34
+ from uiprotect.data.types import EventType, FixSizeOrderedDict, ModelType
35
+ from uiprotect.data.user import Group, User
36
+ from uiprotect.data.websocket import (
37
+ WSAction,
38
+ WSJSONPacketFrame,
39
+ WSPacket,
40
+ WSSubscriptionMessage,
41
+ )
42
+ from uiprotect.exceptions import ClientError
43
+ from uiprotect.utils import utc_now
44
+
45
+ _LOGGER = logging.getLogger(__name__)
46
+
47
+ MAX_SUPPORTED_CAMERAS = 256
48
+ MAX_EVENT_HISTORY_IN_STATE_MACHINE = MAX_SUPPORTED_CAMERAS * 2
49
+ STATS_KEYS = {
50
+ "storageStats",
51
+ "stats",
52
+ "systemInfo",
53
+ "phyRate",
54
+ "wifiConnectionState",
55
+ "upSince",
56
+ "uptime",
57
+ "lastSeen",
58
+ "recordingSchedules",
59
+ }
60
+ IGNORE_DEVICE_KEYS = {"nvrMac", "guid"}
61
+
62
+ CAMERA_EVENT_ATTR_MAP: dict[EventType, tuple[str, str]] = {
63
+ EventType.MOTION: ("last_motion", "last_motion_event_id"),
64
+ EventType.SMART_DETECT: ("last_smart_detect", "last_smart_detect_event_id"),
65
+ EventType.SMART_DETECT_LINE: ("last_smart_detect", "last_smart_detect_event_id"),
66
+ EventType.SMART_AUDIO_DETECT: (
67
+ "last_smart_audio_detect",
68
+ "last_smart_audio_detect_event_id",
69
+ ),
70
+ EventType.RING: ("last_ring", "last_ring_event_id"),
71
+ }
72
+
73
+
74
+ def _remove_stats_keys(data: dict[str, Any]) -> None:
75
+ for key in STATS_KEYS.intersection(data):
76
+ del data[key]
77
+
78
+
79
+ def _process_light_event(event: Event) -> None:
80
+ if event.light is None:
81
+ return
82
+
83
+ dt = event.light.last_motion
84
+ if dt is None or event.start >= dt or (event.end is not None and event.end >= dt):
85
+ event.light.last_motion_event_id = event.id
86
+
87
+
88
+ def _process_sensor_event(event: Event) -> None:
89
+ if event.sensor is None:
90
+ return
91
+
92
+ if event.type == EventType.MOTION_SENSOR:
93
+ dt = event.sensor.motion_detected_at
94
+ if (
95
+ dt is None
96
+ or event.start >= dt
97
+ or (event.end is not None and event.end >= dt)
98
+ ):
99
+ event.sensor.last_motion_event_id = event.id
100
+ elif event.type in {EventType.SENSOR_CLOSED, EventType.SENSOR_OPENED}:
101
+ dt = event.sensor.open_status_changed_at
102
+ if (
103
+ dt is None
104
+ or event.start >= dt
105
+ or (event.end is not None and event.end >= dt)
106
+ ):
107
+ event.sensor.last_contact_event_id = event.id
108
+ elif event.type == EventType.SENSOR_EXTREME_VALUE:
109
+ dt = event.sensor.extreme_value_detected_at
110
+ if (
111
+ dt is None
112
+ or event.start >= dt
113
+ or (event.end is not None and event.end >= dt)
114
+ ):
115
+ event.sensor.extreme_value_detected_at = event.end
116
+ event.sensor.last_value_event_id = event.id
117
+ elif event.type == EventType.SENSOR_ALARM:
118
+ dt = event.sensor.alarm_triggered_at
119
+ if (
120
+ dt is None
121
+ or event.start >= dt
122
+ or (event.end is not None and event.end >= dt)
123
+ ):
124
+ event.sensor.last_value_event_id = event.id
125
+
126
+
127
+ def _process_camera_event(event: Event) -> None:
128
+ if event.camera is None:
129
+ return
130
+
131
+ dt_attr, event_attr = CAMERA_EVENT_ATTR_MAP[event.type]
132
+ dt = getattr(event.camera, dt_attr)
133
+ if dt is None or event.start >= dt or (event.end is not None and event.end >= dt):
134
+ setattr(event.camera, event_attr, event.id)
135
+ setattr(event.camera, dt_attr, event.start)
136
+ if event.type in {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE}:
137
+ for smart_type in event.smart_detect_types:
138
+ event.camera.last_smart_detect_event_ids[smart_type] = event.id
139
+ event.camera.last_smart_detects[smart_type] = event.start
140
+ elif event.type == EventType.SMART_AUDIO_DETECT:
141
+ for smart_type in event.smart_detect_types:
142
+ audio_type = smart_type.audio_type
143
+ if audio_type is None:
144
+ continue
145
+ event.camera.last_smart_audio_detect_event_ids[audio_type] = event.id
146
+ event.camera.last_smart_audio_detects[audio_type] = event.start
147
+
148
+
149
+ @dataclass
150
+ class WSStat:
151
+ model: str
152
+ action: str
153
+ keys: list[str]
154
+ keys_set: list[str]
155
+ size: int
156
+ filtered: bool
157
+
158
+
159
+ class ProtectDeviceRef(ProtectBaseObject):
160
+ model: ModelType
161
+ id: str
162
+
163
+
164
+ class Bootstrap(ProtectBaseObject):
165
+ auth_user_id: str
166
+ access_key: str
167
+ cameras: dict[str, Camera]
168
+ users: dict[str, User]
169
+ groups: dict[str, Group]
170
+ liveviews: dict[str, Liveview]
171
+ nvr: NVR
172
+ viewers: dict[str, Viewer]
173
+ lights: dict[str, Light]
174
+ bridges: dict[str, Bridge]
175
+ sensors: dict[str, Sensor]
176
+ doorlocks: dict[str, Doorlock]
177
+ chimes: dict[str, Chime]
178
+ last_update_id: UUID
179
+
180
+ # TODO:
181
+ # schedules
182
+ # agreements
183
+
184
+ # not directly from UniFi
185
+ events: dict[str, Event] = FixSizeOrderedDict()
186
+ capture_ws_stats: bool = False
187
+ mac_lookup: dict[str, ProtectDeviceRef] = {}
188
+ id_lookup: dict[str, ProtectDeviceRef] = {}
189
+ _ws_stats: list[WSStat] = PrivateAttr([])
190
+ _has_doorbell: bool | None = PrivateAttr(None)
191
+ _has_smart: bool | None = PrivateAttr(None)
192
+ _has_media: bool | None = PrivateAttr(None)
193
+ _recording_start: datetime | None = PrivateAttr(None)
194
+ _refresh_tasks: set[asyncio.Task[None]] = PrivateAttr(set())
195
+
196
+ @classmethod
197
+ def unifi_dict_to_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
198
+ api = cls._get_api(data.get("api"))
199
+ data["macLookup"] = {}
200
+ data["idLookup"] = {}
201
+ for model_type in ModelType.bootstrap_models():
202
+ key = model_type + "s"
203
+ items: dict[str, ProtectModel] = {}
204
+ for item in data[key]:
205
+ if (
206
+ api is not None
207
+ and api.ignore_unadopted
208
+ and not item.get("isAdopted", True)
209
+ ):
210
+ continue
211
+
212
+ ref = {"model": model_type, "id": item["id"]}
213
+ items[item["id"]] = item
214
+ data["idLookup"][item["id"]] = ref
215
+ if "mac" in item:
216
+ cleaned_mac = item["mac"].lower().replace(":", "")
217
+ data["macLookup"][cleaned_mac] = ref
218
+ data[key] = items
219
+
220
+ return super().unifi_dict_to_dict(data)
221
+
222
+ def unifi_dict(
223
+ self,
224
+ data: dict[str, Any] | None = None,
225
+ exclude: set[str] | None = None,
226
+ ) -> dict[str, Any]:
227
+ data = super().unifi_dict(data=data, exclude=exclude)
228
+
229
+ if "events" in data:
230
+ del data["events"]
231
+ if "captureWsStats" in data:
232
+ del data["captureWsStats"]
233
+ if "macLookup" in data:
234
+ del data["macLookup"]
235
+ if "idLookup" in data:
236
+ del data["idLookup"]
237
+
238
+ for model_type in ModelType.bootstrap_models():
239
+ attr = model_type + "s"
240
+ if attr in data and isinstance(data[attr], dict):
241
+ data[attr] = list(data[attr].values())
242
+
243
+ return data
244
+
245
+ @property
246
+ def ws_stats(self) -> list[WSStat]:
247
+ return self._ws_stats
248
+
249
+ def clear_ws_stats(self) -> None:
250
+ self._ws_stats = []
251
+
252
+ @property
253
+ def auth_user(self) -> User:
254
+ user: User = self.api.bootstrap.users[self.auth_user_id]
255
+ return user
256
+
257
+ @property
258
+ def has_doorbell(self) -> bool:
259
+ if self._has_doorbell is None:
260
+ self._has_doorbell = any(
261
+ c.feature_flags.is_doorbell for c in self.cameras.values()
262
+ )
263
+
264
+ return self._has_doorbell
265
+
266
+ @property
267
+ def recording_start(self) -> datetime | None:
268
+ """Get earilest recording date."""
269
+ if self._recording_start is None:
270
+ try:
271
+ self._recording_start = min(
272
+ c.stats.video.recording_start
273
+ for c in self.cameras.values()
274
+ if c.stats.video.recording_start is not None
275
+ )
276
+ except ValueError:
277
+ return None
278
+ return self._recording_start
279
+
280
+ @property
281
+ def has_smart_detections(self) -> bool:
282
+ """Check if any camera has smart detections."""
283
+ if self._has_smart is None:
284
+ self._has_smart = any(
285
+ c.feature_flags.has_smart_detect for c in self.cameras.values()
286
+ )
287
+ return self._has_smart
288
+
289
+ @property
290
+ def has_media(self) -> bool:
291
+ """Checks if user can read media for any camera."""
292
+ if self._has_media is None:
293
+ if self.recording_start is None:
294
+ return False
295
+ self._has_media = any(
296
+ c.can_read_media(self.auth_user) for c in self.cameras.values()
297
+ )
298
+ return self._has_media
299
+
300
+ def get_device_from_mac(self, mac: str) -> ProtectAdoptableDeviceModel | None:
301
+ """Retrieve a device from MAC address."""
302
+ mac = mac.lower().replace(":", "").replace("-", "").replace("_", "")
303
+ ref = self.mac_lookup.get(mac)
304
+ if ref is None:
305
+ return None
306
+
307
+ devices = getattr(self, f"{ref.model.value}s")
308
+ return cast(ProtectAdoptableDeviceModel, devices.get(ref.id))
309
+
310
+ def get_device_from_id(self, device_id: str) -> ProtectAdoptableDeviceModel | None:
311
+ """Retrieve a device from device ID (without knowing model type)."""
312
+ ref = self.id_lookup.get(device_id)
313
+ if ref is None:
314
+ return None
315
+ devices = getattr(self, f"{ref.model.value}s")
316
+ return cast(ProtectAdoptableDeviceModel, devices.get(ref.id))
317
+
318
+ def process_event(self, event: Event) -> None:
319
+ if event.type in CAMERA_EVENT_ATTR_MAP and event.camera is not None:
320
+ _process_camera_event(event)
321
+ elif event.type == EventType.MOTION_LIGHT and event.light is not None:
322
+ _process_light_event(event)
323
+ elif event.type == EventType.MOTION_SENSOR and event.sensor is not None:
324
+ _process_sensor_event(event)
325
+
326
+ self.events[event.id] = event
327
+
328
+ def _create_stat(
329
+ self,
330
+ packet: WSPacket,
331
+ keys_set: list[str],
332
+ filtered: bool,
333
+ ) -> None:
334
+ if self.capture_ws_stats:
335
+ self._ws_stats.append(
336
+ WSStat(
337
+ model=packet.action_frame.data["modelKey"],
338
+ action=packet.action_frame.data["action"],
339
+ keys=list(packet.data_frame.data),
340
+ keys_set=keys_set,
341
+ size=len(packet.raw),
342
+ filtered=filtered,
343
+ ),
344
+ )
345
+
346
+ def _get_frame_data(
347
+ self,
348
+ packet: WSPacket,
349
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
350
+ if self.capture_ws_stats:
351
+ return deepcopy(packet.action_frame.data), deepcopy(packet.data_frame.data)
352
+ return packet.action_frame.data, packet.data_frame.data
353
+
354
+ def _process_add_packet(
355
+ self,
356
+ packet: WSPacket,
357
+ data: dict[str, Any],
358
+ ) -> WSSubscriptionMessage | None:
359
+ obj = create_from_unifi_dict(data, api=self._api)
360
+
361
+ if isinstance(obj, Event):
362
+ self.process_event(obj)
363
+ elif isinstance(obj, NVR):
364
+ self.nvr = obj
365
+ elif (
366
+ isinstance(obj, ProtectAdoptableDeviceModel)
367
+ and obj.model is not None
368
+ and obj.model.value in ModelType.bootstrap_models()
369
+ ):
370
+ key = obj.model.value + "s"
371
+ if not self.api.ignore_unadopted or (
372
+ obj.is_adopted and not obj.is_adopted_by_other
373
+ ):
374
+ getattr(self, key)[obj.id] = obj
375
+ ref = ProtectDeviceRef(model=obj.model, id=obj.id)
376
+ self.id_lookup[obj.id] = ref
377
+ self.mac_lookup[obj.mac.lower().replace(":", "")] = ref
378
+ else:
379
+ _LOGGER.debug("Unexpected bootstrap model type for add: %s", obj.model)
380
+ return None
381
+
382
+ updated = obj.dict()
383
+ self._create_stat(packet, list(updated), False)
384
+ return WSSubscriptionMessage(
385
+ action=WSAction.ADD,
386
+ new_update_id=self.last_update_id,
387
+ changed_data=updated,
388
+ new_obj=obj,
389
+ )
390
+
391
+ def _process_remove_packet(
392
+ self,
393
+ packet: WSPacket,
394
+ data: dict[str, Any] | None,
395
+ ) -> WSSubscriptionMessage | None:
396
+ model = packet.action_frame.data.get("modelKey")
397
+ device_id = packet.action_frame.data.get("id")
398
+ devices = getattr(self, f"{model}s", None)
399
+
400
+ if devices is None:
401
+ return None
402
+
403
+ self.id_lookup.pop(device_id, None)
404
+ device = devices.pop(device_id, None)
405
+ if device is None:
406
+ return None
407
+ self.mac_lookup.pop(device.mac.lower().replace(":", ""), None)
408
+
409
+ self._create_stat(packet, [], False)
410
+ return WSSubscriptionMessage(
411
+ action=WSAction.REMOVE,
412
+ new_update_id=self.last_update_id,
413
+ changed_data={},
414
+ old_obj=device,
415
+ )
416
+
417
+ def _process_nvr_update(
418
+ self,
419
+ packet: WSPacket,
420
+ data: dict[str, Any],
421
+ ignore_stats: bool,
422
+ ) -> WSSubscriptionMessage | None:
423
+ if ignore_stats:
424
+ _remove_stats_keys(data)
425
+ # nothing left to process
426
+ if not data:
427
+ self._create_stat(packet, [], True)
428
+ return None
429
+
430
+ # for another NVR in stack
431
+ nvr_id = packet.action_frame.data.get("id")
432
+ if nvr_id and nvr_id != self.nvr.id:
433
+ self._create_stat(packet, [], True)
434
+ return None
435
+
436
+ data = self.nvr.unifi_dict_to_dict(data)
437
+ # nothing left to process
438
+ if not data:
439
+ self._create_stat(packet, [], True)
440
+ return None
441
+
442
+ old_nvr = self.nvr.copy()
443
+ self.nvr = self.nvr.update_from_dict(deepcopy(data))
444
+
445
+ self._create_stat(packet, list(data), False)
446
+ return WSSubscriptionMessage(
447
+ action=WSAction.UPDATE,
448
+ new_update_id=self.last_update_id,
449
+ changed_data=data,
450
+ new_obj=self.nvr,
451
+ old_obj=old_nvr,
452
+ )
453
+
454
+ def _process_device_update(
455
+ self,
456
+ packet: WSPacket,
457
+ action: dict[str, Any],
458
+ data: dict[str, Any],
459
+ ignore_stats: bool,
460
+ ) -> WSSubscriptionMessage | None:
461
+ model_type = action["modelKey"]
462
+ if ignore_stats:
463
+ _remove_stats_keys(data)
464
+ for key in IGNORE_DEVICE_KEYS.intersection(data):
465
+ del data[key]
466
+ # `last_motion` from cameras update every 100 milliseconds when a motion event is active
467
+ # this overrides the behavior to only update `last_motion` when a new event starts
468
+ if model_type == "camera" and "lastMotion" in data:
469
+ del data["lastMotion"]
470
+ # nothing left to process
471
+ if not data:
472
+ self._create_stat(packet, [], True)
473
+ return None
474
+
475
+ key = f"{model_type}s"
476
+ devices = getattr(self, key)
477
+ if action["id"] in devices:
478
+ if action["id"] not in devices:
479
+ raise ValueError(
480
+ f"Unknown device update for {model_type}: { action['id']}",
481
+ )
482
+ obj: ProtectModelWithId = devices[action["id"]]
483
+ data = obj.unifi_dict_to_dict(data)
484
+ old_obj = obj.copy()
485
+ obj = obj.update_from_dict(deepcopy(data))
486
+ now = utc_now()
487
+
488
+ if isinstance(obj, Event):
489
+ self.process_event(obj)
490
+ elif isinstance(obj, Camera):
491
+ if "last_ring" in data and obj.last_ring:
492
+ is_recent = obj.last_ring + RECENT_EVENT_MAX >= now
493
+ _LOGGER.debug("last_ring for %s (%s)", obj.id, is_recent)
494
+ if is_recent:
495
+ obj.set_ring_timeout()
496
+ elif (
497
+ isinstance(obj, Sensor)
498
+ and "alarm_triggered_at" in data
499
+ and obj.alarm_triggered_at
500
+ ):
501
+ is_recent = obj.alarm_triggered_at + RECENT_EVENT_MAX >= now
502
+ _LOGGER.debug("alarm_triggered_at for %s (%s)", obj.id, is_recent)
503
+ if is_recent:
504
+ obj.set_alarm_timeout()
505
+
506
+ devices[action["id"]] = obj
507
+
508
+ self._create_stat(packet, list(data), False)
509
+ return WSSubscriptionMessage(
510
+ action=WSAction.UPDATE,
511
+ new_update_id=self.last_update_id,
512
+ changed_data=data,
513
+ new_obj=obj,
514
+ old_obj=old_obj,
515
+ )
516
+
517
+ # ignore updates to events that phase out
518
+ if model_type != ModelType.EVENT.value:
519
+ _LOGGER.debug("Unexpected %s: %s", key, action["id"])
520
+ return None
521
+
522
+ def process_ws_packet(
523
+ self,
524
+ packet: WSPacket,
525
+ models: set[ModelType] | None = None,
526
+ ignore_stats: bool = False,
527
+ ) -> WSSubscriptionMessage | None:
528
+ if models is None:
529
+ models = set()
530
+
531
+ if not isinstance(packet.action_frame, WSJSONPacketFrame):
532
+ _LOGGER.debug(
533
+ "Unexpected action frame format: %s",
534
+ packet.action_frame.payload_format,
535
+ )
536
+
537
+ if not isinstance(packet.data_frame, WSJSONPacketFrame):
538
+ _LOGGER.debug(
539
+ "Unexpected data frame format: %s",
540
+ packet.data_frame.payload_format,
541
+ )
542
+
543
+ action, data = self._get_frame_data(packet)
544
+ if action["newUpdateId"] is not None:
545
+ self.last_update_id = UUID(action["newUpdateId"])
546
+
547
+ if action["modelKey"] not in ModelType.values():
548
+ _LOGGER.debug("Unknown model type: %s", action["modelKey"])
549
+ self._create_stat(packet, [], True)
550
+ return None
551
+
552
+ if len(models) > 0 and ModelType(action["modelKey"]) not in models:
553
+ self._create_stat(packet, [], True)
554
+ return None
555
+
556
+ if action["action"] == "remove":
557
+ return self._process_remove_packet(packet, data)
558
+
559
+ if data is None or len(data) == 0:
560
+ self._create_stat(packet, [], True)
561
+ return None
562
+
563
+ try:
564
+ if action["action"] == "add":
565
+ return self._process_add_packet(packet, data)
566
+
567
+ if action["action"] == "update":
568
+ if action["modelKey"] == ModelType.NVR.value:
569
+ return self._process_nvr_update(packet, data, ignore_stats)
570
+ if (
571
+ action["modelKey"] in ModelType.bootstrap_models()
572
+ or action["modelKey"] == ModelType.EVENT.value
573
+ ):
574
+ return self._process_device_update(
575
+ packet,
576
+ action,
577
+ data,
578
+ ignore_stats,
579
+ )
580
+ _LOGGER.debug(
581
+ "Unexpected bootstrap model type deviceadoptedfor update: %s",
582
+ action["modelKey"],
583
+ )
584
+ except (ValidationError, ValueError) as err:
585
+ self._handle_ws_error(action, err)
586
+
587
+ self._create_stat(packet, [], True)
588
+ return None
589
+
590
+ def _handle_ws_error(self, action: dict[str, Any], err: Exception) -> None:
591
+ msg = ""
592
+ if action["modelKey"] == "event":
593
+ msg = f"Validation error processing event: {action['id']}. Ignoring event."
594
+ else:
595
+ try:
596
+ model_type = ModelType(action["modelKey"])
597
+ device_id = action["id"]
598
+ task = asyncio.create_task(self.refresh_device(model_type, device_id))
599
+ self._refresh_tasks.add(task)
600
+ task.add_done_callback(self._refresh_tasks.discard)
601
+ except (ValueError, IndexError):
602
+ msg = f"{action['action']} packet caused invalid state. Unable to refresh device."
603
+ else:
604
+ msg = f"{action['action']} packet caused invalid state. Refreshing device: {model_type} {device_id}"
605
+ _LOGGER.debug("%s Error: %s", msg, err)
606
+
607
+ async def refresh_device(self, model_type: ModelType, device_id: str) -> None:
608
+ """Refresh a device in the bootstrap."""
609
+ try:
610
+ if model_type == ModelType.NVR:
611
+ device: ProtectModelWithId = await self.api.get_nvr()
612
+ else:
613
+ device = await self.api.get_device(model_type, device_id)
614
+ except (
615
+ ValidationError,
616
+ TimeoutError,
617
+ asyncio.TimeoutError,
618
+ asyncio.CancelledError,
619
+ ClientError,
620
+ ServerDisconnectedError,
621
+ ):
622
+ _LOGGER.warning("Failed to refresh model: %s %s", model_type, device_id)
623
+ return
624
+
625
+ if isinstance(device, NVR):
626
+ self.nvr = device
627
+ else:
628
+ devices = getattr(self, f"{model_type.value}s")
629
+ devices[device.id] = device
630
+ _LOGGER.debug("Successfully refresh model: %s %s", model_type, device_id)
631
+
632
+ async def get_is_prerelease(self) -> bool:
633
+ """Get if current version of Protect is a prerelease version."""
634
+ return await self.nvr.get_is_prerelease()
@@ -0,0 +1,77 @@
1
+ """UniFi Protect Data Conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from uiprotect.data.devices import (
8
+ Bridge,
9
+ Camera,
10
+ Chime,
11
+ Doorlock,
12
+ Light,
13
+ Sensor,
14
+ Viewer,
15
+ )
16
+ from uiprotect.data.nvr import NVR, Event, Liveview
17
+ from uiprotect.data.types import ModelType
18
+ from uiprotect.data.user import CloudAccount, Group, User, UserLocation
19
+ from uiprotect.exceptions import DataDecodeError
20
+
21
+ if TYPE_CHECKING:
22
+ from uiprotect.api import ProtectApiClient
23
+ from uiprotect.data.base import ProtectModel
24
+
25
+
26
+ MODEL_TO_CLASS: dict[str, type[ProtectModel]] = {
27
+ ModelType.EVENT: Event,
28
+ ModelType.GROUP: Group,
29
+ ModelType.USER_LOCATION: UserLocation,
30
+ ModelType.CLOUD_IDENTITY: CloudAccount,
31
+ ModelType.USER: User,
32
+ ModelType.NVR: NVR,
33
+ ModelType.LIGHT: Light,
34
+ ModelType.CAMERA: Camera,
35
+ ModelType.LIVEVIEW: Liveview,
36
+ ModelType.VIEWPORT: Viewer,
37
+ ModelType.BRIDGE: Bridge,
38
+ ModelType.SENSOR: Sensor,
39
+ ModelType.DOORLOCK: Doorlock,
40
+ ModelType.CHIME: Chime,
41
+ }
42
+
43
+
44
+ def get_klass_from_dict(data: dict[str, Any]) -> type[ProtectModel]:
45
+ """
46
+ Helper method to read the `modelKey` from a UFP JSON dict and get the correct Python class for conversion.
47
+ Will raise `DataDecodeError` if the `modelKey` is for an unknown object.
48
+ """
49
+ if "modelKey" not in data:
50
+ raise DataDecodeError("No modelKey")
51
+
52
+ model = ModelType(data["modelKey"])
53
+
54
+ klass = MODEL_TO_CLASS.get(model)
55
+
56
+ if klass is None:
57
+ raise DataDecodeError("Unknown modelKey")
58
+
59
+ return klass
60
+
61
+
62
+ def create_from_unifi_dict(
63
+ data: dict[str, Any],
64
+ api: ProtectApiClient | None = None,
65
+ klass: type[ProtectModel] | None = None,
66
+ ) -> ProtectModel:
67
+ """
68
+ Helper method to read the `modelKey` from a UFP JSON dict and convert to currect Python class.
69
+ Will raise `DataDecodeError` if the `modelKey` is for an unknown object.
70
+ """
71
+ if "modelKey" not in data:
72
+ raise DataDecodeError("No modelKey")
73
+
74
+ if klass is None:
75
+ klass = get_klass_from_dict(data)
76
+
77
+ return klass.from_unifi_dict(**data, api=api)