ui-cli 1.2.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 (46) hide show
  1. ui_cli/__init__.py +31 -0
  2. ui_cli/client.py +269 -0
  3. ui_cli/commands/__init__.py +1 -0
  4. ui_cli/commands/devices.py +187 -0
  5. ui_cli/commands/groups.py +503 -0
  6. ui_cli/commands/hosts.py +114 -0
  7. ui_cli/commands/isp.py +100 -0
  8. ui_cli/commands/local/__init__.py +63 -0
  9. ui_cli/commands/local/apgroups.py +445 -0
  10. ui_cli/commands/local/clients.py +1537 -0
  11. ui_cli/commands/local/config.py +758 -0
  12. ui_cli/commands/local/devices.py +570 -0
  13. ui_cli/commands/local/dpi.py +369 -0
  14. ui_cli/commands/local/events.py +289 -0
  15. ui_cli/commands/local/firewall.py +285 -0
  16. ui_cli/commands/local/health.py +195 -0
  17. ui_cli/commands/local/networks.py +426 -0
  18. ui_cli/commands/local/portfwd.py +153 -0
  19. ui_cli/commands/local/stats.py +234 -0
  20. ui_cli/commands/local/utils.py +85 -0
  21. ui_cli/commands/local/vouchers.py +410 -0
  22. ui_cli/commands/local/wan.py +302 -0
  23. ui_cli/commands/local/wlans.py +257 -0
  24. ui_cli/commands/mcp.py +416 -0
  25. ui_cli/commands/sdwan.py +168 -0
  26. ui_cli/commands/sites.py +65 -0
  27. ui_cli/commands/speedtest.py +192 -0
  28. ui_cli/commands/status.py +410 -0
  29. ui_cli/commands/version.py +13 -0
  30. ui_cli/config.py +106 -0
  31. ui_cli/groups.py +567 -0
  32. ui_cli/local_client.py +897 -0
  33. ui_cli/main.py +61 -0
  34. ui_cli/models.py +188 -0
  35. ui_cli/output.py +251 -0
  36. ui_cli-1.2.1.dist-info/METADATA +1315 -0
  37. ui_cli-1.2.1.dist-info/RECORD +46 -0
  38. ui_cli-1.2.1.dist-info/WHEEL +4 -0
  39. ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
  40. ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
  41. ui_mcp/ARCHITECTURE.md +243 -0
  42. ui_mcp/README.md +235 -0
  43. ui_mcp/__init__.py +7 -0
  44. ui_mcp/__main__.py +10 -0
  45. ui_mcp/cli_runner.py +112 -0
  46. ui_mcp/server.py +468 -0
