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
uiprotect/cli/chimes.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from uiprotect.api import ProtectApiClient
|
|
9
|
+
from uiprotect.cli import base
|
|
10
|
+
from uiprotect.data import Chime
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(rich_markup_mode="rich")
|
|
13
|
+
|
|
14
|
+
ARG_DEVICE_ID = typer.Argument(None, help="ID of chime to select for subcommands")
|
|
15
|
+
ARG_REPEAT = typer.Argument(..., help="Repeat times count", min=1, max=6)
|
|
16
|
+
ARG_VOLUME = typer.Argument(..., help="Volume", min=1, max=100)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ChimeContext(base.CliContext):
|
|
21
|
+
devices: dict[str, Chime]
|
|
22
|
+
device: Chime | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.callback(invoke_without_command=True)
|
|
29
|
+
def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
|
|
30
|
+
"""
|
|
31
|
+
Chime device CLI.
|
|
32
|
+
|
|
33
|
+
Returns full list of Chimes without any arguments passed.
|
|
34
|
+
"""
|
|
35
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
36
|
+
context = ChimeContext(
|
|
37
|
+
protect=ctx.obj.protect,
|
|
38
|
+
device=None,
|
|
39
|
+
devices=protect.bootstrap.chimes,
|
|
40
|
+
output_format=ctx.obj.output_format,
|
|
41
|
+
)
|
|
42
|
+
ctx.obj = context
|
|
43
|
+
|
|
44
|
+
if device_id is not None and device_id not in ALL_COMMANDS:
|
|
45
|
+
if (device := protect.bootstrap.chimes.get(device_id)) is None:
|
|
46
|
+
typer.secho("Invalid chime ID", fg="red")
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
ctx.obj.device = device
|
|
49
|
+
|
|
50
|
+
if not ctx.invoked_subcommand:
|
|
51
|
+
if device_id in ALL_COMMANDS:
|
|
52
|
+
ctx.invoke(ALL_COMMANDS[device_id], ctx)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if ctx.obj.device is not None:
|
|
56
|
+
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
base.print_unifi_dict(ctx.obj.devices)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def cameras(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
camera_ids: list[str] = typer.Argument(
|
|
66
|
+
None,
|
|
67
|
+
help="Set to [] to empty list of cameras",
|
|
68
|
+
),
|
|
69
|
+
add: bool = typer.Option(False, "-a", "--add", help="Add cameras instead of set"),
|
|
70
|
+
remove: bool = typer.Option(
|
|
71
|
+
False,
|
|
72
|
+
"-r",
|
|
73
|
+
"--remove",
|
|
74
|
+
help="Remove cameras instead of set",
|
|
75
|
+
),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Returns or sets paired doorbells for the chime."""
|
|
78
|
+
base.require_device_id(ctx)
|
|
79
|
+
obj: Chime = ctx.obj.device
|
|
80
|
+
|
|
81
|
+
if add and remove:
|
|
82
|
+
typer.secho("Add and remove are mutally exclusive", fg="red")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
if len(camera_ids) == 0:
|
|
86
|
+
base.print_unifi_list(obj.cameras)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
90
|
+
|
|
91
|
+
if len(camera_ids) == 1 and camera_ids[0] == "[]":
|
|
92
|
+
camera_ids = []
|
|
93
|
+
|
|
94
|
+
for camera_id in camera_ids:
|
|
95
|
+
if (camera := protect.bootstrap.cameras.get(camera_id)) is None:
|
|
96
|
+
typer.secho(f"Invalid camera ID: {camera_id}", fg="red")
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
|
|
99
|
+
if not camera.feature_flags.is_doorbell:
|
|
100
|
+
typer.secho(f"Camera is not a doorbell: {camera_id}", fg="red")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
if add:
|
|
104
|
+
camera_ids = list(set(obj.camera_ids) | set(camera_ids))
|
|
105
|
+
elif remove:
|
|
106
|
+
camera_ids = list(set(obj.camera_ids) - set(camera_ids))
|
|
107
|
+
|
|
108
|
+
data_before_changes = obj.dict_with_excludes()
|
|
109
|
+
obj.camera_ids = camera_ids
|
|
110
|
+
base.run(ctx, obj.save_device(data_before_changes))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def set_volume(
|
|
115
|
+
ctx: typer.Context,
|
|
116
|
+
value: int = ARG_VOLUME,
|
|
117
|
+
camera_id: Optional[str] = typer.Option(
|
|
118
|
+
None,
|
|
119
|
+
"-c",
|
|
120
|
+
"--camera",
|
|
121
|
+
help="Camera ID to apply volume to",
|
|
122
|
+
),
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Set volume level for chime rings."""
|
|
125
|
+
base.require_device_id(ctx)
|
|
126
|
+
obj: Chime = ctx.obj.device
|
|
127
|
+
if camera_id is None:
|
|
128
|
+
base.run(ctx, obj.set_volume(value))
|
|
129
|
+
else:
|
|
130
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
131
|
+
camera = protect.bootstrap.cameras.get(camera_id)
|
|
132
|
+
if camera is None:
|
|
133
|
+
typer.secho(f"Invalid camera ID: {camera_id}", fg="red")
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
base.run(ctx, obj.set_volume_for_camera(camera, value))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def play(
|
|
140
|
+
ctx: typer.Context,
|
|
141
|
+
volume: Optional[int] = typer.Option(None, "-v", "--volume", min=1, max=100),
|
|
142
|
+
repeat_times: Optional[int] = typer.Option(None, "-r", "--repeat", min=1, max=6),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Plays chime tone."""
|
|
145
|
+
base.require_device_id(ctx)
|
|
146
|
+
obj: Chime = ctx.obj.device
|
|
147
|
+
base.run(ctx, obj.play(volume=volume, repeat_times=repeat_times))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def play_buzzer(ctx: typer.Context) -> None:
|
|
152
|
+
"""Plays chime buzzer."""
|
|
153
|
+
base.require_device_id(ctx)
|
|
154
|
+
obj: Chime = ctx.obj.device
|
|
155
|
+
base.run(ctx, obj.play_buzzer())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command()
|
|
159
|
+
def set_repeat_times(
|
|
160
|
+
ctx: typer.Context,
|
|
161
|
+
value: int = ARG_REPEAT,
|
|
162
|
+
camera_id: Optional[str] = typer.Option(
|
|
163
|
+
None,
|
|
164
|
+
"-c",
|
|
165
|
+
"--camera",
|
|
166
|
+
help="Camera ID to apply repeat times to",
|
|
167
|
+
),
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Set number of times for a chime to repeat when doorbell is rang."""
|
|
170
|
+
base.require_device_id(ctx)
|
|
171
|
+
obj: Chime = ctx.obj.device
|
|
172
|
+
if camera_id is None:
|
|
173
|
+
base.run(ctx, obj.set_repeat_times(value))
|
|
174
|
+
else:
|
|
175
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
176
|
+
camera = protect.bootstrap.cameras.get(camera_id)
|
|
177
|
+
if camera is None:
|
|
178
|
+
typer.secho(f"Invalid camera ID: {camera_id}", fg="red")
|
|
179
|
+
raise typer.Exit(1)
|
|
180
|
+
base.run(ctx, obj.set_repeat_times_for_camera(camera, value))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from uiprotect.api import ProtectApiClient
|
|
10
|
+
from uiprotect.cli import base
|
|
11
|
+
from uiprotect.data import Doorlock
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(rich_markup_mode="rich")
|
|
14
|
+
|
|
15
|
+
ARG_DEVICE_ID = typer.Argument(None, help="ID of doorlock to select for subcommands")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DoorlockContext(base.CliContext):
|
|
20
|
+
devices: dict[str, Doorlock]
|
|
21
|
+
device: Doorlock | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback(invoke_without_command=True)
|
|
28
|
+
def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Doorlock device CLI.
|
|
31
|
+
|
|
32
|
+
Returns full list of Doorlocks without any arguments passed.
|
|
33
|
+
"""
|
|
34
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
35
|
+
context = DoorlockContext(
|
|
36
|
+
protect=ctx.obj.protect,
|
|
37
|
+
device=None,
|
|
38
|
+
devices=protect.bootstrap.doorlocks,
|
|
39
|
+
output_format=ctx.obj.output_format,
|
|
40
|
+
)
|
|
41
|
+
ctx.obj = context
|
|
42
|
+
|
|
43
|
+
if device_id is not None and device_id not in ALL_COMMANDS:
|
|
44
|
+
if (device := protect.bootstrap.doorlocks.get(device_id)) is None:
|
|
45
|
+
typer.secho("Invalid doorlock ID", fg="red")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
ctx.obj.device = device
|
|
48
|
+
|
|
49
|
+
if not ctx.invoked_subcommand:
|
|
50
|
+
if device_id in ALL_COMMANDS:
|
|
51
|
+
ctx.invoke(ALL_COMMANDS[device_id], ctx)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if ctx.obj.device is not None:
|
|
55
|
+
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
base.print_unifi_dict(ctx.obj.devices)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def camera(ctx: typer.Context, camera_id: Optional[str] = typer.Argument(None)) -> None:
|
|
63
|
+
"""Returns or sets tha paired camera for a doorlock."""
|
|
64
|
+
base.require_device_id(ctx)
|
|
65
|
+
obj: Doorlock = ctx.obj.device
|
|
66
|
+
|
|
67
|
+
if camera_id is None:
|
|
68
|
+
base.print_unifi_obj(obj.camera, ctx.obj.output_format)
|
|
69
|
+
else:
|
|
70
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
71
|
+
if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None:
|
|
72
|
+
typer.secho("Invalid camera ID")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
base.run(ctx, obj.set_paired_camera(camera_obj))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def set_status_light(ctx: typer.Context, enabled: bool) -> None:
|
|
79
|
+
"""Sets status light for the lock."""
|
|
80
|
+
base.require_device_id(ctx)
|
|
81
|
+
obj: Doorlock = ctx.obj.device
|
|
82
|
+
|
|
83
|
+
base.run(ctx, obj.set_status_light(enabled))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def set_auto_close_time(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
duration: int = typer.Argument(..., min=0, max=3600),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Sets auto-close time for the lock (in seconds). 0 = disabled."""
|
|
92
|
+
base.require_device_id(ctx)
|
|
93
|
+
obj: Doorlock = ctx.obj.device
|
|
94
|
+
|
|
95
|
+
base.run(ctx, obj.set_auto_close_time(timedelta(seconds=duration)))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def unlock(ctx: typer.Context) -> None:
|
|
100
|
+
"""Unlocks the lock."""
|
|
101
|
+
base.require_device_id(ctx)
|
|
102
|
+
obj: Doorlock = ctx.obj.device
|
|
103
|
+
base.run(ctx, obj.open_lock())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def lock(ctx: typer.Context) -> None:
|
|
108
|
+
"""Locks the lock."""
|
|
109
|
+
base.require_device_id(ctx)
|
|
110
|
+
obj: Doorlock = ctx.obj.device
|
|
111
|
+
base.run(ctx, obj.close_lock())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@app.command()
|
|
115
|
+
def calibrate(ctx: typer.Context, force: bool = base.OPTION_FORCE) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Calibrate the doorlock.
|
|
118
|
+
|
|
119
|
+
Door must be open and lock unlocked.
|
|
120
|
+
"""
|
|
121
|
+
base.require_device_id(ctx)
|
|
122
|
+
obj: Doorlock = ctx.obj.device
|
|
123
|
+
|
|
124
|
+
if force or typer.confirm("Is the door open and unlocked?"):
|
|
125
|
+
base.run(ctx, obj.calibrate())
|
uiprotect/cli/events.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.progress import Progress
|
|
11
|
+
|
|
12
|
+
from uiprotect import data as d
|
|
13
|
+
from uiprotect.api import ProtectApiClient
|
|
14
|
+
from uiprotect.cli import base
|
|
15
|
+
from uiprotect.exceptions import NvrError
|
|
16
|
+
from uiprotect.utils import local_datetime
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(rich_markup_mode="rich")
|
|
19
|
+
|
|
20
|
+
ARG_EVENT_ID = typer.Argument(None, help="ID of camera to select for subcommands")
|
|
21
|
+
OPTION_START = typer.Option(None, "-s", "--start")
|
|
22
|
+
OPTION_END = typer.Option(None, "-e", "--end")
|
|
23
|
+
OPTION_LIMIT = typer.Option(None, "-l", "--limit")
|
|
24
|
+
OPTION_OFFSET = typer.Option(None, "-o", "--offet")
|
|
25
|
+
OPTION_TYPES = typer.Option(None, "-t", "--type")
|
|
26
|
+
OPTION_SMART_TYPES = typer.Option(
|
|
27
|
+
None,
|
|
28
|
+
"-d",
|
|
29
|
+
"--smart-detect",
|
|
30
|
+
help="If provided, will only return smartDetectZone events",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class EventContext(base.CliContext):
|
|
36
|
+
events: dict[str, d.Event] | None = None
|
|
37
|
+
event: d.Event | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ALL_COMMANDS: dict[str, Callable[..., None]] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback(invoke_without_command=True)
|
|
44
|
+
def main(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
event_id: Optional[str] = ARG_EVENT_ID,
|
|
47
|
+
start: Optional[datetime] = OPTION_START,
|
|
48
|
+
end: Optional[datetime] = OPTION_END,
|
|
49
|
+
limit: Optional[int] = OPTION_LIMIT,
|
|
50
|
+
offset: Optional[int] = OPTION_OFFSET,
|
|
51
|
+
types: Optional[list[d.EventType]] = OPTION_TYPES,
|
|
52
|
+
smart_types: Optional[list[d.SmartDetectObjectType]] = OPTION_SMART_TYPES,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Events CLI.
|
|
56
|
+
|
|
57
|
+
Returns list of events from the last 24 hours without any arguments passed.
|
|
58
|
+
"""
|
|
59
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
60
|
+
context = EventContext(
|
|
61
|
+
protect=ctx.obj.protect,
|
|
62
|
+
event=None,
|
|
63
|
+
events=None,
|
|
64
|
+
output_format=ctx.obj.output_format,
|
|
65
|
+
)
|
|
66
|
+
ctx.obj = context
|
|
67
|
+
|
|
68
|
+
if event_id is not None and event_id not in ALL_COMMANDS:
|
|
69
|
+
try:
|
|
70
|
+
ctx.obj.event = base.run(ctx, protect.get_event(event_id))
|
|
71
|
+
except NvrError as err:
|
|
72
|
+
typer.secho("Invalid event ID", fg="red")
|
|
73
|
+
raise typer.Exit(1) from err
|
|
74
|
+
|
|
75
|
+
if not ctx.invoked_subcommand:
|
|
76
|
+
if ctx.obj.event is not None:
|
|
77
|
+
base.print_unifi_obj(ctx.obj.event, ctx.obj.output_format)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
if types is not None and len(types) == 0:
|
|
81
|
+
types = None
|
|
82
|
+
if smart_types is not None and len(smart_types) == 0:
|
|
83
|
+
smart_types = None
|
|
84
|
+
events = base.run(
|
|
85
|
+
ctx,
|
|
86
|
+
protect.get_events(
|
|
87
|
+
start=start,
|
|
88
|
+
end=end,
|
|
89
|
+
limit=limit,
|
|
90
|
+
offset=offset,
|
|
91
|
+
types=types,
|
|
92
|
+
smart_detect_types=smart_types,
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
ctx.obj.events = {}
|
|
96
|
+
for event in events:
|
|
97
|
+
ctx.obj.events[event.id] = event
|
|
98
|
+
|
|
99
|
+
if event_id in ALL_COMMANDS:
|
|
100
|
+
ctx.invoke(ALL_COMMANDS[event_id], ctx)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
base.print_unifi_dict(ctx.obj.events)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def require_event_id(ctx: typer.Context) -> None:
|
|
107
|
+
"""Requires event ID in context"""
|
|
108
|
+
if ctx.obj.event is None:
|
|
109
|
+
typer.secho("Requires a valid event ID to be selected")
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def require_no_event_id(ctx: typer.Context) -> None:
|
|
114
|
+
"""Requires no device ID in context"""
|
|
115
|
+
if ctx.obj.event is not None or ctx.obj.events is None:
|
|
116
|
+
typer.secho("Requires no event ID to be selected")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def list_ids(ctx: typer.Context) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Prints list of "id type timestamp" for each event.
|
|
124
|
+
|
|
125
|
+
Timestamps dispalyed in your locale timezone. If it is not configured
|
|
126
|
+
correctly, it will default to UTC. You can override your timezone with
|
|
127
|
+
the TZ environment variable.
|
|
128
|
+
"""
|
|
129
|
+
require_no_event_id(ctx)
|
|
130
|
+
objs: dict[str, d.Event] = ctx.obj.events
|
|
131
|
+
to_print: list[tuple[str, str, datetime]] = []
|
|
132
|
+
longest_event = 0
|
|
133
|
+
for obj in objs.values():
|
|
134
|
+
event_type = obj.type.value
|
|
135
|
+
if event_type in {
|
|
136
|
+
d.EventType.SMART_DETECT.value,
|
|
137
|
+
d.EventType.SMART_DETECT_LINE.value,
|
|
138
|
+
}:
|
|
139
|
+
event_type = f"{event_type}[{','.join(obj.smart_detect_types)}]"
|
|
140
|
+
longest_event = max(len(event_type), longest_event)
|
|
141
|
+
dt = obj.timestamp or obj.start
|
|
142
|
+
dt = local_datetime(dt)
|
|
143
|
+
|
|
144
|
+
to_print.append((obj.id, event_type, dt))
|
|
145
|
+
|
|
146
|
+
if ctx.obj.output_format == base.OutputFormatEnum.JSON:
|
|
147
|
+
base.json_output(to_print)
|
|
148
|
+
else:
|
|
149
|
+
for item in to_print:
|
|
150
|
+
typer.echo(f"{item[0]}\t{item[1]:{longest_event}}\t{item[2]}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
ALL_COMMANDS["list-ids"] = list_ids
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def save_thumbnail(
|
|
158
|
+
ctx: typer.Context,
|
|
159
|
+
output_path: Path = typer.Argument(..., help="JPEG format"),
|
|
160
|
+
) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Saves thumbnail for event.
|
|
163
|
+
|
|
164
|
+
Only for ring, motion and smartDetectZone events.
|
|
165
|
+
"""
|
|
166
|
+
require_event_id(ctx)
|
|
167
|
+
event: d.Event = ctx.obj.event
|
|
168
|
+
|
|
169
|
+
thumbnail = base.run(ctx, event.get_thumbnail())
|
|
170
|
+
if thumbnail is None:
|
|
171
|
+
typer.secho("Could not get thumbnail", fg="red")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
|
|
174
|
+
Path(output_path).write_bytes(thumbnail)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command()
|
|
178
|
+
def save_animated_thumbnail(
|
|
179
|
+
ctx: typer.Context,
|
|
180
|
+
output_path: Path = typer.Argument(..., help="GIF format"),
|
|
181
|
+
) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Saves animated thumbnail for event.
|
|
184
|
+
|
|
185
|
+
Only for ring, motion and smartDetectZone events.
|
|
186
|
+
"""
|
|
187
|
+
require_event_id(ctx)
|
|
188
|
+
event: d.Event = ctx.obj.event
|
|
189
|
+
|
|
190
|
+
thumbnail = base.run(ctx, event.get_animated_thumbnail())
|
|
191
|
+
if thumbnail is None:
|
|
192
|
+
typer.secho("Could not get thumbnail", fg="red")
|
|
193
|
+
raise typer.Exit(1)
|
|
194
|
+
|
|
195
|
+
Path(output_path).write_bytes(thumbnail)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command()
|
|
199
|
+
def save_heatmap(
|
|
200
|
+
ctx: typer.Context,
|
|
201
|
+
output_path: Path = typer.Argument(..., help="PNG format"),
|
|
202
|
+
) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Saves heatmap for event.
|
|
205
|
+
|
|
206
|
+
Only motion events have heatmaps.
|
|
207
|
+
"""
|
|
208
|
+
require_event_id(ctx)
|
|
209
|
+
event: d.Event = ctx.obj.event
|
|
210
|
+
|
|
211
|
+
heatmap = base.run(ctx, event.get_heatmap())
|
|
212
|
+
if heatmap is None:
|
|
213
|
+
typer.secho("Could not get heatmap", fg="red")
|
|
214
|
+
raise typer.Exit(1)
|
|
215
|
+
|
|
216
|
+
Path(output_path).write_bytes(heatmap)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.command()
|
|
220
|
+
def save_video(
|
|
221
|
+
ctx: typer.Context,
|
|
222
|
+
output_path: Path = typer.Argument(..., help="MP4 format"),
|
|
223
|
+
channel: int = typer.Option(
|
|
224
|
+
0,
|
|
225
|
+
"-c",
|
|
226
|
+
"--channel",
|
|
227
|
+
min=0,
|
|
228
|
+
max=3,
|
|
229
|
+
help="0 = High, 1 = Medium, 2 = Low, 3 = Package",
|
|
230
|
+
),
|
|
231
|
+
) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Exports video for event.
|
|
234
|
+
|
|
235
|
+
Only for ring, motion and smartDetectZone events.
|
|
236
|
+
"""
|
|
237
|
+
require_event_id(ctx)
|
|
238
|
+
event: d.Event = ctx.obj.event
|
|
239
|
+
|
|
240
|
+
with Progress() as pb:
|
|
241
|
+
task_id = pb.add_task("(1/2) Exporting", total=100)
|
|
242
|
+
|
|
243
|
+
async def callback(step: int, current: int, total: int) -> None:
|
|
244
|
+
pb.update(
|
|
245
|
+
task_id,
|
|
246
|
+
total=total,
|
|
247
|
+
completed=current,
|
|
248
|
+
description="(2/2) Downloading",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
base.run(
|
|
252
|
+
ctx,
|
|
253
|
+
event.get_video(
|
|
254
|
+
channel,
|
|
255
|
+
output_file=output_path,
|
|
256
|
+
progress_callback=callback,
|
|
257
|
+
),
|
|
258
|
+
)
|
uiprotect/cli/lights.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from uiprotect.api import ProtectApiClient
|
|
10
|
+
from uiprotect.cli import base
|
|
11
|
+
from uiprotect.data import Light
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(rich_markup_mode="rich")
|
|
14
|
+
|
|
15
|
+
ARG_DEVICE_ID = typer.Argument(None, help="ID of light to select for subcommands")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class LightContext(base.CliContext):
|
|
20
|
+
devices: dict[str, Light]
|
|
21
|
+
device: Light | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback(invoke_without_command=True)
|
|
28
|
+
def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Lights device CLI.
|
|
31
|
+
|
|
32
|
+
Returns full list of Viewers without any arguments passed.
|
|
33
|
+
"""
|
|
34
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
35
|
+
context = LightContext(
|
|
36
|
+
protect=ctx.obj.protect,
|
|
37
|
+
device=None,
|
|
38
|
+
devices=protect.bootstrap.lights,
|
|
39
|
+
output_format=ctx.obj.output_format,
|
|
40
|
+
)
|
|
41
|
+
ctx.obj = context
|
|
42
|
+
|
|
43
|
+
if device_id is not None and device_id not in ALL_COMMANDS:
|
|
44
|
+
if (device := protect.bootstrap.lights.get(device_id)) is None:
|
|
45
|
+
typer.secho("Invalid light ID", fg="red")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
ctx.obj.device = device
|
|
48
|
+
|
|
49
|
+
if not ctx.invoked_subcommand:
|
|
50
|
+
if device_id in ALL_COMMANDS:
|
|
51
|
+
ctx.invoke(ALL_COMMANDS[device_id], ctx)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if ctx.obj.device is not None:
|
|
55
|
+
base.print_unifi_obj(ctx.obj.device, ctx.obj.output_format)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
base.print_unifi_dict(ctx.obj.devices)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def camera(ctx: typer.Context, camera_id: Optional[str] = typer.Argument(None)) -> None:
|
|
63
|
+
"""Returns or sets tha paired camera for a light."""
|
|
64
|
+
base.require_device_id(ctx)
|
|
65
|
+
obj: Light = ctx.obj.device
|
|
66
|
+
|
|
67
|
+
if camera_id is None:
|
|
68
|
+
base.print_unifi_obj(obj.camera, ctx.obj.output_format)
|
|
69
|
+
else:
|
|
70
|
+
protect: ProtectApiClient = ctx.obj.protect
|
|
71
|
+
if (camera_obj := protect.bootstrap.cameras.get(camera_id)) is None:
|
|
72
|
+
typer.secho("Invalid camera ID")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
base.run(ctx, obj.set_paired_camera(camera_obj))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def set_status_light(ctx: typer.Context, enabled: bool) -> None:
|
|
79
|
+
"""Sets status light for light device."""
|
|
80
|
+
base.require_device_id(ctx)
|
|
81
|
+
obj: Light = ctx.obj.device
|
|
82
|
+
|
|
83
|
+
base.run(ctx, obj.set_status_light(enabled))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def set_led_level(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
led_level: int = typer.Argument(..., min=1, max=6),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Sets brightness of LED on light."""
|
|
92
|
+
base.require_device_id(ctx)
|
|
93
|
+
obj: Light = ctx.obj.device
|
|
94
|
+
|
|
95
|
+
base.run(ctx, obj.set_led_level(led_level))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def set_sensitivity(
|
|
100
|
+
ctx: typer.Context,
|
|
101
|
+
sensitivity: int = typer.Argument(..., min=0, max=100),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Sets motion sensitivity for the light."""
|
|
104
|
+
base.require_device_id(ctx)
|
|
105
|
+
obj: Light = ctx.obj.device
|
|
106
|
+
|
|
107
|
+
base.run(ctx, obj.set_sensitivity(sensitivity))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def set_duration(
|
|
112
|
+
ctx: typer.Context,
|
|
113
|
+
duration: int = typer.Argument(..., min=15, max=900),
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Sets timeout duration (in seconds) for light."""
|
|
116
|
+
base.require_device_id(ctx)
|
|
117
|
+
obj: Light = ctx.obj.device
|
|
118
|
+
|
|
119
|
+
base.run(ctx, obj.set_duration(timedelta(seconds=duration)))
|