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,531 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import shutil
6
+ import time
7
+ from collections.abc import Callable, Coroutine
8
+ from copy import deepcopy
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from shlex import split
12
+ from subprocess import run
13
+ from typing import Any, overload
14
+
15
+ import aiohttp
16
+ from PIL import Image
17
+
18
+ from uiprotect.api import ProtectApiClient
19
+ from uiprotect.data import EventType, WSJSONPacketFrame, WSPacket
20
+ from uiprotect.exceptions import BadRequest
21
+ from uiprotect.test_util.anonymize import (
22
+ anonymize_data,
23
+ anonymize_prefixed_event_id,
24
+ )
25
+ from uiprotect.utils import from_js_time, is_online, run_async, write_json
26
+
27
+ BLANK_VIDEO_CMD = "ffmpeg -y -hide_banner -loglevel error -f lavfi -i color=size=1280x720:rate=25:color=black -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -t {length} {filename}"
28
+
29
+
30
+ def placeholder_image(
31
+ output_path: Path,
32
+ width: int,
33
+ height: int | None = None,
34
+ ) -> None:
35
+ if height is None:
36
+ height = width
37
+
38
+ image = Image.new("RGB", (width, height), (128, 128, 128))
39
+ image.save(output_path, "PNG")
40
+
41
+
42
+ _LOGGER = logging.getLogger(__name__)
43
+ LOG_CALLABLE = Callable[[str], None]
44
+ PROGRESS_CALLABLE = Callable[[int, str], Coroutine[Any, Any, None]]
45
+
46
+
47
+ class SampleDataGenerator:
48
+ """Generate sample data for debugging and testing purposes"""
49
+
50
+ _record_num_ws: int = 0
51
+ _record_ws_start_time: float = time.monotonic()
52
+ _record_listen_for_events: bool = False
53
+ _record_ws_messages: dict[str, dict[str, Any]] = {}
54
+ _log: LOG_CALLABLE | None = None
55
+ _log_warning: LOG_CALLABLE | None = None
56
+ _ws_progress: PROGRESS_CALLABLE | None = None
57
+
58
+ constants: dict[str, Any] = {}
59
+ client: ProtectApiClient
60
+ output_folder: Path
61
+ do_zip: bool
62
+ anonymize: bool
63
+ wait_time: int
64
+
65
+ def __init__(
66
+ self,
67
+ client: ProtectApiClient,
68
+ output: Path,
69
+ anonymize: bool,
70
+ wait_time: int,
71
+ log: LOG_CALLABLE | None = None,
72
+ log_warning: LOG_CALLABLE | None = None,
73
+ ws_progress: PROGRESS_CALLABLE | None = None,
74
+ do_zip: bool = False,
75
+ ) -> None:
76
+ self.client = client
77
+ self.output_folder = output
78
+ self.do_zip = do_zip
79
+ self.anonymize = anonymize
80
+ self.wait_time = wait_time
81
+ self._log = log
82
+ self._log_warning = log_warning
83
+ self._ws_progress = ws_progress
84
+
85
+ if self._log_warning is None and self._log is not None:
86
+ self._log_warning = self._log
87
+
88
+ def log(self, msg: str) -> None:
89
+ if self._log is not None:
90
+ self._log(msg)
91
+ else:
92
+ _LOGGER.debug(msg)
93
+
94
+ def log_warning(self, msg: str) -> None:
95
+ if self._log_warning is not None:
96
+ self._log_warning(msg)
97
+ else:
98
+ _LOGGER.warning(msg)
99
+
100
+ def generate(self) -> None:
101
+ run_async(self.async_generate())
102
+
103
+ async def async_generate(self, close_session: bool = True) -> None:
104
+ self.log(f"Output folder: {self.output_folder}")
105
+ self.output_folder.mkdir(parents=True, exist_ok=True)
106
+ websocket = await self.client.get_websocket()
107
+ websocket.subscribe(self._handle_ws_message)
108
+
109
+ self.log("Updating devices...")
110
+ await self.client.update()
111
+
112
+ bootstrap: dict[str, Any] = await self.client.api_request_obj("bootstrap")
113
+ bootstrap = await self.write_json_file("sample_bootstrap", bootstrap)
114
+ self.constants["server_name"] = bootstrap["nvr"]["name"]
115
+ self.constants["server_id"] = bootstrap["nvr"]["mac"]
116
+ self.constants["server_version"] = bootstrap["nvr"]["version"]
117
+ self.constants["server_ip"] = bootstrap["nvr"]["host"]
118
+ self.constants["server_model"] = bootstrap["nvr"]["type"]
119
+ self.constants["last_update_id"] = bootstrap["lastUpdateId"]
120
+ self.constants["user_id"] = bootstrap["authUserId"]
121
+ self.constants["counts"] = {
122
+ "camera": len(bootstrap["cameras"]),
123
+ "user": len(bootstrap["users"]),
124
+ "group": len(bootstrap["groups"]),
125
+ "liveview": len(bootstrap["liveviews"]),
126
+ "viewer": len(bootstrap["viewers"]),
127
+ "light": len(bootstrap["lights"]),
128
+ "bridge": len(bootstrap["bridges"]),
129
+ "sensor": len(bootstrap["sensors"]),
130
+ "doorlock": len(bootstrap["doorlocks"]),
131
+ "chime": len(bootstrap["chimes"]),
132
+ }
133
+
134
+ motion_event, smart_detection = await self.generate_event_data()
135
+ await self.generate_device_data(motion_event, smart_detection)
136
+ await self.record_ws_events()
137
+
138
+ if close_session:
139
+ await self.client.close_session()
140
+
141
+ await self.write_json_file("sample_constants", self.constants, anonymize=False)
142
+
143
+ if self.do_zip:
144
+ self.log("Zipping files...")
145
+
146
+ def zip_files() -> None:
147
+ shutil.make_archive(str(self.output_folder), "zip", self.output_folder)
148
+ shutil.rmtree(self.output_folder)
149
+
150
+ loop = asyncio.get_running_loop()
151
+ await loop.run_in_executor(None, zip_files)
152
+
153
+ async def record_ws_events(self) -> None:
154
+ if self.wait_time <= 0:
155
+ self.log("Skipping recording Websocket messages...")
156
+ return
157
+
158
+ self._record_num_ws = 0
159
+ self._record_ws_start_time = time.monotonic()
160
+ self._record_listen_for_events = True
161
+ self._record_ws_messages = {}
162
+
163
+ self.log(f"Waiting {self.wait_time} seconds for WS messages...")
164
+ if self._ws_progress is not None:
165
+ await self._ws_progress(self.wait_time, "Waiting for WS messages")
166
+ else:
167
+ await asyncio.sleep(self.wait_time)
168
+
169
+ self._record_listen_for_events = False
170
+ await self.client.async_disconnect_ws()
171
+ await self.write_json_file(
172
+ "sample_ws_messages",
173
+ self._record_ws_messages,
174
+ anonymize=False,
175
+ )
176
+
177
+ @overload
178
+ async def write_json_file(
179
+ self,
180
+ name: str,
181
+ data: list[Any],
182
+ anonymize: bool | None = None,
183
+ ) -> list[Any]: ...
184
+
185
+ @overload
186
+ async def write_json_file(
187
+ self,
188
+ name: str,
189
+ data: dict[str, Any],
190
+ anonymize: bool | None = None,
191
+ ) -> dict[str, Any]: ...
192
+
193
+ async def write_json_file(
194
+ self,
195
+ name: str,
196
+ data: list[Any] | dict[str, Any],
197
+ anonymize: bool | None = None,
198
+ ) -> list[Any] | dict[str, Any]:
199
+ if anonymize is None:
200
+ anonymize = self.anonymize
201
+
202
+ if anonymize:
203
+ data = anonymize_data(data)
204
+
205
+ self.log(f"Writing {name}...")
206
+ await write_json(self.output_folder / f"{name}.json", data)
207
+
208
+ return data
209
+
210
+ async def write_binary_file(
211
+ self,
212
+ name: str,
213
+ ext: str,
214
+ raw: bytes | None,
215
+ ) -> None:
216
+ def write() -> None:
217
+ if raw is None:
218
+ self.log(f"No image data, skipping {name}...")
219
+ return
220
+
221
+ self.log(f"Writing {name}...")
222
+ Path(self.output_folder / f"{name}.{ext}").write_bytes(raw)
223
+
224
+ loop = asyncio.get_running_loop()
225
+ await loop.run_in_executor(None, write)
226
+
227
+ async def write_image_file(self, name: str, raw: bytes | None) -> None:
228
+ await self.write_binary_file(name, "png", raw)
229
+
230
+ async def generate_event_data(
231
+ self,
232
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
233
+ data = await self.client.get_events_raw()
234
+
235
+ self.constants["time"] = datetime.now(tz=timezone.utc).isoformat()
236
+ self.constants["event_count"] = len(data)
237
+
238
+ motion_event: dict[str, Any] | None = None
239
+ smart_detection: dict[str, Any] | None = None
240
+ for event_dict in reversed(data):
241
+ if (
242
+ motion_event is None
243
+ and event_dict["type"] == EventType.MOTION.value
244
+ and event_dict["camera"] is not None
245
+ and event_dict["thumbnail"] is not None
246
+ and event_dict["heatmap"] is not None
247
+ and event_dict["end"] is not None
248
+ ):
249
+ motion_event = deepcopy(event_dict)
250
+ self.log(f"Using motion event: {motion_event['id']}...")
251
+ elif (
252
+ smart_detection is None
253
+ and event_dict["type"] == EventType.SMART_DETECT.value
254
+ and event_dict["camera"] is not None
255
+ and event_dict["end"] is not None
256
+ ):
257
+ smart_detection = deepcopy(event_dict)
258
+ self.log(f"Using smart detection event: {smart_detection['id']}...")
259
+
260
+ if motion_event is not None and smart_detection is not None:
261
+ break
262
+
263
+ # anonymize data after pulling events
264
+ data = await self.write_json_file("sample_raw_events", data)
265
+
266
+ return motion_event, smart_detection
267
+
268
+ async def generate_device_data(
269
+ self,
270
+ motion_event: dict[str, Any] | None,
271
+ smart_detection: dict[str, Any] | None,
272
+ ) -> None:
273
+ await asyncio.gather(
274
+ self.generate_camera_data(),
275
+ self.generate_motion_data(motion_event),
276
+ self.generate_smart_detection_data(smart_detection),
277
+ self.generate_light_data(),
278
+ self.generate_viewport_data(),
279
+ self.generate_sensor_data(),
280
+ self.generate_lock_data(),
281
+ self.generate_chime_data(),
282
+ self.generate_bridge_data(),
283
+ self.generate_liveview_data(),
284
+ )
285
+
286
+ async def generate_camera_data(self) -> None:
287
+ objs = await self.client.api_request_list("cameras")
288
+ device_id: str | None = None
289
+ camera_is_online = False
290
+ for obj_dict in objs:
291
+ device_id = obj_dict["id"]
292
+ if is_online(obj_dict):
293
+ camera_is_online = True
294
+ break
295
+
296
+ if device_id is None:
297
+ self.log("No camera found. Skipping camera endpoints...")
298
+ return
299
+
300
+ # json data
301
+ obj = await self.client.api_request_obj(f"cameras/{device_id}")
302
+ await self.write_json_file("sample_camera", deepcopy(obj))
303
+ self.constants["camera_online"] = camera_is_online
304
+
305
+ if not camera_is_online:
306
+ self.log(
307
+ "Camera is not online, skipping snapshot, thumbnail and heatmap generation",
308
+ )
309
+
310
+ # snapshot
311
+ width = obj["channels"][0]["width"]
312
+ height = obj["channels"][0]["height"]
313
+ filename = "sample_camera_snapshot"
314
+ if self.anonymize:
315
+ self.log(f"Writing {filename}...")
316
+ placeholder_image(self.output_folder / f"{filename}.png", width, height)
317
+ else:
318
+ snapshot = await self.client.get_camera_snapshot(obj["id"], width, height)
319
+ await self.write_image_file(filename, snapshot)
320
+
321
+ async def generate_motion_data(
322
+ self,
323
+ motion_event: dict[str, Any] | None,
324
+ ) -> None:
325
+ if motion_event is None:
326
+ self.log("No motion event, skipping thumbnail and heatmap generation...")
327
+ return
328
+
329
+ # event thumbnail
330
+ filename = "sample_camera_thumbnail"
331
+ thumbnail_id = motion_event["thumbnail"]
332
+ if self.anonymize:
333
+ self.log(f"Writing {filename}...")
334
+ placeholder_image(self.output_folder / f"{filename}.png", 640, 360)
335
+ thumbnail_id = anonymize_prefixed_event_id(thumbnail_id)
336
+ else:
337
+ img = await self.client.get_event_thumbnail(thumbnail_id)
338
+ await self.write_image_file(filename, img)
339
+ self.constants["camera_thumbnail"] = thumbnail_id
340
+
341
+ # event heatmap
342
+ filename = "sample_camera_heatmap"
343
+ heatmap_id = motion_event["heatmap"]
344
+ if self.anonymize:
345
+ self.log(f"Writing {filename}...")
346
+ placeholder_image(self.output_folder / f"{filename}.png", 640, 360)
347
+ heatmap_id = anonymize_prefixed_event_id(heatmap_id)
348
+ else:
349
+ img = await self.client.get_event_heatmap(heatmap_id)
350
+ await self.write_image_file(filename, img)
351
+ self.constants["camera_heatmap"] = heatmap_id
352
+
353
+ # event video
354
+ filename = "sample_camera_video"
355
+ length = int((motion_event["end"] - motion_event["start"]) / 1000)
356
+ if self.anonymize:
357
+ run(
358
+ split( # noqa: S603
359
+ BLANK_VIDEO_CMD.format(
360
+ length=length,
361
+ filename=self.output_folder / f"{filename}.mp4",
362
+ ),
363
+ ),
364
+ check=True,
365
+ )
366
+ else:
367
+ video = await self.client.get_camera_video(
368
+ motion_event["camera"],
369
+ from_js_time(motion_event["start"]),
370
+ from_js_time(motion_event["end"]),
371
+ 2,
372
+ )
373
+ await self.write_binary_file(filename, "mp4", video)
374
+ self.constants["camera_video_length"] = length
375
+
376
+ async def generate_smart_detection_data(
377
+ self,
378
+ smart_detection: dict[str, Any] | None,
379
+ ) -> None:
380
+ if smart_detection is None:
381
+ self.log("No smart detection event, skipping smart detection data...")
382
+ return
383
+
384
+ try:
385
+ data = await self.client.get_event_smart_detect_track_raw(
386
+ smart_detection["id"],
387
+ )
388
+ except BadRequest:
389
+ self.log_warning("Event smart tracking missing")
390
+ else:
391
+ await self.write_json_file("sample_event_smart_track", data)
392
+
393
+ async def generate_light_data(self) -> None:
394
+ objs = await self.client.api_request_list("lights")
395
+ device_id: str | None = None
396
+ for obj_dict in objs:
397
+ device_id = obj_dict["id"]
398
+ if is_online(obj_dict):
399
+ break
400
+
401
+ if device_id is None:
402
+ self.log("No light found. Skipping light endpoints...")
403
+ return
404
+
405
+ obj = await self.client.api_request_obj(f"lights/{device_id}")
406
+ await self.write_json_file("sample_light", obj)
407
+
408
+ async def generate_viewport_data(self) -> None:
409
+ objs = await self.client.api_request_list("viewers")
410
+ device_id: str | None = None
411
+ for obj_dict in objs:
412
+ device_id = obj_dict["id"]
413
+ if is_online(obj_dict):
414
+ break
415
+
416
+ if device_id is None:
417
+ self.log("No viewer found. Skipping viewer endpoints...")
418
+ return
419
+
420
+ obj = await self.client.api_request_obj(f"viewers/{device_id}")
421
+ await self.write_json_file("sample_viewport", obj)
422
+
423
+ async def generate_sensor_data(self) -> None:
424
+ objs = await self.client.api_request_list("sensors")
425
+ device_id: str | None = None
426
+ for obj_dict in objs:
427
+ device_id = obj_dict["id"]
428
+ if is_online(obj_dict):
429
+ break
430
+
431
+ if device_id is None:
432
+ self.log("No sensor found. Skipping sensor endpoints...")
433
+ return
434
+
435
+ obj = await self.client.api_request_obj(f"sensors/{device_id}")
436
+ await self.write_json_file("sample_sensor", obj)
437
+
438
+ async def generate_lock_data(self) -> None:
439
+ objs = await self.client.api_request_list("doorlocks")
440
+ device_id: str | None = None
441
+ for obj_dict in objs:
442
+ device_id = obj_dict["id"]
443
+ if is_online(obj_dict):
444
+ break
445
+
446
+ if device_id is None:
447
+ self.log("No doorlock found. Skipping doorlock endpoints...")
448
+ return
449
+
450
+ obj = await self.client.api_request_obj(f"doorlocks/{device_id}")
451
+ await self.write_json_file("sample_doorlock", obj)
452
+
453
+ async def generate_chime_data(self) -> None:
454
+ objs = await self.client.api_request_list("chimes")
455
+ device_id: str | None = None
456
+ for obj_dict in objs:
457
+ device_id = obj_dict["id"]
458
+ if is_online(obj_dict):
459
+ break
460
+
461
+ if device_id is None:
462
+ self.log("No chime found. Skipping doorlock endpoints...")
463
+ return
464
+
465
+ obj = await self.client.api_request_obj(f"chimes/{device_id}")
466
+ await self.write_json_file("sample_chime", obj)
467
+
468
+ async def generate_bridge_data(self) -> None:
469
+ objs = await self.client.api_request_list("bridges")
470
+ device_id: str | None = None
471
+ for obj_dict in objs:
472
+ device_id = obj_dict["id"]
473
+ if is_online(obj_dict):
474
+ break
475
+
476
+ if device_id is None:
477
+ self.log("No bridge found. Skipping bridge endpoints...")
478
+ return
479
+
480
+ obj = await self.client.api_request_obj(f"bridges/{device_id}")
481
+ await self.write_json_file("sample_bridge", obj)
482
+
483
+ async def generate_liveview_data(self) -> None:
484
+ objs = await self.client.api_request_list("liveviews")
485
+ device_id: str | None = None
486
+ for obj_dict in objs:
487
+ device_id = obj_dict["id"]
488
+ break
489
+
490
+ if device_id is None:
491
+ self.log("No liveview found. Skipping liveview endpoints...")
492
+ return
493
+
494
+ obj = await self.client.api_request_obj(f"liveviews/{device_id}")
495
+ await self.write_json_file("sample_liveview", obj)
496
+
497
+ def _handle_ws_message(self, msg: aiohttp.WSMessage) -> None:
498
+ if not self._record_listen_for_events:
499
+ return
500
+
501
+ now = time.monotonic()
502
+ self._record_num_ws += 1
503
+ time_offset = now - self._record_ws_start_time
504
+
505
+ if msg.type == aiohttp.WSMsgType.BINARY:
506
+ packet = WSPacket(msg.data)
507
+
508
+ if not isinstance(packet.action_frame, WSJSONPacketFrame):
509
+ self.log_warning(
510
+ f"Got non-JSON action frame: {packet.action_frame.payload_format}",
511
+ )
512
+ return
513
+
514
+ if not isinstance(packet.data_frame, WSJSONPacketFrame):
515
+ self.log_warning(
516
+ f"Got non-JSON data frame: {packet.data_frame.payload_format}",
517
+ )
518
+ return
519
+
520
+ if self.anonymize:
521
+ packet.action_frame.data = anonymize_data(packet.action_frame.data)
522
+ packet.data_frame.data = anonymize_data(packet.data_frame.data)
523
+ packet.pack_frames()
524
+
525
+ self._record_ws_messages[str(time_offset)] = {
526
+ "raw": packet.raw_base64,
527
+ "action": packet.action_frame.data,
528
+ "data": packet.data_frame.data,
529
+ }
530
+ else:
531
+ self.log_warning(f"Got non-binary message: {msg.type}")