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