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