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
ui_cli/commands/isp.py ADDED
@@ -0,0 +1,100 @@
1
+ """ISP metrics commands."""
2
+
3
+ import asyncio
4
+ from enum import Enum
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from ui_cli.client import APIError, UniFiClient
10
+ from ui_cli.output import OutputFormat, print_error, render_output
11
+
12
+ app = typer.Typer(help="View ISP performance metrics")
13
+
14
+ # Column definitions for ISP metrics table
15
+ ISP_COLUMNS = [
16
+ ("siteId", "Site ID"),
17
+ ("hostId", "Host ID"),
18
+ ("ispName", "ISP Name"),
19
+ ("avgLatency", "Avg Latency (ms)"),
20
+ ("maxLatency", "Max Latency (ms)"),
21
+ ("downloadKbps", "Download (kbps)"),
22
+ ("uploadKbps", "Upload (kbps)"),
23
+ ("packetLoss", "Packet Loss (%)"),
24
+ ("uptime", "Uptime"),
25
+ ("timestamp", "Timestamp"),
26
+ ]
27
+
28
+
29
+ class MetricInterval(str, Enum):
30
+ """ISP metric interval options."""
31
+
32
+ FIVE_MIN = "5m"
33
+ HOURLY = "1h"
34
+
35
+
36
+ @app.command("metrics")
37
+ def get_metrics(
38
+ interval: Annotated[
39
+ MetricInterval,
40
+ typer.Option(
41
+ "--interval",
42
+ "-i",
43
+ help="Metric interval: 5m (24h retention) or 1h (30d retention)",
44
+ ),
45
+ ] = MetricInterval.HOURLY,
46
+ hours: Annotated[
47
+ int | None,
48
+ typer.Option(
49
+ "--hours",
50
+ "-H",
51
+ help="Hours of data to retrieve (default: 24 for 5m, 168 for 1h)",
52
+ ),
53
+ ] = None,
54
+ output: Annotated[
55
+ OutputFormat,
56
+ typer.Option(
57
+ "--output",
58
+ "-o",
59
+ help="Output format: table, json, or csv",
60
+ ),
61
+ ] = OutputFormat.TABLE,
62
+ verbose: Annotated[
63
+ bool,
64
+ typer.Option(
65
+ "--verbose",
66
+ "-v",
67
+ help="Show detailed request/response information",
68
+ ),
69
+ ] = False,
70
+ ) -> None:
71
+ """Get ISP performance metrics for all sites.
72
+
73
+ Metrics include latency (avg/max), download/upload speeds, uptime/downtime,
74
+ packet loss, and ISP information.
75
+
76
+ Data retention:
77
+ - 5m interval: 24 hours
78
+ - 1h interval: 30 days
79
+ """
80
+
81
+ async def _get() -> list:
82
+ client = UniFiClient()
83
+ return await client.get_isp_metrics(metric_type=interval.value, duration_hours=hours)
84
+
85
+ try:
86
+ metrics = asyncio.run(_get())
87
+
88
+ if verbose:
89
+ typer.echo(f"Found {len(metrics)} metric record(s)")
90
+
91
+ render_output(
92
+ data=metrics,
93
+ output_format=output,
94
+ columns=ISP_COLUMNS,
95
+ title=f"ISP Metrics ({interval.value})",
96
+ verbose=verbose,
97
+ )
98
+ except APIError as e:
99
+ print_error(e.message)
100
+ raise typer.Exit(1)
@@ -0,0 +1,63 @@
1
+ """Local controller commands - ./ui local or ./ui lo."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from ui_cli.commands.local.utils import QUICK_TIMEOUT, get_timeout, run_with_spinner, set_timeout_override, spinner
8
+
9
+ # Re-export for convenience
10
+ __all__ = ["app", "get_timeout", "run_with_spinner", "spinner", "QUICK_TIMEOUT"]
11
+
12
+
13
+ app = typer.Typer(
14
+ name="local",
15
+ help="Local UniFi Controller commands (UDM, Cloud Key, self-hosted)",
16
+ no_args_is_help=True,
17
+ )
18
+
19
+
20
+ @app.callback()
21
+ def local_callback(
22
+ quick: Annotated[
23
+ bool,
24
+ typer.Option(
25
+ "--quick",
26
+ "-q",
27
+ help="Use short timeout (5s) for quick connectivity checks",
28
+ ),
29
+ ] = False,
30
+ timeout: Annotated[
31
+ int | None,
32
+ typer.Option(
33
+ "--timeout",
34
+ "-t",
35
+ help="Request timeout in seconds (default: 15)",
36
+ ),
37
+ ] = None,
38
+ ) -> None:
39
+ """Local controller commands with optional timeout override."""
40
+ if quick:
41
+ set_timeout_override(QUICK_TIMEOUT)
42
+ elif timeout is not None:
43
+ set_timeout_override(timeout)
44
+
45
+
46
+ # Import subcommands after app is defined to avoid circular imports
47
+ from ui_cli.commands.local import apgroups, clients, config, devices, dpi, events, firewall, health, networks, portfwd, stats, vouchers, wan, wlans
48
+
49
+ # Register subcommands
50
+ app.add_typer(apgroups.app, name="apgroups")
51
+ app.add_typer(clients.app, name="clients")
52
+ app.add_typer(config.app, name="config")
53
+ app.add_typer(devices.app, name="devices")
54
+ app.add_typer(dpi.app, name="dpi")
55
+ app.add_typer(events.app, name="events")
56
+ app.add_typer(firewall.app, name="firewall")
57
+ app.add_typer(health.app, name="health")
58
+ app.add_typer(networks.app, name="networks")
59
+ app.add_typer(portfwd.app, name="portfwd")
60
+ app.add_typer(stats.app, name="stats")
61
+ app.add_typer(vouchers.app, name="vouchers")
62
+ app.add_typer(wan.app, name="wan")
63
+ app.add_typer(wlans.app, name="wlans")
@@ -0,0 +1,445 @@
1
+ """AP Group (Broadcasting Group) management commands for local controller."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import typer
6
+
7
+ from ui_cli.commands.local.utils import run_with_spinner
8
+ from ui_cli.local_client import LocalAPIError, UniFiLocalClient
9
+ from ui_cli.output import (
10
+ OutputFormat,
11
+ console,
12
+ output_csv,
13
+ output_json,
14
+ print_error,
15
+ print_success,
16
+ )
17
+
18
+ app = typer.Typer(name="apgroups", help="AP Group (broadcasting) management", no_args_is_help=True)
19
+
20
+
21
+ def find_ap_group(
22
+ groups: list[dict[str, Any]], identifier: str
23
+ ) -> dict[str, Any] | None:
24
+ """Find AP group by ID or name."""
25
+ identifier_lower = identifier.lower()
26
+
27
+ # First try exact ID match
28
+ for g in groups:
29
+ if g.get("_id") == identifier:
30
+ return g
31
+
32
+ # Try exact name match
33
+ for g in groups:
34
+ name = g.get("name", "").lower()
35
+ if name == identifier_lower:
36
+ return g
37
+
38
+ # Try partial name match
39
+ for g in groups:
40
+ name = g.get("name", "").lower()
41
+ if identifier_lower in name:
42
+ return g
43
+
44
+ return None
45
+
46
+
47
+ def find_device(
48
+ devices: list[dict[str, Any]], identifier: str
49
+ ) -> dict[str, Any] | None:
50
+ """Find device by MAC, name, or IP."""
51
+ identifier_lower = identifier.lower().replace("-", ":")
52
+
53
+ for d in devices:
54
+ # Match by MAC
55
+ if d.get("mac", "").lower() == identifier_lower:
56
+ return d
57
+ # Match by name
58
+ if d.get("name", "").lower() == identifier_lower:
59
+ return d
60
+ # Match by IP
61
+ if d.get("ip", "") == identifier:
62
+ return d
63
+
64
+ # Partial name match
65
+ for d in devices:
66
+ if identifier_lower in d.get("name", "").lower():
67
+ return d
68
+
69
+ return None
70
+
71
+
72
+ @app.command("list")
73
+ def list_groups(
74
+ output: Annotated[
75
+ OutputFormat,
76
+ typer.Option("--output", "-o", help="Output format"),
77
+ ] = OutputFormat.TABLE,
78
+ all_groups: Annotated[
79
+ bool,
80
+ typer.Option("--all", "-a", help="Show all groups including system groups"),
81
+ ] = False,
82
+ ) -> None:
83
+ """List all AP groups (broadcasting groups).
84
+
85
+ AP groups determine which WLANs are broadcast on which Access Points.
86
+ By default, system-managed groups (like 'All APs') are hidden.
87
+ """
88
+
89
+ async def _list():
90
+ client = UniFiLocalClient()
91
+ groups = await client.get_ap_groups()
92
+ devices = await client.get_devices()
93
+ return groups, devices
94
+
95
+ try:
96
+ groups, devices = run_with_spinner(_list(), "Fetching AP groups...")
97
+ except LocalAPIError as e:
98
+ print_error(str(e))
99
+ raise typer.Exit(1)
100
+
101
+ if not groups:
102
+ console.print("[dim]No AP groups found[/dim]")
103
+ return
104
+
105
+ # Build device lookup
106
+ device_lookup = {
107
+ d.get("mac", "").lower(): d.get("name", d.get("mac", ""))
108
+ for d in devices
109
+ if d.get("type") == "uap"
110
+ }
111
+
112
+ # Filter out system groups unless --all
113
+ if not all_groups:
114
+ groups = [
115
+ g for g in groups
116
+ if not g.get("attr_hidden_id") and not g.get("for_wlanconf")
117
+ ]
118
+
119
+ if output == OutputFormat.JSON:
120
+ output_json(groups)
121
+ elif output == OutputFormat.CSV:
122
+ columns = [
123
+ ("_id", "ID"),
124
+ ("name", "Name"),
125
+ ("device_count", "Devices"),
126
+ ]
127
+ csv_data = []
128
+ for g in groups:
129
+ csv_data.append({
130
+ "_id": g.get("_id", ""),
131
+ "name": g.get("name", ""),
132
+ "device_count": len(g.get("device_macs", [])),
133
+ })
134
+ output_csv(csv_data, columns)
135
+ else:
136
+ from rich.table import Table
137
+
138
+ table = Table(title="AP Groups", show_header=True, header_style="bold cyan")
139
+ table.add_column("Name")
140
+ table.add_column("Devices", justify="right")
141
+ table.add_column("Access Points")
142
+
143
+ for g in groups:
144
+ name = g.get("name", "")
145
+ device_macs = g.get("device_macs", [])
146
+ device_count = str(len(device_macs))
147
+
148
+ # Resolve device names
149
+ ap_names = []
150
+ for mac in device_macs[:5]: # Show max 5
151
+ ap_name = device_lookup.get(mac.lower(), mac)
152
+ ap_names.append(ap_name)
153
+ if len(device_macs) > 5:
154
+ ap_names.append(f"... +{len(device_macs) - 5} more")
155
+
156
+ table.add_row(name, device_count, ", ".join(ap_names) if ap_names else "-")
157
+
158
+ console.print(table)
159
+ console.print(f"\n[dim]{len(groups)} group(s)[/dim]")
160
+
161
+
162
+ @app.command("get")
163
+ def get_group(
164
+ identifier: Annotated[str, typer.Argument(help="AP group ID or name")],
165
+ output: Annotated[
166
+ OutputFormat,
167
+ typer.Option("--output", "-o", help="Output format"),
168
+ ] = OutputFormat.TABLE,
169
+ ) -> None:
170
+ """Get detailed AP group information."""
171
+
172
+ async def _get():
173
+ client = UniFiLocalClient()
174
+ groups = await client.get_ap_groups()
175
+ devices = await client.get_devices()
176
+ return find_ap_group(groups, identifier), devices
177
+
178
+ try:
179
+ group, devices = run_with_spinner(_get(), "Finding AP group...")
180
+ except LocalAPIError as e:
181
+ print_error(str(e))
182
+ raise typer.Exit(1)
183
+
184
+ if not group:
185
+ print_error(f"AP group '{identifier}' not found")
186
+ raise typer.Exit(1)
187
+
188
+ if output == OutputFormat.JSON:
189
+ output_json(group)
190
+ return
191
+
192
+ # Build device lookup
193
+ device_lookup = {
194
+ d.get("mac", "").lower(): d
195
+ for d in devices
196
+ if d.get("type") == "uap"
197
+ }
198
+
199
+ # Table output
200
+ from rich.table import Table
201
+
202
+ name = group.get("name", "Unknown")
203
+ console.print()
204
+ console.print(f"[bold cyan]AP Group: {name}[/bold cyan]")
205
+ console.print("-" * 40)
206
+ console.print()
207
+
208
+ table = Table(show_header=False, box=None, padding=(0, 2))
209
+ table.add_column("Key", style="dim")
210
+ table.add_column("Value")
211
+
212
+ table.add_row("ID:", group.get("_id", ""))
213
+ table.add_row("Name:", name)
214
+
215
+ device_macs = group.get("device_macs", [])
216
+ table.add_row("Device Count:", str(len(device_macs)))
217
+
218
+ console.print(table)
219
+
220
+ if device_macs:
221
+ console.print()
222
+ console.print("[bold]Access Points:[/bold]")
223
+
224
+ ap_table = Table(show_header=True, header_style="bold")
225
+ ap_table.add_column("Name")
226
+ ap_table.add_column("MAC")
227
+ ap_table.add_column("Model")
228
+ ap_table.add_column("IP")
229
+
230
+ for mac in device_macs:
231
+ device = device_lookup.get(mac.lower())
232
+ if device:
233
+ ap_table.add_row(
234
+ device.get("name", "-"),
235
+ mac,
236
+ device.get("model", "-"),
237
+ device.get("ip", "-"),
238
+ )
239
+ else:
240
+ ap_table.add_row("-", mac, "-", "-")
241
+
242
+ console.print(ap_table)
243
+
244
+ console.print()
245
+
246
+
247
+ @app.command("create")
248
+ def create_group(
249
+ name: Annotated[str, typer.Argument(help="Name for the new AP group")],
250
+ devices: Annotated[
251
+ list[str] | None,
252
+ typer.Option("--device", "-d", help="Device MAC, name, or IP to add (can be repeated)"),
253
+ ] = None,
254
+ output: Annotated[
255
+ OutputFormat,
256
+ typer.Option("--output", "-o", help="Output format"),
257
+ ] = OutputFormat.TABLE,
258
+ ) -> None:
259
+ """Create a new AP group."""
260
+
261
+ async def _create():
262
+ client = UniFiLocalClient()
263
+
264
+ # Resolve device identifiers to MACs
265
+ device_macs = []
266
+ if devices:
267
+ all_devices = await client.get_devices()
268
+ aps = [d for d in all_devices if d.get("type") == "uap"]
269
+
270
+ for dev_id in devices:
271
+ device = find_device(aps, dev_id)
272
+ if not device:
273
+ raise LocalAPIError(f"Device '{dev_id}' not found")
274
+ device_macs.append(device.get("mac", "").lower())
275
+
276
+ return await client.create_ap_group(name, device_macs)
277
+
278
+ try:
279
+ group = run_with_spinner(_create(), "Creating AP group...")
280
+ except LocalAPIError as e:
281
+ print_error(str(e))
282
+ raise typer.Exit(1)
283
+
284
+ if output == OutputFormat.JSON:
285
+ output_json(group)
286
+ else:
287
+ print_success(f"Created AP group '{name}'")
288
+ if devices:
289
+ console.print(f" [dim]Devices:[/dim] {len(devices)}")
290
+
291
+
292
+ @app.command("delete")
293
+ def delete_group(
294
+ identifier: Annotated[str, typer.Argument(help="AP group ID or name")],
295
+ yes: Annotated[
296
+ bool,
297
+ typer.Option("--yes", "-y", help="Skip confirmation"),
298
+ ] = False,
299
+ ) -> None:
300
+ """Delete an AP group."""
301
+
302
+ async def _get_group():
303
+ client = UniFiLocalClient()
304
+ groups = await client.get_ap_groups()
305
+ return find_ap_group(groups, identifier)
306
+
307
+ try:
308
+ group = run_with_spinner(_get_group(), "Finding AP group...")
309
+ except LocalAPIError as e:
310
+ print_error(str(e))
311
+ raise typer.Exit(1)
312
+
313
+ if not group:
314
+ print_error(f"AP group '{identifier}' not found")
315
+ raise typer.Exit(1)
316
+
317
+ name = group.get("name", identifier)
318
+ group_id = group.get("_id", "")
319
+
320
+ # Check if group can be deleted
321
+ if group.get("attr_no_delete"):
322
+ print_error(f"AP group '{name}' cannot be deleted (system group)")
323
+ raise typer.Exit(1)
324
+
325
+ if not yes:
326
+ confirm = typer.confirm(f"Delete AP group '{name}'?")
327
+ if not confirm:
328
+ console.print("[dim]Cancelled[/dim]")
329
+ raise typer.Exit(0)
330
+
331
+ async def _delete():
332
+ client = UniFiLocalClient()
333
+ return await client.delete_ap_group(group_id)
334
+
335
+ try:
336
+ success = run_with_spinner(_delete(), "Deleting AP group...")
337
+ except LocalAPIError as e:
338
+ print_error(str(e))
339
+ raise typer.Exit(1)
340
+
341
+ if success:
342
+ print_success(f"Deleted AP group '{name}'")
343
+ else:
344
+ print_error(f"Failed to delete AP group '{name}'")
345
+ raise typer.Exit(1)
346
+
347
+
348
+ @app.command("add-device")
349
+ def add_device(
350
+ group: Annotated[str, typer.Argument(help="AP group ID or name")],
351
+ device: Annotated[str, typer.Argument(help="Device MAC, name, or IP to add")],
352
+ output: Annotated[
353
+ OutputFormat,
354
+ typer.Option("--output", "-o", help="Output format"),
355
+ ] = OutputFormat.TABLE,
356
+ ) -> None:
357
+ """Add a device to an AP group."""
358
+
359
+ async def _add():
360
+ client = UniFiLocalClient()
361
+ groups = await client.get_ap_groups()
362
+ devices = await client.get_devices()
363
+
364
+ ap_group = find_ap_group(groups, group)
365
+ if not ap_group:
366
+ raise LocalAPIError(f"AP group '{group}' not found")
367
+
368
+ aps = [d for d in devices if d.get("type") == "uap"]
369
+ ap = find_device(aps, device)
370
+ if not ap:
371
+ raise LocalAPIError(f"Device '{device}' not found")
372
+
373
+ device_mac = ap.get("mac", "").lower()
374
+ current_macs = [m.lower() for m in ap_group.get("device_macs", [])]
375
+
376
+ if device_mac in current_macs:
377
+ raise LocalAPIError(f"Device '{ap.get('name', device)}' is already in group")
378
+
379
+ new_macs = current_macs + [device_mac]
380
+ return await client.update_ap_group(
381
+ ap_group.get("_id", ""),
382
+ ap_group.get("name", ""),
383
+ new_macs,
384
+ ), ap.get("name", device)
385
+
386
+ try:
387
+ updated, device_name = run_with_spinner(_add(), "Adding device to group...")
388
+ except LocalAPIError as e:
389
+ print_error(str(e))
390
+ raise typer.Exit(1)
391
+
392
+ if output == OutputFormat.JSON:
393
+ output_json(updated)
394
+ else:
395
+ print_success(f"Added '{device_name}' to AP group '{updated.get('name', group)}'")
396
+
397
+
398
+ @app.command("remove-device")
399
+ def remove_device(
400
+ group: Annotated[str, typer.Argument(help="AP group ID or name")],
401
+ device: Annotated[str, typer.Argument(help="Device MAC, name, or IP to remove")],
402
+ output: Annotated[
403
+ OutputFormat,
404
+ typer.Option("--output", "-o", help="Output format"),
405
+ ] = OutputFormat.TABLE,
406
+ ) -> None:
407
+ """Remove a device from an AP group."""
408
+
409
+ async def _remove():
410
+ client = UniFiLocalClient()
411
+ groups = await client.get_ap_groups()
412
+ devices = await client.get_devices()
413
+
414
+ ap_group = find_ap_group(groups, group)
415
+ if not ap_group:
416
+ raise LocalAPIError(f"AP group '{group}' not found")
417
+
418
+ aps = [d for d in devices if d.get("type") == "uap"]
419
+ ap = find_device(aps, device)
420
+ if not ap:
421
+ raise LocalAPIError(f"Device '{device}' not found")
422
+
423
+ device_mac = ap.get("mac", "").lower()
424
+ current_macs = [m.lower() for m in ap_group.get("device_macs", [])]
425
+
426
+ if device_mac not in current_macs:
427
+ raise LocalAPIError(f"Device '{ap.get('name', device)}' is not in group")
428
+
429
+ new_macs = [m for m in current_macs if m != device_mac]
430
+ return await client.update_ap_group(
431
+ ap_group.get("_id", ""),
432
+ ap_group.get("name", ""),
433
+ new_macs,
434
+ ), ap.get("name", device)
435
+
436
+ try:
437
+ updated, device_name = run_with_spinner(_remove(), "Removing device from group...")
438
+ except LocalAPIError as e:
439
+ print_error(str(e))
440
+ raise typer.Exit(1)
441
+
442
+ if output == OutputFormat.JSON:
443
+ output_json(updated)
444
+ else:
445
+ print_success(f"Removed '{device_name}' from AP group '{updated.get('name', group)}'")