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,1537 @@
1
+ """Client commands for local controller."""
2
+
3
+ import asyncio
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from ui_cli.commands.local.utils import run_with_spinner
9
+ from ui_cli.local_client import (
10
+ LocalAPIError,
11
+ LocalAuthenticationError,
12
+ LocalConnectionError,
13
+ UniFiLocalClient,
14
+ )
15
+ from ui_cli.output import OutputFormat, console, output_count_table, output_csv, output_json, output_table
16
+
17
+ app = typer.Typer(help="Manage connected clients")
18
+
19
+
20
+ # Column definitions for client output: (key, header)
21
+ CLIENT_COLUMNS = [
22
+ ("name", "Name"),
23
+ ("mac", "MAC"),
24
+ ("ip", "IP"),
25
+ ("oui", "Vendor"),
26
+ ("network", "Network"),
27
+ ("type", "Type"),
28
+ ("signal", "Signal"),
29
+ ("satisfaction", "Experience"),
30
+ ]
31
+
32
+ CLIENT_COLUMNS_VERBOSE = [
33
+ ("name", "Name"),
34
+ ("mac", "MAC"),
35
+ ("ip", "IP"),
36
+ ("network", "Network"),
37
+ ("type", "Type"),
38
+ ("oui", "Vendor"),
39
+ ("signal", "Signal"),
40
+ ("satisfaction", "Experience"),
41
+ ("tx_rate", "TX Rate"),
42
+ ("rx_rate", "RX Rate"),
43
+ ("uptime", "Uptime"),
44
+ ]
45
+
46
+
47
+ def format_client(client: dict, verbose: bool = False) -> dict:
48
+ """Format raw client data for display."""
49
+ # Determine connection type
50
+ is_wired = client.get("is_wired", False)
51
+ conn_type = "Wired" if is_wired else "Wireless"
52
+
53
+ # Get network name
54
+ network = client.get("network", client.get("essid", ""))
55
+
56
+ # Format signal strength (wireless only)
57
+ signal = ""
58
+ if not is_wired:
59
+ rssi = client.get("rssi")
60
+ if rssi is not None:
61
+ signal = f"{rssi} dBm"
62
+
63
+ # Format experience/satisfaction score
64
+ satisfaction = client.get("satisfaction")
65
+ if satisfaction is not None:
66
+ satisfaction = f"{satisfaction}%"
67
+ else:
68
+ satisfaction = ""
69
+
70
+ # Format uptime
71
+ uptime_seconds = client.get("uptime", 0)
72
+ if uptime_seconds:
73
+ hours, remainder = divmod(uptime_seconds, 3600)
74
+ minutes, _ = divmod(remainder, 60)
75
+ uptime = f"{int(hours)}h {int(minutes)}m"
76
+ else:
77
+ uptime = ""
78
+
79
+ # Format rates (in Mbps)
80
+ tx_rate = client.get("tx_rate", 0)
81
+ rx_rate = client.get("rx_rate", 0)
82
+ tx_rate_str = f"{tx_rate / 1000:.0f} Mbps" if tx_rate else ""
83
+ rx_rate_str = f"{rx_rate / 1000:.0f} Mbps" if rx_rate else ""
84
+
85
+ # Fixed IP info
86
+ use_fixedip = client.get("use_fixedip", False)
87
+ fixed_ip = client.get("fixed_ip", "")
88
+
89
+ result = {
90
+ "name": client.get("name") or client.get("hostname") or "(unknown)",
91
+ "mac": client.get("mac", "").upper(),
92
+ "ip": client.get("ip", "") or client.get("last_ip", ""),
93
+ "network": network or client.get("last_connection_network_name", ""),
94
+ "type": conn_type,
95
+ "oui": client.get("oui", ""),
96
+ "signal": signal,
97
+ "satisfaction": satisfaction,
98
+ "tx_rate": tx_rate_str,
99
+ "rx_rate": rx_rate_str,
100
+ "uptime": uptime,
101
+ "fixed_ip": fixed_ip if use_fixedip else "",
102
+ "use_fixedip": use_fixedip,
103
+ }
104
+
105
+ return result
106
+
107
+
108
+ def handle_error(e: Exception) -> None:
109
+ """Handle and display API errors."""
110
+ if isinstance(e, LocalAuthenticationError):
111
+ console.print(f"[red]Authentication error:[/red] {e.message}")
112
+ elif isinstance(e, LocalConnectionError):
113
+ console.print(f"[red]Connection error:[/red] {e.message}")
114
+ elif isinstance(e, LocalAPIError):
115
+ console.print(f"[red]API error:[/red] {e.message}")
116
+ else:
117
+ console.print(f"[red]Error:[/red] {e}")
118
+ raise typer.Exit(1)
119
+
120
+
121
+ def is_mac_address(value: str) -> bool:
122
+ """Check if a string looks like a MAC address."""
123
+ # MAC formats: AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF or AABBCCDDEEFF
124
+ import re
125
+ mac_patterns = [
126
+ r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$', # AA:BB:CC:DD:EE:FF
127
+ r'^([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}$', # AA-BB-CC-DD-EE-FF
128
+ r'^[0-9A-Fa-f]{12}$', # AABBCCDDEEFF
129
+ ]
130
+ return any(re.match(pattern, value) for pattern in mac_patterns)
131
+
132
+
133
+ async def resolve_client_identifier(
134
+ api_client: UniFiLocalClient,
135
+ identifier: str,
136
+ ) -> tuple[str | None, str | None]:
137
+ """Resolve a client name or MAC to (mac, name).
138
+
139
+ Returns (mac, name) if found, (None, None) if not found.
140
+ If identifier is a MAC, returns it directly with the name if found.
141
+ If identifier is a name, searches for matching client.
142
+ """
143
+ if is_mac_address(identifier):
144
+ # It's a MAC address - try to get the client to find its name
145
+ client_data = await api_client.get_client(identifier)
146
+ if client_data:
147
+ name = client_data.get("name") or client_data.get("hostname") or identifier
148
+ return identifier.lower().replace("-", ":"), name
149
+ return identifier.lower().replace("-", ":"), None
150
+
151
+ # It's a name - search for it in all clients
152
+ clients = await api_client.list_all_clients()
153
+ identifier_lower = identifier.lower()
154
+
155
+ for client in clients:
156
+ name = client.get("name") or client.get("hostname") or ""
157
+ if name.lower() == identifier_lower:
158
+ return client.get("mac", "").lower(), name
159
+
160
+ # Try partial match if exact match not found
161
+ matches = []
162
+ for client in clients:
163
+ name = client.get("name") or client.get("hostname") or ""
164
+ if identifier_lower in name.lower():
165
+ matches.append((client.get("mac", "").lower(), name))
166
+
167
+ if len(matches) == 1:
168
+ return matches[0]
169
+ elif len(matches) > 1:
170
+ console.print(f"[yellow]Multiple clients match '{identifier}':[/yellow]")
171
+ for mac, name in matches:
172
+ console.print(f" - {name} ({mac.upper()})")
173
+ return None, None
174
+
175
+ return None, None
176
+
177
+
178
+ @app.command("list")
179
+ def list_clients(
180
+ output: Annotated[
181
+ OutputFormat,
182
+ typer.Option("--output", "-o", help="Output format"),
183
+ ] = OutputFormat.TABLE,
184
+ network: Annotated[
185
+ str | None,
186
+ typer.Option("--network", "-n", help="Filter by network/SSID"),
187
+ ] = None,
188
+ wired: Annotated[
189
+ bool,
190
+ typer.Option("--wired", "-w", help="Show only wired clients"),
191
+ ] = False,
192
+ wireless: Annotated[
193
+ bool,
194
+ typer.Option("--wireless", "-W", help="Show only wireless clients"),
195
+ ] = False,
196
+ verbose: Annotated[
197
+ bool,
198
+ typer.Option("--verbose", "-v", help="Show additional details"),
199
+ ] = False,
200
+ group: Annotated[
201
+ str | None,
202
+ typer.Option("--group", "-g", help="Filter by client group"),
203
+ ] = None,
204
+ ) -> None:
205
+ """List active (connected) clients."""
206
+ async def _list():
207
+ client = UniFiLocalClient()
208
+ return await client.list_clients()
209
+
210
+ try:
211
+ clients = run_with_spinner(_list(), "Fetching clients...")
212
+ except Exception as e:
213
+ handle_error(e)
214
+ return
215
+
216
+ # Apply group filter
217
+ if group:
218
+ from ui_cli.groups import GroupManager
219
+ gm = GroupManager()
220
+ result = gm.get_group(group)
221
+ if not result:
222
+ console.print(f"[red]Error:[/red] Group '{group}' not found")
223
+ raise typer.Exit(1)
224
+
225
+ _, grp = result
226
+ if grp.type == "static":
227
+ member_macs = {m.mac.upper() for m in grp.members or []}
228
+ clients = [c for c in clients if c.get("mac", "").upper() in member_macs]
229
+ else:
230
+ # Auto group - evaluate rules
231
+ clients = gm.evaluate_auto_group(group, clients)
232
+
233
+ # Apply other filters
234
+ if wired:
235
+ clients = [c for c in clients if c.get("is_wired", False)]
236
+ elif wireless:
237
+ clients = [c for c in clients if not c.get("is_wired", False)]
238
+
239
+ if network:
240
+ network_lower = network.lower()
241
+ clients = [
242
+ c
243
+ for c in clients
244
+ if network_lower in (c.get("network", "") or c.get("essid", "")).lower()
245
+ ]
246
+
247
+ # Format for output
248
+ formatted = [format_client(c, verbose=verbose) for c in clients]
249
+
250
+ columns = CLIENT_COLUMNS_VERBOSE if verbose else CLIENT_COLUMNS
251
+
252
+ title = f"Clients in '{group}'" if group else "Connected Clients"
253
+
254
+ if output == OutputFormat.JSON:
255
+ output_json(formatted, verbose=verbose)
256
+ elif output == OutputFormat.CSV:
257
+ output_csv(formatted, columns)
258
+ else:
259
+ output_table(formatted, columns, title=title)
260
+
261
+
262
+ @app.command("all")
263
+ def list_all_clients(
264
+ output: Annotated[
265
+ OutputFormat,
266
+ typer.Option("--output", "-o", help="Output format"),
267
+ ] = OutputFormat.TABLE,
268
+ verbose: Annotated[
269
+ bool,
270
+ typer.Option("--verbose", "-v", help="Show additional details"),
271
+ ] = False,
272
+ ) -> None:
273
+ """List all known clients (including offline)."""
274
+ async def _list():
275
+ client = UniFiLocalClient()
276
+ return await client.list_all_clients()
277
+
278
+ try:
279
+ clients = run_with_spinner(_list(), "Fetching all clients...")
280
+ except Exception as e:
281
+ handle_error(e)
282
+ return
283
+
284
+ # Format for output
285
+ formatted = [format_client(c, verbose=verbose) for c in clients]
286
+
287
+ columns = CLIENT_COLUMNS_VERBOSE if verbose else CLIENT_COLUMNS
288
+
289
+ if output == OutputFormat.JSON:
290
+ output_json(formatted, verbose=verbose)
291
+ elif output == OutputFormat.CSV:
292
+ output_csv(formatted, columns)
293
+ else:
294
+ output_table(formatted, columns, title="All Known Clients")
295
+
296
+
297
+ @app.command("get")
298
+ def get_client(
299
+ identifier: Annotated[
300
+ str | None,
301
+ typer.Argument(help="Client MAC address or name"),
302
+ ] = None,
303
+ output: Annotated[
304
+ OutputFormat,
305
+ typer.Option("--output", "-o", help="Output format"),
306
+ ] = OutputFormat.TABLE,
307
+ ) -> None:
308
+ """Get details for a specific client.
309
+
310
+ Examples:
311
+ ./ui lo clients get my-iPhone
312
+ ./ui lo clients get AA:BB:CC:DD:EE:FF
313
+ """
314
+ if not identifier:
315
+ console.print("[yellow]Usage:[/yellow] ./ui lo clients get <name or MAC>")
316
+ console.print()
317
+ console.print("Examples:")
318
+ console.print(" ./ui lo clients get my-iPhone")
319
+ console.print(" ./ui lo clients get AA:BB:CC:DD:EE:FF")
320
+ raise typer.Exit(1)
321
+ async def _get():
322
+ api_client = UniFiLocalClient()
323
+ mac, name = await resolve_client_identifier(api_client, identifier)
324
+ if not mac:
325
+ return None, None
326
+ client_data = await api_client.get_client(mac)
327
+ return client_data, name
328
+
329
+ try:
330
+ client_data, resolved_name = run_with_spinner(_get(), "Finding client...")
331
+ except Exception as e:
332
+ handle_error(e)
333
+ return
334
+
335
+ if not client_data:
336
+ console.print(f"[yellow]Client not found:[/yellow] {identifier}")
337
+ raise typer.Exit(1)
338
+
339
+ if output == OutputFormat.JSON:
340
+ output_json(client_data)
341
+ else:
342
+ # Display as key-value pairs
343
+ formatted = format_client(client_data, verbose=True)
344
+ display_name = resolved_name or formatted.get("name", identifier)
345
+ console.print()
346
+ console.print(f"[bold]Client Details: {display_name}[/bold]")
347
+ console.print("─" * 40)
348
+ for key, value in formatted.items():
349
+ if value:
350
+ console.print(f" [dim]{key}:[/dim] {value}")
351
+ console.print()
352
+
353
+
354
+ @app.command("set-ip")
355
+ def set_fixed_ip(
356
+ identifier: Annotated[
357
+ str,
358
+ typer.Argument(help="Client MAC address or name"),
359
+ ],
360
+ ip: Annotated[
361
+ str,
362
+ typer.Argument(help="Fixed IP address to assign"),
363
+ ],
364
+ yes: Annotated[
365
+ bool,
366
+ typer.Option("--yes", "-y", help="Skip confirmation prompt"),
367
+ ] = False,
368
+ no_kick: Annotated[
369
+ bool,
370
+ typer.Option("--no-kick", help="Don't kick client to force DHCP renewal"),
371
+ ] = False,
372
+ output: Annotated[
373
+ OutputFormat,
374
+ typer.Option("--output", "-o", help="Output format"),
375
+ ] = OutputFormat.TABLE,
376
+ ) -> None:
377
+ """Set a fixed IP address for a client (DHCP reservation).
378
+
379
+ By default, kicks the client after setting the IP to force immediate
380
+ DHCP renewal. Use --no-kick to skip this.
381
+
382
+ Examples:
383
+ ui lo clients set-ip "Shelly Keller" 192.168.30.21
384
+ ui lo clients set-ip AA:BB:CC:DD:EE:FF 192.168.30.21 -y
385
+ ui lo clients set-ip "Device" 192.168.1.100 --no-kick
386
+ """
387
+ import re
388
+
389
+ # Validate IP address format
390
+ ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
391
+ if not re.match(ip_pattern, ip):
392
+ console.print(f"[red]Invalid IP address:[/red] {ip}")
393
+ raise typer.Exit(1)
394
+
395
+ # Validate IP octets
396
+ octets = [int(x) for x in ip.split('.')]
397
+ if any(o < 0 or o > 255 for o in octets):
398
+ console.print(f"[red]Invalid IP address:[/red] {ip}")
399
+ raise typer.Exit(1)
400
+
401
+ async def _resolve_and_get_id():
402
+ api_client = UniFiLocalClient()
403
+ mac, name = await resolve_client_identifier(api_client, identifier)
404
+ if not mac:
405
+ return None, None, None, api_client
406
+
407
+ # Get user record to find the _id
408
+ # Need to search all users for this MAC
409
+ response = await api_client.get("/rest/user")
410
+ users = response.get("data", [])
411
+ user_id = None
412
+ for user in users:
413
+ if user.get("mac", "").lower() == mac.lower():
414
+ user_id = user.get("_id")
415
+ break
416
+
417
+ return mac, name, user_id, api_client
418
+
419
+ try:
420
+ mac, name, user_id, api_client = run_with_spinner(
421
+ _resolve_and_get_id(), "Finding client..."
422
+ )
423
+ except Exception as e:
424
+ handle_error(e)
425
+ return
426
+
427
+ if not mac:
428
+ console.print(f"[yellow]Client not found:[/yellow] {identifier}")
429
+ raise typer.Exit(1)
430
+
431
+ if not user_id:
432
+ console.print(f"[red]Error:[/red] Could not find user record for {identifier}")
433
+ console.print("[dim]The client may not have connected recently enough to have a user record.[/dim]")
434
+ raise typer.Exit(1)
435
+
436
+ display = f"{name} ({mac.upper()})" if name else mac.upper()
437
+
438
+ # Confirm action
439
+ if not yes:
440
+ if not typer.confirm(f"Set fixed IP {ip} for {display}?"):
441
+ console.print("[dim]Cancelled[/dim]")
442
+ raise typer.Exit(0)
443
+
444
+ # Execute action
445
+ async def _set_ip():
446
+ return await api_client.set_client_fixed_ip(user_id, fixed_ip=ip)
447
+
448
+ try:
449
+ success = run_with_spinner(_set_ip(), "Setting fixed IP...")
450
+ except Exception as e:
451
+ handle_error(e)
452
+ return
453
+
454
+ if not success:
455
+ if output == OutputFormat.JSON:
456
+ output_json({"success": False, "name": name, "mac": mac, "fixed_ip": ip, "error": "API call failed"})
457
+ else:
458
+ console.print(f"[red]Failed to set fixed IP for:[/red] {display}")
459
+ raise typer.Exit(1)
460
+
461
+ if output != OutputFormat.JSON:
462
+ console.print(f"[green]Set fixed IP:[/green] {display} -> {ip}")
463
+
464
+ # Kick client to force DHCP renewal (unless --no-kick)
465
+ kicked = False
466
+ if not no_kick:
467
+ async def _kick():
468
+ return await api_client.kick_client(mac)
469
+
470
+ try:
471
+ kicked = run_with_spinner(_kick(), "Kicking client for DHCP renewal...")
472
+ if output != OutputFormat.JSON:
473
+ if kicked:
474
+ console.print(f"[green]Kicked client:[/green] {display} (will reconnect with new IP)")
475
+ else:
476
+ console.print(f"[yellow]Could not kick client[/yellow] - may need manual reconnect")
477
+ except Exception:
478
+ if output != OutputFormat.JSON:
479
+ console.print(f"[yellow]Could not kick client[/yellow] - may need manual reconnect")
480
+
481
+ if output == OutputFormat.JSON:
482
+ output_json({"success": True, "name": name, "mac": mac, "fixed_ip": ip, "kicked": kicked})
483
+
484
+
485
+ def format_bytes(bytes_val: int) -> str:
486
+ """Format bytes to human-readable string."""
487
+ if bytes_val < 1024:
488
+ return f"{bytes_val} B"
489
+ elif bytes_val < 1024 * 1024:
490
+ return f"{bytes_val / 1024:.1f} KB"
491
+ elif bytes_val < 1024 * 1024 * 1024:
492
+ return f"{bytes_val / (1024 * 1024):.1f} MB"
493
+ else:
494
+ return f"{bytes_val / (1024 * 1024 * 1024):.2f} GB"
495
+
496
+
497
+ def format_uptime(seconds: int) -> str:
498
+ """Format uptime seconds to human-readable string."""
499
+ if seconds < 60:
500
+ return f"{seconds}s"
501
+ elif seconds < 3600:
502
+ minutes = seconds // 60
503
+ return f"{minutes}m"
504
+ elif seconds < 86400:
505
+ hours = seconds // 3600
506
+ minutes = (seconds % 3600) // 60
507
+ return f"{hours}h {minutes}m"
508
+ else:
509
+ days = seconds // 86400
510
+ hours = (seconds % 86400) // 3600
511
+ return f"{days}d {hours}h"
512
+
513
+
514
+ @app.command("status")
515
+ def client_status(
516
+ identifier: Annotated[
517
+ str | None,
518
+ typer.Argument(help="Client MAC address or name"),
519
+ ] = None,
520
+ output: Annotated[
521
+ OutputFormat,
522
+ typer.Option("--output", "-o", help="Output format"),
523
+ ] = OutputFormat.TABLE,
524
+ ) -> None:
525
+ """Show client connection and block status.
526
+
527
+ Examples:
528
+ ./ui lo clients status my-iPhone
529
+ ./ui lo clients status AA:BB:CC:DD:EE:FF
530
+ """
531
+ if not identifier:
532
+ console.print("[yellow]Usage:[/yellow] ./ui lo clients status <name or MAC>")
533
+ console.print()
534
+ console.print("Examples:")
535
+ console.print(" ./ui lo clients status my-iPhone")
536
+ console.print(" ./ui lo clients status AA:BB:CC:DD:EE:FF")
537
+ raise typer.Exit(1)
538
+
539
+ async def _get_status():
540
+ api_client = UniFiLocalClient()
541
+ mac, name = await resolve_client_identifier(api_client, identifier)
542
+ if not mac:
543
+ return None, None, None, None
544
+ # Get from all clients (includes offline) for block status
545
+ all_clients = await api_client.list_all_clients()
546
+ client_info = None
547
+ for c in all_clients:
548
+ if c.get("mac", "").lower() == mac.lower():
549
+ client_info = c
550
+ break
551
+ # Also check active clients for online status and live data
552
+ active_clients = await api_client.list_clients()
553
+ active_info = None
554
+ for c in active_clients:
555
+ if c.get("mac", "").lower() == mac.lower():
556
+ active_info = c
557
+ break
558
+ is_online = active_info is not None
559
+ return client_info, active_info, name, is_online
560
+
561
+ try:
562
+ client_info, active_info, resolved_name, is_online = run_with_spinner(_get_status(), "Checking status...")
563
+ except Exception as e:
564
+ handle_error(e)
565
+ return
566
+
567
+ if not client_info:
568
+ console.print(f"[yellow]Client not found:[/yellow] {identifier}")
569
+ raise typer.Exit(1)
570
+
571
+ # Build status info - use active_info for live data if online
572
+ info = active_info if active_info else client_info
573
+
574
+ name = resolved_name or info.get("name") or info.get("hostname") or "(unknown)"
575
+ mac = info.get("mac", "").upper()
576
+ is_blocked = client_info.get("blocked", False)
577
+ is_guest = info.get("is_guest", False)
578
+ ip = info.get("ip") or info.get("last_ip") or ""
579
+ is_wired = info.get("is_wired", False)
580
+ conn_type = "Wired" if is_wired else "Wireless"
581
+
582
+ # Network and AP info
583
+ network = info.get("network") or info.get("essid") or info.get("last_connection_network_name") or ""
584
+ ap_name = info.get("last_uplink_name") or ""
585
+
586
+ # Wireless-specific info
587
+ signal = info.get("signal") # dBm
588
+ rssi = info.get("rssi")
589
+ channel = info.get("channel")
590
+ radio = info.get("radio_proto", "") # e.g., "ac", "ax"
591
+
592
+ # Connection quality
593
+ satisfaction = info.get("satisfaction")
594
+
595
+ # Connection speed
596
+ tx_rate = info.get("tx_rate", 0) # in kbps
597
+ rx_rate = info.get("rx_rate", 0)
598
+
599
+ # Data usage
600
+ tx_bytes = info.get("tx_bytes", 0)
601
+ rx_bytes = info.get("rx_bytes", 0)
602
+
603
+ # Uptime
604
+ uptime = info.get("uptime", 0)
605
+
606
+ # Vendor
607
+ vendor = info.get("oui", "")
608
+
609
+ status_data = {
610
+ "name": name,
611
+ "mac": mac,
612
+ "ip": ip,
613
+ "online": is_online,
614
+ "blocked": is_blocked,
615
+ "guest": is_guest,
616
+ "type": conn_type,
617
+ "network": network,
618
+ "ap": ap_name if not is_wired else None,
619
+ "signal": signal,
620
+ "rssi": rssi,
621
+ "channel": channel,
622
+ "radio": radio,
623
+ "satisfaction": satisfaction,
624
+ "tx_rate": tx_rate,
625
+ "rx_rate": rx_rate,
626
+ "tx_bytes": tx_bytes,
627
+ "rx_bytes": rx_bytes,
628
+ "uptime": uptime,
629
+ "vendor": vendor,
630
+ }
631
+
632
+ if output == OutputFormat.JSON:
633
+ output_json(status_data)
634
+ else:
635
+ console.print()
636
+ console.print(f"[bold]Client Status: {name}[/bold]")
637
+ console.print("─" * 40)
638
+ console.print(f" [dim]MAC:[/dim] {mac}")
639
+ if vendor:
640
+ console.print(f" [dim]Vendor:[/dim] {vendor}")
641
+ if ip:
642
+ console.print(f" [dim]IP:[/dim] {ip}")
643
+ console.print(f" [dim]Type:[/dim] {conn_type}")
644
+ if network:
645
+ console.print(f" [dim]Network:[/dim] {network}")
646
+ if ap_name and not is_wired:
647
+ console.print(f" [dim]AP:[/dim] {ap_name}")
648
+
649
+ # Wireless info section
650
+ if not is_wired and is_online:
651
+ console.print()
652
+ console.print(" [bold]WiFi Info[/bold]")
653
+ if signal is not None:
654
+ # Color code signal strength
655
+ if signal >= -50:
656
+ sig_color = "green"
657
+ elif signal >= -70:
658
+ sig_color = "yellow"
659
+ else:
660
+ sig_color = "red"
661
+ console.print(f" [dim]Signal:[/dim] [{sig_color}]{signal} dBm[/{sig_color}]")
662
+ if channel:
663
+ channel_info = f"Ch {channel}"
664
+ if radio:
665
+ channel_info += f" ({radio.upper()})"
666
+ console.print(f" [dim]Channel:[/dim] {channel_info}")
667
+ if satisfaction is not None:
668
+ # Color code experience
669
+ if satisfaction >= 80:
670
+ exp_color = "green"
671
+ elif satisfaction >= 50:
672
+ exp_color = "yellow"
673
+ else:
674
+ exp_color = "red"
675
+ console.print(f" [dim]Experience:[/dim] [{exp_color}]{satisfaction}%[/{exp_color}]")
676
+
677
+ # Connection info section (when online)
678
+ if is_online:
679
+ console.print()
680
+ console.print(" [bold]Connection[/bold]")
681
+ if uptime:
682
+ console.print(f" [dim]Uptime:[/dim] {format_uptime(uptime)}")
683
+ if tx_rate or rx_rate:
684
+ tx_str = f"{tx_rate / 1000:.0f}" if tx_rate else "0"
685
+ rx_str = f"{rx_rate / 1000:.0f}" if rx_rate else "0"
686
+ console.print(f" [dim]Speed:[/dim] ↑{tx_str} / ↓{rx_str} Mbps")
687
+ if tx_bytes or rx_bytes:
688
+ console.print(f" [dim]Data:[/dim] ↑{format_bytes(tx_bytes)} / ↓{format_bytes(rx_bytes)}")
689
+
690
+ # Status section
691
+ console.print()
692
+ console.print(" [bold]Status[/bold]")
693
+ if is_online:
694
+ console.print(f" [dim]Online:[/dim] [green]Yes[/green]")
695
+ else:
696
+ console.print(f" [dim]Online:[/dim] [dim]No[/dim]")
697
+
698
+ if is_blocked:
699
+ console.print(f" [dim]Blocked:[/dim] [red]Yes[/red]")
700
+ else:
701
+ console.print(f" [dim]Blocked:[/dim] [green]No[/green]")
702
+
703
+ if is_guest:
704
+ console.print(f" [dim]Guest:[/dim] Yes")
705
+
706
+ console.print()
707
+
708
+
709
+ @app.command("block")
710
+ def block_client(
711
+ identifier: Annotated[
712
+ str | None,
713
+ typer.Argument(help="Client MAC address or name"),
714
+ ] = None,
715
+ group: Annotated[
716
+ str | None,
717
+ typer.Option("--group", "-g", help="Block all clients in a group"),
718
+ ] = None,
719
+ yes: Annotated[
720
+ bool,
721
+ typer.Option("--yes", "-y", help="Skip confirmation prompt"),
722
+ ] = False,
723
+ output: Annotated[
724
+ OutputFormat,
725
+ typer.Option("--output", "-o", help="Output format"),
726
+ ] = OutputFormat.TABLE,
727
+ ) -> None:
728
+ """Block a client or all clients in a group.
729
+
730
+ Examples:
731
+ ./ui lo clients block my-iPhone
732
+ ./ui lo clients block AA:BB:CC:DD:EE:FF -y
733
+ ./ui lo clients block -g kids-devices
734
+ """
735
+ if group and identifier:
736
+ console.print("[red]Error:[/red] Specify client OR --group, not both")
737
+ raise typer.Exit(1)
738
+
739
+ if not group and not identifier:
740
+ console.print("[yellow]Usage:[/yellow] ./ui lo clients block <name or MAC>")
741
+ console.print(" ./ui lo clients block --group <group-name>")
742
+ console.print()
743
+ console.print("Examples:")
744
+ console.print(" ./ui lo clients block my-iPhone")
745
+ console.print(" ./ui lo clients block -g kids-devices")
746
+ raise typer.Exit(1)
747
+
748
+ # Handle group blocking
749
+ if group:
750
+ _block_group(group, yes, output)
751
+ return
752
+
753
+ # Resolve identifier to MAC
754
+ async def _resolve():
755
+ api_client = UniFiLocalClient()
756
+ return await resolve_client_identifier(api_client, identifier)
757
+
758
+ try:
759
+ mac, name = run_with_spinner(_resolve(), "Finding client...")
760
+ except Exception as e:
761
+ handle_error(e)
762
+ return
763
+
764
+ if not mac:
765
+ console.print(f"[yellow]Client not found:[/yellow] {identifier}")
766
+ raise typer.Exit(1)
767
+
768
+ display = f"{name} ({mac.upper()})" if name else mac.upper()
769
+
770
+ # Confirm action
771
+ if not yes:
772
+ if not typer.confirm(f"Block client {display}?"):
773
+ console.print("[dim]Cancelled[/dim]")
774
+ raise typer.Exit(0)
775
+
776
+ # Execute action
777
+ async def _block():
778
+ api_client = UniFiLocalClient()
779
+ return await api_client.block_client(mac)
780
+
781
+ try:
782
+ success = run_with_spinner(_block(), "Blocking client...")
783
+ except Exception as e:
784
+ handle_error(e)
785
+ return
786
+
787
+ if success:
788
+ if output == OutputFormat.JSON:
789
+ output_json({"success": True, "action": "blocked", "name": name, "mac": mac})
790
+ else:
791
+ console.print(f"[green]Blocked client:[/green] {display}")
792
+ else:
793
+ if output == OutputFormat.JSON:
794
+ output_json({"success": False, "action": "blocked", "name": name, "mac": mac, "error": "API call failed"})
795
+ else:
796
+ console.print(f"[red]Failed to block client:[/red] {display}")
797
+ raise typer.Exit(1)
798
+
799
+
800
+ def _block_group(group: str, yes: bool, output: OutputFormat) -> None:
801
+ """Block all clients in a group."""
802
+ from ui_cli.groups import GroupManager
803
+
804
+ gm = GroupManager()
805
+ result = gm.get_group(group)
806
+ if not result:
807
+ console.print(f"[red]Error:[/red] Group '{group}' not found")
808
+ raise typer.Exit(1)
809
+
810
+ _, grp = result
811
+
812
+ async def _get_members():
813
+ api_client = UniFiLocalClient()
814
+ if grp.type == "static":
815
+ members = []
816
+ for m in grp.members or []:
817
+ members.append({"mac": m.mac, "name": m.alias})
818
+ return members, api_client
819
+ else:
820
+ # Auto group - evaluate rules
821
+ clients = await api_client.list_all_clients()
822
+ matching = gm.evaluate_auto_group(group, clients)
823
+ members = [{"mac": c["mac"], "name": c.get("name") or c.get("hostname")} for c in matching]
824
+ return members, api_client
825
+
826
+ try:
827
+ members, api_client = run_with_spinner(_get_members(), "Getting group members...")
828
+ except Exception as e:
829
+ handle_error(e)
830
+ return
831
+
832
+ if not members:
833
+ console.print(f"[yellow]No members in group '{grp.name}'[/yellow]")
834
+ return
835
+
836
+ # Confirm action
837
+ if not yes:
838
+ if not typer.confirm(f"Block {len(members)} clients in group '{grp.name}'?"):
839
+ console.print("[dim]Cancelled[/dim]")
840
+ raise typer.Exit(0)
841
+
842
+ console.print(f"\nBlocking {len(members)} clients in group \"{grp.name}\"...\n")
843
+
844
+ # Block each client
845
+ results = {"blocked": 0, "already": 0, "failed": 0}
846
+ result_details = []
847
+
848
+ async def _block_one(mac: str):
849
+ return await api_client.block_client(mac)
850
+
851
+ for member in members:
852
+ mac = member["mac"]
853
+ name = member["name"] or mac
854
+ display = f"{name} ({mac.upper()})" if name != mac else mac.upper()
855
+
856
+ try:
857
+ # Check current status first
858
+ async def _check():
859
+ all_clients = await api_client.list_all_clients()
860
+ for c in all_clients:
861
+ if c.get("mac", "").upper() == mac.upper():
862
+ return c.get("blocked", False)
863
+ return False
864
+
865
+ is_blocked = asyncio.run(_check())
866
+
867
+ if is_blocked:
868
+ console.print(f"[dim]- {display} - already blocked[/dim]")
869
+ results["already"] += 1
870
+ result_details.append({"mac": mac, "name": name, "status": "already_blocked"})
871
+ else:
872
+ success = asyncio.run(_block_one(mac))
873
+ if success:
874
+ console.print(f"[green]✓[/green] {display} - blocked")
875
+ results["blocked"] += 1
876
+ result_details.append({"mac": mac, "name": name, "status": "blocked"})
877
+ else:
878
+ console.print(f"[red]✗[/red] {display} - failed")
879
+ results["failed"] += 1
880
+ result_details.append({"mac": mac, "name": name, "status": "failed"})
881
+ except Exception:
882
+ console.print(f"[red]✗[/red] {display} - failed")
883
+ results["failed"] += 1
884
+ result_details.append({"mac": mac, "name": name, "status": "failed"})
885
+
886
+ console.print(f"\nBlocked: {results['blocked']} | Already blocked: {results['already']} | Failed: {results['failed']}")
887
+
888
+ if output == OutputFormat.JSON:
889
+ output_json({"group": grp.name, "results": result_details, "summary": results})
890
+
891
+
892
+ @app.command("unblock")
893
+ def unblock_client(
894
+ identifier: Annotated[
895
+ str | None,
896
+ typer.Argument(help="Client MAC address or name"),
897
+ ] = None,
898
+ group: Annotated[
899
+ str | None,
900
+ typer.Option("--group", "-g", help="Unblock all clients in a group"),
901
+ ] = None,
902
+ yes: Annotated[
903
+ bool,
904
+ typer.Option("--yes", "-y", help="Skip confirmation prompt"),
905
+ ] = False,
906
+ output: Annotated[
907
+ OutputFormat,
908
+ typer.Option("--output", "-o", help="Output format"),
909
+ ] = OutputFormat.TABLE,
910
+ ) -> None:
911
+ """Unblock a client or all clients in a group.
912
+
913
+ Examples:
914
+ ./ui lo clients unblock my-iPhone
915
+ ./ui lo clients unblock AA:BB:CC:DD:EE:FF -y
916
+ ./ui lo clients unblock -g kids-devices
917
+ """
918
+ if group and identifier:
919
+ console.print("[red]Error:[/red] Specify client OR --group, not both")
920
+ raise typer.Exit(1)
921
+
922
+ if not group and not identifier:
923
+ console.print("[yellow]Usage:[/yellow] ./ui lo clients unblock <name or MAC>")
924
+ console.print(" ./ui lo clients unblock --group <group-name>")
925
+ console.print()
926
+ console.print("Examples:")
927
+ console.print(" ./ui lo clients unblock my-iPhone")
928
+ console.print(" ./ui lo clients unblock -g kids-devices")
929
+ raise typer.Exit(1)
930
+
931
+ # Handle group unblocking
932
+ if group:
933
+ _unblock_group(group, yes, output)
934
+ return
935
+
936
+ # Resolve identifier to MAC
937
+ async def _resolve():
938
+ api_client = UniFiLocalClient()
939
+ return await resolve_client_identifier(api_client, identifier)
940
+
941
+ try:
942
+ mac, name = run_with_spinner(_resolve(), "Finding client...")
943
+ except Exception as e:
944
+ handle_error(e)
945
+ return
946
+
947
+ if not mac:
948
+ console.print(f"[yellow]Client not found:[/yellow] {identifier}")
949
+ raise typer.Exit(1)
950
+
951
+ display = f"{name} ({mac.upper()})" if name else mac.upper()
952
+
953
+ # Confirm action
954
+ if not yes:
955
+ if not typer.confirm(f"Unblock client {display}?"):
956
+ console.print("[dim]Cancelled[/dim]")
957
+ raise typer.Exit(0)
958
+
959
+ # Execute action
960
+ async def _unblock():
961
+ api_client = UniFiLocalClient()
962
+ return await api_client.unblock_client(mac)
963
+
964
+ try:
965
+ success = run_with_spinner(_unblock(), "Unblocking client...")
966
+ except Exception as e:
967
+ handle_error(e)
968
+ return
969
+
970
+ if success:
971
+ if output == OutputFormat.JSON:
972
+ output_json({"success": True, "action": "unblocked", "name": name, "mac": mac})
973
+ else:
974
+ console.print(f"[green]Unblocked client:[/green] {display}")
975
+ else:
976
+ if output == OutputFormat.JSON:
977
+ output_json({"success": False, "action": "unblocked", "name": name, "mac": mac, "error": "API call failed"})
978
+ else:
979
+ console.print(f"[red]Failed to unblock client:[/red] {display}")
980
+ raise typer.Exit(1)
981
+
982
+
983
+ def _unblock_group(group: str, yes: bool, output: OutputFormat) -> None:
984
+ """Unblock all clients in a group."""
985
+ from ui_cli.groups import GroupManager
986
+
987
+ gm = GroupManager()
988
+ result = gm.get_group(group)
989
+ if not result:
990
+ console.print(f"[red]Error:[/red] Group '{group}' not found")
991
+ raise typer.Exit(1)
992
+
993
+ _, grp = result
994
+
995
+ async def _get_members():
996
+ api_client = UniFiLocalClient()
997
+ if grp.type == "static":
998
+ members = []
999
+ for m in grp.members or []:
1000
+ members.append({"mac": m.mac, "name": m.alias})
1001
+ return members, api_client
1002
+ else:
1003
+ clients = await api_client.list_all_clients()
1004
+ matching = gm.evaluate_auto_group(group, clients)
1005
+ members = [{"mac": c["mac"], "name": c.get("name") or c.get("hostname")} for c in matching]
1006
+ return members, api_client
1007
+
1008
+ try:
1009
+ members, api_client = run_with_spinner(_get_members(), "Getting group members...")
1010
+ except Exception as e:
1011
+ handle_error(e)
1012
+ return
1013
+
1014
+ if not members:
1015
+ console.print(f"[yellow]No members in group '{grp.name}'[/yellow]")
1016
+ return
1017
+
1018
+ if not yes:
1019
+ if not typer.confirm(f"Unblock {len(members)} clients in group '{grp.name}'?"):
1020
+ console.print("[dim]Cancelled[/dim]")
1021
+ raise typer.Exit(0)
1022
+
1023
+ console.print(f"\nUnblocking {len(members)} clients in group \"{grp.name}\"...\n")
1024
+
1025
+ results = {"unblocked": 0, "not_blocked": 0, "failed": 0}
1026
+ result_details = []
1027
+
1028
+ for member in members:
1029
+ mac = member["mac"]
1030
+ name = member["name"] or mac
1031
+ display = f"{name} ({mac.upper()})" if name != mac else mac.upper()
1032
+
1033
+ try:
1034
+ async def _check():
1035
+ all_clients = await api_client.list_all_clients()
1036
+ for c in all_clients:
1037
+ if c.get("mac", "").upper() == mac.upper():
1038
+ return c.get("blocked", False)
1039
+ return False
1040
+
1041
+ is_blocked = asyncio.run(_check())
1042
+
1043
+ if not is_blocked:
1044
+ console.print(f"[dim]- {display} - not blocked[/dim]")
1045
+ results["not_blocked"] += 1
1046
+ result_details.append({"mac": mac, "name": name, "status": "not_blocked"})
1047
+ else:
1048
+ async def _unblock_one():
1049
+ return await api_client.unblock_client(mac)
1050
+
1051
+ success = asyncio.run(_unblock_one())
1052
+ if success:
1053
+ console.print(f"[green]✓[/green] {display} - unblocked")
1054
+ results["unblocked"] += 1
1055
+ result_details.append({"mac": mac, "name": name, "status": "unblocked"})
1056
+ else:
1057
+ console.print(f"[red]✗[/red] {display} - failed")
1058
+ results["failed"] += 1
1059
+ result_details.append({"mac": mac, "name": name, "status": "failed"})
1060
+ except Exception:
1061
+ console.print(f"[red]✗[/red] {display} - failed")
1062
+ results["failed"] += 1
1063
+ result_details.append({"mac": mac, "name": name, "status": "failed"})
1064
+
1065
+ console.print(f"\nUnblocked: {results['unblocked']} | Not blocked: {results['not_blocked']} | Failed: {results['failed']}")
1066
+
1067
+ if output == OutputFormat.JSON:
1068
+ output_json({"group": grp.name, "results": result_details, "summary": results})
1069
+
1070
+
1071
+ @app.command("kick")
1072
+ def kick_client(
1073
+ identifier: Annotated[
1074
+ str | None,
1075
+ typer.Argument(help="Client MAC address or name"),
1076
+ ] = None,
1077
+ group: Annotated[
1078
+ str | None,
1079
+ typer.Option("--group", "-g", help="Kick all clients in a group"),
1080
+ ] = None,
1081
+ yes: Annotated[
1082
+ bool,
1083
+ typer.Option("--yes", "-y", help="Skip confirmation prompt"),
1084
+ ] = False,
1085
+ output: Annotated[
1086
+ OutputFormat,
1087
+ typer.Option("--output", "-o", help="Output format"),
1088
+ ] = OutputFormat.TABLE,
1089
+ ) -> None:
1090
+ """Kick (disconnect) a client or all clients in a group.
1091
+
1092
+ Examples:
1093
+ ./ui lo clients kick my-iPhone
1094
+ ./ui lo clients kick AA:BB:CC:DD:EE:FF -y
1095
+ ./ui lo clients kick -g kids-devices
1096
+ """
1097
+ if group and identifier:
1098
+ console.print("[red]Error:[/red] Specify client OR --group, not both")
1099
+ raise typer.Exit(1)
1100
+
1101
+ if not group and not identifier:
1102
+ console.print("[yellow]Usage:[/yellow] ./ui lo clients kick <name or MAC>")
1103
+ console.print(" ./ui lo clients kick --group <group-name>")
1104
+ console.print()
1105
+ console.print("Examples:")
1106
+ console.print(" ./ui lo clients kick my-iPhone")
1107
+ console.print(" ./ui lo clients kick -g kids-devices")
1108
+ raise typer.Exit(1)
1109
+
1110
+ # Handle group kicking
1111
+ if group:
1112
+ _kick_group(group, yes, output)
1113
+ return
1114
+
1115
+ # Resolve identifier to MAC
1116
+ async def _resolve():
1117
+ api_client = UniFiLocalClient()
1118
+ return await resolve_client_identifier(api_client, identifier)
1119
+
1120
+ try:
1121
+ mac, name = run_with_spinner(_resolve(), "Finding client...")
1122
+ except Exception as e:
1123
+ handle_error(e)
1124
+ return
1125
+
1126
+ if not mac:
1127
+ console.print(f"[yellow]Client not found:[/yellow] {identifier}")
1128
+ raise typer.Exit(1)
1129
+
1130
+ display = f"{name} ({mac.upper()})" if name else mac.upper()
1131
+
1132
+ # Confirm action
1133
+ if not yes:
1134
+ if not typer.confirm(f"Kick client {display}?"):
1135
+ console.print("[dim]Cancelled[/dim]")
1136
+ raise typer.Exit(0)
1137
+
1138
+ # Execute action
1139
+ async def _kick():
1140
+ api_client = UniFiLocalClient()
1141
+ return await api_client.kick_client(mac)
1142
+
1143
+ try:
1144
+ success = run_with_spinner(_kick(), "Kicking client...")
1145
+ except Exception as e:
1146
+ handle_error(e)
1147
+ return
1148
+
1149
+ if success:
1150
+ if output == OutputFormat.JSON:
1151
+ output_json({"success": True, "action": "kicked", "name": name, "mac": mac})
1152
+ else:
1153
+ console.print(f"[green]Kicked client:[/green] {display}")
1154
+ else:
1155
+ if output == OutputFormat.JSON:
1156
+ output_json({"success": False, "action": "kicked", "name": name, "mac": mac, "error": "API call failed"})
1157
+ else:
1158
+ console.print(f"[red]Failed to kick client:[/red] {display}")
1159
+ raise typer.Exit(1)
1160
+
1161
+
1162
+ def _kick_group(group: str, yes: bool, output: OutputFormat) -> None:
1163
+ """Kick all clients in a group."""
1164
+ from ui_cli.groups import GroupManager
1165
+
1166
+ gm = GroupManager()
1167
+ result = gm.get_group(group)
1168
+ if not result:
1169
+ console.print(f"[red]Error:[/red] Group '{group}' not found")
1170
+ raise typer.Exit(1)
1171
+
1172
+ _, grp = result
1173
+
1174
+ async def _get_members():
1175
+ api_client = UniFiLocalClient()
1176
+ if grp.type == "static":
1177
+ members = []
1178
+ for m in grp.members or []:
1179
+ members.append({"mac": m.mac, "name": m.alias})
1180
+ return members, api_client
1181
+ else:
1182
+ clients = await api_client.list_clients() # Only online clients
1183
+ matching = gm.evaluate_auto_group(group, clients)
1184
+ members = [{"mac": c["mac"], "name": c.get("name") or c.get("hostname")} for c in matching]
1185
+ return members, api_client
1186
+
1187
+ try:
1188
+ members, api_client = run_with_spinner(_get_members(), "Getting group members...")
1189
+ except Exception as e:
1190
+ handle_error(e)
1191
+ return
1192
+
1193
+ if not members:
1194
+ console.print(f"[yellow]No members in group '{grp.name}'[/yellow]")
1195
+ return
1196
+
1197
+ if not yes:
1198
+ if not typer.confirm(f"Kick {len(members)} clients in group '{grp.name}'?"):
1199
+ console.print("[dim]Cancelled[/dim]")
1200
+ raise typer.Exit(0)
1201
+
1202
+ console.print(f"\nKicking {len(members)} clients in group \"{grp.name}\"...\n")
1203
+
1204
+ results = {"kicked": 0, "failed": 0}
1205
+ result_details = []
1206
+
1207
+ for member in members:
1208
+ mac = member["mac"]
1209
+ name = member["name"] or mac
1210
+ display = f"{name} ({mac.upper()})" if name != mac else mac.upper()
1211
+
1212
+ try:
1213
+ async def _kick_one():
1214
+ return await api_client.kick_client(mac)
1215
+
1216
+ success = asyncio.run(_kick_one())
1217
+ if success:
1218
+ console.print(f"[green]✓[/green] {display} - kicked")
1219
+ results["kicked"] += 1
1220
+ result_details.append({"mac": mac, "name": name, "status": "kicked"})
1221
+ else:
1222
+ console.print(f"[red]✗[/red] {display} - failed")
1223
+ results["failed"] += 1
1224
+ result_details.append({"mac": mac, "name": name, "status": "failed"})
1225
+ except Exception:
1226
+ console.print(f"[red]✗[/red] {display} - failed")
1227
+ results["failed"] += 1
1228
+ result_details.append({"mac": mac, "name": name, "status": "failed"})
1229
+
1230
+ console.print(f"\nKicked: {results['kicked']} | Failed: {results['failed']}")
1231
+
1232
+ if output == OutputFormat.JSON:
1233
+ output_json({"group": grp.name, "results": result_details, "summary": results})
1234
+
1235
+
1236
+ class CountBy(str, typer.Typer):
1237
+ """Grouping options for count command."""
1238
+
1239
+ TYPE = "type"
1240
+ NETWORK = "network"
1241
+ VENDOR = "vendor"
1242
+ AP = "ap"
1243
+ EXPERIENCE = "experience"
1244
+
1245
+
1246
+ def get_experience_category(satisfaction: int | None) -> str:
1247
+ """Categorize experience score."""
1248
+ if satisfaction is None:
1249
+ return "Unknown"
1250
+ if satisfaction >= 80:
1251
+ return "Good (80%+)"
1252
+ if satisfaction >= 50:
1253
+ return "Fair (50-79%)"
1254
+ return "Poor (<50%)"
1255
+
1256
+
1257
+ @app.command("count")
1258
+ def count_clients(
1259
+ by: Annotated[
1260
+ str,
1261
+ typer.Option(
1262
+ "--by",
1263
+ "-b",
1264
+ help="Group by: type, network, vendor, ap, experience",
1265
+ ),
1266
+ ] = "type",
1267
+ include_offline: Annotated[
1268
+ bool,
1269
+ typer.Option(
1270
+ "--include-offline",
1271
+ "-a",
1272
+ help="Include offline clients in count",
1273
+ ),
1274
+ ] = False,
1275
+ output: Annotated[
1276
+ OutputFormat,
1277
+ typer.Option("--output", "-o", help="Output format"),
1278
+ ] = OutputFormat.TABLE,
1279
+ ) -> None:
1280
+ """Count clients grouped by category (online only by default)."""
1281
+ async def _count():
1282
+ api_client = UniFiLocalClient()
1283
+ if include_offline:
1284
+ return await api_client.list_all_clients()
1285
+ else:
1286
+ return await api_client.list_clients()
1287
+
1288
+ try:
1289
+ clients = run_with_spinner(_count(), "Counting clients...")
1290
+ except Exception as e:
1291
+ handle_error(e)
1292
+ return
1293
+
1294
+ # Count by the specified grouping
1295
+ counts: dict[str, int] = {}
1296
+ by_lower = by.lower()
1297
+
1298
+ for client in clients:
1299
+ if by_lower == "type":
1300
+ key = "Wired" if client.get("is_wired", False) else "Wireless"
1301
+ elif by_lower == "network":
1302
+ key = client.get("network") or client.get("essid") or "(none)"
1303
+ elif by_lower == "vendor":
1304
+ key = client.get("oui") or "(unknown)"
1305
+ elif by_lower == "ap":
1306
+ # Get AP name - wireless clients have ap_mac and last_uplink_name
1307
+ if client.get("is_wired", False):
1308
+ key = "(wired)"
1309
+ else:
1310
+ key = client.get("last_uplink_name") or client.get("ap_mac", "(unknown)")
1311
+ elif by_lower == "experience":
1312
+ satisfaction = client.get("satisfaction")
1313
+ key = get_experience_category(satisfaction)
1314
+ else:
1315
+ console.print(f"[red]Invalid grouping:[/red] {by}")
1316
+ console.print("Valid options: type, network, vendor, ap, experience")
1317
+ raise typer.Exit(1)
1318
+
1319
+ counts[key] = counts.get(key, 0) + 1
1320
+
1321
+ # Determine title and headers based on grouping
1322
+ titles = {
1323
+ "type": ("Client Count by Type", "Type"),
1324
+ "network": ("Client Count by Network", "Network"),
1325
+ "vendor": ("Client Count by Vendor", "Vendor"),
1326
+ "ap": ("Client Count by Access Point", "Access Point"),
1327
+ "experience": ("Client Count by Experience", "Experience"),
1328
+ }
1329
+ title, group_header = titles.get(by_lower, ("Client Count", "Group"))
1330
+
1331
+ if output == OutputFormat.JSON:
1332
+ output_json({"counts": counts, "total": sum(counts.values())})
1333
+ elif output == OutputFormat.CSV:
1334
+ # Output as CSV
1335
+ rows = [{"group": k, "count": v} for k, v in sorted(counts.items())]
1336
+ rows.append({"group": "Total", "count": sum(counts.values())})
1337
+ output_csv(rows, [("group", group_header), ("count", "Count")])
1338
+ else:
1339
+ output_count_table(counts, group_header=group_header, title=title)
1340
+
1341
+
1342
+ @app.command("rename")
1343
+ def rename_client(
1344
+ identifier: Annotated[
1345
+ str,
1346
+ typer.Argument(help="Client MAC address or current name"),
1347
+ ],
1348
+ new_name: Annotated[
1349
+ str,
1350
+ typer.Argument(help="New name for the client"),
1351
+ ],
1352
+ yes: Annotated[
1353
+ bool,
1354
+ typer.Option("--yes", "-y", help="Skip confirmation prompt"),
1355
+ ] = False,
1356
+ output: Annotated[
1357
+ OutputFormat,
1358
+ typer.Option("--output", "-o", help="Output format"),
1359
+ ] = OutputFormat.TABLE,
1360
+ ) -> None:
1361
+ """Rename a client (set display name).
1362
+
1363
+ Sets a custom name for a client that will be shown in the UniFi
1364
+ controller instead of the hostname or MAC address.
1365
+
1366
+ Examples:
1367
+ ui lo clients rename 7A:3E:07:23:13:75 "AEG Dampfgarer"
1368
+ ui lo clients rename "old-name" "new-name" -y
1369
+ """
1370
+ async def _resolve_and_get_id():
1371
+ api_client = UniFiLocalClient()
1372
+ mac, current_name = await resolve_client_identifier(api_client, identifier)
1373
+ if not mac:
1374
+ return None, None, None, api_client
1375
+
1376
+ # Get user record to find the _id
1377
+ response = await api_client.get("/rest/user")
1378
+ users = response.get("data", [])
1379
+ user_id = None
1380
+ for user in users:
1381
+ if user.get("mac", "").lower() == mac.lower():
1382
+ user_id = user.get("_id")
1383
+ break
1384
+
1385
+ return mac, current_name, user_id, api_client
1386
+
1387
+ try:
1388
+ mac, current_name, user_id, api_client = run_with_spinner(
1389
+ _resolve_and_get_id(), "Finding client..."
1390
+ )
1391
+ except Exception as e:
1392
+ handle_error(e)
1393
+ return
1394
+
1395
+ if not mac:
1396
+ console.print(f"[yellow]Client not found:[/yellow] {identifier}")
1397
+ raise typer.Exit(1)
1398
+
1399
+ if not user_id:
1400
+ console.print(f"[red]Error:[/red] Could not find user record for {identifier}")
1401
+ console.print("[dim]The client may not have connected recently enough to have a user record.[/dim]")
1402
+ raise typer.Exit(1)
1403
+
1404
+ display = f"{current_name} ({mac.upper()})" if current_name else mac.upper()
1405
+
1406
+ # Confirm action
1407
+ if not yes:
1408
+ if not typer.confirm(f"Rename {display} to \"{new_name}\"?"):
1409
+ console.print("[dim]Cancelled[/dim]")
1410
+ raise typer.Exit(0)
1411
+
1412
+ # Execute action
1413
+ async def _rename():
1414
+ return await api_client.set_client_name(user_id, new_name)
1415
+
1416
+ try:
1417
+ success = run_with_spinner(_rename(), "Renaming client...")
1418
+ except Exception as e:
1419
+ handle_error(e)
1420
+ return
1421
+
1422
+ if success:
1423
+ if output == OutputFormat.JSON:
1424
+ output_json({
1425
+ "success": True,
1426
+ "mac": mac,
1427
+ "old_name": current_name,
1428
+ "new_name": new_name,
1429
+ })
1430
+ else:
1431
+ console.print(f"[green]Renamed:[/green] {mac.upper()} -> \"{new_name}\"")
1432
+ else:
1433
+ if output == OutputFormat.JSON:
1434
+ output_json({
1435
+ "success": False,
1436
+ "mac": mac,
1437
+ "old_name": current_name,
1438
+ "new_name": new_name,
1439
+ "error": "API call failed",
1440
+ })
1441
+ else:
1442
+ console.print(f"[red]Failed to rename client:[/red] {display}")
1443
+ raise typer.Exit(1)
1444
+
1445
+
1446
+ @app.command("duplicates")
1447
+ def find_duplicates(
1448
+ output: Annotated[
1449
+ OutputFormat,
1450
+ typer.Option("--output", "-o", help="Output format"),
1451
+ ] = OutputFormat.TABLE,
1452
+ ) -> None:
1453
+ """Find clients with duplicate names.
1454
+
1455
+ Searches all known clients (including offline) for duplicate names.
1456
+ This can indicate:
1457
+ - Same device with multiple NICs (WiFi + Ethernet)
1458
+ - Different devices that happen to share a name
1459
+
1460
+ Shows connection type (wired/wireless) to help distinguish.
1461
+ """
1462
+ async def _list():
1463
+ api_client = UniFiLocalClient()
1464
+ return await api_client.list_all_clients()
1465
+
1466
+ try:
1467
+ clients = run_with_spinner(_list(), "Finding duplicates...")
1468
+ except Exception as e:
1469
+ handle_error(e)
1470
+ return
1471
+
1472
+ # Group clients by name
1473
+ by_name: dict[str, list[dict]] = {}
1474
+ for client in clients:
1475
+ name = client.get("name") or client.get("hostname") or ""
1476
+ if not name:
1477
+ continue
1478
+ name_lower = name.lower()
1479
+ if name_lower not in by_name:
1480
+ by_name[name_lower] = []
1481
+ by_name[name_lower].append(client)
1482
+
1483
+ # Find duplicates (names with more than one client)
1484
+ duplicates = {name: clients for name, clients in by_name.items() if len(clients) > 1}
1485
+
1486
+ if not duplicates:
1487
+ console.print("[green]No duplicate client names found.[/green]")
1488
+ return
1489
+
1490
+ if output == OutputFormat.JSON:
1491
+ # Format for JSON output
1492
+ result = []
1493
+ for name, clients in sorted(duplicates.items()):
1494
+ # Determine if likely multi-NIC (has both wired and wireless)
1495
+ has_wired = any(c.get("is_wired", False) for c in clients)
1496
+ has_wireless = any(not c.get("is_wired", False) for c in clients)
1497
+ likely_multi_nic = has_wired and has_wireless
1498
+
1499
+ for client in clients:
1500
+ is_wired = client.get("is_wired", False)
1501
+ result.append({
1502
+ "name": client.get("name") or client.get("hostname"),
1503
+ "mac": client.get("mac", "").upper(),
1504
+ "ip": client.get("ip") or client.get("last_ip") or "",
1505
+ "type": "wired" if is_wired else "wireless",
1506
+ "vendor": client.get("oui", ""),
1507
+ "likely_multi_nic": likely_multi_nic,
1508
+ })
1509
+ output_json(result)
1510
+ else:
1511
+ # Table output grouped by name
1512
+ console.print()
1513
+ console.print(f"[bold]Found {len(duplicates)} duplicate name(s):[/bold]")
1514
+ console.print()
1515
+
1516
+ for name, clients in sorted(duplicates.items()):
1517
+ display_name = clients[0].get("name") or clients[0].get("hostname")
1518
+
1519
+ # Check if likely multi-NIC device
1520
+ has_wired = any(c.get("is_wired", False) for c in clients)
1521
+ has_wireless = any(not c.get("is_wired", False) for c in clients)
1522
+ likely_multi_nic = has_wired and has_wireless
1523
+
1524
+ if likely_multi_nic:
1525
+ console.print(f"[yellow]{display_name}[/yellow] ({len(clients)} NICs) [dim]← likely same device[/dim]")
1526
+ else:
1527
+ console.print(f"[yellow]{display_name}[/yellow] ({len(clients)} clients)")
1528
+
1529
+ for client in clients:
1530
+ mac = client.get("mac", "").upper()
1531
+ ip = client.get("ip") or client.get("last_ip") or "no IP"
1532
+ is_wired = client.get("is_wired", False)
1533
+ conn_type = "[blue]wired[/blue]" if is_wired else "[cyan]wifi[/cyan]"
1534
+ vendor = client.get("oui", "")
1535
+ vendor_str = f" - {vendor}" if vendor else ""
1536
+ console.print(f" • {mac} ({ip}) {conn_type}{vendor_str}")
1537
+ console.print()