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,410 @@
|
|
|
1
|
+
"""Status command - check API connectivity and authentication."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
+
|
|
11
|
+
from ui_cli import __version__
|
|
12
|
+
from ui_cli.config import settings
|
|
13
|
+
from ui_cli.local_client import (
|
|
14
|
+
LocalAPIError,
|
|
15
|
+
LocalAuthenticationError,
|
|
16
|
+
LocalConnectionError,
|
|
17
|
+
UniFiLocalClient,
|
|
18
|
+
)
|
|
19
|
+
from ui_cli.output import OutputFormat, console, output_json
|
|
20
|
+
|
|
21
|
+
# Timeout for status checks (seconds)
|
|
22
|
+
STATUS_CHECK_TIMEOUT = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(help="Check API connectivity and authentication status")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def mask_api_key(key: str, show_full: bool = False) -> str:
|
|
29
|
+
"""Mask API key for display."""
|
|
30
|
+
if not key:
|
|
31
|
+
return "(not configured)"
|
|
32
|
+
if show_full:
|
|
33
|
+
return key
|
|
34
|
+
if len(key) <= 8:
|
|
35
|
+
return "****"
|
|
36
|
+
return f"****...{key[-6:]}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def check_site_manager_api(verbose: bool = False) -> dict:
|
|
40
|
+
"""Check Site Manager API connectivity and auth."""
|
|
41
|
+
result = {
|
|
42
|
+
"name": "Site Manager API",
|
|
43
|
+
"url": settings.api_url,
|
|
44
|
+
"api_key_configured": bool(settings.api_key),
|
|
45
|
+
"api_key_display": mask_api_key(settings.api_key, show_full=verbose),
|
|
46
|
+
"connection": None,
|
|
47
|
+
"connection_time_ms": None,
|
|
48
|
+
"authentication": None,
|
|
49
|
+
"error": None,
|
|
50
|
+
"hosts_count": None,
|
|
51
|
+
"sites_count": None,
|
|
52
|
+
"devices_count": None,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if not settings.api_key:
|
|
56
|
+
result["error"] = "Set UNIFI_API_KEY in .env file"
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
headers = {
|
|
60
|
+
"X-API-Key": settings.api_key,
|
|
61
|
+
"Accept": "application/json",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
async with httpx.AsyncClient(timeout=STATUS_CHECK_TIMEOUT) as client:
|
|
66
|
+
# Test connection and auth with hosts endpoint
|
|
67
|
+
start = time.perf_counter()
|
|
68
|
+
response = await client.get(
|
|
69
|
+
f"{settings.api_url}/hosts",
|
|
70
|
+
headers=headers,
|
|
71
|
+
)
|
|
72
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
73
|
+
|
|
74
|
+
result["connection"] = "OK"
|
|
75
|
+
result["connection_time_ms"] = round(elapsed_ms, 1)
|
|
76
|
+
|
|
77
|
+
if response.status_code == 200:
|
|
78
|
+
result["authentication"] = "Valid"
|
|
79
|
+
data = response.json()
|
|
80
|
+
hosts = data.get("data", [])
|
|
81
|
+
result["hosts_count"] = len(hosts)
|
|
82
|
+
|
|
83
|
+
# Get sites count
|
|
84
|
+
sites_resp = await client.get(
|
|
85
|
+
f"{settings.api_url}/sites",
|
|
86
|
+
headers=headers,
|
|
87
|
+
)
|
|
88
|
+
if sites_resp.status_code == 200:
|
|
89
|
+
sites_data = sites_resp.json()
|
|
90
|
+
result["sites_count"] = len(sites_data.get("data", []))
|
|
91
|
+
|
|
92
|
+
# Get devices count
|
|
93
|
+
devices_resp = await client.get(
|
|
94
|
+
f"{settings.api_url}/devices",
|
|
95
|
+
headers=headers,
|
|
96
|
+
)
|
|
97
|
+
if devices_resp.status_code == 200:
|
|
98
|
+
devices_data = devices_resp.json()
|
|
99
|
+
# Flatten devices from host groups
|
|
100
|
+
total_devices = 0
|
|
101
|
+
for host_group in devices_data.get("data", []):
|
|
102
|
+
total_devices += len(host_group.get("devices", []))
|
|
103
|
+
result["devices_count"] = total_devices
|
|
104
|
+
|
|
105
|
+
elif response.status_code == 401:
|
|
106
|
+
result["authentication"] = "FAILED"
|
|
107
|
+
result["error"] = "Invalid API key"
|
|
108
|
+
elif response.status_code == 429:
|
|
109
|
+
result["authentication"] = "Valid"
|
|
110
|
+
result["error"] = "Rate limit exceeded"
|
|
111
|
+
else:
|
|
112
|
+
result["authentication"] = "FAILED"
|
|
113
|
+
result["error"] = f"HTTP {response.status_code}"
|
|
114
|
+
|
|
115
|
+
except httpx.ConnectError:
|
|
116
|
+
result["connection"] = "FAILED"
|
|
117
|
+
result["error"] = "Could not connect to api.ui.com"
|
|
118
|
+
except httpx.TimeoutException:
|
|
119
|
+
result["connection"] = "FAILED"
|
|
120
|
+
result["error"] = "Connection timeout"
|
|
121
|
+
except Exception as e:
|
|
122
|
+
result["connection"] = "FAILED"
|
|
123
|
+
result["error"] = str(e)
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def check_local_controller(verbose: bool = False) -> dict:
|
|
129
|
+
"""Check Local Controller connectivity and auth."""
|
|
130
|
+
# Determine auth method for display
|
|
131
|
+
if settings.controller_api_key:
|
|
132
|
+
auth_method = "API Key"
|
|
133
|
+
elif settings.controller_username:
|
|
134
|
+
auth_method = "Username/Password"
|
|
135
|
+
else:
|
|
136
|
+
auth_method = "(not configured)"
|
|
137
|
+
|
|
138
|
+
result = {
|
|
139
|
+
"name": "Local Controller",
|
|
140
|
+
"url": settings.controller_url or "(not configured)",
|
|
141
|
+
"username": settings.controller_username or "(not configured)",
|
|
142
|
+
"site": settings.controller_site or "default",
|
|
143
|
+
"configured": settings.is_local_configured,
|
|
144
|
+
"auth_method": auth_method,
|
|
145
|
+
"connection": None,
|
|
146
|
+
"connection_time_ms": None,
|
|
147
|
+
"authentication": None,
|
|
148
|
+
"error": None,
|
|
149
|
+
"controller_type": None,
|
|
150
|
+
"clients_count": None,
|
|
151
|
+
"devices_count": None,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if not settings.controller_url:
|
|
155
|
+
result["error"] = "Set UNIFI_CONTROLLER_URL in .env file"
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
if not settings.is_local_configured:
|
|
159
|
+
result["error"] = (
|
|
160
|
+
"Set UNIFI_CONTROLLER_API_KEY or "
|
|
161
|
+
"UNIFI_CONTROLLER_USERNAME/UNIFI_CONTROLLER_PASSWORD in .env file"
|
|
162
|
+
)
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
client = UniFiLocalClient(timeout=STATUS_CHECK_TIMEOUT)
|
|
167
|
+
start = time.perf_counter()
|
|
168
|
+
if settings.controller_api_key:
|
|
169
|
+
# API key mode: no session handshake needed — use a lightweight request
|
|
170
|
+
await client.get_health()
|
|
171
|
+
else:
|
|
172
|
+
await client.login()
|
|
173
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
174
|
+
|
|
175
|
+
result["connection"] = "OK"
|
|
176
|
+
result["connection_time_ms"] = round(elapsed_ms, 1)
|
|
177
|
+
result["authentication"] = "Valid"
|
|
178
|
+
result["controller_type"] = "UDM" if client._is_udm else "Cloud Key/Self-hosted"
|
|
179
|
+
|
|
180
|
+
# Get counts
|
|
181
|
+
try:
|
|
182
|
+
clients = await client.list_clients()
|
|
183
|
+
result["clients_count"] = len(clients)
|
|
184
|
+
except LocalAPIError:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
devices = await client.get_devices()
|
|
189
|
+
result["devices_count"] = len(devices)
|
|
190
|
+
except LocalAPIError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
except LocalAuthenticationError as e:
|
|
194
|
+
result["connection"] = "OK"
|
|
195
|
+
result["authentication"] = "FAILED"
|
|
196
|
+
result["error"] = str(e)
|
|
197
|
+
except LocalConnectionError as e:
|
|
198
|
+
result["connection"] = "FAILED"
|
|
199
|
+
result["error"] = str(e)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
result["connection"] = "FAILED"
|
|
202
|
+
result["error"] = str(e)
|
|
203
|
+
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def print_status_table(cloud_status: dict, local_status: dict | None = None) -> None:
|
|
208
|
+
"""Print status in formatted table."""
|
|
209
|
+
from rich.table import Table
|
|
210
|
+
|
|
211
|
+
console.print()
|
|
212
|
+
console.print(f"[bold cyan]UniFi CLI v{__version__}[/bold cyan]")
|
|
213
|
+
console.print("─" * 40)
|
|
214
|
+
console.print()
|
|
215
|
+
|
|
216
|
+
# Site Manager API section
|
|
217
|
+
console.print("[bold]Site Manager API[/bold] (api.ui.com)")
|
|
218
|
+
|
|
219
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
220
|
+
table.add_column("Key", style="dim")
|
|
221
|
+
table.add_column("Value")
|
|
222
|
+
|
|
223
|
+
table.add_row("URL:", cloud_status["url"])
|
|
224
|
+
|
|
225
|
+
# API Key status
|
|
226
|
+
if cloud_status["api_key_configured"]:
|
|
227
|
+
table.add_row("API Key:", f"[green]{cloud_status['api_key_display']}[/green] (configured)")
|
|
228
|
+
else:
|
|
229
|
+
table.add_row("API Key:", f"[red]{cloud_status['api_key_display']}[/red]")
|
|
230
|
+
|
|
231
|
+
# Connection status
|
|
232
|
+
if cloud_status["connection"] == "OK":
|
|
233
|
+
table.add_row(
|
|
234
|
+
"Connection:",
|
|
235
|
+
f"[green]OK[/green] ({cloud_status['connection_time_ms']}ms)"
|
|
236
|
+
)
|
|
237
|
+
elif cloud_status["connection"] == "FAILED":
|
|
238
|
+
table.add_row("Connection:", "[red]FAILED[/red]")
|
|
239
|
+
else:
|
|
240
|
+
table.add_row("Connection:", "[dim]-[/dim]")
|
|
241
|
+
|
|
242
|
+
# Authentication status
|
|
243
|
+
if cloud_status["authentication"] == "Valid":
|
|
244
|
+
table.add_row("Authentication:", "[green]Valid[/green]")
|
|
245
|
+
elif cloud_status["authentication"] == "FAILED":
|
|
246
|
+
table.add_row("Authentication:", "[red]FAILED[/red]")
|
|
247
|
+
else:
|
|
248
|
+
table.add_row("Authentication:", "[dim]-[/dim]")
|
|
249
|
+
|
|
250
|
+
console.print(table)
|
|
251
|
+
|
|
252
|
+
# Error message
|
|
253
|
+
if cloud_status["error"]:
|
|
254
|
+
console.print()
|
|
255
|
+
console.print(f" [red]Error:[/red] {cloud_status['error']}")
|
|
256
|
+
|
|
257
|
+
# Account info (if authenticated)
|
|
258
|
+
if cloud_status["authentication"] == "Valid" and cloud_status["hosts_count"] is not None:
|
|
259
|
+
console.print()
|
|
260
|
+
console.print("[bold]Account Summary:[/bold]")
|
|
261
|
+
|
|
262
|
+
info_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
263
|
+
info_table.add_column("Key", style="dim")
|
|
264
|
+
info_table.add_column("Value")
|
|
265
|
+
|
|
266
|
+
info_table.add_row("Hosts:", str(cloud_status["hosts_count"]))
|
|
267
|
+
info_table.add_row("Sites:", str(cloud_status["sites_count"]))
|
|
268
|
+
info_table.add_row("Devices:", str(cloud_status["devices_count"]))
|
|
269
|
+
|
|
270
|
+
console.print(info_table)
|
|
271
|
+
|
|
272
|
+
# Local Controller section
|
|
273
|
+
if local_status:
|
|
274
|
+
console.print()
|
|
275
|
+
console.print("[bold]Local Controller[/bold]")
|
|
276
|
+
|
|
277
|
+
local_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
278
|
+
local_table.add_column("Key", style="dim")
|
|
279
|
+
local_table.add_column("Value")
|
|
280
|
+
|
|
281
|
+
local_table.add_row("URL:", local_status["url"])
|
|
282
|
+
local_table.add_row("Site:", local_status["site"])
|
|
283
|
+
|
|
284
|
+
if local_status["configured"]:
|
|
285
|
+
local_table.add_row("Username:", f"[green]{local_status['username']}[/green]")
|
|
286
|
+
else:
|
|
287
|
+
local_table.add_row("Username:", f"[red]{local_status['username']}[/red]")
|
|
288
|
+
|
|
289
|
+
# Connection status
|
|
290
|
+
if local_status["connection"] == "OK":
|
|
291
|
+
local_table.add_row(
|
|
292
|
+
"Connection:",
|
|
293
|
+
f"[green]OK[/green] ({local_status['connection_time_ms']}ms)"
|
|
294
|
+
)
|
|
295
|
+
elif local_status["connection"] == "FAILED":
|
|
296
|
+
local_table.add_row("Connection:", "[red]FAILED[/red]")
|
|
297
|
+
else:
|
|
298
|
+
local_table.add_row("Connection:", "[dim]-[/dim]")
|
|
299
|
+
|
|
300
|
+
# Authentication status
|
|
301
|
+
if local_status["authentication"] == "Valid":
|
|
302
|
+
local_table.add_row("Authentication:", "[green]Valid[/green]")
|
|
303
|
+
elif local_status["authentication"] == "FAILED":
|
|
304
|
+
local_table.add_row("Authentication:", "[red]FAILED[/red]")
|
|
305
|
+
else:
|
|
306
|
+
local_table.add_row("Authentication:", "[dim]-[/dim]")
|
|
307
|
+
|
|
308
|
+
# Controller type
|
|
309
|
+
if local_status["controller_type"]:
|
|
310
|
+
local_table.add_row("Type:", local_status["controller_type"])
|
|
311
|
+
|
|
312
|
+
console.print(local_table)
|
|
313
|
+
|
|
314
|
+
# Error message
|
|
315
|
+
if local_status["error"]:
|
|
316
|
+
console.print()
|
|
317
|
+
console.print(f" [red]Error:[/red] {local_status['error']}")
|
|
318
|
+
|
|
319
|
+
# Controller info (if authenticated)
|
|
320
|
+
if local_status["authentication"] == "Valid":
|
|
321
|
+
console.print()
|
|
322
|
+
console.print("[bold]Controller Summary:[/bold]")
|
|
323
|
+
|
|
324
|
+
ctrl_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
325
|
+
ctrl_table.add_column("Key", style="dim")
|
|
326
|
+
ctrl_table.add_column("Value")
|
|
327
|
+
|
|
328
|
+
if local_status["clients_count"] is not None:
|
|
329
|
+
ctrl_table.add_row("Clients:", str(local_status["clients_count"]))
|
|
330
|
+
if local_status["devices_count"] is not None:
|
|
331
|
+
ctrl_table.add_row("Devices:", str(local_status["devices_count"]))
|
|
332
|
+
|
|
333
|
+
console.print(ctrl_table)
|
|
334
|
+
|
|
335
|
+
console.print()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def check_all_status(verbose: bool = False) -> tuple[dict, dict]:
|
|
339
|
+
"""Check both Cloud API and Local Controller status."""
|
|
340
|
+
cloud_status = await check_site_manager_api(verbose=verbose)
|
|
341
|
+
local_status = await check_local_controller(verbose=verbose)
|
|
342
|
+
return cloud_status, local_status
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def check_with_spinner(verbose: bool = False) -> tuple[dict, dict]:
|
|
346
|
+
"""Check both APIs with progress spinner."""
|
|
347
|
+
cloud_status = None
|
|
348
|
+
local_status = None
|
|
349
|
+
|
|
350
|
+
with Progress(
|
|
351
|
+
SpinnerColumn(),
|
|
352
|
+
TextColumn("[cyan]{task.description}"),
|
|
353
|
+
console=console,
|
|
354
|
+
transient=True,
|
|
355
|
+
) as progress:
|
|
356
|
+
# Check Cloud API
|
|
357
|
+
task = progress.add_task("Checking Cloud API...", total=None)
|
|
358
|
+
cloud_status = await check_site_manager_api(verbose=verbose)
|
|
359
|
+
progress.remove_task(task)
|
|
360
|
+
|
|
361
|
+
# Check Local Controller
|
|
362
|
+
if settings.controller_url:
|
|
363
|
+
task = progress.add_task("Checking Local Controller...", total=None)
|
|
364
|
+
local_status = await check_local_controller(verbose=verbose)
|
|
365
|
+
progress.remove_task(task)
|
|
366
|
+
else:
|
|
367
|
+
local_status = await check_local_controller(verbose=verbose)
|
|
368
|
+
|
|
369
|
+
return cloud_status, local_status
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@app.callback(invoke_without_command=True)
|
|
373
|
+
def status(
|
|
374
|
+
ctx: typer.Context,
|
|
375
|
+
output: Annotated[
|
|
376
|
+
OutputFormat,
|
|
377
|
+
typer.Option(
|
|
378
|
+
"--output",
|
|
379
|
+
"-o",
|
|
380
|
+
help="Output format: table or json",
|
|
381
|
+
),
|
|
382
|
+
] = OutputFormat.TABLE,
|
|
383
|
+
verbose: Annotated[
|
|
384
|
+
bool,
|
|
385
|
+
typer.Option(
|
|
386
|
+
"--verbose",
|
|
387
|
+
"-v",
|
|
388
|
+
help="Show detailed information including full API key",
|
|
389
|
+
),
|
|
390
|
+
] = False,
|
|
391
|
+
) -> None:
|
|
392
|
+
"""Check API connectivity and authentication status."""
|
|
393
|
+
|
|
394
|
+
# Run async checks with spinner
|
|
395
|
+
cloud_status, local_status = asyncio.run(check_with_spinner(verbose=verbose))
|
|
396
|
+
|
|
397
|
+
if output == OutputFormat.JSON:
|
|
398
|
+
result = {
|
|
399
|
+
"cloud_api": cloud_status,
|
|
400
|
+
"local_controller": local_status,
|
|
401
|
+
}
|
|
402
|
+
output_json(result, verbose=verbose)
|
|
403
|
+
else:
|
|
404
|
+
print_status_table(cloud_status, local_status)
|
|
405
|
+
|
|
406
|
+
# Exit with error code if neither is authenticated
|
|
407
|
+
cloud_ok = cloud_status["authentication"] == "Valid"
|
|
408
|
+
local_ok = local_status["authentication"] == "Valid"
|
|
409
|
+
if not cloud_ok and not local_ok:
|
|
410
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Version command - display CLI version."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from ui_cli import __version__
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Display CLI version")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.callback(invoke_without_command=True)
|
|
11
|
+
def version() -> None:
|
|
12
|
+
"""Display the CLI version."""
|
|
13
|
+
typer.echo(f"ui-cli version {__version__}")
|
ui_cli/config.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Configuration management using pydantic-settings."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_config_files() -> tuple[str, ...]:
|
|
11
|
+
"""Get config files in priority order (first found wins).
|
|
12
|
+
|
|
13
|
+
Priority:
|
|
14
|
+
1. XDG config (~/.config/ui-cli/config)
|
|
15
|
+
2. Home dotfile (~/.ui-cli.env)
|
|
16
|
+
3. Project local (.env)
|
|
17
|
+
"""
|
|
18
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
|
19
|
+
candidates = [
|
|
20
|
+
Path(xdg_config) / "ui-cli" / "config",
|
|
21
|
+
Path.home() / ".ui-cli.env",
|
|
22
|
+
Path(".env"),
|
|
23
|
+
]
|
|
24
|
+
# Return all existing files (pydantic-settings will use first match per variable)
|
|
25
|
+
return tuple(str(p) for p in candidates if p.exists())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Settings(BaseSettings):
|
|
29
|
+
"""Application settings loaded from environment variables."""
|
|
30
|
+
|
|
31
|
+
model_config = SettingsConfigDict(
|
|
32
|
+
env_prefix="UNIFI_",
|
|
33
|
+
env_file=_get_config_files(),
|
|
34
|
+
env_file_encoding="utf-8",
|
|
35
|
+
extra="ignore",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Site Manager API (cloud)
|
|
39
|
+
api_key: str = Field(
|
|
40
|
+
default="",
|
|
41
|
+
description="UniFi API key for authentication",
|
|
42
|
+
)
|
|
43
|
+
api_url: str = Field(
|
|
44
|
+
default="https://api.ui.com/v1",
|
|
45
|
+
description="UniFi API base URL",
|
|
46
|
+
)
|
|
47
|
+
timeout: int = Field(
|
|
48
|
+
default=15,
|
|
49
|
+
description="Request timeout in seconds",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Local Controller API
|
|
53
|
+
controller_url: str = Field(
|
|
54
|
+
default="",
|
|
55
|
+
description="Local controller URL (e.g., https://192.168.1.1)",
|
|
56
|
+
)
|
|
57
|
+
controller_username: str = Field(
|
|
58
|
+
default="",
|
|
59
|
+
description="Local controller username",
|
|
60
|
+
)
|
|
61
|
+
controller_password: str = Field(
|
|
62
|
+
default="",
|
|
63
|
+
description="Local controller password",
|
|
64
|
+
)
|
|
65
|
+
controller_api_key: str = Field(
|
|
66
|
+
default="",
|
|
67
|
+
description="Local controller API key (UniFi OS Dashboard → Settings → Admins → API Keys). Env: UNIFI_CONTROLLER_API_KEY",
|
|
68
|
+
)
|
|
69
|
+
controller_site: str = Field(
|
|
70
|
+
default="default",
|
|
71
|
+
description="Site name for local controller",
|
|
72
|
+
)
|
|
73
|
+
controller_verify_ssl: bool = Field(
|
|
74
|
+
default=False,
|
|
75
|
+
description="Verify SSL certificates (disable for self-signed)",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_configured(self) -> bool:
|
|
80
|
+
"""Check if Site Manager API key is configured."""
|
|
81
|
+
return bool(self.api_key)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_local_configured(self) -> bool:
|
|
85
|
+
"""Check if local controller is configured.
|
|
86
|
+
|
|
87
|
+
Accepts either:
|
|
88
|
+
- controller_url + controller_api_key (API key auth, UniFi OS >= 5.0.3)
|
|
89
|
+
- controller_url + controller_username + controller_password (legacy auth)
|
|
90
|
+
"""
|
|
91
|
+
if not self.controller_url:
|
|
92
|
+
return False
|
|
93
|
+
if self.controller_api_key:
|
|
94
|
+
return True
|
|
95
|
+
return bool(self.controller_username and self.controller_password)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def session_file(self) -> Path:
|
|
99
|
+
"""Path to session storage file."""
|
|
100
|
+
config_dir = Path.home() / ".config" / "ui-cli"
|
|
101
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
return config_dir / "session.json"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Global settings instance
|
|
106
|
+
settings = Settings()
|