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,443 @@
1
+ """Profile formatting utilities for the Eero CLI.
2
+
3
+ This module provides formatting functions for displaying user profile data.
4
+ """
5
+
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from eero.models.profile import Profile
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from .base import (
13
+ DetailLevel,
14
+ build_panel,
15
+ console,
16
+ field,
17
+ field_bool,
18
+ )
19
+
20
+ # ==================== Profile Tables ====================
21
+
22
+
23
+ def create_profiles_table(profiles: List[Profile]) -> Table:
24
+ """Create a table displaying profiles.
25
+
26
+ Args:
27
+ profiles: List of Profile objects
28
+
29
+ Returns:
30
+ Rich Table object
31
+ """
32
+ table = Table(title="Profiles")
33
+ table.add_column("ID", style="dim")
34
+ table.add_column("Name", style="cyan")
35
+ table.add_column("State", style="green")
36
+ table.add_column("Paused", style="yellow")
37
+ table.add_column("Schedule", style="magenta")
38
+ table.add_column("Content Filter", style="cyan")
39
+
40
+ for profile in profiles:
41
+ paused_style = "red" if profile.paused else "green"
42
+ schedule_status = "Enabled" if profile.schedule_enabled else "Disabled"
43
+ content_filter_enabled = (
44
+ "Enabled"
45
+ if profile.content_filter and any(vars(profile.content_filter).values())
46
+ else "Disabled"
47
+ )
48
+ state_value = profile.state.value if profile.state else "Unknown"
49
+
50
+ table.add_row(
51
+ profile.id or "N/A",
52
+ profile.name,
53
+ state_value,
54
+ f"[{paused_style}]{'Yes' if profile.paused else 'No'}[/{paused_style}]",
55
+ schedule_status,
56
+ content_filter_enabled,
57
+ )
58
+
59
+ return table
60
+
61
+
62
+ def create_profile_devices_table(devices: List[Dict[str, Any]]) -> Table:
63
+ """Create a table displaying devices in a profile.
64
+
65
+ Args:
66
+ devices: List of device dictionaries from profile
67
+
68
+ Returns:
69
+ Rich Table object
70
+ """
71
+ table = Table(title="Profile Devices")
72
+ table.add_column("ID", style="dim")
73
+ table.add_column("Name", style="cyan")
74
+ table.add_column("Nickname", style="blue")
75
+ table.add_column("IP", style="green")
76
+ table.add_column("MAC", style="yellow")
77
+ table.add_column("Connected", style="magenta")
78
+ table.add_column("Type", style="cyan")
79
+ table.add_column("Manufacturer", style="green")
80
+ table.add_column("Connection Type", style="blue")
81
+ table.add_column("Eero Location", style="yellow")
82
+
83
+ for device in devices:
84
+ device_id = "Unknown"
85
+ if device.get("url"):
86
+ parts = device["url"].split("/")
87
+ if len(parts) >= 2:
88
+ device_id = parts[-1]
89
+
90
+ device_name = (
91
+ device.get("display_name")
92
+ or device.get("hostname")
93
+ or device.get("nickname")
94
+ or "Unknown"
95
+ )
96
+ connected = device.get("connected", False)
97
+ connected_style = "green" if connected else "red"
98
+ source = device.get("source", {})
99
+ eero_location = source.get("location") if source else "Unknown"
100
+
101
+ table.add_row(
102
+ device_id,
103
+ device_name,
104
+ device.get("nickname") or "",
105
+ device.get("ip") or "Unknown",
106
+ device.get("mac") or "Unknown",
107
+ f"[{connected_style}]{'Yes' if connected else 'No'}[/{connected_style}]",
108
+ device.get("device_type") or "Unknown",
109
+ device.get("manufacturer") or "Unknown",
110
+ device.get("connection_type") or "Unknown",
111
+ eero_location,
112
+ )
113
+
114
+ return table
115
+
116
+
117
+ # ==================== Profile Brief View Panels ====================
118
+
119
+
120
+ def _profile_basic_panel(profile: Profile, extensive: bool = False) -> Panel:
121
+ """Build the basic profile info panel."""
122
+ paused_style = "red" if profile.paused else "green"
123
+
124
+ lines = [
125
+ field("Name", profile.name),
126
+ field("ID", profile.id),
127
+ ]
128
+
129
+ # State
130
+ state_value = profile.state.value if profile.state else "Unknown"
131
+ state_style = "green" if state_value == "Active" else "yellow"
132
+ lines.append(f"[bold]State:[/bold] [{state_style}]{state_value}[/{state_style}]")
133
+
134
+ # Paused status
135
+ lines.append(
136
+ f"[bold]Paused:[/bold] [{paused_style}]{'Yes' if profile.paused else 'No'}[/{paused_style}]"
137
+ )
138
+
139
+ # Default profile indicator
140
+ is_default = getattr(profile, "default", False)
141
+ if is_default:
142
+ lines.append("[bold]Default:[/bold] [cyan]Yes[/cyan]")
143
+
144
+ if extensive:
145
+ lines.extend(
146
+ [
147
+ field("Devices", profile.device_count),
148
+ field("Connected Devices", profile.connected_device_count),
149
+ field_bool("Premium Enabled", profile.premium_enabled),
150
+ field_bool("Schedule Enabled", profile.schedule_enabled),
151
+ ]
152
+ )
153
+ else:
154
+ lines.append(field_bool("Schedule", profile.schedule_enabled))
155
+
156
+ # Content filter summary
157
+ filter_enabled = bool(profile.content_filter and any(vars(profile.content_filter).values()))
158
+ lines.append(field_bool("Content Filter", filter_enabled))
159
+
160
+ return build_panel(lines, f"Profile: {profile.name}", "blue")
161
+
162
+
163
+ def _profile_device_summary_panel(profile: Profile) -> Optional[Panel]:
164
+ """Build the device summary panel for brief view."""
165
+ devices = profile.devices
166
+ if not devices:
167
+ return None
168
+
169
+ total_devices = len(devices)
170
+ connected_count = sum(1 for d in devices if d.get("connected", False))
171
+ disconnected_count = total_devices - connected_count
172
+
173
+ # Count by device type
174
+ device_types: Dict[str, int] = {}
175
+ for device in devices:
176
+ device_type = device.get("device_type", "unknown")
177
+ device_types[device_type] = device_types.get(device_type, 0) + 1
178
+
179
+ lines = [
180
+ field("Total Devices", total_devices),
181
+ f"[bold]Connected:[/bold] [green]{connected_count}[/green]",
182
+ f"[bold]Disconnected:[/bold] [dim]{disconnected_count}[/dim]",
183
+ ]
184
+
185
+ # Add device type breakdown if there are multiple types
186
+ if len(device_types) > 1:
187
+ lines.append("")
188
+ lines.append("[bold]By Type:[/bold]")
189
+ sorted_types = sorted(device_types.items(), key=lambda x: x[1], reverse=True)
190
+ for dtype, count in sorted_types[:5]:
191
+ dtype_display = dtype.replace("_", " ").title()
192
+ lines.append(f" • {dtype_display}: {count}")
193
+
194
+ return build_panel(lines, "Device Summary", "green")
195
+
196
+
197
+ def _profile_content_filters_panel(profile: Profile) -> Optional[Panel]:
198
+ """Build the content filters panel for brief view."""
199
+ # Try unified_content_filters first
200
+ unified_filters = getattr(profile, "unified_content_filters", None)
201
+ if unified_filters:
202
+ dns_policies = (
203
+ unified_filters.get("dns_policies", {})
204
+ if isinstance(unified_filters, dict)
205
+ else getattr(unified_filters, "dns_policies", {})
206
+ )
207
+
208
+ if dns_policies:
209
+ lines = []
210
+ filter_map = {
211
+ "block_gaming_content": "Gaming",
212
+ "block_illegal_content": "Illegal Content",
213
+ "block_messaging_content": "Messaging",
214
+ "block_pornographic_content": "Adult Content",
215
+ "block_social_content": "Social Media",
216
+ "block_shopping_content": "Shopping",
217
+ "block_streaming_content": "Streaming",
218
+ "block_violent_content": "Violent Content",
219
+ "safe_search_enabled": "Safe Search",
220
+ "youtube_restricted": "YouTube Restricted",
221
+ }
222
+
223
+ enabled_filters = []
224
+ for key, label in filter_map.items():
225
+ value = (
226
+ dns_policies.get(key, False)
227
+ if isinstance(dns_policies, dict)
228
+ else getattr(dns_policies, key, False)
229
+ )
230
+ if value:
231
+ enabled_filters.append(label)
232
+
233
+ if enabled_filters:
234
+ lines.append("[bold]Blocked Categories:[/bold]")
235
+ for f in enabled_filters:
236
+ lines.append(f" • [red]{f}[/red]")
237
+ return build_panel(lines, "Content Filters", "yellow")
238
+
239
+ # Fallback to content_filter
240
+ if profile.content_filter:
241
+ filter_enabled = any(vars(profile.content_filter).values())
242
+ if filter_enabled:
243
+ lines = ["[bold]Active Filters:[/bold]"]
244
+ for name, value in vars(profile.content_filter).items():
245
+ if value:
246
+ display_name = " ".join(word.capitalize() for word in name.split("_"))
247
+ lines.append(f" • [red]{display_name}[/red]")
248
+ return build_panel(lines, "Content Filters", "yellow")
249
+
250
+ return None
251
+
252
+
253
+ def _profile_dns_security_panel(profile: Profile) -> Optional[Panel]:
254
+ """Build the DNS security panel for brief view."""
255
+ premium_dns = getattr(profile, "premium_dns", None)
256
+ if not premium_dns:
257
+ return None
258
+
259
+ dns_policies = (
260
+ premium_dns.get("dns_policies", {})
261
+ if isinstance(premium_dns, dict)
262
+ else getattr(premium_dns, "dns_policies", {})
263
+ )
264
+
265
+ ad_block = (
266
+ premium_dns.get("ad_block_settings", {})
267
+ if isinstance(premium_dns, dict)
268
+ else getattr(premium_dns, "ad_block_settings", {})
269
+ )
270
+
271
+ lines = []
272
+
273
+ # DNS policies
274
+ policy_map = {
275
+ "block_pornographic_content": "Block Adult",
276
+ "block_illegal_content": "Block Illegal",
277
+ "block_violent_content": "Block Violent",
278
+ "safe_search_enabled": "Safe Search",
279
+ }
280
+
281
+ for key, label in policy_map.items():
282
+ value = (
283
+ dns_policies.get(key, False)
284
+ if isinstance(dns_policies, dict)
285
+ else getattr(dns_policies, key, False)
286
+ )
287
+ if value:
288
+ lines.append(f"[bold]{label}:[/bold] [green]Enabled[/green]")
289
+
290
+ # Ad blocking
291
+ ad_block_enabled = (
292
+ ad_block.get("enabled", False)
293
+ if isinstance(ad_block, dict)
294
+ else getattr(ad_block, "enabled", False)
295
+ )
296
+ if ad_block_enabled:
297
+ lines.append("[bold]Ad Block:[/bold] [green]Enabled[/green]")
298
+
299
+ # DNS provider
300
+ dns_provider = (
301
+ premium_dns.get("dns_provider")
302
+ if isinstance(premium_dns, dict)
303
+ else getattr(premium_dns, "dns_provider", None)
304
+ )
305
+ if dns_provider:
306
+ lines.append(field("DNS Provider", dns_provider))
307
+
308
+ return build_panel(lines, "DNS Security", "magenta") if lines else None
309
+
310
+
311
+ def _profile_blocked_apps_panel(profile: Profile) -> Optional[Panel]:
312
+ """Build the blocked applications panel."""
313
+ premium_dns = getattr(profile, "premium_dns", None)
314
+ if not premium_dns:
315
+ return None
316
+
317
+ blocked_apps = (
318
+ premium_dns.get("blocked_applications", [])
319
+ if isinstance(premium_dns, dict)
320
+ else getattr(premium_dns, "blocked_applications", [])
321
+ )
322
+
323
+ if not blocked_apps:
324
+ return None
325
+
326
+ lines = ["[bold]Blocked Applications:[/bold]"] + [f" • {app}" for app in blocked_apps]
327
+ return build_panel(lines, "Blocked Apps", "red")
328
+
329
+
330
+ def _profile_custom_lists_panel(profile: Profile) -> Optional[Panel]:
331
+ """Build the custom block/allow lists panel for brief view."""
332
+ block_list = profile.custom_block_list or []
333
+ allow_list = profile.custom_allow_list or []
334
+
335
+ if not block_list and not allow_list:
336
+ return None
337
+
338
+ lines = []
339
+
340
+ if block_list:
341
+ lines.append(f"[bold]Blocked Domains:[/bold] [red]{len(block_list)}[/red]")
342
+ for domain in block_list[:3]:
343
+ lines.append(f" • {domain}")
344
+ if len(block_list) > 3:
345
+ lines.append(f" [dim]... and {len(block_list) - 3} more[/dim]")
346
+
347
+ if allow_list:
348
+ if lines:
349
+ lines.append("")
350
+ lines.append(f"[bold]Allowed Domains:[/bold] [green]{len(allow_list)}[/green]")
351
+ for domain in allow_list[:3]:
352
+ lines.append(f" • {domain}")
353
+ if len(allow_list) > 3:
354
+ lines.append(f" [dim]... and {len(allow_list) - 3} more[/dim]")
355
+
356
+ return build_panel(lines, "Custom Lists", "cyan")
357
+
358
+
359
+ def _profile_schedule_panel(profile: Profile) -> Optional[Panel]:
360
+ """Build the schedule panel."""
361
+ if not profile.schedule_enabled or not profile.schedule_blocks:
362
+ return None
363
+
364
+ lines = [
365
+ f"[bold]{', '.join(block.days)}:[/bold] {block.start_time} - {block.end_time}"
366
+ for block in profile.schedule_blocks
367
+ ]
368
+ return build_panel(lines, "Schedule", "green")
369
+
370
+
371
+ # ==================== Main Profile Details Function ====================
372
+
373
+
374
+ def print_profile_details(profile: Profile, detail_level: DetailLevel = "brief") -> None:
375
+ """Print profile information with configurable detail level.
376
+
377
+ Args:
378
+ profile: Profile object
379
+ detail_level: "brief" or "full"
380
+ """
381
+ extensive = detail_level == "full"
382
+
383
+ # Basic info panel (always shown)
384
+ console.print(_profile_basic_panel(profile, extensive))
385
+
386
+ if not extensive:
387
+ # Brief view panels
388
+
389
+ # Device summary
390
+ summary_panel = _profile_device_summary_panel(profile)
391
+ if summary_panel:
392
+ console.print(summary_panel)
393
+
394
+ # Content filters
395
+ filters_panel = _profile_content_filters_panel(profile)
396
+ if filters_panel:
397
+ console.print(filters_panel)
398
+
399
+ # DNS security
400
+ dns_panel = _profile_dns_security_panel(profile)
401
+ if dns_panel:
402
+ console.print(dns_panel)
403
+
404
+ # Blocked apps
405
+ apps_panel = _profile_blocked_apps_panel(profile)
406
+ if apps_panel:
407
+ console.print(apps_panel)
408
+
409
+ # Custom lists
410
+ lists_panel = _profile_custom_lists_panel(profile)
411
+ if lists_panel:
412
+ console.print(lists_panel)
413
+
414
+ # Devices table
415
+ if profile.devices:
416
+ console.print(create_profile_devices_table(profile.devices))
417
+ else:
418
+ console.print("[bold yellow]No devices in this profile[/bold yellow]")
419
+
420
+ else:
421
+ # Extensive view panels
422
+
423
+ # Schedule
424
+ schedule_panel = _profile_schedule_panel(profile)
425
+ if schedule_panel:
426
+ console.print(schedule_panel)
427
+
428
+ # Content filter details
429
+ if profile.content_filter:
430
+ filter_enabled = any(vars(profile.content_filter).values())
431
+ if filter_enabled:
432
+ filter_settings = []
433
+ for name, value in vars(profile.content_filter).items():
434
+ if value:
435
+ display_name = " ".join(word.capitalize() for word in name.split("_"))
436
+ filter_settings.append(f"[bold]{display_name}:[/bold] Enabled")
437
+ console.print(build_panel(filter_settings, "Content Filtering", "yellow"))
438
+
439
+ # Block/Allow lists (full)
440
+ if profile.custom_block_list:
441
+ console.print(build_panel(profile.custom_block_list, "Blocked Domains", "red"))
442
+ if profile.custom_allow_list:
443
+ console.print(build_panel(profile.custom_allow_list, "Allowed Domains", "green"))
eeroctl/main.py ADDED
@@ -0,0 +1,161 @@
1
+ """CLI entry point with noun-first command structure.
2
+
3
+ This module provides the main CLI entry point with global flags
4
+ and registers command groups from the commands/ module.
5
+ """
6
+
7
+ import logging
8
+ import sys
9
+ from importlib.metadata import version
10
+ from typing import Optional
11
+
12
+ import click
13
+ import eero
14
+ from rich.console import Console
15
+
16
+ from .commands import (
17
+ activity_group,
18
+ auth_group,
19
+ completion_group,
20
+ device_group,
21
+ eero_group,
22
+ network_group,
23
+ profile_group,
24
+ troubleshoot_group,
25
+ )
26
+ from .context import create_cli_context
27
+ from .utils import get_preferred_network
28
+
29
+ _LOGGER = logging.getLogger(__name__)
30
+
31
+
32
+ # ==================== Version Info ====================
33
+
34
+
35
+ def _get_version_info() -> str:
36
+ """Build version string with Python and eero-api versions."""
37
+ eeroctl_version = version("eeroctl")
38
+ python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
39
+ return f"%(prog)s {eeroctl_version}\nPython {python_version}\neero-api {eero.__version__}"
40
+
41
+
42
+ # ==================== Main CLI Group ====================
43
+
44
+
45
+ @click.group(invoke_without_command=True)
46
+ @click.option(
47
+ "--debug",
48
+ is_flag=True,
49
+ help="Enable debug logging.",
50
+ )
51
+ @click.option(
52
+ "--quiet",
53
+ "-q",
54
+ is_flag=True,
55
+ help="Suppress non-essential output.",
56
+ )
57
+ @click.option(
58
+ "--no-color",
59
+ is_flag=True,
60
+ help="Disable colored output.",
61
+ )
62
+ @click.option(
63
+ "--output",
64
+ "-o",
65
+ type=click.Choice(["table", "list", "json", "yaml", "text"]),
66
+ default="table",
67
+ help="Output format (default: table).",
68
+ )
69
+ @click.option(
70
+ "--network-id",
71
+ "-n",
72
+ help="Network ID to operate on.",
73
+ )
74
+ @click.option(
75
+ "--non-interactive",
76
+ is_flag=True,
77
+ help="Never prompt for input; fail if confirmation required.",
78
+ )
79
+ @click.option(
80
+ "--force",
81
+ "-y",
82
+ "--yes",
83
+ is_flag=True,
84
+ help="Skip confirmation prompts for disruptive actions.",
85
+ )
86
+ @click.version_option(message=_get_version_info())
87
+ @click.pass_context
88
+ def cli(
89
+ ctx: click.Context,
90
+ debug: bool,
91
+ quiet: bool,
92
+ no_color: bool,
93
+ output: str,
94
+ network_id: Optional[str],
95
+ non_interactive: bool,
96
+ force: bool,
97
+ ):
98
+ """Eero network management CLI.
99
+
100
+ Manage your Eero mesh Wi-Fi network from the command line.
101
+ Use --help with any command for more information.
102
+ """
103
+ # Setup logging
104
+ if debug:
105
+ logging.basicConfig(level=logging.DEBUG)
106
+ else:
107
+ logging.basicConfig(level=logging.WARNING)
108
+
109
+ # Create console
110
+ console = Console(force_terminal=not no_color, no_color=no_color, quiet=quiet)
111
+
112
+ # Create context
113
+ cli_ctx = create_cli_context(
114
+ debug=debug,
115
+ quiet=quiet,
116
+ no_color=no_color,
117
+ output_format=output,
118
+ non_interactive=non_interactive,
119
+ force=force,
120
+ )
121
+
122
+ # Override console with the configured one
123
+ cli_ctx.console = console
124
+
125
+ # Load preferred network if not specified
126
+ if network_id:
127
+ cli_ctx.network_id = network_id
128
+ else:
129
+ preferred_network = get_preferred_network()
130
+ if preferred_network:
131
+ cli_ctx.network_id = preferred_network
132
+
133
+ ctx.obj = cli_ctx
134
+
135
+ # Show help if no command specified
136
+ if ctx.invoked_subcommand is None:
137
+ click.echo(ctx.get_help())
138
+
139
+
140
+ # ==================== Register Command Groups ====================
141
+
142
+ cli.add_command(auth_group, name="auth")
143
+ cli.add_command(network_group, name="network")
144
+ cli.add_command(eero_group, name="eero")
145
+ cli.add_command(device_group, name="device")
146
+ cli.add_command(profile_group, name="profile")
147
+ cli.add_command(activity_group, name="activity")
148
+ cli.add_command(troubleshoot_group, name="troubleshoot")
149
+ cli.add_command(completion_group, name="completion")
150
+
151
+
152
+ # ==================== Entry Point ====================
153
+
154
+
155
+ def main():
156
+ """Main entry point for the CLI."""
157
+ cli()
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()