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.
- eeroctl/__init__.py +19 -0
- eeroctl/commands/__init__.py +32 -0
- eeroctl/commands/activity.py +237 -0
- eeroctl/commands/auth.py +471 -0
- eeroctl/commands/completion.py +142 -0
- eeroctl/commands/device.py +492 -0
- eeroctl/commands/eero/__init__.py +12 -0
- eeroctl/commands/eero/base.py +224 -0
- eeroctl/commands/eero/led.py +154 -0
- eeroctl/commands/eero/nightlight.py +235 -0
- eeroctl/commands/eero/updates.py +82 -0
- eeroctl/commands/network/__init__.py +18 -0
- eeroctl/commands/network/advanced.py +191 -0
- eeroctl/commands/network/backup.py +162 -0
- eeroctl/commands/network/base.py +331 -0
- eeroctl/commands/network/dhcp.py +118 -0
- eeroctl/commands/network/dns.py +197 -0
- eeroctl/commands/network/forwards.py +115 -0
- eeroctl/commands/network/guest.py +162 -0
- eeroctl/commands/network/security.py +162 -0
- eeroctl/commands/network/speedtest.py +99 -0
- eeroctl/commands/network/sqm.py +194 -0
- eeroctl/commands/profile.py +671 -0
- eeroctl/commands/troubleshoot.py +317 -0
- eeroctl/context.py +254 -0
- eeroctl/errors.py +156 -0
- eeroctl/exit_codes.py +68 -0
- eeroctl/formatting/__init__.py +90 -0
- eeroctl/formatting/base.py +181 -0
- eeroctl/formatting/device.py +430 -0
- eeroctl/formatting/eero.py +591 -0
- eeroctl/formatting/misc.py +87 -0
- eeroctl/formatting/network.py +659 -0
- eeroctl/formatting/profile.py +443 -0
- eeroctl/main.py +161 -0
- eeroctl/options.py +429 -0
- eeroctl/output.py +739 -0
- eeroctl/safety.py +259 -0
- eeroctl/utils.py +181 -0
- eeroctl-1.7.1.dist-info/METADATA +115 -0
- eeroctl-1.7.1.dist-info/RECORD +45 -0
- eeroctl-1.7.1.dist-info/WHEEL +5 -0
- eeroctl-1.7.1.dist-info/entry_points.txt +3 -0
- eeroctl-1.7.1.dist-info/licenses/LICENSE +21 -0
- 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())
|