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/main.py ADDED
@@ -0,0 +1,61 @@
1
+ """UniFi Site Manager CLI - Main entry point."""
2
+
3
+ import typer
4
+
5
+ from ui_cli import __version__
6
+ from ui_cli.commands import devices, groups, hosts, isp, mcp, sdwan, sites, speedtest, status, version
7
+ from ui_cli.commands import local
8
+
9
+ # Create main app
10
+ app = typer.Typer(
11
+ name="ui",
12
+ help="UniFi Site Manager CLI - Manage your UniFi infrastructure from the command line.",
13
+ no_args_is_help=True,
14
+ add_completion=True,
15
+ )
16
+
17
+ # Register command groups
18
+ app.add_typer(status.app, name="status")
19
+ app.add_typer(hosts.app, name="hosts")
20
+ app.add_typer(sites.app, name="sites")
21
+ app.add_typer(devices.app, name="devices")
22
+ app.add_typer(isp.app, name="isp")
23
+ app.add_typer(sdwan.app, name="sdwan")
24
+ app.add_typer(version.app, name="version")
25
+ app.add_typer(speedtest.app, name="speedtest")
26
+
27
+ # Local controller commands (with alias)
28
+ app.add_typer(local.app, name="local")
29
+ app.add_typer(local.app, name="lo")
30
+
31
+ # MCP server management
32
+ app.add_typer(mcp.app, name="mcp")
33
+
34
+ # Client groups (local storage, no controller needed)
35
+ app.add_typer(groups.app, name="groups")
36
+
37
+
38
+ def version_callback(value: bool) -> None:
39
+ """Print version and exit."""
40
+ if value:
41
+ typer.echo(f"ui-cli version {__version__}")
42
+ raise typer.Exit()
43
+
44
+
45
+ @app.callback()
46
+ def main(
47
+ version: bool = typer.Option(
48
+ None,
49
+ "--version",
50
+ "-V",
51
+ callback=version_callback,
52
+ is_eager=True,
53
+ help="Show version and exit",
54
+ ),
55
+ ) -> None:
56
+ """UniFi Site Manager CLI - Manage your UniFi infrastructure from the command line."""
57
+ pass
58
+
59
+
60
+ if __name__ == "__main__":
61
+ app()
ui_cli/models.py ADDED
@@ -0,0 +1,188 @@
1
+ """Pydantic models for UniFi API responses."""
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ # ========== Host Models ==========
10
+
11
+
12
+ class HostReportedState(BaseModel):
13
+ """Host reported state information."""
14
+
15
+ hostname: str | None = None
16
+ version: str | None = None
17
+ hardware_id: str | None = Field(None, alias="hardwareId")
18
+ firmware_version: str | None = Field(None, alias="firmwareVersion")
19
+ ip_address: str | None = Field(None, alias="ipAddress")
20
+ mac_address: str | None = Field(None, alias="macAddress")
21
+
22
+ class Config:
23
+ populate_by_name = True
24
+
25
+
26
+ class Host(BaseModel):
27
+ """UniFi host (console/controller) model."""
28
+
29
+ id: str
30
+ hardware_id: str | None = Field(None, alias="hardwareId")
31
+ type: str | None = None
32
+ ip_address: str | None = Field(None, alias="ipAddress")
33
+ is_blocked: bool | None = Field(None, alias="isBlocked")
34
+ last_connection_state_change: datetime | None = Field(
35
+ None, alias="lastConnectionStateChange"
36
+ )
37
+ latest_backup_time: datetime | None = Field(None, alias="latestBackupTime")
38
+ registration_time: datetime | None = Field(None, alias="registrationTime")
39
+ owner: bool | None = None
40
+ reported_state: HostReportedState | None = Field(None, alias="reportedState")
41
+ user_data: dict[str, Any] | None = Field(None, alias="userData")
42
+
43
+ class Config:
44
+ populate_by_name = True
45
+
46
+
47
+ # ========== Site Models ==========
48
+
49
+
50
+ class SiteMeta(BaseModel):
51
+ """Site metadata."""
52
+
53
+ name: str | None = None
54
+ desc: str | None = None
55
+ timezone: str | None = None
56
+ gateway_mac: str | None = Field(None, alias="gatewayMac")
57
+
58
+ class Config:
59
+ populate_by_name = True
60
+
61
+
62
+ class SiteStatistics(BaseModel):
63
+ """Site statistics."""
64
+
65
+ counts: dict[str, int] | None = None
66
+
67
+ class Config:
68
+ populate_by_name = True
69
+
70
+
71
+ class Site(BaseModel):
72
+ """UniFi site model."""
73
+
74
+ site_id: str | None = Field(None, alias="siteId")
75
+ host_id: str | None = Field(None, alias="hostId")
76
+ is_owner: bool | None = Field(None, alias="isOwner")
77
+ permission: str | None = None
78
+ meta: SiteMeta | None = None
79
+ statistics: SiteStatistics | None = None
80
+ subscription_end_time: datetime | None = Field(None, alias="subscriptionEndTime")
81
+
82
+ class Config:
83
+ populate_by_name = True
84
+
85
+
86
+ # ========== Device Models ==========
87
+
88
+
89
+ class DeviceUidb(BaseModel):
90
+ """Device UIDB (database) information."""
91
+
92
+ id: str | None = None
93
+ guid: str | None = None
94
+ images: dict[str, Any] | None = None
95
+
96
+ class Config:
97
+ populate_by_name = True
98
+
99
+
100
+ class Device(BaseModel):
101
+ """UniFi device model."""
102
+
103
+ id: str
104
+ mac: str | None = None
105
+ name: str | None = None
106
+ model: str | None = None
107
+ shortname: str | None = None
108
+ ip: str | None = None
109
+ product_line: str | None = Field(None, alias="productLine")
110
+ status: str | None = None
111
+ version: str | None = None
112
+ firmware_status: str | None = Field(None, alias="firmwareStatus")
113
+ is_console: bool | None = Field(None, alias="isConsole")
114
+ is_managed: bool | None = Field(None, alias="isManaged")
115
+ startup_time: datetime | None = Field(None, alias="startupTime")
116
+ adoption_time: datetime | None = Field(None, alias="adoptionTime")
117
+ host_id: str | None = Field(None, alias="hostId")
118
+ host_name: str | None = Field(None, alias="hostName")
119
+ updated_at: datetime | None = Field(None, alias="updatedAt")
120
+ uidb: DeviceUidb | None = None
121
+
122
+ class Config:
123
+ populate_by_name = True
124
+
125
+
126
+ # ========== ISP Metrics Models ==========
127
+
128
+
129
+ class ISPMetric(BaseModel):
130
+ """ISP performance metric model."""
131
+
132
+ site_id: str | None = Field(None, alias="siteId")
133
+ host_id: str | None = Field(None, alias="hostId")
134
+ timestamp: datetime | None = None
135
+ avg_latency: float | None = Field(None, alias="avgLatency")
136
+ max_latency: float | None = Field(None, alias="maxLatency")
137
+ download_kbps: float | None = Field(None, alias="downloadKbps")
138
+ upload_kbps: float | None = Field(None, alias="uploadKbps")
139
+ uptime: float | None = None
140
+ downtime: float | None = None
141
+ packet_loss: float | None = Field(None, alias="packetLoss")
142
+ isp_name: str | None = Field(None, alias="ispName")
143
+ isp_asn: str | None = Field(None, alias="ispAsn")
144
+
145
+ class Config:
146
+ populate_by_name = True
147
+
148
+
149
+ # ========== SD-WAN Models ==========
150
+
151
+
152
+ class SDWanSettings(BaseModel):
153
+ """SD-WAN configuration settings."""
154
+
155
+ hubs_interconnect: bool | None = Field(None, alias="hubsInterconnect")
156
+ spoke_to_hub_tunnels_mode: str | None = Field(None, alias="spokeToHubTunnelsMode")
157
+ spokes_auto_scale_and_nat_enabled: bool | None = Field(
158
+ None, alias="spokesAutoScaleAndNatEnabled"
159
+ )
160
+
161
+ class Config:
162
+ populate_by_name = True
163
+
164
+
165
+ class SDWanConfig(BaseModel):
166
+ """SD-WAN configuration model."""
167
+
168
+ id: str
169
+ name: str | None = None
170
+ type: str | None = None
171
+ variant: str | None = None
172
+ settings: SDWanSettings | None = None
173
+
174
+ class Config:
175
+ populate_by_name = True
176
+
177
+
178
+ class SDWanStatus(BaseModel):
179
+ """SD-WAN deployment status model."""
180
+
181
+ fingerprint: str | None = None
182
+ updated_at: datetime | None = Field(None, alias="updatedAt")
183
+ status: str | None = None
184
+ progress: float | None = None
185
+ errors: list[str] | None = None
186
+
187
+ class Config:
188
+ populate_by_name = True
ui_cli/output.py ADDED
@@ -0,0 +1,251 @@
1
+ """Output formatters for table, JSON, and CSV formats."""
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from rich.console import Console
10
+ from rich.json import JSON
11
+ from rich.table import Table
12
+
13
+ console = Console()
14
+
15
+
16
+ class OutputFormat(str, Enum):
17
+ """Available output formats."""
18
+
19
+ TABLE = "table"
20
+ JSON = "json"
21
+ CSV = "csv"
22
+ YAML = "yaml"
23
+
24
+
25
+ def flatten_dict(data: dict[str, Any], parent_key: str = "", sep: str = ".") -> dict[str, Any]:
26
+ """Flatten nested dictionary for CSV output."""
27
+ items: list[tuple[str, Any]] = []
28
+ for key, value in data.items():
29
+ new_key = f"{parent_key}{sep}{key}" if parent_key else key
30
+ if isinstance(value, dict):
31
+ items.extend(flatten_dict(value, new_key, sep=sep).items())
32
+ elif isinstance(value, list):
33
+ items.append((new_key, json.dumps(value)))
34
+ else:
35
+ items.append((new_key, value))
36
+ return dict(items)
37
+
38
+
39
+ def output_json(data: Any, verbose: bool = False) -> None:
40
+ """Output data as formatted JSON."""
41
+ if verbose:
42
+ console.print(JSON(json.dumps(data, indent=2, default=str)))
43
+ else:
44
+ print(json.dumps(data, indent=2, default=str))
45
+
46
+
47
+ def get_nested_value(data: dict[str, Any], key: str) -> Any:
48
+ """Get value from nested dict using dot notation key."""
49
+ value = data
50
+ for part in key.split("."):
51
+ if isinstance(value, dict):
52
+ value = value.get(part, "")
53
+ else:
54
+ return ""
55
+ return value
56
+
57
+
58
+ def output_csv(
59
+ data: list[dict[str, Any]],
60
+ columns: list[tuple[str, str]] | None = None,
61
+ ) -> None:
62
+ """Output data as CSV.
63
+
64
+ Args:
65
+ data: List of dictionaries to output
66
+ columns: Optional list of (key, header) tuples. If None, flattens all fields.
67
+ """
68
+ if not data:
69
+ return
70
+
71
+ output = io.StringIO()
72
+
73
+ if columns:
74
+ # Use specified columns with headers
75
+ headers = [header for _, header in columns]
76
+ writer = csv.writer(output)
77
+ writer.writerow(headers)
78
+
79
+ for item in data:
80
+ row = []
81
+ for key, _ in columns:
82
+ value = get_nested_value(item, key)
83
+ if value is None:
84
+ value = ""
85
+ elif isinstance(value, bool):
86
+ value = "Yes" if value else "No"
87
+ elif isinstance(value, (list, dict)):
88
+ value = json.dumps(value)
89
+ else:
90
+ value = str(value)
91
+ row.append(value)
92
+ writer.writerow(row)
93
+ else:
94
+ # Flatten and output all fields
95
+ flattened = [flatten_dict(item) for item in data]
96
+ all_keys: set[str] = set()
97
+ for item in flattened:
98
+ all_keys.update(item.keys())
99
+ fieldnames = sorted(all_keys)
100
+
101
+ writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
102
+ writer.writeheader()
103
+ for item in flattened:
104
+ writer.writerow(item)
105
+
106
+ print(output.getvalue(), end="")
107
+
108
+
109
+ def output_table(
110
+ data: list[dict[str, Any]],
111
+ columns: list[tuple[str, str]],
112
+ title: str | None = None,
113
+ ) -> None:
114
+ """Output data as a Rich table.
115
+
116
+ Args:
117
+ data: List of dictionaries to display
118
+ columns: List of (key, header) tuples defining columns
119
+ title: Optional table title
120
+ """
121
+ table = Table(title=title, show_header=True, header_style="bold cyan")
122
+
123
+ # Add columns
124
+ for _, header in columns:
125
+ table.add_column(header)
126
+
127
+ # Add rows
128
+ for item in data:
129
+ row = []
130
+ for key, _ in columns:
131
+ value = item
132
+ # Handle nested keys like "meta.name"
133
+ for part in key.split("."):
134
+ if isinstance(value, dict):
135
+ value = value.get(part, "")
136
+ else:
137
+ value = ""
138
+ break
139
+ # Format value
140
+ if value is None:
141
+ value = ""
142
+ elif isinstance(value, bool):
143
+ value = "Yes" if value else "No"
144
+ elif isinstance(value, (list, dict)):
145
+ value = json.dumps(value)
146
+ else:
147
+ value = str(value)
148
+ row.append(value)
149
+ table.add_row(*row)
150
+
151
+ console.print(table)
152
+
153
+
154
+ def output_single_table(
155
+ data: dict[str, Any],
156
+ title: str | None = None,
157
+ ) -> None:
158
+ """Output a single item as a key-value table."""
159
+ table = Table(title=title, show_header=True, header_style="bold cyan")
160
+ table.add_column("Field", style="cyan")
161
+ table.add_column("Value")
162
+
163
+ flat = flatten_dict(data)
164
+ for key, value in flat.items():
165
+ if value is None:
166
+ value = ""
167
+ elif isinstance(value, bool):
168
+ value = "Yes" if value else "No"
169
+ else:
170
+ value = str(value)
171
+ table.add_row(key, value)
172
+
173
+ console.print(table)
174
+
175
+
176
+ def output_count_table(
177
+ counts: dict[str, int],
178
+ group_header: str = "Group",
179
+ count_header: str = "Count",
180
+ title: str | None = None,
181
+ ) -> None:
182
+ """Output count data as a table with totals."""
183
+ table = Table(title=title, show_header=True, header_style="bold cyan")
184
+ table.add_column(group_header)
185
+ table.add_column(count_header, justify="right")
186
+
187
+ total = 0
188
+ for group, count in sorted(counts.items()):
189
+ table.add_row(group, str(count))
190
+ total += count
191
+
192
+ # Add separator and total
193
+ table.add_row("─" * 20, "─" * 10, style="dim")
194
+ table.add_row("Total", str(total), style="bold")
195
+
196
+ console.print(table)
197
+
198
+
199
+ def render_output(
200
+ data: Any,
201
+ output_format: OutputFormat,
202
+ columns: list[tuple[str, str]] | None = None,
203
+ title: str | None = None,
204
+ verbose: bool = False,
205
+ is_single: bool = False,
206
+ ) -> None:
207
+ """Render data in the specified format.
208
+
209
+ Args:
210
+ data: Data to render (list or dict)
211
+ output_format: Output format (table, json, csv)
212
+ columns: Column definitions for table format [(key, header), ...]
213
+ title: Title for table output
214
+ verbose: Enable verbose output
215
+ is_single: If True, render as single item detail view
216
+ """
217
+ if output_format == OutputFormat.JSON:
218
+ output_json(data, verbose=verbose)
219
+ elif output_format == OutputFormat.CSV:
220
+ if isinstance(data, dict):
221
+ data = [data]
222
+ output_csv(data, columns=columns)
223
+ else: # TABLE
224
+ if is_single and isinstance(data, dict):
225
+ output_single_table(data, title=title)
226
+ elif isinstance(data, list) and columns:
227
+ output_table(data, columns=columns, title=title)
228
+ elif isinstance(data, dict):
229
+ output_single_table(data, title=title)
230
+ else:
231
+ console.print(data)
232
+
233
+
234
+ def print_error(message: str) -> None:
235
+ """Print an error message."""
236
+ console.print(f"[red]Error:[/red] {message}")
237
+
238
+
239
+ def print_warning(message: str) -> None:
240
+ """Print a warning message."""
241
+ console.print(f"[yellow]Warning:[/yellow] {message}")
242
+
243
+
244
+ def print_success(message: str) -> None:
245
+ """Print a success message."""
246
+ console.print(f"[green]{message}[/green]")
247
+
248
+
249
+ def print_info(message: str) -> None:
250
+ """Print an info message."""
251
+ console.print(f"[cyan]{message}[/cyan]")