ui-cli 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. ui_cli/__init__.py +31 -0
  2. ui_cli/client.py +269 -0
  3. ui_cli/commands/__init__.py +1 -0
  4. ui_cli/commands/devices.py +187 -0
  5. ui_cli/commands/groups.py +503 -0
  6. ui_cli/commands/hosts.py +114 -0
  7. ui_cli/commands/isp.py +100 -0
  8. ui_cli/commands/local/__init__.py +63 -0
  9. ui_cli/commands/local/apgroups.py +445 -0
  10. ui_cli/commands/local/clients.py +1537 -0
  11. ui_cli/commands/local/config.py +758 -0
  12. ui_cli/commands/local/devices.py +570 -0
  13. ui_cli/commands/local/dpi.py +369 -0
  14. ui_cli/commands/local/events.py +289 -0
  15. ui_cli/commands/local/firewall.py +285 -0
  16. ui_cli/commands/local/health.py +195 -0
  17. ui_cli/commands/local/networks.py +426 -0
  18. ui_cli/commands/local/portfwd.py +153 -0
  19. ui_cli/commands/local/stats.py +234 -0
  20. ui_cli/commands/local/utils.py +85 -0
  21. ui_cli/commands/local/vouchers.py +410 -0
  22. ui_cli/commands/local/wan.py +302 -0
  23. ui_cli/commands/local/wlans.py +257 -0
  24. ui_cli/commands/mcp.py +416 -0
  25. ui_cli/commands/sdwan.py +168 -0
  26. ui_cli/commands/sites.py +65 -0
  27. ui_cli/commands/speedtest.py +192 -0
  28. ui_cli/commands/status.py +410 -0
  29. ui_cli/commands/version.py +13 -0
  30. ui_cli/config.py +106 -0
  31. ui_cli/groups.py +567 -0
  32. ui_cli/local_client.py +897 -0
  33. ui_cli/main.py +61 -0
  34. ui_cli/models.py +188 -0
  35. ui_cli/output.py +251 -0
  36. ui_cli-1.2.1.dist-info/METADATA +1315 -0
  37. ui_cli-1.2.1.dist-info/RECORD +46 -0
  38. ui_cli-1.2.1.dist-info/WHEEL +4 -0
  39. ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
  40. ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
  41. ui_mcp/ARCHITECTURE.md +243 -0
  42. ui_mcp/README.md +235 -0
  43. ui_mcp/__init__.py +7 -0
  44. ui_mcp/__main__.py +10 -0
  45. ui_mcp/cli_runner.py +112 -0
  46. ui_mcp/server.py +468 -0
