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