@@ -0,0 +1,570 @@
1
+ """Device management commands for local controller."""
2
+
3
+ import asyncio
4
+ from typing import Annotated, Any
5
+
6
+ import typer
7
+
8
+ from ui_cli.commands.local.utils import run_with_spinner
9
+ from ui_cli.local_client import LocalAPIError, UniFiLocalClient
10
+ from ui_cli.output import (
11
+ OutputFormat,
12
+ console,
13
+ output_csv,
14
+ output_json,
15
+ print_error,
16
+ print_success,
17
+ )
18
+
19
+ app = typer.Typer(name="devices", help="Manage UniFi devices", no_args_is_help=True)
20
+
21
+
22
+ # Device type display names
23
+ DEVICE_TYPES = {
24
+ "ugw": "Gateway",
25
+ "usw": "Switch",
26
+ "uap": "Access Point",
27
+ "udm": "Dream Machine",
28
+ "uxg": "Next-Gen Gateway",
29
+ "ubb": "Building Bridge",
30
+ "uck": "Cloud Key",
31
+ "uph": "Phone",
32
+ "ulte": "LTE Backup",
33
+ }
34
+
35
+
36
+ def get_device_type(device: dict[str, Any]) -> str:
37
+ """Get human-readable device type."""
38
+ dev_type = device.get("type", "")
39
+ return DEVICE_TYPES.get(dev_type, dev_type.upper() if dev_type else "Unknown")
40
+
41
+
42
+ def get_device_status(device: dict[str, Any]) -> tuple[str, str]:
43
+ """Get device status with color."""
44
+ state = device.get("state", 0)
45
+ # UniFi states: 0=offline, 1=connected, 2=pending, 4=upgrading, 5=provisioning
46
+ if state == 1:
47
+ return "online", "green"
48
+ elif state == 0:
49
+ return "offline", "red"
50
+ elif state == 2:
51
+ return "pending", "yellow"
52
+ elif state == 4:
53
+ return "upgrading", "cyan"
54
+ elif state == 5:
55
+ return "provisioning", "yellow"
56
+ elif state == 6:
57
+ return "heartbeat missed", "yellow"
58
+ return f"state:{state}", "dim"
59
+
60
+
61
+ def get_uptime(device: dict[str, Any]) -> str:
62
+ """Format device uptime."""
63
+ uptime = device.get("uptime", 0)
64
+ if not uptime:
65
+ return "-"
66
+
67
+ days = uptime // 86400
68
+ hours = (uptime % 86400) // 3600
69
+ minutes = (uptime % 3600) // 60
70
+
71
+ if days > 0:
72
+ return f"{days}d {hours}h"
73
+ elif hours > 0:
74
+ return f"{hours}h {minutes}m"
75
+ return f"{minutes}m"
76
+
77
+
78
+ def get_load(device: dict[str, Any]) -> str:
79
+ """Get system load average."""
80
+ load = device.get("sys_stats", {}).get("loadavg_1", "")
81
+ if load:
82
+ return f"{float(load):.2f}"
83
+ return "-"
84
+
85
+
86
+ def format_version(device: dict[str, Any]) -> str:
87
+ """Format firmware version."""
88
+ version = device.get("version", "")
89
+ if version:
90
+ return version
91
+ return "-"
92
+
93
+
94
+ def find_device(devices: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None:
95
+ """Find device by ID, MAC, name, or IP."""
96
+ identifier_lower = identifier.lower()
97
+
98
+ # First try exact ID match
99
+ for d in devices:
100
+ device_id = d.get("_id", "")
101
+ if device_id == identifier:
102
+ return d
103
+
104
+ # Try exact MAC match
105
+ for d in devices:
106
+ mac = d.get("mac", "").lower()
107
+ if mac == identifier_lower or mac.replace(":", "") == identifier_lower.replace(":", ""):
108
+ return d
109
+
110
+ # Try name match (exact then partial)
111
+ for d in devices:
112
+ name = d.get("name", "").lower()
113
+ if name == identifier_lower:
114
+ return d
115
+
116
+ for d in devices:
117
+ name = d.get("name", "").lower()
118
+ if identifier_lower in name:
119
+ return d
120
+
121
+ # Try IP match
122
+ for d in devices:
123
+ ip = d.get("ip", "")
124
+ if ip == identifier:
125
+ return d
126
+
127
+ return None
128
+
129
+
130
+ @app.command("list")
131
+ def list_devices(
132
+ output: Annotated[
133
+ OutputFormat,
134
+ typer.Option("--output", "-o", help="Output format"),
135
+ ] = OutputFormat.TABLE,
136
+ device_type: Annotated[
137
+ str | None,
138
+ typer.Option("--type", "-t", help="Filter by device type (uap, usw, ugw, udm)"),
139
+ ] = None,
140
+ verbose: Annotated[
141
+ bool,
142
+ typer.Option("--verbose", "-v", help="Show additional details"),
143
+ ] = False,
144
+ ) -> None:
145
+ """List all UniFi devices."""
146
+
147
+ async def _list():
148
+ client = UniFiLocalClient()
149
+ return await client.get_devices()
150
+
151
+ try:
152
+ devices = run_with_spinner(_list(), "Fetching devices...")
153
+ except LocalAPIError as e:
154
+ print_error(str(e))
155
+ raise typer.Exit(1)
156
+
157
+ if not devices:
158
+ console.print("[dim]No devices found[/dim]")
159
+ return
160
+
161
+ # Filter by type if specified
162
+ if device_type:
163
+ type_lower = device_type.lower()
164
+ devices = [d for d in devices if d.get("type", "").lower() == type_lower]
165
+ if not devices:
166
+ console.print(f"[dim]No {device_type} devices found[/dim]")
167
+ return
168
+
169
+ # Sort by type then name
170
+ devices.sort(key=lambda d: (d.get("type", ""), d.get("name", "").lower()))
171
+
172
+ if output == OutputFormat.JSON:
173
+ output_json(devices)
174
+ elif output == OutputFormat.CSV:
175
+ columns = [
176
+ ("_id", "ID"),
177
+ ("mac", "MAC"),
178
+ ("name", "Name"),
179
+ ("model", "Model"),
180
+ ("type", "Type"),
181
+ ("ip", "IP"),
182
+ ("version", "Version"),
183
+ ("state", "State"),
184
+ ("uptime", "Uptime"),
185
+ ]
186
+ csv_data = []
187
+ for d in devices:
188
+ status, _ = get_device_status(d)
189
+ csv_data.append({
190
+ "_id": d.get("_id", ""),
191
+ "mac": d.get("mac", ""),
192
+ "name": d.get("name", ""),
193
+ "model": d.get("model", ""),
194
+ "type": get_device_type(d),
195
+ "ip": d.get("ip", ""),
196
+ "version": format_version(d),
197
+ "state": status,
198
+ "uptime": get_uptime(d),
199
+ })
200
+ output_csv(csv_data, columns)
201
+ else:
202
+ from rich.table import Table
203
+
204
+ table = Table(title="UniFi Devices", show_header=True, header_style="bold cyan")
205
+ table.add_column("ID", style="dim")
206
+ table.add_column("Name")
207
+ table.add_column("Model")
208
+ table.add_column("Type")
209
+ table.add_column("IP")
210
+ table.add_column("MAC", style="dim")
211
+ table.add_column("Version")
212
+ table.add_column("Status")
213
+ table.add_column("Uptime", justify="right")
214
+ if verbose:
215
+ table.add_column("Load")
216
+ table.add_column("Clients", justify="right")
217
+
218
+ for d in devices:
219
+ device_id = d.get("_id", "")
220
+ name = d.get("name", "(unnamed)")
221
+ model = d.get("model", "")
222
+ dev_type = get_device_type(d)
223
+ ip = d.get("ip", "")
224
+ mac = d.get("mac", "")
225
+ version = format_version(d)
226
+ status, status_style = get_device_status(d)
227
+ uptime = get_uptime(d)
228
+
229
+ if verbose:
230
+ load = get_load(d)
231
+ # Client count varies by device type
232
+ num_sta = d.get("num_sta", d.get("user-num_sta", 0))
233
+ clients = str(num_sta) if num_sta else "-"
234
+
235
+ table.add_row(
236
+ device_id,
237
+ name,
238
+ model,
239
+ dev_type,
240
+ ip,
241
+ mac,
242
+ version,
243
+ f"[{status_style}]{status}[/{status_style}]",
244
+ uptime,
245
+ load,
246
+ clients,
247
+ )
248
+ else:
249
+ table.add_row(
250
+ device_id,
251
+ name,
252
+ model,
253
+ dev_type,
254
+ ip,
255
+ mac,
256
+ version,
257
+ f"[{status_style}]{status}[/{status_style}]",
258
+ uptime,
259
+ )
260
+
261
+ console.print(table)
262
+ console.print(f"\n[dim]{len(devices)} device(s)[/dim]")
263
+
264
+
265
+ @app.command("get")
266
+ def get_device(
267
+ identifier: Annotated[str, typer.Argument(help="Device MAC, name, or IP")],
268
+ output: Annotated[
269
+ OutputFormat,
270
+ typer.Option("--output", "-o", help="Output format"),
271
+ ] = OutputFormat.TABLE,
272
+ ) -> None:
273
+ """Get detailed device information."""
274
+
275
+ async def _get():
276
+ client = UniFiLocalClient()
277
+ devices = await client.get_devices()
278
+ return find_device(devices, identifier)
279
+
280
+ try:
281
+ device = run_with_spinner(_get(), "Finding device...")
282
+ except LocalAPIError as e:
283
+ print_error(str(e))
284
+ raise typer.Exit(1)
285
+
286
+ if not device:
287
+ print_error(f"Device '{identifier}' not found")
288
+ raise typer.Exit(1)
289
+
290
+ if output == OutputFormat.JSON:
291
+ output_json(device)
292
+ return
293
+
294
+ # Table output
295
+ from rich.table import Table
296
+
297
+ name = device.get("name", "Unknown")
298
+ console.print()
299
+ console.print(f"[bold cyan]Device: {name}[/bold cyan]")
300
+ console.print("─" * 50)
301
+ console.print()
302
+
303
+ table = Table(show_header=False, box=None, padding=(0, 2))
304
+ table.add_column("Key", style="dim")
305
+ table.add_column("Value")
306
+
307
+ table.add_row("ID:", device.get("_id", ""))
308
+ table.add_row("MAC:", device.get("mac", ""))
309
+ table.add_row("Model:", device.get("model", ""))
310
+ table.add_row("Type:", get_device_type(device))
311
+ table.add_row("IP:", device.get("ip", ""))
312
+ table.add_row("", "")
313
+
314
+ status, status_style = get_device_status(device)
315
+ table.add_row("Status:", f"[{status_style}]{status}[/{status_style}]")
316
+ table.add_row("Uptime:", get_uptime(device))
317
+ table.add_row("Version:", format_version(device))
318
+
319
+ # Check for upgrade
320
+ upgradable = device.get("upgradable", False)
321
+ if upgradable:
322
+ upgrade_to = device.get("upgrade_to_firmware", "")
323
+ table.add_row("Upgrade:", f"[yellow]{upgrade_to} available[/yellow]")
324
+
325
+ table.add_row("", "")
326
+
327
+ # System stats
328
+ sys_stats = device.get("sys_stats", {})
329
+ if sys_stats:
330
+ load = sys_stats.get("loadavg_1", "")
331
+ mem = sys_stats.get("mem_used", 0)
332
+ mem_total = sys_stats.get("mem_total", 0)
333
+ if load:
334
+ table.add_row("Load:", f"{float(load):.2f}")
335
+ if mem_total:
336
+ mem_pct = (mem / mem_total) * 100 if mem_total else 0
337
+ table.add_row("Memory:", f"{mem_pct:.0f}%")
338
+
339
+ # Network stats for APs
340
+ num_sta = device.get("num_sta", device.get("user-num_sta", 0))
341
+ if num_sta:
342
+ table.add_row("Clients:", str(num_sta))
343
+
344
+ # Port info for switches
345
+ port_table = device.get("port_table", [])
346
+ if port_table:
347
+ active_ports = sum(1 for p in port_table if p.get("up", False))
348
+ table.add_row("Ports:", f"{active_ports}/{len(port_table)} active")
349
+
350
+ # Radio info for APs
351
+ radio_table = device.get("radio_table", [])
352
+ if radio_table:
353
+ radios = []
354
+ for r in radio_table:
355
+ band = "5G" if r.get("radio") == "na" else "2.4G"
356
+ channel = r.get("channel", "")
357
+ if channel:
358
+ radios.append(f"{band}:ch{channel}")
359
+ if radios:
360
+ table.add_row("Radios:", ", ".join(radios))
361
+
362
+ console.print(table)
363
+ console.print()
364
+
365
+
366
+ @app.command("restart")
367
+ def restart_device(
368
+ identifier: Annotated[str, typer.Argument(help="Device MAC, name, or IP")],
369
+ yes: Annotated[
370
+ bool,
371
+ typer.Option("--yes", "-y", help="Skip confirmation"),
372
+ ] = False,
373
+ output: Annotated[
374
+ OutputFormat,
375
+ typer.Option("--output", "-o", help="Output format"),
376
+ ] = OutputFormat.TABLE,
377
+ ) -> None:
378
+ """Restart/reboot a device."""
379
+
380
+ async def _get_device():
381
+ client = UniFiLocalClient()
382
+ devices = await client.get_devices()
383
+ return find_device(devices, identifier)
384
+
385
+ try:
386
+ device = run_with_spinner(_get_device(), "Finding device...")
387
+ except LocalAPIError as e:
388
+ print_error(str(e))
389
+ raise typer.Exit(1)
390
+
391
+ if not device:
392
+ print_error(f"Device '{identifier}' not found")
393
+ raise typer.Exit(1)
394
+
395
+ name = device.get("name", device.get("mac", identifier))
396
+ mac = device.get("mac", "")
397
+
398
+ if not yes:
399
+ confirm = typer.confirm(f"Restart device '{name}'?")
400
+ if not confirm:
401
+ console.print("[dim]Cancelled[/dim]")
402
+ raise typer.Exit(0)
403
+
404
+ async def _restart():
405
+ client = UniFiLocalClient()
406
+ return await client.restart_device(mac)
407
+
408
+ try:
409
+ success = run_with_spinner(_restart(), "Restarting device...")
410
+ except LocalAPIError as e:
411
+ print_error(str(e))
412
+ raise typer.Exit(1)
413
+
414
+ if success:
415
+ if output == OutputFormat.JSON:
416
+ output_json({"success": True, "action": "restart", "name": name, "mac": mac})
417
+ else:
418
+ print_success(f"Restart command sent to '{name}'")
419
+ console.print("[dim]Device will reboot shortly[/dim]")
420
+ else:
421
+ if output == OutputFormat.JSON:
422
+ output_json({"success": False, "action": "restart", "name": name, "mac": mac, "error": "API call failed"})
423
+ else:
424
+ print_error(f"Failed to restart '{name}'")
425
+ raise typer.Exit(1)
426
+
427
+
428
+ @app.command("upgrade")
429
+ def upgrade_device(
430
+ identifier: Annotated[str, typer.Argument(help="Device MAC, name, or IP")],
431
+ yes: Annotated[
432
+ bool,
433
+ typer.Option("--yes", "-y", help="Skip confirmation"),
434
+ ] = False,
435
+ ) -> None:
436
+ """Upgrade device firmware."""
437
+
438
+ async def _get_device():
439
+ client = UniFiLocalClient()
440
+ devices = await client.get_devices()
441
+ return find_device(devices, identifier)
442
+
443
+ try:
444
+ device = run_with_spinner(_get_device(), "Finding device...")
445
+ except LocalAPIError as e:
446
+ print_error(str(e))
447
+ raise typer.Exit(1)
448
+
449
+ if not device:
450
+ print_error(f"Device '{identifier}' not found")
451
+ raise typer.Exit(1)
452
+
453
+ name = device.get("name", device.get("mac", identifier))
454
+ mac = device.get("mac", "")
455
+ current_version = device.get("version", "unknown")
456
+
457
+ # Check if upgrade is available
458
+ if not device.get("upgradable", False):
459
+ console.print(f"[dim]'{name}' is already on the latest firmware ({current_version})[/dim]")
460
+ return
461
+
462
+ upgrade_to = device.get("upgrade_to_firmware", "latest")
463
+
464
+ if not yes:
465
+ confirm = typer.confirm(f"Upgrade '{name}' from {current_version} to {upgrade_to}?")
466
+ if not confirm:
467
+ console.print("[dim]Cancelled[/dim]")
468
+ raise typer.Exit(0)
469
+
470
+ async def _upgrade():
471
+ client = UniFiLocalClient()
472
+ return await client.upgrade_device(mac)
473
+
474
+ try:
475
+ success = run_with_spinner(_upgrade(), "Starting upgrade...")
476
+ except LocalAPIError as e:
477
+ print_error(str(e))
478
+ raise typer.Exit(1)
479
+
480
+ if success:
481
+ print_success(f"Upgrade started for '{name}'")
482
+ console.print(f"[dim]Upgrading to {upgrade_to}. Device will reboot when complete.[/dim]")
483
+ else:
484
+ print_error(f"Failed to start upgrade for '{name}'")
485
+ raise typer.Exit(1)
486
+
487
+
488
+ @app.command("locate")
489
+ def locate_device(
490
+ identifier: Annotated[str, typer.Argument(help="Device MAC, name, or IP")],
491
+ off: Annotated[
492
+ bool,
493
+ typer.Option("--off", help="Turn off locate LED"),
494
+ ] = False,
495
+ ) -> None:
496
+ """Flash LED to locate a device."""
497
+
498
+ async def _get_device():
499
+ client = UniFiLocalClient()
500
+ devices = await client.get_devices()
501
+ return find_device(devices, identifier)
502
+
503
+ try:
504
+ device = run_with_spinner(_get_device(), "Finding device...")
505
+ except LocalAPIError as e:
506
+ print_error(str(e))
507
+ raise typer.Exit(1)
508
+
509
+ if not device:
510
+ print_error(f"Device '{identifier}' not found")
511
+ raise typer.Exit(1)
512
+
513
+ name = device.get("name", device.get("mac", identifier))
514
+ mac = device.get("mac", "")
515
+
516
+ async def _locate():
517
+ client = UniFiLocalClient()
518
+ return await client.locate_device(mac, enabled=not off)
519
+
520
+ try:
521
+ success = run_with_spinner(_locate(), "Setting locate LED...")
522
+ except LocalAPIError as e:
523
+ print_error(str(e))
524
+ raise typer.Exit(1)
525
+
526
+ if success:
527
+ if off:
528
+ print_success(f"Locate LED turned off for '{name}'")
529
+ else:
530
+ print_success(f"Locate LED flashing on '{name}'")
531
+ console.print("[dim]Run with --off to stop[/dim]")
532
+ else:
533
+ print_error(f"Failed to set locate LED for '{name}'")
534
+ raise typer.Exit(1)
535
+
536
+
537
+ @app.command("adopt")
538
+ def adopt_device(
539
+ identifier: Annotated[str, typer.Argument(help="Device MAC address")],
540
+ yes: Annotated[
541
+ bool,
542
+ typer.Option("--yes", "-y", help="Skip confirmation"),
543
+ ] = False,
544
+ ) -> None:
545
+ """Adopt a pending device."""
546
+
547
+ if not yes:
548
+ confirm = typer.confirm(f"Adopt device '{identifier}'?")
549
+ if not confirm:
550
+ console.print("[dim]Cancelled[/dim]")
551
+ raise typer.Exit(0)
552
+
553
+ async def _adopt():
554
+ client = UniFiLocalClient()
555
+ return await client.adopt_device(identifier)
556
+
557
+ try:
558
+ success = run_with_spinner(_adopt(), "Adopting device...")
559
+ except LocalAPIError as e:
560
+ print_error(str(e))
561
+ raise typer.Exit(1)
562
+
563
+ if success:
564
+ print_success(f"Adoption started for '{identifier}'")
565
+ console.print("[dim]Device will provision and appear in device list[/dim]")
566
+ else:
567
+ print_error(f"Failed to adopt '{identifier}'")
568
+ raise typer.Exit(1)
569
+
570
+