@@ -0,0 +1,410 @@
1
+ """Voucher management commands for guest WiFi access."""
2
+
3
+ import asyncio
4
+ from datetime import datetime, timezone
5
+ from typing import Annotated, Any
6
+
7
+ import typer
8
+
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
+ output_table,
16
+ print_error,
17
+ print_success,
18
+ )
19
+
20
+ app = typer.Typer(name="vouchers", help="Guest WiFi voucher management", no_args_is_help=True)
21
+
22
+
23
+ def format_duration(minutes: int | None) -> str:
24
+ """Format duration in human-readable form."""
25
+ if not minutes:
26
+ return "-"
27
+
28
+ if minutes < 60:
29
+ return f"{minutes}m"
30
+ elif minutes < 1440:
31
+ hours = minutes // 60
32
+ return f"{hours}h"
33
+ else:
34
+ days = minutes // 1440
35
+ return f"{days}d"
36
+
37
+
38
+ def format_quota(mb: int | None) -> str:
39
+ """Format data quota in human-readable form."""
40
+ if not mb or mb == 0:
41
+ return "No limit"
42
+
43
+ if mb >= 1024:
44
+ gb = mb / 1024
45
+ return f"{gb:.1f} GB"
46
+ return f"{mb} MB"
47
+
48
+
49
+ def format_timestamp(ts: int | None) -> str:
50
+ """Format Unix timestamp to date string."""
51
+ if not ts:
52
+ return "-"
53
+ try:
54
+ dt = datetime.fromtimestamp(ts, tz=timezone.utc)
55
+ return dt.strftime("%Y-%m-%d")
56
+ except (ValueError, OSError):
57
+ return str(ts)
58
+
59
+
60
+ def format_code(code: str | None) -> str:
61
+ """Format voucher code with dash for readability."""
62
+ if not code:
63
+ return "-"
64
+ # Insert dash in middle if not present
65
+ if "-" not in code and len(code) == 10:
66
+ return f"{code[:5]}-{code[5:]}"
67
+ return code
68
+
69
+
70
+ def is_voucher_expired(voucher: dict[str, Any]) -> bool:
71
+ """Check if voucher is expired."""
72
+ create_time = voucher.get("create_time", 0)
73
+ duration = voucher.get("duration", 0) # in minutes
74
+ if create_time and duration:
75
+ expires_at = create_time + (duration * 60) # convert to seconds
76
+ return datetime.now(timezone.utc).timestamp() > expires_at
77
+ return False
78
+
79
+
80
+ def get_voucher_status(voucher: dict[str, Any]) -> tuple[str, str]:
81
+ """Get voucher status and style."""
82
+ used = voucher.get("used", 0)
83
+ quota = voucher.get("quota", 1) # multi-use count
84
+
85
+ # Check if expired
86
+ if is_voucher_expired(voucher):
87
+ return "expired", "red"
88
+
89
+ if used >= quota:
90
+ return "used", "dim"
91
+ elif used > 0:
92
+ return "partial", "yellow"
93
+ else:
94
+ return "unused", "green"
95
+
96
+
97
+ @app.command("list")
98
+ def list_vouchers(
99
+ unused: Annotated[
100
+ bool,
101
+ typer.Option("--unused", "-u", help="Show only unused vouchers"),
102
+ ] = False,
103
+ used: Annotated[
104
+ bool,
105
+ typer.Option("--used", help="Show only used vouchers"),
106
+ ] = False,
107
+ output: Annotated[
108
+ OutputFormat,
109
+ typer.Option("--output", "-o", help="Output format"),
110
+ ] = OutputFormat.TABLE,
111
+ ) -> None:
112
+ """List all vouchers."""
113
+ from ui_cli.commands.local.utils import run_with_spinner
114
+
115
+ async def _list():
116
+ client = UniFiLocalClient()
117
+ return await client.get_vouchers()
118
+
119
+ try:
120
+ vouchers = run_with_spinner(_list(), "Fetching vouchers...")
121
+ except LocalAPIError as e:
122
+ print_error(str(e))
123
+ raise typer.Exit(1)
124
+
125
+ if not vouchers:
126
+ console.print("[dim]No vouchers found[/dim]")
127
+ return
128
+
129
+ # Filter if requested
130
+ if unused:
131
+ vouchers = [v for v in vouchers if v.get("used", 0) == 0]
132
+ elif used:
133
+ vouchers = [v for v in vouchers if v.get("used", 0) > 0]
134
+
135
+ if not vouchers:
136
+ console.print("[dim]No matching vouchers[/dim]")
137
+ return
138
+
139
+ if output == OutputFormat.JSON:
140
+ output_json(vouchers)
141
+ elif output == OutputFormat.CSV:
142
+ columns = [
143
+ ("_id", "ID"),
144
+ ("code", "Code"),
145
+ ("duration", "Duration (min)"),
146
+ ("quota", "Uses"),
147
+ ("used", "Used"),
148
+ ("qos_usage_quota", "Quota (MB)"),
149
+ ("note", "Note"),
150
+ ("create_time", "Created"),
151
+ ]
152
+ csv_data = []
153
+ for v in vouchers:
154
+ csv_data.append({
155
+ "_id": v.get("_id", ""),
156
+ "code": format_code(v.get("code")),
157
+ "duration": v.get("duration", 0),
158
+ "quota": v.get("quota", 1),
159
+ "used": v.get("used", 0),
160
+ "qos_usage_quota": v.get("qos_usage_quota", ""),
161
+ "note": v.get("note", ""),
162
+ "create_time": format_timestamp(v.get("create_time")),
163
+ })
164
+ output_csv(csv_data, columns)
165
+ else:
166
+ from rich.table import Table
167
+
168
+ table = Table(title="Guest Vouchers", show_header=True, header_style="bold cyan")
169
+ table.add_column("ID", style="dim")
170
+ table.add_column("Code")
171
+ table.add_column("Duration")
172
+ table.add_column("Quota")
173
+ table.add_column("Used")
174
+ table.add_column("Status")
175
+ table.add_column("Note", style="dim")
176
+
177
+ for v in vouchers:
178
+ voucher_id = v.get("_id", "")
179
+ code = format_code(v.get("code"))
180
+ duration = format_duration(v.get("duration", 0))
181
+ quota = format_quota(v.get("qos_usage_quota"))
182
+ multi_use = v.get("quota", 1)
183
+ used_count = v.get("used", 0)
184
+ used_str = f"{used_count}/{multi_use}"
185
+ note = v.get("note", "") or ""
186
+ status, style = get_voucher_status(v)
187
+
188
+ table.add_row(
189
+ voucher_id,
190
+ code,
191
+ duration,
192
+ quota,
193
+ used_str,
194
+ f"[{style}]{status}[/{style}]",
195
+ note[:20] + "..." if len(note) > 20 else note,
196
+ )
197
+
198
+ console.print(table)
199
+ console.print(f"\n[dim]{len(vouchers)} voucher(s)[/dim]")
200
+
201
+
202
+ @app.command("create")
203
+ def create_voucher(
204
+ count: Annotated[
205
+ int,
206
+ typer.Option("--count", "-c", help="Number of vouchers to create"),
207
+ ] = 1,
208
+ duration: Annotated[
209
+ int,
210
+ typer.Option("--duration", "-d", help="Duration in minutes (1440 = 24h)"),
211
+ ] = 1440,
212
+ quota: Annotated[
213
+ int,
214
+ typer.Option("--quota", "-q", help="Data quota in MB (0 = unlimited)"),
215
+ ] = 0,
216
+ up_limit: Annotated[
217
+ int,
218
+ typer.Option("--up", help="Upload limit in kbps (0 = unlimited)"),
219
+ ] = 0,
220
+ down_limit: Annotated[
221
+ int,
222
+ typer.Option("--down", help="Download limit in kbps (0 = unlimited)"),
223
+ ] = 0,
224
+ multi_use: Annotated[
225
+ int,
226
+ typer.Option("--multi-use", "-m", help="Number of uses per voucher"),
227
+ ] = 1,
228
+ note: Annotated[
229
+ str | None,
230
+ typer.Option("--note", "-n", help="Note/description"),
231
+ ] = None,
232
+ output: Annotated[
233
+ OutputFormat,
234
+ typer.Option("--output", "-o", help="Output format"),
235
+ ] = OutputFormat.TABLE,
236
+ ) -> None:
237
+ """Create new voucher(s)."""
238
+
239
+ async def _create():
240
+ client = UniFiLocalClient()
241
+ return await client.create_voucher(
242
+ count=count,
243
+ duration=duration,
244
+ quota=quota,
245
+ up_limit=up_limit,
246
+ down_limit=down_limit,
247
+ multi_use=multi_use,
248
+ note=note,
249
+ )
250
+
251
+ try:
252
+ result = run_with_spinner(_create(), "Creating voucher...")
253
+ except LocalAPIError as e:
254
+ print_error(str(e))
255
+ raise typer.Exit(1)
256
+
257
+ if not result:
258
+ print_error("Failed to create vouchers")
259
+ raise typer.Exit(1)
260
+
261
+ # Create API returns minimal data, fetch full voucher list to get details
262
+ # Filter by create_time from the result
263
+ create_time = result[0].get("create_time", 0) if result else 0
264
+
265
+ async def _fetch():
266
+ client = UniFiLocalClient()
267
+ vouchers = await client.get_vouchers()
268
+ # Filter vouchers created at or after our create_time
269
+ return [v for v in vouchers if v.get("create_time", 0) >= create_time][:count]
270
+
271
+ try:
272
+ created = run_with_spinner(_fetch(), "Fetching created vouchers...")
273
+ except LocalAPIError:
274
+ created = []
275
+
276
+ if not created:
277
+ print_success(f"Created {count} voucher(s)")
278
+ console.print("[dim]Run 'ui lo vouchers list' to see them[/dim]")
279
+ return
280
+
281
+ if output == OutputFormat.JSON:
282
+ output_json(created)
283
+ return
284
+
285
+ # Table output
286
+ from rich.table import Table
287
+
288
+ console.print()
289
+ print_success(f"Created {len(created)} voucher(s):")
290
+ console.print()
291
+
292
+ table = Table(show_header=True, header_style="bold cyan", box=None)
293
+ table.add_column("Code")
294
+ table.add_column("Duration")
295
+ table.add_column("Quota")
296
+ if multi_use > 1:
297
+ table.add_column("Uses")
298
+
299
+ for v in created:
300
+ code = format_code(v.get("code"))
301
+ dur = format_duration(duration)
302
+ q = format_quota(quota)
303
+
304
+ if multi_use > 1:
305
+ table.add_row(f"[green]{code}[/green]", dur, q, str(multi_use))
306
+ else:
307
+ table.add_row(f"[green]{code}[/green]", dur, q)
308
+
309
+ console.print(table)
310
+ console.print()
311
+ console.print("[dim]Tip: Share these codes with guests for WiFi access[/dim]")
312
+ console.print()
313
+
314
+
315
+ @app.command("revoke")
316
+ def revoke_voucher(
317
+ voucher_id: Annotated[str, typer.Argument(help="Voucher ID to revoke")],
318
+ yes: Annotated[
319
+ bool,
320
+ typer.Option("--yes", "-y", help="Skip confirmation"),
321
+ ] = False,
322
+ ) -> None:
323
+ """Revoke/delete a voucher."""
324
+
325
+ if not yes:
326
+ confirm = typer.confirm(f"Revoke voucher {voucher_id}?")
327
+ if not confirm:
328
+ console.print("[dim]Cancelled[/dim]")
329
+ raise typer.Exit(0)
330
+
331
+ async def _revoke():
332
+ client = UniFiLocalClient()
333
+ return await client.revoke_voucher(voucher_id)
334
+
335
+ try:
336
+ success = run_with_spinner(_revoke(), "Revoking voucher...")
337
+ except LocalAPIError as e:
338
+ print_error(str(e))
339
+ raise typer.Exit(1)
340
+
341
+ if success:
342
+ print_success(f"Voucher {voucher_id} revoked")
343
+ else:
344
+ print_error(f"Failed to revoke voucher {voucher_id}")
345
+ raise typer.Exit(1)
346
+
347
+
348
+ @app.command("delete-all")
349
+ def delete_all_vouchers(
350
+ yes: Annotated[
351
+ bool,
352
+ typer.Option("--yes", "-y", help="Skip confirmation"),
353
+ ] = False,
354
+ expired_only: Annotated[
355
+ bool,
356
+ typer.Option("--expired", "-e", help="Delete only expired vouchers"),
357
+ ] = False,
358
+ ) -> None:
359
+ """Delete all vouchers."""
360
+
361
+ async def _get_vouchers():
362
+ client = UniFiLocalClient()
363
+ return await client.get_vouchers()
364
+
365
+ try:
366
+ vouchers = run_with_spinner(_get_vouchers(), "Fetching vouchers...")
367
+ except LocalAPIError as e:
368
+ print_error(str(e))
369
+ raise typer.Exit(1)
370
+
371
+ if not vouchers:
372
+ console.print("[dim]No vouchers to delete[/dim]")
373
+ return
374
+
375
+ # Filter to expired only if requested
376
+ if expired_only:
377
+ vouchers = [v for v in vouchers if is_voucher_expired(v)]
378
+ if not vouchers:
379
+ console.print("[dim]No expired vouchers to delete[/dim]")
380
+ return
381
+
382
+ count = len(vouchers)
383
+ label = "expired vouchers" if expired_only else "vouchers"
384
+
385
+ if not yes:
386
+ confirm = typer.confirm(f"Delete all {count} {label}?")
387
+ if not confirm:
388
+ console.print("[dim]Cancelled[/dim]")
389
+ raise typer.Exit(0)
390
+
391
+ async def _delete_all():
392
+ client = UniFiLocalClient()
393
+ deleted = 0
394
+ for v in vouchers:
395
+ voucher_id = v.get("_id")
396
+ if voucher_id:
397
+ try:
398
+ if await client.revoke_voucher(voucher_id):
399
+ deleted += 1
400
+ except LocalAPIError:
401
+ pass
402
+ return deleted
403
+
404
+ try:
405
+ deleted = run_with_spinner(_delete_all(), "Deleting vouchers...")
406
+ except LocalAPIError as e:
407
+ print_error(str(e))
408
+ raise typer.Exit(1)
409
+
410
+ print_success(f"Deleted {deleted}/{count} {label}")
@@ -0,0 +1,302 @@
1
+ """WAN configuration commands for local controller."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import typer
6
+
7
+ from ui_cli.local_client import LocalAPIError, UniFiLocalClient
8
+ from ui_cli.output import (
9
+ OutputFormat,
10
+ console,
11
+ output_csv,
12
+ output_json,
13
+ print_error,
14
+ )
15
+
16
+ app = typer.Typer(name="wan", help="WAN configuration (upstream DNS, type)", no_args_is_help=True)
17
+
18
+
19
+ def _is_wan(network: dict[str, Any]) -> bool:
20
+ return network.get("purpose") in ("wan", "wan2")
21
+
22
+
23
+ def _pref(network: dict[str, Any]) -> str:
24
+ return network.get("wan_dns_preference") or "auto"
25
+
26
+
27
+ @app.command("list")
28
+ def list_wans(
29
+ output: Annotated[
30
+ OutputFormat,
31
+ typer.Option("--output", "-o", help="Output format"),
32
+ ] = OutputFormat.TABLE,
33
+ ) -> None:
34
+ """List WAN uplinks with their DNS settings."""
35
+ from ui_cli.commands.local.utils import run_with_spinner
36
+
37
+ async def _list():
38
+ client = UniFiLocalClient()
39
+ nets = await client.get_networks()
40
+ return [n for n in nets if _is_wan(n)]
41
+
42
+ try:
43
+ wans = run_with_spinner(_list(), "Fetching WAN networks...")
44
+ except LocalAPIError as e:
45
+ print_error(str(e))
46
+ raise typer.Exit(1)
47
+
48
+ if not wans:
49
+ console.print("[dim]No WAN networks found[/dim]")
50
+ return
51
+
52
+ if output == OutputFormat.JSON:
53
+ output_json(wans)
54
+ return
55
+
56
+ if output == OutputFormat.CSV:
57
+ columns = [
58
+ ("_id", "ID"),
59
+ ("name", "Name"),
60
+ ("purpose", "Purpose"),
61
+ ("wan_type", "Type"),
62
+ ("wan_dns_preference", "DNS Pref"),
63
+ ("wan_dns1", "DNS1"),
64
+ ("wan_dns2", "DNS2"),
65
+ ]
66
+ output_csv(wans, columns)
67
+ return
68
+
69
+ from rich.table import Table
70
+
71
+ table = Table(title="WAN Uplinks", show_header=True, header_style="bold cyan")
72
+ table.add_column("ID", style="dim")
73
+ table.add_column("Name")
74
+ table.add_column("Purpose")
75
+ table.add_column("Type")
76
+ table.add_column("DNS Pref")
77
+ table.add_column("DNS1")
78
+ table.add_column("DNS2")
79
+
80
+ for n in wans:
81
+ table.add_row(
82
+ n.get("_id", ""),
83
+ n.get("name", ""),
84
+ n.get("purpose", ""),
85
+ n.get("wan_type", "-"),
86
+ _pref(n),
87
+ n.get("wan_dns1", "") or "-",
88
+ n.get("wan_dns2", "") or "-",
89
+ )
90
+
91
+ console.print(table)
92
+ console.print(f"\n[dim]{len(wans)} WAN(s)[/dim]")
93
+
94
+
95
+ @app.command("get")
96
+ def get_wan(
97
+ wan_id: Annotated[str, typer.Argument(help="WAN ID or name")],
98
+ output: Annotated[
99
+ OutputFormat,
100
+ typer.Option("--output", "-o", help="Output format"),
101
+ ] = OutputFormat.TABLE,
102
+ ) -> None:
103
+ """Show WAN details (DNS, IPv6 DNS, gateway, type)."""
104
+ from ui_cli.commands.local.utils import run_with_spinner
105
+
106
+ async def _get():
107
+ client = UniFiLocalClient()
108
+ nets = await client.get_networks()
109
+ wans = [n for n in nets if _is_wan(n)]
110
+ for n in wans:
111
+ if n.get("_id") == wan_id or n.get("name", "").lower() == wan_id.lower():
112
+ return n
113
+ for n in wans:
114
+ if wan_id.lower() in n.get("name", "").lower():
115
+ return n
116
+ return None
117
+
118
+ try:
119
+ wan = run_with_spinner(_get(), "Finding WAN...")
120
+ except LocalAPIError as e:
121
+ print_error(str(e))
122
+ raise typer.Exit(1)
123
+
124
+ if not wan:
125
+ print_error(f"WAN '{wan_id}' not found")
126
+ raise typer.Exit(1)
127
+
128
+ if output == OutputFormat.JSON:
129
+ output_json(wan)
130
+ return
131
+
132
+ from rich.table import Table
133
+
134
+ console.print()
135
+ console.print(f"[bold cyan]WAN: {wan.get('name', 'Unknown')}[/bold cyan]")
136
+ console.print("─" * 40)
137
+ console.print()
138
+
139
+ table = Table(show_header=False, box=None, padding=(0, 2))
140
+ table.add_column("Key", style="dim")
141
+ table.add_column("Value")
142
+
143
+ table.add_row("ID:", wan.get("_id", ""))
144
+ table.add_row("Purpose:", wan.get("purpose", ""))
145
+ table.add_row("Type:", wan.get("wan_type", "-"))
146
+ table.add_row("", "")
147
+ table.add_row("DNS Preference:", _pref(wan))
148
+ table.add_row("DNS1:", wan.get("wan_dns1", "") or "-")
149
+ table.add_row("DNS2:", wan.get("wan_dns2", "") or "-")
150
+ table.add_row("", "")
151
+ table.add_row("IPv6 DNS Preference:", wan.get("wan_ipv6_dns_preference", "auto"))
152
+ table.add_row("IPv6 DNS1:", wan.get("wan_ipv6_dns1", "") or "-")
153
+ table.add_row("IPv6 DNS2:", wan.get("wan_ipv6_dns2", "") or "-")
154
+
155
+ console.print(table)
156
+ console.print()
157
+
158
+
159
+ @app.command("update")
160
+ def update_wan(
161
+ wan_ids: Annotated[
162
+ list[str],
163
+ typer.Argument(help="WAN ID(s) or name(s) — pass multiple to update in one call"),
164
+ ],
165
+ dns1: Annotated[
166
+ str | None,
167
+ typer.Option("--dns1", help="Primary upstream DNS server (wan_dns1)"),
168
+ ] = None,
169
+ dns2: Annotated[
170
+ str | None,
171
+ typer.Option("--dns2", help="Secondary upstream DNS server (wan_dns2)"),
172
+ ] = None,
173
+ dns6_1: Annotated[
174
+ str | None,
175
+ typer.Option("--dns6-1", help="Primary IPv6 upstream DNS server (wan_ipv6_dns1)"),
176
+ ] = None,
177
+ dns6_2: Annotated[
178
+ str | None,
179
+ typer.Option("--dns6-2", help="Secondary IPv6 upstream DNS server (wan_ipv6_dns2)"),
180
+ ] = None,
181
+ auto: Annotated[
182
+ bool,
183
+ typer.Option(
184
+ "--auto",
185
+ help="Switch DNS preference back to auto (ISP-provided); clears dns1/dns2",
186
+ ),
187
+ ] = False,
188
+ output: Annotated[
189
+ OutputFormat,
190
+ typer.Option("--output", "-o", help="Output format"),
191
+ ] = OutputFormat.TABLE,
192
+ ) -> None:
193
+ """Update WAN DNS settings.
194
+
195
+ Setting --dns1 or --dns2 implies wan_dns_preference=manual. --auto reverts
196
+ to ISP-provided DNS and clears the manual DNS entries.
197
+ """
198
+ from ui_cli.commands.local.utils import run_with_spinner
199
+ from ui_cli.output import print_success
200
+
201
+ v4_requested = dns1 is not None or dns2 is not None
202
+ v6_requested = dns6_1 is not None or dns6_2 is not None
203
+
204
+ if not v4_requested and not v6_requested and not auto:
205
+ print_error("At least one option required (--dns1, --dns2, --dns6-1, --dns6-2, --auto)")
206
+ raise typer.Exit(1)
207
+
208
+ if auto and (v4_requested or v6_requested):
209
+ print_error("--auto cannot be combined with --dns1/--dns2/--dns6-1/--dns6-2")
210
+ raise typer.Exit(1)
211
+
212
+ def _resolve(wans: list[dict[str, Any]], needle: str) -> dict[str, Any] | None:
213
+ for n in wans:
214
+ if n.get("_id") == needle or n.get("name", "").lower() == needle.lower():
215
+ return n
216
+ for n in wans:
217
+ if needle.lower() in n.get("name", "").lower():
218
+ return n
219
+ return None
220
+
221
+ async def _update_one(
222
+ client: UniFiLocalClient,
223
+ wans: list[dict[str, Any]],
224
+ needle: str,
225
+ ) -> tuple[dict[str, Any] | None, str | None]:
226
+ wan = _resolve(wans, needle)
227
+ if not wan:
228
+ return None, f"WAN '{needle}' not found"
229
+
230
+ net_id = wan["_id"]
231
+ payload: dict[str, Any] = {"_id": net_id}
232
+
233
+ if auto:
234
+ payload["wan_dns_preference"] = "auto"
235
+ payload["wan_dns1"] = ""
236
+ payload["wan_dns2"] = ""
237
+ elif v4_requested:
238
+ payload["wan_dns_preference"] = "manual"
239
+ if dns1 is not None:
240
+ payload["wan_dns1"] = dns1
241
+ if dns2 is not None:
242
+ payload["wan_dns2"] = dns2
243
+
244
+ if v6_requested:
245
+ payload["wan_ipv6_dns_preference"] = "manual"
246
+ if dns6_1 is not None:
247
+ payload["wan_ipv6_dns1"] = dns6_1
248
+ if dns6_2 is not None:
249
+ payload["wan_ipv6_dns2"] = dns6_2
250
+
251
+ updated = await client.update_network(net_id, payload)
252
+ return updated, None
253
+
254
+ async def _update_all():
255
+ client = UniFiLocalClient()
256
+ nets = await client.get_networks()
257
+ wans = [n for n in nets if _is_wan(n)]
258
+ results: list[tuple[str, dict[str, Any] | None, str | None]] = []
259
+ for needle in wan_ids:
260
+ result, err = await _update_one(client, wans, needle)
261
+ results.append((needle, result, err))
262
+ return results
263
+
264
+ try:
265
+ results = run_with_spinner(_update_all(), "Updating WAN(s)...")
266
+ except LocalAPIError as e:
267
+ print_error(str(e))
268
+ raise typer.Exit(1)
269
+
270
+ if output == OutputFormat.JSON:
271
+ output_json([
272
+ {"wan": needle, "error": err, "result": result}
273
+ for needle, result, err in results
274
+ ])
275
+ raise typer.Exit(1 if any(err for _, _, err in results) else 0)
276
+
277
+ exit_code = 0
278
+ for needle, result, err in results:
279
+ if err:
280
+ print_error(f"[{needle}] {err}")
281
+ exit_code = 1
282
+ continue
283
+ if not result:
284
+ print_error(f"[{needle}] Update failed - no response from controller")
285
+ exit_code = 1
286
+ continue
287
+
288
+ name = result.get("name", needle)
289
+ print_success(f"Updated WAN '{name}'")
290
+ console.print(f" DNS Pref: {result.get('wan_dns_preference', 'auto')}")
291
+ if result.get("wan_dns1") or result.get("wan_dns2"):
292
+ console.print(
293
+ f" DNS: {result.get('wan_dns1', '') or '-'}, {result.get('wan_dns2', '') or '-'}"
294
+ )
295
+ if result.get("wan_ipv6_dns1") or result.get("wan_ipv6_dns2"):
296
+ console.print(
297
+ f" IPv6 DNS: {result.get('wan_ipv6_dns1', '') or '-'}, "
298
+ f"{result.get('wan_ipv6_dns2', '') or '-'}"
299
+ )
300
+
301
+ if exit_code:
302
+ raise typer.Exit(exit_code)