uiprotect 7.5.2__py3-none-any.whl → 7.32.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.
uiprotect/cli/__init__.py CHANGED
@@ -3,19 +3,21 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import base64
5
5
  import logging
6
+ import ssl
6
7
  import sys
7
8
  from pathlib import Path
8
- from typing import Optional, cast
9
+ from typing import cast
9
10
 
11
+ import aiohttp
10
12
  import orjson
11
13
  import typer
12
14
  from rich.progress import track
13
15
 
14
- from uiprotect.api import ProtectApiClient
16
+ from uiprotect.api import MetaInfo, ProtectApiClient
15
17
 
16
- from ..data import Version, WSPacket
18
+ from ..data import WSPacket
17
19
  from ..test_util import SampleDataGenerator
18
- from ..utils import RELEASE_CACHE, get_local_timezone, run_async
20
+ from ..utils import get_local_timezone, run_async
19
21
  from ..utils import profile_ws as profile_ws_job
20
22
  from .aiports import app as aiports_app
21
23
  from .base import CliContext, OutputFormatEnum
@@ -60,6 +62,13 @@ OPTION_PASSWORD = typer.Option(
60
62
  hide_input=True,
61
63
  envvar="UFP_PASSWORD",
62
64
  )
65
+ OPTION_API_KEY = typer.Option(
66
+ None,
67
+ "--api-key",
68
+ "-k",
69
+ help="UniFi Protect API key (required for public API operations)",
70
+ envvar="UFP_API_KEY",
71
+ )
63
72
  OPTION_ADDRESS = typer.Option(
64
73
  ...,
65
74
  "--address",
@@ -76,10 +85,10 @@ OPTION_PORT = typer.Option(
76
85
  envvar="UFP_PORT",
77
86
  )
78
87
  OPTION_SECONDS = typer.Option(15, "--seconds", "-s", help="Seconds to pull events")
79
- OPTION_VERIFY = typer.Option(
88
+ OPTION_VERIFY_SSL = typer.Option(
80
89
  True,
81
- "--no-verify",
82
- help="Verify SSL",
90
+ "--verify-ssl/--no-verify-ssl",
91
+ help="Verify SSL certificate. Disable for self-signed certificates.",
83
92
  envvar="UFP_SSL_VERIFY",
84
93
  )
85
94
  OPTION_ANON = typer.Option(True, "--actual", help="Do not anonymize test data")
@@ -135,14 +144,36 @@ if backup_app is not None:
135
144
  app.add_typer(backup_app, name="backup")
136
145
 
137
146
 
147
+ def _is_ssl_error(exc: BaseException) -> bool:
148
+ """Check if an exception is an SSL certificate verification error."""
149
+ if isinstance(exc, aiohttp.ClientConnectorCertificateError):
150
+ return True
151
+ if isinstance(exc, aiohttp.ClientConnectorSSLError):
152
+ return True
153
+ if isinstance(exc, ssl.SSLCertVerificationError):
154
+ return True
155
+ # Check nested exceptions
156
+ if exc.__cause__ is not None:
157
+ return _is_ssl_error(exc.__cause__)
158
+ return False
159
+
160
+
161
+ async def _connect_and_bootstrap(protect: ProtectApiClient) -> None:
162
+ """Connect to the Protect API and fetch bootstrap data."""
163
+ protect._bootstrap = await protect.get_bootstrap()
164
+ await protect.close_session()
165
+ await protect.close_public_api_session()
166
+
167
+
138
168
  @app.callback()
139
169
  def main(
140
170
  ctx: typer.Context,
141
171
  username: str = OPTION_USERNAME,
142
172
  password: str = OPTION_PASSWORD,
173
+ api_key: str | None = OPTION_API_KEY,
143
174
  address: str = OPTION_ADDRESS,
144
175
  port: int = OPTION_PORT,
145
- verify: bool = OPTION_VERIFY,
176
+ verify_ssl: bool = OPTION_VERIFY_SSL,
146
177
  output_format: OutputFormatEnum = OPTION_OUT_FORMAT,
147
178
  include_unadopted: bool = OPTION_UNADOPTED,
148
179
  ) -> None:
@@ -155,15 +186,51 @@ def main(
155
186
  port,
156
187
  username,
157
188
  password,
158
- verify_ssl=verify,
189
+ api_key,
190
+ verify_ssl=verify_ssl,
159
191
  ignore_unadopted=not include_unadopted,
160
192
  )
161
193
 
162
- async def update() -> None:
163
- protect._bootstrap = await protect.get_bootstrap()
194
+ async def close_protect() -> None:
195
+ """Close the Protect API client sessions."""
164
196
  await protect.close_session()
197
+ await protect.close_public_api_session()
198
+
199
+ try:
200
+ run_async(_connect_and_bootstrap(protect))
201
+ except Exception as exc:
202
+ # Always close the session on error to avoid "Unclosed client session" warning
203
+ run_async(close_protect())
204
+
205
+ if verify_ssl and _is_ssl_error(exc):
206
+ typer.secho(
207
+ "SSL certificate verification failed. "
208
+ "This is common with self-signed certificates on UniFi devices.",
209
+ fg="yellow",
210
+ )
211
+ if typer.confirm("Would you like to disable SSL verification and retry?"):
212
+ # Create new client with SSL disabled
213
+ protect = ProtectApiClient(
214
+ address,
215
+ port,
216
+ username,
217
+ password,
218
+ api_key,
219
+ verify_ssl=False,
220
+ ignore_unadopted=not include_unadopted,
221
+ )
222
+ run_async(_connect_and_bootstrap(protect))
223
+ typer.secho(
224
+ "Connected successfully with SSL verification disabled.\n"
225
+ "Tip: Use --no-verify-ssl to skip this prompt in the future.",
226
+ fg="green",
227
+ )
228
+ else:
229
+ typer.secho("Connection aborted.", fg="red")
230
+ raise typer.Exit(code=1) from exc
231
+ else:
232
+ raise
165
233
 
166
- run_async(update())
167
234
  ctx.obj = CliContext(protect=protect, output_format=output_format)
168
235
 
169
236
 
@@ -222,7 +289,7 @@ def generate_sample_data(
222
289
  ctx: typer.Context,
223
290
  anonymize: bool = OPTION_ANON,
224
291
  wait_time: int = OPTION_WAIT,
225
- output_folder: Optional[Path] = OPTION_OUTPUT,
292
+ output_folder: Path | None = OPTION_OUTPUT,
226
293
  do_zip: bool = OPTION_ZIP,
227
294
  ) -> None:
228
295
  """Generates sample data for UniFi Protect instance."""
@@ -258,7 +325,7 @@ def generate_sample_data(
258
325
  def profile_ws(
259
326
  ctx: typer.Context,
260
327
  wait_time: int = OPTION_WAIT,
261
- output_path: Optional[Path] = OPTION_OUTPUT,
328
+ output_path: Path | None = OPTION_OUTPUT,
262
329
  ) -> None:
263
330
  """Profiles Websocket messages for UniFi Protect instance."""
264
331
  protect = cast(ProtectApiClient, ctx.obj.protect)
@@ -275,6 +342,7 @@ def profile_ws(
275
342
  unsub()
276
343
  await protect.async_disconnect_ws()
277
344
  await protect.close_session()
345
+ await protect.close_public_api_session()
278
346
 
279
347
  _setup_logger()
280
348
 
@@ -284,7 +352,7 @@ def profile_ws(
284
352
  @app.command()
285
353
  def decode_ws_msg(
286
354
  ws_file: typer.FileBinaryRead = OPTION_WS_FILE,
287
- ws_data: Optional[str] = ARG_WS_DATA,
355
+ ws_data: str | None = ARG_WS_DATA,
288
356
  ) -> None:
289
357
  """Decodes a base64 encoded UniFi Protect Websocket binary message."""
290
358
  if ws_file is None and ws_data is None: # type: ignore[unreachable]
@@ -304,19 +372,36 @@ def decode_ws_msg(
304
372
 
305
373
 
306
374
  @app.command()
307
- def release_versions(ctx: typer.Context) -> None:
308
- """Updates the release version cache on disk."""
375
+ def create_api_key(
376
+ ctx: typer.Context,
377
+ name: str = typer.Argument(..., help="Name for the API key"),
378
+ ) -> None:
379
+ """Create a new API key for the current user."""
309
380
  protect = cast(ProtectApiClient, ctx.obj.protect)
310
381
 
311
- async def callback() -> set[Version]:
312
- versions = await protect.get_release_versions()
382
+ async def callback() -> str:
383
+ api_key = await protect.create_api_key(name)
313
384
  await protect.close_session()
314
- return versions
385
+ await protect.close_public_api_session()
386
+ return api_key
315
387
 
316
388
  _setup_logger()
389
+ result = run_async(callback())
390
+ typer.echo(result)
317
391
 
318
- versions = run_async(callback())
319
- output = orjson.dumps(sorted([str(v) for v in versions]))
320
392
 
321
- Path(RELEASE_CACHE).write_bytes(output)
322
- typer.echo(output.decode("utf-8"))
393
+ @app.command()
394
+ def get_meta_info(ctx: typer.Context) -> None:
395
+ """Get metadata about the current UniFi Protect instance."""
396
+ protect = cast(ProtectApiClient, ctx.obj.protect)
397
+
398
+ async def callback() -> MetaInfo:
399
+ meta = await protect.get_meta_info()
400
+ await protect.close_session()
401
+ await protect.close_public_api_session()
402
+ return meta
403
+
404
+ _setup_logger()
405
+
406
+ result = run_async(callback())
407
+ typer.echo(result.model_dump_json())
uiprotect/cli/aiports.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Optional
5
4
 
6
5
  import typer
7
6
 
@@ -26,7 +25,7 @@ ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
26
25
 
27
26
 
28
27
  @app.callback(invoke_without_command=True)
29
- def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
28
+ def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
30
29
  """
31
30
  AiPort device CLI.
32
31
 
uiprotect/cli/backup.py CHANGED
@@ -10,7 +10,7 @@ from dataclasses import dataclass
10
10
  from datetime import datetime, timedelta, timezone
11
11
  from enum import Enum
12
12
  from pathlib import Path
13
- from typing import TYPE_CHECKING, Any, Optional, cast
13
+ from typing import TYPE_CHECKING, Any, cast
14
14
 
15
15
  import aiofiles
16
16
  import aiofiles.os as aos
@@ -396,9 +396,9 @@ def _setup_logger(verbose: bool) -> None:
396
396
  @app.callback()
397
397
  def main(
398
398
  ctx: typer.Context,
399
- start: Optional[str] = OPTION_START,
400
- end: Optional[str] = OPTION_END,
401
- output_folder: Optional[Path] = OPTION_OUTPUT,
399
+ start: str | None = OPTION_START,
400
+ end: str | None = OPTION_END,
401
+ output_folder: Path | None = OPTION_OUTPUT,
402
402
  thumbnail_format: str = OPTION_THUMBNAIL_FORMAT,
403
403
  gif_format: str = OPTION_GIF_FORMAT,
404
404
  event_format: str = OPTION_EVENT_FORMAT,
@@ -1064,6 +1064,7 @@ async def _events(
1064
1064
  finally:
1065
1065
  _LOGGER.debug("Cleaning up Protect connection/database...")
1066
1066
  await ctx.protect.close_session()
1067
+ await ctx.protect.close_public_api_session()
1067
1068
  await ctx.db_engine.dispose()
1068
1069
 
1069
1070
 
uiprotect/cli/base.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from collections.abc import Callable, Coroutine, Mapping, Sequence
4
4
  from dataclasses import dataclass
5
5
  from enum import Enum
6
- from typing import Any, Optional, TypeVar
6
+ from typing import Any, TypeVar
7
7
 
8
8
  import orjson
9
9
  import typer
@@ -36,6 +36,7 @@ def run(ctx: typer.Context, func: Coroutine[Any, Any, T]) -> T:
36
36
  async def callback() -> T:
37
37
  return_value = await func
38
38
  await ctx.obj.protect.close_session()
39
+ await ctx.obj.protect.close_public_api_session()
39
40
  return return_value
40
41
 
41
42
  try:
@@ -165,7 +166,7 @@ def set_ssh(ctx: typer.Context, enabled: bool) -> None:
165
166
  run(ctx, obj.set_ssh(enabled))
166
167
 
167
168
 
168
- def set_name(ctx: typer.Context, name: Optional[str] = typer.Argument(None)) -> None:
169
+ def set_name(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None:
169
170
  """Sets name for the device"""
170
171
  require_device_id(ctx)
171
172
  obj: NVR | ProtectAdoptableDeviceModel = ctx.obj.device
@@ -203,7 +204,7 @@ def unadopt(ctx: typer.Context, force: bool = OPTION_FORCE) -> None:
203
204
  run(ctx, obj.unadopt())
204
205
 
205
206
 
206
- def adopt(ctx: typer.Context, name: Optional[str] = typer.Argument(None)) -> None:
207
+ def adopt(ctx: typer.Context, name: str | None = typer.Argument(None)) -> None:
207
208
  """
208
209
  Adopts a device.
209
210
 
@@ -223,7 +224,6 @@ def init_common_commands(
223
224
  device_commands: dict[str, Callable[..., Any]] = {}
224
225
 
225
226
  deviceless_commands["list-ids"] = app.command()(list_ids)
226
- device_commands["protect-url"] = app.command()(protect_url)
227
227
  device_commands["is-wired"] = app.command()(is_wired)
228
228
  device_commands["is-wifi"] = app.command()(is_wifi)
229
229
  device_commands["is-bluetooth"] = app.command()(is_bluetooth)
uiprotect/cli/cameras.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass
4
4
  from datetime import datetime, timezone
5
5
  from pathlib import Path
6
- from typing import Optional, cast
6
+ from typing import cast
7
7
 
8
8
  import typer
9
9
  from rich.progress import Progress
@@ -27,7 +27,7 @@ ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
27
27
 
28
28
 
29
29
  @app.callback(invoke_without_command=True)
30
- def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
30
+ def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
31
31
  """
32
32
  Camera device CLI.
33
33
 
@@ -74,7 +74,7 @@ def timelapse_url(ctx: typer.Context) -> None:
74
74
  @app.command()
75
75
  def privacy_mode(
76
76
  ctx: typer.Context,
77
- enabled: Optional[bool] = typer.Argument(None),
77
+ enabled: bool | None = typer.Argument(None),
78
78
  ) -> None:
79
79
  """
80
80
  Returns/sets library managed privacy mode.
@@ -91,7 +91,7 @@ def privacy_mode(
91
91
 
92
92
 
93
93
  @app.command()
94
- def chime_type(ctx: typer.Context, value: Optional[d.ChimeType] = None) -> None:
94
+ def chime_type(ctx: typer.Context, value: d.ChimeType | None = None) -> None:
95
95
  """Returns/sets the current chime type if the camera has a chime."""
96
96
  base.require_device_id(ctx)
97
97
  obj: d.Camera = ctx.obj.device
@@ -137,9 +137,9 @@ def stream_urls(ctx: typer.Context) -> None:
137
137
  def save_snapshot(
138
138
  ctx: typer.Context,
139
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"),
140
+ width: int | None = typer.Option(None, "-w", "--width"),
141
+ height: int | None = typer.Option(None, "-h", "--height"),
142
+ dt: datetime | None = typer.Option(None, "-t", "--timestamp"),
143
143
  package: bool = typer.Option(False, "-p", "--package", help="Get package camera"),
144
144
  ) -> None:
145
145
  """
@@ -188,7 +188,7 @@ def save_video(
188
188
  max=3,
189
189
  help="0 = High, 1 = Medium, 2 = Low, 3 = Package",
190
190
  ),
191
- fps: Optional[int] = typer.Option(
191
+ fps: int | None = typer.Option(
192
192
  None,
193
193
  "--fps",
194
194
  min=1,
@@ -245,7 +245,7 @@ def save_video(
245
245
  def play_audio(
246
246
  ctx: typer.Context,
247
247
  url: str = typer.Argument(..., help="ffmpeg playable URL"),
248
- ffmpeg_path: Optional[Path] = typer.Option(
248
+ ffmpeg_path: Path | None = typer.Option(
249
249
  None,
250
250
  "--ffmpeg-path",
251
251
  help="Path to ffmpeg executable",
@@ -487,13 +487,37 @@ def set_speaker_volume(
487
487
  ctx: typer.Context,
488
488
  level: int = typer.Argument(..., min=0, max=100),
489
489
  ) -> None:
490
- """Sets the speaker sensitivity level on camera"""
490
+ """Sets the speaker output volume on camera"""
491
491
  base.require_device_id(ctx)
492
492
  obj: d.Camera = ctx.obj.device
493
493
 
494
494
  base.run(ctx, obj.set_speaker_volume(level))
495
495
 
496
496
 
497
+ @app.command()
498
+ def set_volume(
499
+ ctx: typer.Context,
500
+ level: int = typer.Argument(..., min=0, max=100),
501
+ ) -> None:
502
+ """Sets the general volume level on camera"""
503
+ base.require_device_id(ctx)
504
+ obj: d.Camera = ctx.obj.device
505
+
506
+ base.run(ctx, obj.set_volume(level))
507
+
508
+
509
+ @app.command()
510
+ def set_ring_volume(
511
+ ctx: typer.Context,
512
+ level: int = typer.Argument(..., min=0, max=100),
513
+ ) -> None:
514
+ """Sets the doorbell ring volume"""
515
+ base.require_device_id(ctx)
516
+ obj: d.Camera = ctx.obj.device
517
+
518
+ base.run(ctx, obj.set_ring_volume(level))
519
+
520
+
497
521
  @app.command()
498
522
  def set_system_sounds(ctx: typer.Context, enabled: bool) -> None:
499
523
  """Sets system sound playback through speakers"""
@@ -542,15 +566,15 @@ def set_osd_bitrate(ctx: typer.Context, enabled: bool) -> None:
542
566
  @app.command()
543
567
  def set_lcd_text(
544
568
  ctx: typer.Context,
545
- text_type: Optional[d.DoorbellMessageType] = typer.Argument(
569
+ text_type: d.DoorbellMessageType | None = typer.Argument(
546
570
  None,
547
571
  help="No value sets it back to the global default doorbell message.",
548
572
  ),
549
- text: Optional[str] = typer.Argument(
573
+ text: str | None = typer.Argument(
550
574
  None,
551
575
  help="Only for CUSTOM_MESSAGE text type",
552
576
  ),
553
- reset_at: Optional[datetime] = typer.Option(
577
+ reset_at: datetime | None = typer.Option(
554
578
  None,
555
579
  "-r",
556
580
  "--reset-time",
@@ -572,3 +596,118 @@ def set_lcd_text(
572
596
  obj: d.Camera = ctx.obj.device
573
597
 
574
598
  base.run(ctx, obj.set_lcd_text(text_type, text, reset_at))
599
+
600
+
601
+ @app.command()
602
+ def create_rtsps_streams(
603
+ ctx: typer.Context,
604
+ qualities: list[str] = typer.Argument(
605
+ ...,
606
+ help="List of stream qualities to create (e.g., high medium low)",
607
+ ),
608
+ ) -> None:
609
+ """
610
+ Creates RTSPS streams for camera.
611
+
612
+ Available qualities are typically: high, medium, low, ultra.
613
+ Requires API key authentication and public API access.
614
+ """
615
+ base.require_device_id(ctx)
616
+ obj: d.Camera = ctx.obj.device
617
+
618
+ async def create_streams() -> None:
619
+ try:
620
+ result = await obj.create_rtsps_streams(qualities)
621
+ if result is None:
622
+ typer.secho("Failed to create RTSPS streams", fg="red")
623
+ raise typer.Exit(1)
624
+
625
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
626
+ stream_data = {
627
+ quality: result.get_stream_url(quality)
628
+ for quality in result.get_available_stream_qualities()
629
+ }
630
+ base.json_output(stream_data)
631
+ else:
632
+ for quality in result.get_available_stream_qualities():
633
+ url = result.get_stream_url(quality)
634
+ typer.echo(f"{quality:10}\t{url}")
635
+ except Exception as e:
636
+ typer.secho(f"Error creating RTSPS streams: {e}", fg="red")
637
+ raise typer.Exit(1) from e
638
+
639
+ base.run(ctx, create_streams())
640
+
641
+
642
+ @app.command()
643
+ def get_rtsps_streams(ctx: typer.Context) -> None:
644
+ """
645
+ Gets existing RTSPS streams for camera.
646
+
647
+ Requires API key authentication and public API access.
648
+ """
649
+ base.require_device_id(ctx)
650
+ obj: d.Camera = ctx.obj.device
651
+
652
+ async def get_streams() -> None:
653
+ try:
654
+ result = await obj.get_rtsps_streams()
655
+ if result is None:
656
+ typer.secho("No RTSPS streams found or failed to retrieve", fg="yellow")
657
+ return
658
+
659
+ if ctx.obj.output_format == base.OutputFormatEnum.JSON:
660
+ stream_data = {
661
+ quality: result.get_stream_url(quality)
662
+ for quality in result.get_available_stream_qualities()
663
+ }
664
+ base.json_output(stream_data)
665
+ else:
666
+ available_qualities = result.get_available_stream_qualities()
667
+ if not available_qualities:
668
+ typer.echo("No RTSPS streams available")
669
+ else:
670
+ for quality in available_qualities:
671
+ url = result.get_stream_url(quality)
672
+ typer.echo(f"{quality:10}\t{url}")
673
+ except Exception as e:
674
+ typer.secho(f"Error getting RTSPS streams: {e}", fg="red")
675
+ raise typer.Exit(1) from e
676
+
677
+ base.run(ctx, get_streams())
678
+
679
+
680
+ @app.command()
681
+ def delete_rtsps_streams(
682
+ ctx: typer.Context,
683
+ qualities: list[str] = typer.Argument(
684
+ ...,
685
+ help="List of stream qualities to delete (e.g., high medium low)",
686
+ ),
687
+ ) -> None:
688
+ """
689
+ Deletes RTSPS streams for camera.
690
+
691
+ Requires API key authentication and public API access.
692
+ """
693
+ base.require_device_id(ctx)
694
+ obj: d.Camera = ctx.obj.device
695
+
696
+ async def delete_streams() -> None:
697
+ try:
698
+ result = await obj.delete_rtsps_streams(qualities)
699
+ if result:
700
+ typer.secho(
701
+ f"Successfully deleted RTSPS streams: {', '.join(qualities)}",
702
+ fg="green",
703
+ )
704
+ else:
705
+ typer.secho(
706
+ f"Failed to delete RTSPS streams: {', '.join(qualities)}", fg="red"
707
+ )
708
+ raise typer.Exit(1)
709
+ except Exception as e:
710
+ typer.secho(f"Error deleting RTSPS streams: {e}", fg="red")
711
+ raise typer.Exit(1) from e
712
+
713
+ base.run(ctx, delete_streams())
uiprotect/cli/chimes.py CHANGED
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Optional
5
4
 
6
5
  import typer
7
6
 
@@ -26,7 +25,7 @@ ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
26
25
 
27
26
 
28
27
  @app.callback(invoke_without_command=True)
29
- def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
28
+ def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
30
29
  """
31
30
  Chime device CLI.
32
31
 
@@ -114,7 +113,7 @@ def cameras(
114
113
  def set_volume(
115
114
  ctx: typer.Context,
116
115
  value: int = ARG_VOLUME,
117
- camera_id: Optional[str] = typer.Option(
116
+ camera_id: str | None = typer.Option(
118
117
  None,
119
118
  "-c",
120
119
  "--camera",
@@ -138,8 +137,8 @@ def set_volume(
138
137
  @app.command()
139
138
  def play(
140
139
  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),
140
+ volume: int | None = typer.Option(None, "-v", "--volume", min=1, max=100),
141
+ repeat_times: int | None = typer.Option(None, "-r", "--repeat", min=1, max=6),
143
142
  ) -> None:
144
143
  """Plays chime tone."""
145
144
  base.require_device_id(ctx)
@@ -159,7 +158,7 @@ def play_buzzer(ctx: typer.Context) -> None:
159
158
  def set_repeat_times(
160
159
  ctx: typer.Context,
161
160
  value: int = ARG_REPEAT,
162
- camera_id: Optional[str] = typer.Option(
161
+ camera_id: str | None = typer.Option(
163
162
  None,
164
163
  "-c",
165
164
  "--camera",
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from datetime import timedelta
5
- from typing import Optional
6
5
 
7
6
  import typer
8
7
 
@@ -25,7 +24,7 @@ ALL_COMMANDS, DEVICE_COMMANDS = base.init_common_commands(app)
25
24
 
26
25
 
27
26
  @app.callback(invoke_without_command=True)
28
- def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
27
+ def main(ctx: typer.Context, device_id: str | None = ARG_DEVICE_ID) -> None:
29
28
  """
30
29
  Doorlock device CLI.
31
30
 
@@ -59,7 +58,7 @@ def main(ctx: typer.Context, device_id: Optional[str] = ARG_DEVICE_ID) -> None:
59
58
 
60
59
 
61
60
  @app.command()
62
- def camera(ctx: typer.Context, camera_id: Optional[str] = typer.Argument(None)) -> None:
61
+ def camera(ctx: typer.Context, camera_id: str | None = typer.Argument(None)) -> None:
63
62
  """Returns or sets tha paired camera for a doorlock."""
64
63
  base.require_device_id(ctx)
65
64
  obj: Doorlock = ctx.obj.device
uiprotect/cli/events.py CHANGED
@@ -4,7 +4,6 @@ from collections.abc import Callable
4
4
  from dataclasses import dataclass
5
5
  from datetime import datetime
6
6
  from pathlib import Path
7
- from typing import Optional
8
7
 
9
8
  import typer
10
9
  from rich.progress import Progress
@@ -43,13 +42,13 @@ ALL_COMMANDS: dict[str, Callable[..., None]] = {}
43
42
  @app.callback(invoke_without_command=True)
44
43
  def main(
45
44
  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,
45
+ event_id: str | None = ARG_EVENT_ID,
46
+ start: datetime | None = OPTION_START,
47
+ end: datetime | None = OPTION_END,
48
+ limit: int | None = OPTION_LIMIT,
49
+ offset: int | None = OPTION_OFFSET,
50
+ types: list[d.EventType] | None = OPTION_TYPES,
51
+ smart_types: list[d.SmartDetectObjectType] | None = OPTION_SMART_TYPES,
53
52
  ) -> None:
54
53
  """
55
54
  Events CLI.