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.
- ui_cli/__init__.py +31 -0
- ui_cli/client.py +269 -0
- ui_cli/commands/__init__.py +1 -0
- ui_cli/commands/devices.py +187 -0
- ui_cli/commands/groups.py +503 -0
- ui_cli/commands/hosts.py +114 -0
- ui_cli/commands/isp.py +100 -0
- ui_cli/commands/local/__init__.py +63 -0
- ui_cli/commands/local/apgroups.py +445 -0
- ui_cli/commands/local/clients.py +1537 -0
- ui_cli/commands/local/config.py +758 -0
- ui_cli/commands/local/devices.py +570 -0
- ui_cli/commands/local/dpi.py +369 -0
- ui_cli/commands/local/events.py +289 -0
- ui_cli/commands/local/firewall.py +285 -0
- ui_cli/commands/local/health.py +195 -0
- ui_cli/commands/local/networks.py +426 -0
- ui_cli/commands/local/portfwd.py +153 -0
- ui_cli/commands/local/stats.py +234 -0
- ui_cli/commands/local/utils.py +85 -0
- ui_cli/commands/local/vouchers.py +410 -0
- ui_cli/commands/local/wan.py +302 -0
- ui_cli/commands/local/wlans.py +257 -0
- ui_cli/commands/mcp.py +416 -0
- ui_cli/commands/sdwan.py +168 -0
- ui_cli/commands/sites.py +65 -0
- ui_cli/commands/speedtest.py +192 -0
- ui_cli/commands/status.py +410 -0
- ui_cli/commands/version.py +13 -0
- ui_cli/config.py +106 -0
- ui_cli/groups.py +567 -0
- ui_cli/local_client.py +897 -0
- ui_cli/main.py +61 -0
- ui_cli/models.py +188 -0
- ui_cli/output.py +251 -0
- ui_cli-1.2.1.dist-info/METADATA +1315 -0
- ui_cli-1.2.1.dist-info/RECORD +46 -0
- ui_cli-1.2.1.dist-info/WHEEL +4 -0
- ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
- ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
- ui_mcp/ARCHITECTURE.md +243 -0
- ui_mcp/README.md +235 -0
- ui_mcp/__init__.py +7 -0
- ui_mcp/__main__.py +10 -0
- ui_mcp/cli_runner.py +112 -0
- 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()
|