eeroctl 1.7.1__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.
Files changed (45) hide show
  1. eeroctl/__init__.py +19 -0
  2. eeroctl/commands/__init__.py +32 -0
  3. eeroctl/commands/activity.py +237 -0
  4. eeroctl/commands/auth.py +471 -0
  5. eeroctl/commands/completion.py +142 -0
  6. eeroctl/commands/device.py +492 -0
  7. eeroctl/commands/eero/__init__.py +12 -0
  8. eeroctl/commands/eero/base.py +224 -0
  9. eeroctl/commands/eero/led.py +154 -0
  10. eeroctl/commands/eero/nightlight.py +235 -0
  11. eeroctl/commands/eero/updates.py +82 -0
  12. eeroctl/commands/network/__init__.py +18 -0
  13. eeroctl/commands/network/advanced.py +191 -0
  14. eeroctl/commands/network/backup.py +162 -0
  15. eeroctl/commands/network/base.py +331 -0
  16. eeroctl/commands/network/dhcp.py +118 -0
  17. eeroctl/commands/network/dns.py +197 -0
  18. eeroctl/commands/network/forwards.py +115 -0
  19. eeroctl/commands/network/guest.py +162 -0
  20. eeroctl/commands/network/security.py +162 -0
  21. eeroctl/commands/network/speedtest.py +99 -0
  22. eeroctl/commands/network/sqm.py +194 -0
  23. eeroctl/commands/profile.py +671 -0
  24. eeroctl/commands/troubleshoot.py +317 -0
  25. eeroctl/context.py +254 -0
  26. eeroctl/errors.py +156 -0
  27. eeroctl/exit_codes.py +68 -0
  28. eeroctl/formatting/__init__.py +90 -0
  29. eeroctl/formatting/base.py +181 -0
  30. eeroctl/formatting/device.py +430 -0
  31. eeroctl/formatting/eero.py +591 -0
  32. eeroctl/formatting/misc.py +87 -0
  33. eeroctl/formatting/network.py +659 -0
  34. eeroctl/formatting/profile.py +443 -0
  35. eeroctl/main.py +161 -0
  36. eeroctl/options.py +429 -0
  37. eeroctl/output.py +739 -0
  38. eeroctl/safety.py +259 -0
  39. eeroctl/utils.py +181 -0
  40. eeroctl-1.7.1.dist-info/METADATA +115 -0
  41. eeroctl-1.7.1.dist-info/RECORD +45 -0
  42. eeroctl-1.7.1.dist-info/WHEEL +5 -0
  43. eeroctl-1.7.1.dist-info/entry_points.txt +3 -0
  44. eeroctl-1.7.1.dist-info/licenses/LICENSE +21 -0
  45. eeroctl-1.7.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,671 @@
