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,574 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Optional, cast
7
+
8
+ import typer
9
+ from rich.progress import Progress
10
+
11
+ from uiprotect import data as d
12
+ from uiprotect.api import ProtectApiClient
13
+ from uiprotect.cli import base
14
+
15
+ app = typer.Typer(rich_markup_mode="rich")
16
+
17
+ ARG_DEVICE_ID = typer.Argument(None, help="ID of camera to select for subcommands")
18
+
19
+
20
+ @dataclass
21
+ class CameraContext(base.CliContext):
22
+ devices: dict[str, d.Camera]
23
+ device: d.Camera | None = None
24
+
25
+
26
+ ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
27
+
28
+
29
+ @app.callback(invoke_without_command=True)
30
+ def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
31
+ """
32
+ Camera device CLI.
33
+
34
+ Returns full list of Cameras without any arguments passed.
35
+ """
36
+ protect: ProtectApiClient = ctx.obj.protect
37
+ context = CameraContext(
38
+ protect=ctx.obj.protect,
39
+ device=None,
40
+ devices=protect.bootstrap.cameras,
41
+ output_format=ctx.obj.output_format,
42
+ )
43
+ ctx.obj = context
44
+
45
+ if device_id is not None and device_id not in ALL_COMMANDS:
46
+ if (device := protect.bootstrap.cameras.get(device_id)) is None:
47
+ typer.secho("Invalid camera ID", fg="red")
48
+ raise typer.Exit(1)
49
+ ctx.obj.device = device
50
+
51
+ if not ctx.invoked_subcommand:
52
+ if device_id in ALL_COMMANDS:
53
+ ctx.invoke(ALL_COMMANDS[device_id], ctx)
54
+ return
55
+
56
+ if ctx.obj.device is not None:
57
+ base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
58
+ return
59
+
60
+ base.print_unifi_dict(ctx.obj.devices)
61
+
62
+
63
+ @app.command()
64
+ def timelapse_url(ctx: typer.Context) -> None:
65
+ """Returns UniFi Protect timelapse URL."""
66
+ base.require_device_id(ctx)
67
+ obj: d.Camera = ctx.obj.device
68
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
69
+ base.json_output(obj.timelapse_url)
70
+ else:
71
+ typer.echo(obj.timelapse_url)
72
+
73
+
74
+ @app.command()
75
+ def privacy_mode(
76
+ ctx: typer.Context,
77
+ enabled: Optional[bool] = typer.Argument(None),
78
+ ) -> None:
79
+ """
80
+ Returns/sets library managed privacy mode.
81
+
82
+ Does not change the microphone sensitivity or recording mode.
83
+ It must be changed seperately.
84
+ """
85
+ base.require_device_id(ctx)
86
+ obj: d.Camera = ctx.obj.device
87
+ if enabled is None:
88
+ base.json_output(obj.is_privacy_on)
89
+ return
90
+ base.run(ctx, obj.set_privacy(enabled))
91
+
92
+
93
+ @app.command()
94
+ def chime_type(ctx: typer.Context, value: Optional[d.ChimeType] = None) -> None:
95
+ """Returns/sets the current chime type if the camera has a chime."""
96
+ base.require_device_id(ctx)
97
+ obj: d.Camera = ctx.obj.device
98
+ if not obj.feature_flags.has_chime:
99
+ typer.secho("Camera does not have a chime", fg="red")
100
+ raise typer.Exit(1)
101
+
102
+ if value is None:
103
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
104
+ base.json_output(obj.chime_type)
105
+ elif obj.chime_type is not None:
106
+ typer.echo(obj.chime_type.name)
107
+ return
108
+
109
+ base.run(ctx, obj.set_chime_type(value))
110
+
111
+
112
+ @app.command()
113
+ def stream_urls(ctx: typer.Context) -> None:
114
+ """Returns all of the enabled RTSP(S) URLs."""
115
+ base.require_device_id(ctx)
116
+ obj: d.Camera = ctx.obj.device
117
+ data: list[tuple[str, str]] = []
118
+ for channel in obj.channels:
119
+ if channel.is_rtsp_enabled:
120
+ rtsp_url = cast(str, channel.rtsp_url)
121
+ rtsps_url = cast(str, channel.rtsps_url)
122
+ data.extend(
123
+ (
124
+ (f"{channel.name} RTSP", rtsp_url),
125
+ (f"{channel.name} RTSPS", rtsps_url),
126
+ ),
127
+ )
128
+
129
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
130
+ base.json_output(data)
131
+ else:
132
+ for name, url in data:
133
+ typer.echo(f"{name:20}\t{url}")
134
+
135
+
136
+ @app.command()
137
+ def save_snapshot(
138
+ ctx: typer.Context,
139
+ output_path: Path = typer.Argument(..., help="JPEG format"),
140
+ width: Optional[int] = typer.Option(None, "-w", "--width"),
141
+ height: Optional[int] = typer.Option(None, "-h", "--height"),
142
+ dt: Optional[datetime] = typer.Option(None, "-t", "--timestamp"),
143
+ package: bool = typer.Option(False, "-p", "--package", help="Get package camera"),
144
+ ) -> None:
145
+ """
146
+ Takes snapshot of camera.
147
+
148
+ If you specify a timestamp, they are approximate. It will not export with down to the second
149
+ accuracy so it may be +/- a few seconds.
150
+
151
+ Timestamps use your locale timezone. If it is not configured correctly,
152
+ it will default to UTC. You can override your timezone with the
153
+ TZ environment variable.
154
+ """
155
+ base.require_device_id(ctx)
156
+ obj: d.Camera = ctx.obj.device
157
+
158
+ if dt is not None:
159
+ local_tz = datetime.now(timezone.utc).astimezone().tzinfo
160
+ dt = dt.replace(tzinfo=local_tz)
161
+
162
+ if package:
163
+ if not obj.feature_flags.has_package_camera:
164
+ typer.secho("Camera does not have package camera", fg="red")
165
+ raise typer.Exit(1)
166
+ snapshot = base.run(ctx, obj.get_package_snapshot(width, height, dt=dt))
167
+ else:
168
+ snapshot = base.run(ctx, obj.get_snapshot(width, height, dt=dt))
169
+
170
+ if snapshot is None:
171
+ typer.secho("Could not get snapshot", fg="red")
172
+ raise typer.Exit(1)
173
+
174
+ Path(output_path).write_bytes(snapshot)
175
+
176
+
177
+ @app.command()
178
+ def save_video(
179
+ ctx: typer.Context,
180
+ output_path: Path = typer.Argument(..., help="MP4 format"),
181
+ start: datetime = typer.Argument(...),
182
+ end: datetime = typer.Argument(...),
183
+ channel: int = typer.Option(
184
+ 0,
185
+ "-c",
186
+ "--channel",
187
+ min=0,
188
+ max=3,
189
+ help="0 = High, 1 = Medium, 2 = Low, 3 = Package",
190
+ ),
191
+ fps: Optional[int] = typer.Option(
192
+ None,
193
+ "--fps",
194
+ min=1,
195
+ max=40,
196
+ help="Export as timelapse. 4 = 60x, 8 = 120x, 20 = 300x, 40 = 600x",
197
+ ),
198
+ ) -> None:
199
+ """
200
+ Exports video of camera.
201
+
202
+ Exports are approximate. It will not export with down to the second
203
+ accuracy so it may be +/- a few seconds.
204
+
205
+ Uses your locale timezone. If it is not configured correctly,
206
+ it will default to UTC. You can override your timezone with the
207
+ TZ environment variable.
208
+ """
209
+ base.require_device_id(ctx)
210
+ obj: d.Camera = ctx.obj.device
211
+
212
+ local_tz = datetime.now(timezone.utc).astimezone().tzinfo
213
+ start = start.replace(tzinfo=local_tz)
214
+ end = end.replace(tzinfo=local_tz)
215
+
216
+ if channel == 4 and not obj.feature_flags.has_package_camera:
217
+ typer.secho("Camera does not have package camera", fg="red")
218
+ raise typer.Exit(1)
219
+
220
+ with Progress() as pb:
221
+ task_id = pb.add_task("(1/2) Exporting", total=100)
222
+
223
+ async def callback(step: int, current: int, total: int) -> None:
224
+ pb.update(
225
+ task_id,
226
+ total=total,
227
+ completed=current,
228
+ description="(2/2) Downloading",
229
+ )
230
+
231
+ base.run(
232
+ ctx,
233
+ obj.get_video(
234
+ start,
235
+ end,
236
+ channel,
237
+ output_file=output_path,
238
+ progress_callback=callback,
239
+ fps=fps,
240
+ ),
241
+ )
242
+
243
+
244
+ @app.command()
245
+ def play_audio(
246
+ ctx: typer.Context,
247
+ url: str = typer.Argument(..., help="ffmpeg playable URL"),
248
+ ffmpeg_path: Optional[Path] = typer.Option(
249
+ None,
250
+ "--ffmpeg-path",
251
+ help="Path to ffmpeg executable",
252
+ envvar="FFMPEG_PATH",
253
+ ),
254
+ ) -> None:
255
+ """Plays audio file on camera speaker."""
256
+ base.require_device_id(ctx)
257
+ obj: d.Camera = ctx.obj.device
258
+ base.run(ctx, obj.play_audio(url, ffmpeg_path=ffmpeg_path))
259
+
260
+
261
+ @app.command()
262
+ def smart_detects(
263
+ ctx: typer.Context,
264
+ values: list[d.SmartDetectObjectType] = typer.Argument(
265
+ None,
266
+ help="Set to [] to empty list of detect types.",
267
+ ),
268
+ add: bool = typer.Option(False, "-a", "--add", help="Add values instead of set"),
269
+ remove: bool = typer.Option(
270
+ False,
271
+ "-r",
272
+ "--remove",
273
+ help="Remove values instead of set",
274
+ ),
275
+ ) -> None:
276
+ """Returns/set smart detect types for camera."""
277
+ base.require_device_id(ctx)
278
+ obj: d.Camera = ctx.obj.device
279
+
280
+ if add and remove:
281
+ typer.secho("Add and remove are mutally exclusive", fg="red")
282
+ raise typer.Exit(1)
283
+
284
+ if not obj.feature_flags.has_smart_detect:
285
+ typer.secho("Camera does not support smart detections", fg="red")
286
+ raise typer.Exit(1)
287
+
288
+ if len(values) == 0:
289
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
290
+ base.json_output(obj.smart_detect_settings.object_types)
291
+ else:
292
+ for value in obj.smart_detect_settings.object_types:
293
+ typer.echo(value.value)
294
+ return
295
+
296
+ if len(values) == 1 and values[0] == "[]":
297
+ values = []
298
+
299
+ for value in values:
300
+ if value not in obj.feature_flags.smart_detect_types:
301
+ typer.secho(f"Camera does not support {value}", fg="red")
302
+ raise typer.Exit(1)
303
+
304
+ if add:
305
+ values = list(set(obj.smart_detect_settings.object_types) | set(values))
306
+ elif remove:
307
+ values = list(set(obj.smart_detect_settings.object_types) - set(values))
308
+
309
+ data_before_changes = obj.dict_with_excludes()
310
+ obj.smart_detect_settings.object_types = values
311
+ base.run(ctx, obj.save_device(data_before_changes))
312
+
313
+
314
+ @app.command()
315
+ def smart_audio_detects(
316
+ ctx: typer.Context,
317
+ values: list[d.SmartDetectAudioType] = typer.Argument(
318
+ None,
319
+ help="Set to [] to empty list of detect types.",
320
+ ),
321
+ add: bool = typer.Option(False, "-a", "--add", help="Add values instead of set"),
322
+ remove: bool = typer.Option(
323
+ False,
324
+ "-r",
325
+ "--remove",
326
+ help="Remove values instead of set",
327
+ ),
328
+ ) -> None:
329
+ """Returns/set smart detect types for camera."""
330
+ base.require_device_id(ctx)
331
+ obj: d.Camera = ctx.obj.device
332
+
333
+ if add and remove:
334
+ typer.secho("Add and remove are mutually exclusive", fg="red")
335
+ raise typer.Exit(1)
336
+
337
+ if not obj.feature_flags.has_smart_detect:
338
+ typer.secho("Camera does not support smart detections", fg="red")
339
+ raise typer.Exit(1)
340
+
341
+ obj.smart_detect_settings.audio_types = obj.smart_detect_settings.audio_types or []
342
+ obj.smart_detect_settings.audio_types = obj.smart_detect_settings.audio_types or []
343
+
344
+ if len(values) == 0:
345
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
346
+ base.json_output(obj.smart_detect_settings.audio_types)
347
+ else:
348
+ for value in obj.smart_detect_settings.audio_types:
349
+ typer.echo(value.value)
350
+ return
351
+
352
+ if len(values) == 1 and values[0] == "[]":
353
+ values = []
354
+
355
+ for value in values:
356
+ if (
357
+ obj.feature_flags.smart_detect_audio_types is None
358
+ or value not in obj.feature_flags.smart_detect_audio_types
359
+ ):
360
+ typer.secho(f"Camera does not support {value}", fg="red")
361
+ raise typer.Exit(1)
362
+
363
+ if add:
364
+ values = list(set(obj.smart_detect_settings.audio_types) | set(values))
365
+ elif remove:
366
+ values = list(set(obj.smart_detect_settings.audio_types) - set(values))
367
+
368
+ data_before_changes = obj.dict_with_excludes()
369
+ obj.smart_detect_settings.audio_types = values
370
+ base.run(ctx, obj.save_device(data_before_changes))
371
+
372
+
373
+ @app.command()
374
+ def set_motion_detection(ctx: typer.Context, enabled: bool) -> None:
375
+ """Sets motion detection on camera"""
376
+ base.require_device_id(ctx)
377
+ obj: d.Camera = ctx.obj.device
378
+
379
+ base.run(ctx, obj.set_motion_detection(enabled))
380
+
381
+
382
+ @app.command()
383
+ def set_recording_mode(ctx: typer.Context, mode: d.RecordingMode) -> None:
384
+ """Sets recording mode on camera"""
385
+ base.require_device_id(ctx)
386
+ obj: d.Camera = ctx.obj.device
387
+
388
+ base.run(ctx, obj.set_recording_mode(mode))
389
+
390
+
391
+ @app.command()
392
+ def set_ir_led_mode(ctx: typer.Context, mode: d.IRLEDMode) -> None:
393
+ """Sets IR LED mode on camera"""
394
+ base.require_device_id(ctx)
395
+ obj: d.Camera = ctx.obj.device
396
+
397
+ base.run(ctx, obj.set_ir_led_model(mode))
398
+
399
+
400
+ @app.command()
401
+ def set_status_light(ctx: typer.Context, enabled: bool) -> None:
402
+ """Sets status indicicator light on camera"""
403
+ base.require_device_id(ctx)
404
+ obj: d.Camera = ctx.obj.device
405
+
406
+ base.run(ctx, obj.set_status_light(enabled))
407
+
408
+
409
+ @app.command()
410
+ def set_hdr(ctx: typer.Context, enabled: bool) -> None:
411
+ """Sets HDR (High Dynamic Range) on camera"""
412
+ base.require_device_id(ctx)
413
+ obj: d.Camera = ctx.obj.device
414
+
415
+ base.run(ctx, obj.set_hdr(enabled))
416
+
417
+
418
+ @app.command()
419
+ def set_color_night_vision(ctx: typer.Context, enabled: bool) -> None:
420
+ """Sets Color Night Vision on camera"""
421
+ base.require_device_id(ctx)
422
+ obj: d.Camera = ctx.obj.device
423
+
424
+ base.run(ctx, obj.set_color_night_vision(enabled=enabled))
425
+
426
+
427
+ @app.command()
428
+ def set_person_track(ctx: typer.Context, enabled: bool) -> None:
429
+ """Sets person tracking on camera"""
430
+ base.require_device_id(ctx)
431
+ obj: d.Camera = ctx.obj.device
432
+
433
+ if not obj.feature_flags.is_ptz:
434
+ typer.secho("Camera does not support person tracking", fg="red")
435
+ raise typer.Exit(1)
436
+
437
+ base.run(ctx, (obj.set_person_track(enabled=enabled)))
438
+
439
+
440
+ @app.command()
441
+ def set_video_mode(ctx: typer.Context, mode: d.VideoMode) -> None:
442
+ """Sets video mode on camera"""
443
+ base.require_device_id(ctx)
444
+ obj: d.Camera = ctx.obj.device
445
+
446
+ base.run(ctx, obj.set_video_mode(mode))
447
+
448
+
449
+ @app.command()
450
+ def set_camera_zoom(
451
+ ctx: typer.Context,
452
+ level: int = typer.Argument(..., min=0, max=100),
453
+ ) -> None:
454
+ """Sets zoom level for camera"""
455
+ base.require_device_id(ctx)
456
+ obj: d.Camera = ctx.obj.device
457
+
458
+ base.run(ctx, obj.set_camera_zoom(level))
459
+
460
+
461
+ @app.command()
462
+ def set_wdr_level(
463
+ ctx: typer.Context,
464
+ level: int = typer.Argument(..., min=0, max=3),
465
+ ) -> None:
466
+ """Sets WDR (Wide Dynamic Range) on camera"""
467
+ base.require_device_id(ctx)
468
+ obj: d.Camera = ctx.obj.device
469
+
470
+ base.run(ctx, obj.set_wdr_level(level))
471
+
472
+
473
+ @app.command()
474
+ def set_mic_volume(
475
+ ctx: typer.Context,
476
+ level: int = typer.Argument(..., min=0, max=100),
477
+ ) -> None:
478
+ """Sets the mic sensitivity level on camera"""
479
+ base.require_device_id(ctx)
480
+ obj: d.Camera = ctx.obj.device
481
+
482
+ base.run(ctx, obj.set_mic_volume(level))
483
+
484
+
485
+ @app.command()
486
+ def set_speaker_volume(
487
+ ctx: typer.Context,
488
+ level: int = typer.Argument(..., min=0, max=100),
489
+ ) -> None:
490
+ """Sets the speaker sensitivity level on camera"""
491
+ base.require_device_id(ctx)
492
+ obj: d.Camera = ctx.obj.device
493
+
494
+ base.run(ctx, obj.set_speaker_volume(level))
495
+
496
+
497
+ @app.command()
498
+ def set_system_sounds(ctx: typer.Context, enabled: bool) -> None:
499
+ """Sets system sound playback through speakers"""
500
+ base.require_device_id(ctx)
501
+ obj: d.Camera = ctx.obj.device
502
+
503
+ base.run(ctx, obj.set_system_sounds(enabled))
504
+
505
+
506
+ @app.command()
507
+ def set_osd_name(ctx: typer.Context, enabled: bool) -> None:
508
+ """Sets whether camera name is in the On Screen Display"""
509
+ base.require_device_id(ctx)
510
+ obj: d.Camera = ctx.obj.device
511
+
512
+ base.run(ctx, obj.set_osd_name(enabled))
513
+
514
+
515
+ @app.command()
516
+ def set_osd_date(ctx: typer.Context, enabled: bool) -> None:
517
+ """Sets whether current date is in the On Screen Display"""
518
+ base.require_device_id(ctx)
519
+ obj: d.Camera = ctx.obj.device
520
+
521
+ base.run(ctx, obj.set_osd_date(enabled))
522
+
523
+
524
+ @app.command()
525
+ def set_osd_logo(ctx: typer.Context, enabled: bool) -> None:
526
+ """Sets whether the UniFi logo is in the On Screen Display"""
527
+ base.require_device_id(ctx)
528
+ obj: d.Camera = ctx.obj.device
529
+
530
+ base.run(ctx, obj.set_osd_logo(enabled))
531
+
532
+
533
+ @app.command()
534
+ def set_osd_bitrate(ctx: typer.Context, enabled: bool) -> None:
535
+ """Sets whether camera bitrate is in the On Screen Display"""
536
+ base.require_device_id(ctx)
537
+ obj: d.Camera = ctx.obj.device
538
+
539
+ base.run(ctx, obj.set_osd_bitrate(enabled))
540
+
541
+
542
+ @app.command()
543
+ def set_lcd_text(
544
+ ctx: typer.Context,
545
+ text_type: Optional[d.DoorbellMessageType] = typer.Argument(
546
+ None,
547
+ help="No value sets it back to the global default doorbell message.",
548
+ ),
549
+ text: Optional[str] = typer.Argument(
550
+ None,
551
+ help="Only for CUSTOM_MESSAGE text type",
552
+ ),
553
+ reset_at: Optional[datetime] = typer.Option(
554
+ None,
555
+ "-r",
556
+ "--reset-time",
557
+ help="Does not apply to default message",
558
+ ),
559
+ ) -> None:
560
+ """
561
+ Sets doorbell LCD text.
562
+
563
+ Uses your locale timezone. If it is not configured correctly,
564
+ it will default to UTC. You can override your timezone with the
565
+ TZ environment variable.
566
+ """
567
+ if reset_at is not None:
568
+ local_tz = datetime.now(timezone.utc).astimezone().tzinfo
569
+ reset_at = reset_at.replace(tzinfo=local_tz)
570
+
571
+ base.require_device_id(ctx)
572
+ obj: d.Camera = ctx.obj.device
573
+
574
+ base.run(ctx, obj.set_lcd_text(text_type, text, reset_at))