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
ui_cli/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """UniFi Site Manager CLI - Manage your UniFi infrastructure from the command line."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ from pathlib import Path
5
+
6
+
7
+ def _version_file_path() -> Path:
8
+ """Return the repository version file path when running from a source checkout."""
9
+ return Path(__file__).resolve().parents[2] / "VERSION"
10
+
11
+
12
+ def _read_version_file() -> str:
13
+ """Read the repository version during local source execution."""
14
+ version_file = _version_file_path()
15
+ if version_file.exists():
16
+ return version_file.read_text(encoding="utf-8").strip()
17
+ return "0.0.0"
18
+
19
+
20
+ def _detect_version() -> str:
21
+ """Prefer the checkout version file and fall back to installed package metadata."""
22
+ version_file = _version_file_path()
23
+ if version_file.exists():
24
+ return version_file.read_text(encoding="utf-8").strip()
25
+ try:
26
+ return version("ui-cli")
27
+ except PackageNotFoundError:
28
+ return "0.0.0"
29
+
30
+
31
+ __version__ = _detect_version()
ui_cli/client.py ADDED
@@ -0,0 +1,269 @@
1
+ """Async HTTP client for UniFi Site Manager API."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from ui_cli.config import settings
8
+
9
+
10
+ class APIError(Exception):
11
+ """Base exception for API errors."""
12
+
13
+ def __init__(self, message: str, status_code: int | None = None):
14
+ self.message = message
15
+ self.status_code = status_code
16
+ super().__init__(self.message)
17
+
18
+
19
+ class AuthenticationError(APIError):
20
+ """Raised when API key is invalid or missing."""
21
+
22
+ pass
23
+
24
+
25
+ class RateLimitError(APIError):
26
+ """Raised when rate limit is exceeded."""
27
+
28
+ def __init__(self, message: str, retry_after: int | None = None):
29
+ super().__init__(message, status_code=429)
30
+ self.retry_after = retry_after
31
+
32
+
33
+ class UniFiClient:
34
+ """Async client for UniFi Site Manager API."""
35
+
36
+ def __init__(
37
+ self,
38
+ api_key: str | None = None,
39
+ base_url: str | None = None,
40
+ timeout: int | None = None,
41
+ ):
42
+ self.api_key = api_key or settings.api_key
43
+ self.base_url = (base_url or settings.api_url).rstrip("/")
44
+ self.timeout = timeout or settings.timeout
45
+
46
+ if not self.api_key:
47
+ raise AuthenticationError(
48
+ "API key not configured. Set UNIFI_API_KEY environment variable or create a .env file."
49
+ )
50
+
51
+ def _get_headers(self) -> dict[str, str]:
52
+ """Get request headers with authentication."""
53
+ return {
54
+ "X-API-Key": self.api_key,
55
+ "Accept": "application/json",
56
+ "Content-Type": "application/json",
57
+ }
58
+
59
+ async def _request(
60
+ self,
61
+ method: str,
62
+ endpoint: str,
63
+ params: dict[str, Any] | None = None,
64
+ ) -> dict[str, Any]:
65
+ """Make an authenticated request to the API."""
66
+ url = f"{self.base_url}/{endpoint.lstrip('/')}"
67
+
68
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
69
+ response = await client.request(
70
+ method=method,
71
+ url=url,
72
+ headers=self._get_headers(),
73
+ params=params,
74
+ )
75
+
76
+ # Handle errors
77
+ if response.status_code == 401:
78
+ raise AuthenticationError("Invalid API key", status_code=401)
79
+ elif response.status_code == 429:
80
+ retry_after = response.headers.get("Retry-After")
81
+ raise RateLimitError(
82
+ "Rate limit exceeded",
83
+ retry_after=int(retry_after) if retry_after else None,
84
+ )
85
+ elif response.status_code >= 400:
86
+ raise APIError(
87
+ f"API error: {response.text}",
88
+ status_code=response.status_code,
89
+ )
90
+
91
+ return response.json()
92
+
93
+ async def get(
94
+ self,
95
+ endpoint: str,
96
+ params: dict[str, Any] | None = None,
97
+ ) -> dict[str, Any]:
98
+ """Make a GET request."""
99
+ return await self._request("GET", endpoint, params=params)
100
+
101
+ # ========== Hosts ==========
102
+
103
+ async def list_hosts(self) -> list[dict[str, Any]]:
104
+ """List all hosts associated with the account."""
105
+ response = await self.get("/hosts")
106
+ return response.get("data", [])
107
+
108
+ async def get_host(self, host_id: str) -> dict[str, Any]:
109
+ """Get details for a specific host."""
110
+ response = await self.get(f"/hosts/{host_id}")
111
+ return response.get("data", {})
112
+
113
+ # ========== Sites ==========
114
+
115
+ async def list_sites(self) -> list[dict[str, Any]]:
116
+ """List all sites."""
117
+ response = await self.get("/sites")
118
+ return response.get("data", [])
119
+
120
+ # ========== Devices ==========
121
+
122
+ async def list_devices_raw(
123
+ self,
124
+ host_ids: list[str] | None = None,
125
+ ) -> list[dict[str, Any]]:
126
+ """List all devices grouped by host (raw API response)."""
127
+ params = {}
128
+ if host_ids:
129
+ params["hostIds[]"] = host_ids
130
+ response = await self.get("/devices", params=params if params else None)
131
+ return response.get("data", [])
132
+
133
+ async def list_devices(
134
+ self,
135
+ host_ids: list[str] | None = None,
136
+ ) -> list[dict[str, Any]]:
137
+ """List all devices, flattened with host info included."""
138
+ raw_data = await self.list_devices_raw(host_ids=host_ids)
139
+
140
+ # Flatten: API returns devices grouped by host
141
+ devices = []
142
+ for host_group in raw_data:
143
+ host_id = host_group.get("hostId", "")
144
+ host_name = host_group.get("hostName", "")
145
+ for device in host_group.get("devices", []):
146
+ # Add host info to each device
147
+ device["hostId"] = host_id
148
+ device["hostName"] = host_name
149
+ devices.append(device)
150
+
151
+ return devices
152
+
153
+ # ========== ISP Metrics (Early Access API) ==========
154
+
155
+ async def get_isp_metrics(
156
+ self,
157
+ metric_type: str = "1h",
158
+ duration_hours: int | None = None,
159
+ ) -> list[dict[str, Any]]:
160
+ """Get ISP metrics.
161
+
162
+ Args:
163
+ metric_type: Either '5m' (24h retention) or '1h' (30d retention)
164
+ duration_hours: Hours of data to retrieve. Defaults to 24 for 5m, 168 (7 days) for 1h
165
+
166
+ Note: Uses Early Access API endpoint (/ea/)
167
+ """
168
+ from datetime import datetime, timedelta, timezone
169
+
170
+ # Set default duration based on metric type
171
+ if duration_hours is None:
172
+ duration_hours = 24 if metric_type == "5m" else 168 # 7 days for hourly
173
+
174
+ # Calculate timestamps
175
+ end_time = datetime.now(timezone.utc)
176
+ begin_time = end_time - timedelta(hours=duration_hours)
177
+
178
+ params = {
179
+ "beginTimestamp": begin_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
180
+ "endTimestamp": end_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
181
+ }
182
+
183
+ # ISP metrics uses EA endpoint, not v1
184
+ ea_url = self.base_url.replace("/v1", "/ea")
185
+ url = f"{ea_url}/isp-metrics/{metric_type}"
186
+
187
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
188
+ response = await client.get(url, headers=self._get_headers(), params=params)
189
+
190
+ if response.status_code == 401:
191
+ raise AuthenticationError("Invalid API key", status_code=401)
192
+ elif response.status_code == 429:
193
+ retry_after = response.headers.get("Retry-After")
194
+ raise RateLimitError(
195
+ "Rate limit exceeded",
196
+ retry_after=int(retry_after) if retry_after else None,
197
+ )
198
+ elif response.status_code >= 400:
199
+ raise APIError(
200
+ f"API error: {response.text}",
201
+ status_code=response.status_code,
202
+ )
203
+
204
+ raw_data = response.json().get("data", [])
205
+
206
+ # Flatten the nested structure
207
+ metrics = []
208
+ for site_data in raw_data:
209
+ site_id = site_data.get("siteId", "")
210
+ host_id = site_data.get("hostId", "")
211
+ for period in site_data.get("periods", []):
212
+ wan_data = period.get("data", {}).get("wan", {})
213
+ metrics.append({
214
+ "siteId": site_id,
215
+ "hostId": host_id,
216
+ "timestamp": period.get("metricTime", ""),
217
+ "avgLatency": wan_data.get("avgLatency"),
218
+ "maxLatency": wan_data.get("maxLatency"),
219
+ "downloadKbps": wan_data.get("download_kbps"),
220
+ "uploadKbps": wan_data.get("upload_kbps"),
221
+ "uptime": wan_data.get("uptime"),
222
+ "downtime": wan_data.get("downtime"),
223
+ "packetLoss": wan_data.get("packetLoss"),
224
+ "ispName": wan_data.get("ispName"),
225
+ "ispAsn": wan_data.get("ispAsn"),
226
+ })
227
+
228
+ return metrics
229
+
230
+ # ========== SD-WAN (Early Access API) ==========
231
+
232
+ async def _ea_get(self, endpoint: str) -> dict[str, Any]:
233
+ """Make a GET request to the Early Access API."""
234
+ ea_url = self.base_url.replace("/v1", "/ea")
235
+ url = f"{ea_url}/{endpoint.lstrip('/')}"
236
+
237
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
238
+ response = await client.get(url, headers=self._get_headers())
239
+
240
+ if response.status_code == 401:
241
+ raise AuthenticationError("Invalid API key", status_code=401)
242
+ elif response.status_code == 429:
243
+ retry_after = response.headers.get("Retry-After")
244
+ raise RateLimitError(
245
+ "Rate limit exceeded",
246
+ retry_after=int(retry_after) if retry_after else None,
247
+ )
248
+ elif response.status_code >= 400:
249
+ raise APIError(
250
+ f"API error: {response.text}",
251
+ status_code=response.status_code,
252
+ )
253
+
254
+ return response.json()
255
+
256
+ async def list_sdwan_configs(self) -> list[dict[str, Any]]:
257
+ """List all SD-WAN configurations."""
258
+ response = await self._ea_get("/sd-wan-configs")
259
+ return response.get("data", [])
260
+
261
+ async def get_sdwan_config(self, config_id: str) -> dict[str, Any]:
262
+ """Get details for a specific SD-WAN configuration."""
263
+ response = await self._ea_get(f"/sd-wan-configs/{config_id}")
264
+ return response.get("data", {})
265
+
266
+ async def get_sdwan_status(self, config_id: str) -> dict[str, Any]:
267
+ """Get deployment status for a specific SD-WAN configuration."""
268
+ response = await self._ea_get(f"/sd-wan-configs/{config_id}/status")
269
+ return response.get("data", {})
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,187 @@
1
+ """Device management commands."""
2
+
3
+ import asyncio
4
+ from collections import Counter
5
+ from enum import Enum
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from ui_cli.client import APIError, UniFiClient
11
+ from ui_cli.output import (
12
+ OutputFormat,
13
+ output_count_table,
14
+ output_csv,
15
+ output_json,
16
+ print_error,
17
+ render_output,
18
+ )
19
+
20
+ app = typer.Typer(help="Manage UniFi devices")
21
+
22
+ # Column definitions for devices table
23
+ DEVICE_COLUMNS = [
24
+ ("id", "ID"),
25
+ ("name", "Name"),
26
+ ("model", "Model"),
27
+ ("ip", "IP Address"),
28
+ ("mac", "MAC"),
29
+ ("productLine", "Product Line"),
30
+ ("status", "Status"),
31
+ ("version", "Version"),
32
+ ("hostName", "Host"),
33
+ ]
34
+
35
+
36
+ class GroupBy(str, Enum):
37
+ """Device grouping options."""
38
+
39
+ HOST = "host"
40
+ MODEL = "model"
41
+ STATUS = "status"
42
+ PRODUCT_LINE = "product-line"
43
+
44
+
45
+ @app.command("list")
46
+ def list_devices(
47
+ host: Annotated[
48
+ str | None,
49
+ typer.Option(
50
+ "--host",
51
+ "-H",
52
+ help="Filter devices by host ID",
53
+ ),
54
+ ] = None,
55
+ output: Annotated[
56
+ OutputFormat,
57
+ typer.Option(
58
+ "--output",
59
+ "-o",
60
+ help="Output format: table, json, or csv",
61
+ ),
62
+ ] = OutputFormat.TABLE,
63
+ verbose: Annotated[
64
+ bool,
65
+ typer.Option(
66
+ "--verbose",
67
+ "-v",
68
+ help="Show detailed request/response information",
69
+ ),
70
+ ] = False,
71
+ ) -> None:
72
+ """List all UniFi devices managed by your hosts."""
73
+
74
+ async def _list() -> list:
75
+ client = UniFiClient()
76
+ host_ids = [host] if host else None
77
+ return await client.list_devices(host_ids=host_ids)
78
+
79
+ try:
80
+ devices = asyncio.run(_list())
81
+
82
+ if verbose:
83
+ typer.echo(f"Found {len(devices)} device(s)")
84
+
85
+ render_output(
86
+ data=devices,
87
+ output_format=output,
88
+ columns=DEVICE_COLUMNS,
89
+ title="UniFi Devices",
90
+ verbose=verbose,
91
+ )
92
+ except APIError as e:
93
+ print_error(e.message)
94
+ raise typer.Exit(1)
95
+
96
+
97
+ @app.command("count")
98
+ def count_devices(
99
+ by: Annotated[
100
+ GroupBy | None,
101
+ typer.Option(
102
+ "--by",
103
+ "-b",
104
+ help="Group count by: host, model, status, or product-line",
105
+ ),
106
+ ] = None,
107
+ host: Annotated[
108
+ str | None,
109
+ typer.Option(
110
+ "--host",
111
+ "-H",
112
+ help="Filter devices by host ID",
113
+ ),
114
+ ] = None,
115
+ output: Annotated[
116
+ OutputFormat,
117
+ typer.Option(
118
+ "--output",
119
+ "-o",
120
+ help="Output format: table, json, or csv",
121
+ ),
122
+ ] = OutputFormat.TABLE,
123
+ verbose: Annotated[
124
+ bool,
125
+ typer.Option(
126
+ "--verbose",
127
+ "-v",
128
+ help="Show detailed request/response information",
129
+ ),
130
+ ] = False,
131
+ ) -> None:
132
+ """Count devices with optional grouping."""
133
+
134
+ async def _list() -> list:
135
+ client = UniFiClient()
136
+ host_ids = [host] if host else None
137
+ return await client.list_devices(host_ids=host_ids)
138
+
139
+ try:
140
+ devices = asyncio.run(_list())
141
+
142
+ if by is None:
143
+ # Simple total count
144
+ count_data = {"total": len(devices)}
145
+
146
+ if output == OutputFormat.JSON:
147
+ output_json(count_data, verbose=verbose)
148
+ elif output == OutputFormat.CSV:
149
+ output_csv([count_data])
150
+ else:
151
+ typer.echo(f"Total devices: {len(devices)}")
152
+ return
153
+
154
+ # Group by specified field
155
+ key_map = {
156
+ GroupBy.HOST: ("hostName", "Host"),
157
+ GroupBy.MODEL: ("model", "Model"),
158
+ GroupBy.STATUS: ("status", "Status"),
159
+ GroupBy.PRODUCT_LINE: ("productLine", "Product Line"),
160
+ }
161
+
162
+ field_key, header = key_map[by]
163
+ counts: Counter = Counter()
164
+
165
+ for device in devices:
166
+ value = device.get(field_key, "Unknown") or "Unknown"
167
+ counts[value] += 1
168
+
169
+ if output == OutputFormat.JSON:
170
+ output_json(dict(counts), verbose=verbose)
171
+ elif output == OutputFormat.CSV:
172
+ csv_data = [{header: k, "Count": v} for k, v in counts.items()]
173
+ output_csv(csv_data)
174
+ else:
175
+ output_count_table(
176
+ counts=dict(counts),
177
+ group_header=header,
178
+ count_header="Count",
179
+ title=f"Device Count by {header}",
180
+ )
181
+
182
+ if verbose:
183
+ typer.echo(f"Total: {sum(counts.values())} device(s)")
184
+
185
+ except APIError as e:
186
+ print_error(e.message)
187
+ raise typer.Exit(1)