1
+ """Profile commands for the Eero CLI.
2
+
3
+ Commands:
4
+ - eero profile list: List all profiles
5
+ - eero profile show: Show profile details
6
+ - eero profile pause: Pause a profile
7
+ - eero profile unpause: Unpause a profile
8
+ - eero profile apps: App blocking management
9
+ - eero profile schedule: Schedule management
10
+ """
11
+
12
+ import asyncio
13
+ import sys
14
+ from typing import Literal, Optional
15
+
16
+ import click
17
+ from eero import EeroClient
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+
21
+ from ..context import EeroCliContext, ensure_cli_context
22
+ from ..errors import is_premium_error
23
+ from ..exit_codes import ExitCode
24
+ from ..options import apply_options, force_option, network_option, output_option
25
+ from ..output import OutputFormat
26
+ from ..safety import OperationRisk, SafetyError, confirm_or_fail
27
+ from ..utils import run_with_client
28
+
29
+
30
+ @click.group(name="profile")
31
+ @click.pass_context
32
+ def profile_group(ctx: click.Context) -> None:
33
+ """Manage profiles and parental controls.
34
+
35
+ \b
36
+ Commands:
37
+ list - List all profiles
38
+ show - Show profile details
39
+ pause - Pause internet access
40
+ unpause - Resume internet access
41
+ apps - Blocked apps management
42
+ schedule - Schedule management
43
+
44
+ \b
45
+ Examples:
46
+ eero profile list
47
+ eero profile show "Kids"
48
+ eero profile pause "Kids" --duration 30m
49
+ eero profile apps block "Kids" tiktok
50
+ """
51
+ ensure_cli_context(ctx)
52
+
53
+
54
+ @profile_group.command(name="list")
55
+ @output_option
56
+ @network_option
57
+ @click.pass_context
58
+ def profile_list(ctx: click.Context, output: Optional[str], network_id: Optional[str]) -> None:
59
+ """List all profiles."""
60
+ cli_ctx = apply_options(ctx, output=output, network_id=network_id)
61
+ console = cli_ctx.console
62
+
63
+ async def run_cmd() -> None:
64
+ async def get_profiles(client: EeroClient) -> None:
65
+ with cli_ctx.status("Getting profiles..."):
66
+ profiles = await client.get_profiles(cli_ctx.network_id)
67
+
68
+ if not profiles:
69
+ console.print("[yellow]No profiles found[/yellow]")
70
+ return
71
+
72
+ if cli_ctx.is_structured_output():
73
+ data = [p.model_dump(mode="json") for p in profiles]
74
+ cli_ctx.render_structured(data, "eero.profile.list/v1")
75
+ elif cli_ctx.output_format == OutputFormat.LIST:
76
+ for p in profiles:
77
+ status = "paused" if p.paused else "active"
78
+ schedule = "enabled" if p.schedule_enabled else "-"
79
+ default = "yes" if p.default else "-"
80
+ premium = "yes" if p.premium_enabled else "-"
81
+ # Use device_count if available, otherwise count devices list
82
+ device_count = (
83
+ p.device_count
84
+ if p.device_count is not None
85
+ else len(p.devices) if p.devices else 0
86
+ )
87
+ # Use print() with fixed-width columns for alignment
88
+ print(
89
+ f"{p.id or '':<14} {p.name:<20} {status:<8} "
90
+ f"{schedule:<10} {default:<8} {premium:<8} {device_count}"
91
+ )
92
+ else:
93
+ table = Table(title="Profiles")
94
+ table.add_column("ID", style="dim")
95
+ table.add_column("Name", style="cyan")
96
+ table.add_column("Status")
97
+ table.add_column("Schedule")
98
+ table.add_column("Default")
99
+ table.add_column("Premium")
100
+ table.add_column("Devices", justify="right")
101
+
102
+ for p in profiles:
103
+ # Status: Paused or Active
104
+ if p.paused:
105
+ status = "[red]Paused[/red]"
106
+ else:
107
+ status = "[green]Active[/green]"
108
+
109
+ # Schedule: Enabled or -
110
+ schedule = "[blue]Enabled[/blue]" if p.schedule_enabled else "[dim]-[/dim]"
111
+
112
+ # Default profile indicator
113
+ default = "[yellow]★[/yellow]" if p.default else "[dim]-[/dim]"
114
+
115
+ # Premium features indicator
116
+ premium = "[magenta]✓[/magenta]" if p.premium_enabled else "[dim]-[/dim]"
117
+
118
+ # Device count - use device_count if available, otherwise count devices list
119
+ device_count = (
120
+ p.device_count
121
+ if p.device_count is not None
122
+ else len(p.devices) if p.devices else 0
123
+ )
124
+
125
+ table.add_row(
126
+ p.id or "", p.name, status, schedule, default, premium, str(device_count)
127
+ )
128
+
129
+ console.print(table)
130
+
131
+ await run_with_client(get_profiles)
132
+
133
+ asyncio.run(run_cmd())
134
+
135
+
136
+ @profile_group.command(name="show")
137
+ @click.argument("profile_id")
138
+ @output_option
139
+ @network_option
140
+ @click.pass_context
141
+ def profile_show(
142
+ ctx: click.Context, profile_id: str, output: Optional[str], network_id: Optional[str]
143
+ ) -> None:
144
+ """Show details of a specific profile.
145
+
146
+ \b
147
+ Arguments:
148
+ PROFILE_ID Profile ID or name
149
+ """
150
+ cli_ctx = apply_options(ctx, output=output, network_id=network_id)
151
+ console = cli_ctx.console
152
+
153
+ async def run_cmd() -> None:
154
+ async def get_profile(client: EeroClient) -> None:
155
+ with cli_ctx.status("Getting profiles..."):
156
+ profiles = await client.get_profiles(cli_ctx.network_id)
157
+
158
+ target = None
159
+ for p in profiles:
160
+ if p.id == profile_id or p.name == profile_id:
161
+ target = p
162
+ break
163
+
164
+ if not target or not target.id:
165
+ console.print(f"[red]Profile '{profile_id}' not found[/red]")
166
+ sys.exit(ExitCode.NOT_FOUND)
167
+
168
+ with cli_ctx.status("Getting profile details..."):
169
+ profile = await client.get_profile(target.id, cli_ctx.network_id)
170
+
171
+ if cli_ctx.is_structured_output():
172
+ cli_ctx.render_structured(profile.model_dump(mode="json"), "eero.profile.show/v1")
173
+ else:
174
+ from ..formatting import print_profile_details
175
+
176
+ detail: Literal["brief", "full"] = (
177
+ "full" if cli_ctx.detail_level == "full" else "brief"
178
+ )
179
+ print_profile_details(profile, detail_level=detail)
180
+
181
+ await run_with_client(get_profile)
182
+
183
+ asyncio.run(run_cmd())
184
+
185
+
186
+ @profile_group.command(name="pause")
187
+ @click.argument("profile_id")
188
+ @click.option("--duration", "-d", help="Duration (e.g., 30m, 1h)")
189
+ @force_option
190
+ @network_option
191
+ @click.pass_context
192
+ def profile_pause(
193
+ ctx: click.Context,
194
+ profile_id: str,
195
+ duration: Optional[str],
196
+ force: Optional[bool],
197
+ network_id: Optional[str],
198
+ ) -> None:
199
+ """Pause internet access for a profile.
200
+
201
+ \b
202
+ Arguments:
203
+ PROFILE_ID Profile ID or name
204
+
205
+ \b
206
+ Options:
207
+ --duration, -d Duration (e.g., 30m, 1h)
208
+ """
209
+ cli_ctx = apply_options(ctx, network_id=network_id, force=force)
210
+ _set_profile_paused(cli_ctx, profile_id, True)
211
+
212
+
213
+ @profile_group.command(name="unpause")
214
+ @click.argument("profile_id")
215
+ @force_option
216
+ @network_option
217
+ @click.pass_context
218
+ def profile_unpause(
219
+ ctx: click.Context, profile_id: str, force: Optional[bool], network_id: Optional[str]
220
+ ) -> None:
221
+ """Resume internet access for a profile.
222
+
223
+ \b
224
+ Arguments:
225
+ PROFILE_ID Profile ID or name
226
+ """
227
+ cli_ctx = apply_options(ctx, network_id=network_id, force=force)
228
+ _set_profile_paused(cli_ctx, profile_id, False)
229
+
230
+
231
+ def _set_profile_paused(cli_ctx: EeroCliContext, profile_id: str, paused: bool) -> None:
232
+ """Pause or unpause a profile."""
233
+ console = cli_ctx.console
234
+ action = "pause" if paused else "unpause"
235
+
236
+ async def run_cmd() -> None:
237
+ async def toggle_pause(client: EeroClient) -> None:
238
+ # Find profile first
239
+ with cli_ctx.status("Finding profile..."):
240
+ profiles = await client.get_profiles(cli_ctx.network_id)
241
+
242
+ target = None
243
+ for p in profiles:
244
+ if p.id == profile_id or p.name == profile_id:
245
+ target = p
246
+ break
247
+
248
+ if not target or not target.id:
249
+ console.print(f"[red]Profile '{profile_id}' not found[/red]")
250
+ sys.exit(ExitCode.NOT_FOUND)
251
+
252
+ try:
253
+ confirm_or_fail(
254
+ action=action,
255
+ target=target.name,
256
+ risk=OperationRisk.MEDIUM,
257
+ force=cli_ctx.force,
258
+ non_interactive=cli_ctx.non_interactive,
259
+ dry_run=cli_ctx.dry_run,
260
+ console=cli_ctx.console,
261
+ )
262
+ except SafetyError as e:
263
+ cli_ctx.renderer.render_error(e.message)
264
+ sys.exit(e.exit_code)
265
+
266
+ with cli_ctx.status(f"{action.capitalize()}ing profile..."):
267
+ result = await client.pause_profile(target.id, paused, cli_ctx.network_id)
268
+
269
+ if result:
270
+ console.print(f"[bold green]Profile {action}d[/bold green]")
271
+ else:
272
+ console.print(f"[red]Failed to {action} profile[/red]")
273
+ sys.exit(ExitCode.GENERIC_ERROR)
274
+
275
+ await run_with_client(toggle_pause)
276
+
277
+ asyncio.run(run_cmd())
278
+
279
+
280
+ # ==================== Apps Subcommand Group ====================
281
+
282
+
283
+ @profile_group.group(name="apps")
284
+ @click.pass_context
285
+ def apps_group(ctx: click.Context) -> None:
286
+ """Manage blocked applications (Eero Plus).
287
+
288
+ \b
289
+ Commands:
290
+ list - List blocked apps
291
+ block - Block app(s)
292
+ unblock - Unblock app(s)
293
+ """
294
+ pass
295
+
296
+
297
+ @apps_group.command(name="list")
298
+ @click.argument("profile_id")
299
+ @output_option
300
+ @network_option
301
+ @click.pass_context
302
+ def apps_list(
303
+ ctx: click.Context, profile_id: str, output: Optional[str], network_id: Optional[str]
304
+ ) -> None:
305
+ """List blocked applications for a profile."""
306
+ cli_ctx = apply_options(ctx, output=output, network_id=network_id)
307
+ console = cli_ctx.console
308
+ renderer = cli_ctx.renderer
309
+
310
+ async def run_cmd() -> None:
311
+ async def get_apps(client: EeroClient) -> None:
312
+ # Find profile first
313
+ with cli_ctx.status("Finding profile..."):
314
+ profiles = await client.get_profiles(cli_ctx.network_id)
315
+
316
+ target = None
317
+ for p in profiles:
318
+ if p.id == profile_id or p.name == profile_id:
319
+ target = p
320
+ break
321
+
322
+ if not target or not target.id:
323
+ console.print(f"[red]Profile '{profile_id}' not found[/red]")
324
+ sys.exit(ExitCode.NOT_FOUND)
325
+
326
+ with cli_ctx.status("Getting blocked apps..."):
327
+ try:
328
+ apps = await client.get_blocked_applications(target.id, cli_ctx.network_id)
329
+ except Exception as e:
330
+ if is_premium_error(e):
331
+ console.print("[yellow]This feature requires Eero Plus[/yellow]")
332
+ sys.exit(ExitCode.PREMIUM_REQUIRED)
333
+ raise
334
+
335
+ if cli_ctx.is_json_output():
336
+ renderer.render_json(
337
+ {"profile": target.name, "blocked_apps": apps}, "eero.profile.apps.list/v1"
338
+ )
339
+ else:
340
+ if not apps:
341
+ console.print("[dim]No blocked applications[/dim]")
342
+ else:
343
+ console.print(f"[bold]Blocked Applications ({len(apps)}):[/bold]")
344
+ for app in apps:
345
+ console.print(f" • {app}")
346
+
347
+ await run_with_client(get_apps)
348
+
349
+ asyncio.run(run_cmd())
350
+
351
+
352
+ @apps_group.command(name="block")
353
+ @click.argument("profile_id")
354
+ @click.argument("apps", nargs=-1, required=True)
355
+ @network_option
356
+ @click.pass_context
357
+ def apps_block(ctx: click.Context, profile_id: str, apps: tuple, network_id: Optional[str]) -> None:
358
+ """Block application(s) for a profile.
359
+
360
+ \b
361
+ Arguments:
362
+ PROFILE_ID Profile ID or name
363
+ APPS App identifier(s) to block
364
+
365
+ \b
366
+ Examples:
367
+ eero profile apps block "Kids" tiktok facebook
368
+ """
369
+ cli_ctx = apply_options(ctx, network_id=network_id)
370
+ console = cli_ctx.console
371
+
372
+ async def run_cmd() -> None:
373
+ async def block_apps(client: EeroClient) -> None:
374
+ # Find profile first
375
+ with cli_ctx.status("Finding profile..."):
376
+ profiles = await client.get_profiles(cli_ctx.network_id)
377
+
378
+ target = None
379
+ for p in profiles:
380
+ if p.id == profile_id or p.name == profile_id:
381
+ target = p
382
+ break
383
+
384
+ if not target or not target.id:
385
+ console.print(f"[red]Profile '{profile_id}' not found[/red]")
386
+ sys.exit(ExitCode.NOT_FOUND)
387
+
388
+ for app in apps:
389
+ with cli_ctx.status(f"Blocking {app}..."):
390
+ try:
391
+ result = await client.add_blocked_application(
392
+ target.id, app, cli_ctx.network_id
393
+ )
394
+ if result:
395
+ console.print(f"[green]✓[/green] {app} blocked")
396
+ else:
397
+ console.print(f"[red]✗[/red] Failed to block {app}")
398
+ except Exception as e:
399
+ if is_premium_error(e):
400
+ console.print("[yellow]This feature requires Eero Plus[/yellow]")
401
+ sys.exit(ExitCode.PREMIUM_REQUIRED)
402
+ console.print(f"[red]✗[/red] Error blocking {app}: {e}")
403
+
404
+ await run_with_client(block_apps)
405
+
406
+ asyncio.run(run_cmd())
407
+
408
+
409
+ @apps_group.command(name="unblock")
410
+ @click.argument("profile_id")
411
+ @click.argument("apps", nargs=-1, required=True)
412
+ @network_option
413
+ @click.pass_context
414
+ def apps_unblock(
415
+ ctx: click.Context, profile_id: str, apps: tuple, network_id: Optional[str]
416
+ ) -> None:
417
+ """Unblock application(s) for a profile.
418
+
419
+ \b
420
+ Arguments:
421
+ PROFILE_ID Profile ID or name
422
+ APPS App identifier(s) to unblock
423
+ """
424
+ cli_ctx = apply_options(ctx, network_id=network_id)
425
+ console = cli_ctx.console
426
+
427
+ async def run_cmd() -> None:
428
+ async def unblock_apps(client: EeroClient) -> None:
429
+ # Find profile first
430
+ with cli_ctx.status("Finding profile..."):
431
+ profiles = await client.get_profiles(cli_ctx.network_id)
432
+
433
+ target = None
434
+ for p in profiles:
435
+ if p.id == profile_id or p.name == profile_id:
436
+ target = p
437
+ break
438
+
439
+ if not target or not target.id:
440
+ console.print(f"[red]Profile '{profile_id}' not found[/red]")
441
+ sys.exit(ExitCode.NOT_FOUND)
442
+
443
+ for app in apps:
444
+ with cli_ctx.status(f"Unblocking {app}..."):
445
+ try:
446
+ result = await client.remove_blocked_application(
447
+ target.id, app, cli_ctx.network_id
448
+ )
449
+ if result:
450
+ console.print(f"[green]✓[/green] {app} unblocked")
451
+ else:
452
+ console.print(f"[red]✗[/red] Failed to unblock {app}")
453
+ except Exception as e:
454
+ if is_premium_error(e):
455
+ console.print("[yellow]This feature requires Eero Plus[/yellow]")
456
+ sys.exit(ExitCode.PREMIUM_REQUIRED)
457
+ console.print(f"[red]✗[/red] Error unblocking {app}: {e}")
458
+
459
+ await run_with_client(unblock_apps)
460
+
461
+ asyncio.run(run_cmd())
462
+
463
+
464
+ # ==================== Schedule Subcommand Group ====================
465
+
466
+
467
+ @profile_group.group(name="schedule")
468
+ @click.pass_context
469
+ def schedule_group(ctx: click.Context) -> None:
470
+ """Manage internet access schedule.
471
+
472
+ \b
473
+ Commands:
474
+ show - Show schedule
475
+ set - Set bedtime schedule
476
+ clear - Clear all schedules
477
+ """
478
+ pass
479
+
480
+
481
+ @schedule_group.command(name="show")
482
+ @click.argument("profile_id")
483
+ @output_option
484
+ @network_option
485
+ @click.pass_context
486
+ def schedule_show(
487
+ ctx: click.Context, profile_id: str, output: Optional[str], network_id: Optional[str]
488
+ ) -> None:
489
+ """Show schedule for a profile."""
490
+ cli_ctx = apply_options(ctx, output=output, network_id=network_id)
491
+ console = cli_ctx.console
492
+ renderer = cli_ctx.renderer
493
+
494
+ async def run_cmd() -> None:
495
+ async def get_schedule(client: EeroClient) -> None:
496
+ # Find profile first
497
+ with cli_ctx.status("Finding profile..."):
498
+ profiles = await client.get_profiles(cli_ctx.network_id)
499
+
500
+ target = None
501
+ for p in profiles:
502
+ if p.id == profile_id or p.name == profile_id:
503
+ target = p
504
+ break
505
+
506
+ if not target or not target.id:
507
+ console.print(f"[red]Profile '{profile_id}' not found[/red]")
508
+ sys.exit(ExitCode.NOT_FOUND)
509
+
510
+ with cli_ctx.status("Getting schedule..."):
511
+ schedule_data = await client.get_profile_schedule(target.id, cli_ctx.network_id)
512
+
513
+ if cli_ctx.is_json_output():
514
+ renderer.render_json(schedule_data, "eero.profile.schedule.show/v1")
515
+ else:
516
+ enabled = schedule_data.get("enabled", False)
517
+ time_blocks = schedule_data.get("time_blocks", [])
518
+
519
+ content = (
520
+ f"[bold]Enabled:[/bold] {'[green]Yes[/green]' if enabled else '[dim]No[/dim]'}"
521
+ )
522
+ if time_blocks:
523
+ content += f"\n[bold]Time Blocks:[/bold] {len(time_blocks)}"
524
+ for i, block in enumerate(time_blocks, 1):
525
+ days = ", ".join(block.get("days", []))
526
+ start = block.get("start", "?")
527
+ end = block.get("end", "?")
528
+ content += f"\n {i}. {days}: {start} - {end}"
529
+
530
+ console.print(Panel(content, title="Schedule", border_style="blue"))
531
+
532
+ await run_with_client(get_schedule)
533
+
534
+ asyncio.run(run_cmd())
535
+
536
+
537
+ @schedule_group.command(name="set")
538
+ @click.argument("profile_id")
539
+ @click.option("--start", required=True, help="Start time (HH:MM)")
540
+ @click.option("--end", required=True, help="End time (HH:MM)")
541
+ @click.option("--days", help="Days (comma-separated, e.g., mon,tue,wed)")
542
+ @force_option
543
+ @network_option
544
+ @click.pass_context
545
+ def schedule_set(
546
+ ctx: click.Context,
547
+ profile_id: str,
548
+ start: str,
549
+ end: str,
550
+ days: Optional[str],
551
+ force: Optional[bool],
552
+ network_id: Optional[str],
553
+ ) -> None:
554
+ """Set bedtime schedule for a profile.
555
+
556
+ \b
557
+ Options:
558
+ --start TEXT Start time (HH:MM, required)
559
+ --end TEXT End time (HH:MM, required)
560
+ --days TEXT Days (comma-separated, defaults to all)
561
+
562
+ \b
563
+ Examples:
564
+ eero profile schedule set "Kids" --start 21:00 --end 07:00
565
+ eero profile schedule set "Kids" --start 22:00 --end 06:00 --days mon,tue,wed,thu,fri
566
+ """
567
+ cli_ctx = apply_options(ctx, network_id=network_id, force=force)
568
+ console = cli_ctx.console
569
+
570
+ days_list = days.split(",") if days else None
571
+
572
+ async def run_cmd() -> None:
573
+ async def set_schedule(client: EeroClient) -> None:
574
+ # Find profile first
575
+ with cli_ctx.status("Finding profile..."):
576
+ profiles = await client.get_profiles(cli_ctx.network_id)
577
+
578
+ target = None
579
+ for p in profiles:
580
+ if p.id == profile_id or p.name == profile_id:
581
+ target = p
582
+ break
583
+
584
+ if not target or not target.id:
585
+ console.print(f"[red]Profile '{profile_id}' not found[/red]")
586
+ sys.exit(ExitCode.NOT_FOUND)
587
+
588
+ try:
589
+ confirm_or_fail(
590
+ action="set bedtime schedule",
591
+ target=f"{target.name} ({start} - {end})",
592
+ risk=OperationRisk.MEDIUM,
593
+ force=cli_ctx.force,
594
+ non_interactive=cli_ctx.non_interactive,
595
+ dry_run=cli_ctx.dry_run,
596
+ console=cli_ctx.console,
597
+ )
598
+ except SafetyError as e:
599
+ cli_ctx.renderer.render_error(e.message)
600
+ sys.exit(e.exit_code)
601
+
602
+ with cli_ctx.status("Setting schedule..."):
603
+ result = await client.enable_bedtime(
604
+ target.id, start, end, days_list, cli_ctx.network_id
605
+ )
606
+
607
+ if result:
608
+ console.print(f"[bold green]Schedule set: {start} - {end}[/bold green]")
609
+ else:
610
+ console.print("[red]Failed to set schedule[/red]")
611
+ sys.exit(ExitCode.GENERIC_ERROR)
612
+
613
+ await run_with_client(set_schedule)
614
+
615
+ asyncio.run(run_cmd())
616
+
617
+
618
+ @schedule_group.command(name="clear")
619
+ @click.argument("profile_id")
620
+ @force_option
621
+ @network_option
622
+ @click.pass_context
623
+ def schedule_clear(
624
+ ctx: click.Context, profile_id: str, force: Optional[bool], network_id: Optional[str]
625
+ ) -> None:
626
+ """Clear all schedules for a profile."""
627
+ cli_ctx = apply_options(ctx, network_id=network_id, force=force)
628
+ console = cli_ctx.console
629
+
630
+ async def run_cmd() -> None:
631
+ async def clear_schedule(client: EeroClient) -> None:
632
+ # Find profile first
633
+ with cli_ctx.status("Finding profile..."):
634
+ profiles = await client.get_profiles(cli_ctx.network_id)
635
+
636
+ target = None
637
+ for p in profiles:
638
+ if p.id == profile_id or p.name == profile_id:
639
+ target = p
640
+ break
641
+
642
+ if not target or not target.id:
643
+ console.print(f"[red]Profile '{profile_id}' not found[/red]")
644
+ sys.exit(ExitCode.NOT_FOUND)
645
+
646
+ try:
647
+ confirm_or_fail(
648
+ action="clear schedule",
649
+ target=target.name,
650
+ risk=OperationRisk.MEDIUM,
651
+ force=cli_ctx.force,
652
+ non_interactive=cli_ctx.non_interactive,
653
+ dry_run=cli_ctx.dry_run,
654
+ console=cli_ctx.console,
655
+ )
656
+ except SafetyError as e:
657
+ cli_ctx.renderer.render_error(e.message)
658
+ sys.exit(e.exit_code)
659
+
660
+ with cli_ctx.status("Clearing schedule..."):
661
+ result = await client.clear_profile_schedule(target.id, cli_ctx.network_id)
662
+
663
+ if result:
664
+ console.print("[bold green]Schedule cleared[/bold green]")
665
+ else:
666
+ console.print("[red]Failed to clear schedule[/red]")
667
+ sys.exit(ExitCode.GENERIC_ERROR)
668
+
669
+ await run_with_client(clear_schedule)
670
+
671
+ asyncio.run(run_cmd())