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/local_client.py
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
"""Async HTTP client for UniFi Local Controller API.
|
|
2
|
+
|
|
3
|
+
Supports both UDM-based controllers (using /proxy/network/api/) and
|
|
4
|
+
Cloud Key / self-hosted controllers (using /api/).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ui_cli.config import settings
|
|
14
|
+
|
|
15
|
+
_API_KEY_REJECTED_MSG = (
|
|
16
|
+
"API key rejected by controller (HTTP 401). Check UNIFI_CONTROLLER_API_KEY."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_quick_timeout() -> int | None:
|
|
21
|
+
"""Get quick timeout from local commands if set.
|
|
22
|
+
|
|
23
|
+
This allows --quick flag to propagate to the client without
|
|
24
|
+
modifying every command file.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
from ui_cli.commands.local.utils import get_timeout
|
|
28
|
+
return get_timeout()
|
|
29
|
+
except ImportError:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LocalAPIError(Exception):
|
|
34
|
+
"""Base exception for local API errors."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
37
|
+
self.message = message
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
super().__init__(self.message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LocalAuthenticationError(LocalAPIError):
|
|
43
|
+
"""Raised when authentication fails."""
|
|
44
|
+
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LocalConnectionError(LocalAPIError):
|
|
49
|
+
"""Raised when connection to controller fails."""
|
|
50
|
+
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SessionExpiredError(LocalAPIError):
|
|
55
|
+
"""Raised when session has expired."""
|
|
56
|
+
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UniFiLocalClient:
|
|
61
|
+
"""Async client for UniFi Local Controller API."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
controller_url: str | None = None,
|
|
66
|
+
username: str | None = None,
|
|
67
|
+
password: str | None = None,
|
|
68
|
+
api_key: str | None = None,
|
|
69
|
+
site: str | None = None,
|
|
70
|
+
verify_ssl: bool | None = None,
|
|
71
|
+
timeout: int | None = None,
|
|
72
|
+
):
|
|
73
|
+
self.controller_url = (controller_url or settings.controller_url).rstrip("/")
|
|
74
|
+
self.username = username or settings.controller_username
|
|
75
|
+
self.password = password or settings.controller_password
|
|
76
|
+
self._api_key: str = api_key or settings.controller_api_key
|
|
77
|
+
self.site = site or settings.controller_site
|
|
78
|
+
self.verify_ssl = verify_ssl if verify_ssl is not None else settings.controller_verify_ssl
|
|
79
|
+
|
|
80
|
+
# Timeout priority: explicit param > --quick flag > settings
|
|
81
|
+
if timeout is not None:
|
|
82
|
+
self.timeout = timeout
|
|
83
|
+
else:
|
|
84
|
+
quick_timeout = _get_quick_timeout()
|
|
85
|
+
self.timeout = quick_timeout if quick_timeout is not None else settings.timeout
|
|
86
|
+
|
|
87
|
+
# Session state
|
|
88
|
+
self._cookies: dict[str, str] = {}
|
|
89
|
+
self._csrf_token: str | None = None
|
|
90
|
+
self._is_udm: bool | None = None # None = not detected yet
|
|
91
|
+
|
|
92
|
+
# API key auth only works on UniFi OS controllers; initialize UDM mode
|
|
93
|
+
if self._api_key:
|
|
94
|
+
self._is_udm = True
|
|
95
|
+
|
|
96
|
+
if not self.controller_url:
|
|
97
|
+
raise LocalAuthenticationError(
|
|
98
|
+
"Controller URL not configured. Set UNIFI_CONTROLLER_URL in .env file."
|
|
99
|
+
)
|
|
100
|
+
# When API key is set, username/password are not required
|
|
101
|
+
if not self._api_key and (not self.username or not self.password):
|
|
102
|
+
raise LocalAuthenticationError(
|
|
103
|
+
"Controller credentials not configured. Set UNIFI_CONTROLLER_USERNAME and UNIFI_CONTROLLER_PASSWORD in .env file."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def api_prefix(self) -> str:
|
|
108
|
+
"""Get API prefix based on controller type."""
|
|
109
|
+
if self._is_udm:
|
|
110
|
+
return f"{self.controller_url}/proxy/network/api/s/{self.site}"
|
|
111
|
+
return f"{self.controller_url}/api/s/{self.site}"
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def auth_url(self) -> str:
|
|
115
|
+
"""Get authentication URL based on controller type."""
|
|
116
|
+
if self._is_udm:
|
|
117
|
+
return f"{self.controller_url}/api/auth/login"
|
|
118
|
+
return f"{self.controller_url}/api/login"
|
|
119
|
+
|
|
120
|
+
def _load_session(self) -> bool:
|
|
121
|
+
"""Load session from file. Returns True if valid session loaded."""
|
|
122
|
+
session_file = settings.session_file
|
|
123
|
+
if not session_file.exists():
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(session_file.read_text())
|
|
128
|
+
|
|
129
|
+
# Check if session is for same controller
|
|
130
|
+
if data.get("controller_url") != self.controller_url:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
# Check if session has expired (sessions typically last 24h)
|
|
134
|
+
expires_at = data.get("expires_at")
|
|
135
|
+
if expires_at:
|
|
136
|
+
expires = datetime.fromisoformat(expires_at)
|
|
137
|
+
if datetime.now(timezone.utc) >= expires:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
self._cookies = data.get("cookies", {})
|
|
141
|
+
self._csrf_token = data.get("csrf_token")
|
|
142
|
+
self._is_udm = data.get("is_udm")
|
|
143
|
+
return bool(self._cookies)
|
|
144
|
+
|
|
145
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def _save_session(self) -> None:
|
|
149
|
+
"""Save session to file."""
|
|
150
|
+
# Session expires in 24 hours
|
|
151
|
+
expires_at = datetime.now(timezone.utc).replace(
|
|
152
|
+
hour=23, minute=59, second=59
|
|
153
|
+
).isoformat()
|
|
154
|
+
|
|
155
|
+
data = {
|
|
156
|
+
"controller_url": self.controller_url,
|
|
157
|
+
"cookies": self._cookies,
|
|
158
|
+
"csrf_token": self._csrf_token,
|
|
159
|
+
"is_udm": self._is_udm,
|
|
160
|
+
"expires_at": expires_at,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
settings.session_file.write_text(json.dumps(data, indent=2))
|
|
164
|
+
|
|
165
|
+
def _clear_session(self) -> None:
|
|
166
|
+
"""Clear stored session."""
|
|
167
|
+
self._cookies = {}
|
|
168
|
+
self._csrf_token = None
|
|
169
|
+
session_file = settings.session_file
|
|
170
|
+
if session_file.exists():
|
|
171
|
+
session_file.unlink()
|
|
172
|
+
|
|
173
|
+
async def _detect_controller_type(self, client: httpx.AsyncClient) -> None:
|
|
174
|
+
"""Detect if this is a UDM-based controller or Cloud Key/self-hosted."""
|
|
175
|
+
# Check if UDM by trying to access a UDM-specific endpoint
|
|
176
|
+
try:
|
|
177
|
+
# UDM has /api/auth/login, Cloud Key has /api/login
|
|
178
|
+
# We check without credentials first to avoid wasting auth attempts
|
|
179
|
+
response = await client.get(
|
|
180
|
+
f"{self.controller_url}/api/users/self",
|
|
181
|
+
)
|
|
182
|
+
# UDM returns 401, Cloud Key returns 404 for this endpoint
|
|
183
|
+
if response.status_code == 401:
|
|
184
|
+
self._is_udm = True
|
|
185
|
+
return
|
|
186
|
+
except httpx.RequestError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
# Try the status endpoint which doesn't require auth
|
|
190
|
+
try:
|
|
191
|
+
response = await client.get(f"{self.controller_url}/status")
|
|
192
|
+
# If we get here, likely Cloud Key or self-hosted
|
|
193
|
+
self._is_udm = False
|
|
194
|
+
return
|
|
195
|
+
except httpx.RequestError:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
# Default to trying UDM first (more common now)
|
|
199
|
+
self._is_udm = True
|
|
200
|
+
|
|
201
|
+
async def login(self) -> bool:
|
|
202
|
+
"""Authenticate with the controller. Returns True on success."""
|
|
203
|
+
async with httpx.AsyncClient(
|
|
204
|
+
timeout=self.timeout,
|
|
205
|
+
verify=self.verify_ssl,
|
|
206
|
+
) as client:
|
|
207
|
+
# Detect controller type if not known
|
|
208
|
+
if self._is_udm is None:
|
|
209
|
+
await self._detect_controller_type(client)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
# Try UDM-style auth first
|
|
213
|
+
if self._is_udm:
|
|
214
|
+
response = await client.post(
|
|
215
|
+
f"{self.controller_url}/api/auth/login",
|
|
216
|
+
json={
|
|
217
|
+
"username": self.username,
|
|
218
|
+
"password": self.password,
|
|
219
|
+
"remember": True,
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if response.status_code == 200:
|
|
224
|
+
self._cookies = dict(response.cookies)
|
|
225
|
+
self._csrf_token = response.headers.get("X-CSRF-Token")
|
|
226
|
+
self._save_session()
|
|
227
|
+
return True
|
|
228
|
+
elif response.status_code == 403:
|
|
229
|
+
# 403 on UDM often means wrong credentials
|
|
230
|
+
raise LocalAuthenticationError(
|
|
231
|
+
"Invalid username or password (or account lacks API access)"
|
|
232
|
+
)
|
|
233
|
+
elif response.status_code == 401:
|
|
234
|
+
raise LocalAuthenticationError("Invalid username or password")
|
|
235
|
+
|
|
236
|
+
# Try Cloud Key / self-hosted style auth
|
|
237
|
+
response = await client.post(
|
|
238
|
+
f"{self.controller_url}/api/login",
|
|
239
|
+
json={
|
|
240
|
+
"username": self.username,
|
|
241
|
+
"password": self.password,
|
|
242
|
+
"remember": True,
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if response.status_code == 200:
|
|
247
|
+
self._cookies = dict(response.cookies)
|
|
248
|
+
self._is_udm = False # Confirmed not UDM
|
|
249
|
+
self._save_session()
|
|
250
|
+
return True
|
|
251
|
+
elif response.status_code == 400:
|
|
252
|
+
# Check response for more details
|
|
253
|
+
try:
|
|
254
|
+
error_data = response.json()
|
|
255
|
+
error_msg = error_data.get("meta", {}).get("msg", "")
|
|
256
|
+
if "Invalid" in error_msg:
|
|
257
|
+
raise LocalAuthenticationError("Invalid username or password")
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
raise LocalAuthenticationError(
|
|
261
|
+
"Authentication failed - check credentials"
|
|
262
|
+
)
|
|
263
|
+
elif response.status_code in (401, 403):
|
|
264
|
+
raise LocalAuthenticationError("Invalid username or password")
|
|
265
|
+
else:
|
|
266
|
+
raise LocalAuthenticationError(
|
|
267
|
+
f"Authentication failed: HTTP {response.status_code}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
except LocalAuthenticationError:
|
|
271
|
+
raise
|
|
272
|
+
except httpx.ConnectError as e:
|
|
273
|
+
raise LocalConnectionError(
|
|
274
|
+
f"Could not connect to controller at {self.controller_url}: {e}"
|
|
275
|
+
)
|
|
276
|
+
except httpx.TimeoutException:
|
|
277
|
+
raise LocalConnectionError(
|
|
278
|
+
f"Connection timeout to {self.controller_url}"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def ensure_authenticated(self) -> None:
|
|
282
|
+
"""Ensure we have a valid session, logging in if needed.
|
|
283
|
+
|
|
284
|
+
When API key auth is configured, this is a no-op — the key is sent
|
|
285
|
+
as a header on every request without any session handshake.
|
|
286
|
+
"""
|
|
287
|
+
if self._api_key:
|
|
288
|
+
return # API key mode: no session needed
|
|
289
|
+
if not self._load_session():
|
|
290
|
+
await self.login()
|
|
291
|
+
|
|
292
|
+
def _get_headers(self) -> dict[str, str]:
|
|
293
|
+
"""Get request headers.
|
|
294
|
+
|
|
295
|
+
In API key mode: sends X-API-KEY, no cookies, no CSRF token.
|
|
296
|
+
In username/password mode: sends X-CSRF-Token if available.
|
|
297
|
+
"""
|
|
298
|
+
headers = {
|
|
299
|
+
"Accept": "application/json",
|
|
300
|
+
"Content-Type": "application/json",
|
|
301
|
+
}
|
|
302
|
+
if self._api_key:
|
|
303
|
+
headers["X-API-KEY"] = self._api_key
|
|
304
|
+
elif self._csrf_token:
|
|
305
|
+
headers["X-CSRF-Token"] = self._csrf_token
|
|
306
|
+
return headers
|
|
307
|
+
|
|
308
|
+
async def _request(
|
|
309
|
+
self,
|
|
310
|
+
method: str,
|
|
311
|
+
endpoint: str,
|
|
312
|
+
data: dict[str, Any] | None = None,
|
|
313
|
+
retry_auth: bool = True,
|
|
314
|
+
) -> dict[str, Any]:
|
|
315
|
+
"""Make an authenticated request to the local API."""
|
|
316
|
+
await self.ensure_authenticated()
|
|
317
|
+
|
|
318
|
+
url = f"{self.api_prefix}/{endpoint.lstrip('/')}"
|
|
319
|
+
|
|
320
|
+
# In API key mode: do not pass cookies (stateless header auth)
|
|
321
|
+
client_kwargs: dict[str, Any] = {
|
|
322
|
+
"timeout": self.timeout,
|
|
323
|
+
"verify": self.verify_ssl,
|
|
324
|
+
}
|
|
325
|
+
if not self._api_key:
|
|
326
|
+
client_kwargs["cookies"] = self._cookies
|
|
327
|
+
|
|
328
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
329
|
+
try:
|
|
330
|
+
response = await client.request(
|
|
331
|
+
method=method,
|
|
332
|
+
url=url,
|
|
333
|
+
headers=self._get_headers(),
|
|
334
|
+
json=data,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Handle 401 (API key rejection or session expiry)
|
|
338
|
+
if response.status_code == 401:
|
|
339
|
+
if self._api_key:
|
|
340
|
+
# API key mode: hard error, no fallback to username/password
|
|
341
|
+
raise LocalAuthenticationError(_API_KEY_REJECTED_MSG)
|
|
342
|
+
if retry_auth:
|
|
343
|
+
self._clear_session()
|
|
344
|
+
await self.login()
|
|
345
|
+
return await self._request(
|
|
346
|
+
method, endpoint, data, retry_auth=False
|
|
347
|
+
)
|
|
348
|
+
raise SessionExpiredError("Session expired and re-login failed")
|
|
349
|
+
|
|
350
|
+
# API key mode: 404/405 may indicate controller doesn't support proxy path
|
|
351
|
+
if self._api_key and response.status_code in (404, 405):
|
|
352
|
+
if self.username and self.password:
|
|
353
|
+
# Fall back to legacy auth path
|
|
354
|
+
self._api_key = "" # disable API key mode
|
|
355
|
+
self._is_udm = None # reset UDM detection
|
|
356
|
+
self._clear_session()
|
|
357
|
+
await self.login() # re-authenticate via username/password
|
|
358
|
+
# Retry the request on the legacy path (retry_auth=False to prevent loops)
|
|
359
|
+
return await self._request(method, endpoint, data, retry_auth=False)
|
|
360
|
+
else:
|
|
361
|
+
raise LocalAuthenticationError(
|
|
362
|
+
f"API key authentication requires UniFi OS (UDM/UDM-Pro/Cloud Gateway, "
|
|
363
|
+
f"firmware >= 5.0.3). This controller returned HTTP {response.status_code}, "
|
|
364
|
+
f"suggesting it does not support API keys. "
|
|
365
|
+
f"Use UNIFI_CONTROLLER_USERNAME/UNIFI_CONTROLLER_PASSWORD for this controller type."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if response.status_code >= 400:
|
|
369
|
+
raise LocalAPIError(
|
|
370
|
+
f"API error: {response.text}",
|
|
371
|
+
status_code=response.status_code,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return response.json()
|
|
375
|
+
|
|
376
|
+
except LocalAPIError:
|
|
377
|
+
raise # Do not let future broad-catch clauses swallow auth errors
|
|
378
|
+
except httpx.ConnectError as e:
|
|
379
|
+
raise LocalConnectionError(f"Connection error: {e}")
|
|
380
|
+
except httpx.TimeoutException:
|
|
381
|
+
raise LocalConnectionError("Request timeout")
|
|
382
|
+
|
|
383
|
+
async def get(self, endpoint: str) -> dict[str, Any]:
|
|
384
|
+
"""Make a GET request."""
|
|
385
|
+
return await self._request("GET", endpoint)
|
|
386
|
+
|
|
387
|
+
async def post(
|
|
388
|
+
self, endpoint: str, data: dict[str, Any] | None = None
|
|
389
|
+
) -> dict[str, Any]:
|
|
390
|
+
"""Make a POST request."""
|
|
391
|
+
return await self._request("POST", endpoint, data=data)
|
|
392
|
+
|
|
393
|
+
# ========== Clients ==========
|
|
394
|
+
|
|
395
|
+
async def list_clients(self) -> list[dict[str, Any]]:
|
|
396
|
+
"""List active (connected) clients."""
|
|
397
|
+
response = await self.get("/stat/sta")
|
|
398
|
+
return response.get("data", [])
|
|
399
|
+
|
|
400
|
+
async def list_all_clients(self) -> list[dict[str, Any]]:
|
|
401
|
+
"""List all known clients (including offline)."""
|
|
402
|
+
response = await self.get("/rest/user")
|
|
403
|
+
return response.get("data", [])
|
|
404
|
+
|
|
405
|
+
async def get_client(self, mac: str) -> dict[str, Any] | None:
|
|
406
|
+
"""Get details for a specific client by MAC address."""
|
|
407
|
+
mac = mac.lower().replace("-", ":")
|
|
408
|
+
response = await self.get(f"/stat/user/{mac}")
|
|
409
|
+
data = response.get("data", [])
|
|
410
|
+
return data[0] if data else None
|
|
411
|
+
|
|
412
|
+
async def block_client(self, mac: str) -> bool:
|
|
413
|
+
"""Block a client by MAC address."""
|
|
414
|
+
mac = mac.lower().replace("-", ":")
|
|
415
|
+
response = await self.post("/cmd/stamgr", data={"cmd": "block-sta", "mac": mac})
|
|
416
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
417
|
+
|
|
418
|
+
async def unblock_client(self, mac: str) -> bool:
|
|
419
|
+
"""Unblock a client by MAC address."""
|
|
420
|
+
mac = mac.lower().replace("-", ":")
|
|
421
|
+
response = await self.post(
|
|
422
|
+
"/cmd/stamgr", data={"cmd": "unblock-sta", "mac": mac}
|
|
423
|
+
)
|
|
424
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
425
|
+
|
|
426
|
+
async def kick_client(self, mac: str) -> bool:
|
|
427
|
+
"""Kick (disconnect) a client by MAC address."""
|
|
428
|
+
mac = mac.lower().replace("-", ":")
|
|
429
|
+
response = await self.post("/cmd/stamgr", data={"cmd": "kick-sta", "mac": mac})
|
|
430
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
431
|
+
|
|
432
|
+
async def set_client_name(self, user_id: str, name: str) -> bool:
|
|
433
|
+
"""Set the display name for a client.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
user_id: The _id of the client/user record (not MAC address)
|
|
437
|
+
name: The name to set for the client
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
True on success
|
|
441
|
+
"""
|
|
442
|
+
response = await self._request(
|
|
443
|
+
"PUT",
|
|
444
|
+
f"/rest/user/{user_id}",
|
|
445
|
+
data={"_id": user_id, "name": name},
|
|
446
|
+
)
|
|
447
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
448
|
+
|
|
449
|
+
# ========== Configuration ==========
|
|
450
|
+
|
|
451
|
+
async def get_networks(self) -> list[dict[str, Any]]:
|
|
452
|
+
"""Get all network configurations (VLANs, subnets)."""
|
|
453
|
+
response = await self.get("/rest/networkconf")
|
|
454
|
+
return response.get("data", [])
|
|
455
|
+
|
|
456
|
+
async def get_wlans(self) -> list[dict[str, Any]]:
|
|
457
|
+
"""Get all wireless network (SSID) configurations."""
|
|
458
|
+
response = await self.get("/rest/wlanconf")
|
|
459
|
+
return response.get("data", [])
|
|
460
|
+
|
|
461
|
+
async def update_network(
|
|
462
|
+
self, network_id: str, payload: dict[str, Any]
|
|
463
|
+
) -> dict[str, Any]:
|
|
464
|
+
"""Update network settings.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
network_id: The _id of the network to update
|
|
468
|
+
payload: Configuration to apply (merged with existing config)
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Updated network configuration
|
|
472
|
+
"""
|
|
473
|
+
response = await self._request("PUT", f"/rest/networkconf/{network_id}", data=payload)
|
|
474
|
+
data = response.get("data", [])
|
|
475
|
+
return data[0] if data else {}
|
|
476
|
+
|
|
477
|
+
async def update_network_dns(
|
|
478
|
+
self,
|
|
479
|
+
network_id: str,
|
|
480
|
+
dns1: str | None = None,
|
|
481
|
+
dns2: str | None = None,
|
|
482
|
+
dns3: str | None = None,
|
|
483
|
+
dns4: str | None = None,
|
|
484
|
+
enabled: bool = True,
|
|
485
|
+
) -> dict[str, Any]:
|
|
486
|
+
"""Update DHCP DNS server configuration for a network.
|
|
487
|
+
|
|
488
|
+
When ``enabled`` is False, custom DNS is disabled and all
|
|
489
|
+
``dhcpd_dns_{1..4}`` slots are cleared regardless of the dns*
|
|
490
|
+
arguments (controller falls back to auto / gateway DNS).
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
network_id: The _id of the network to update.
|
|
494
|
+
dns1: Primary DNS server IP (dhcpd_dns_1).
|
|
495
|
+
dns2: Secondary DNS server IP (dhcpd_dns_2).
|
|
496
|
+
dns3: Tertiary DNS server IP (dhcpd_dns_3).
|
|
497
|
+
dns4: Quaternary DNS server IP (dhcpd_dns_4).
|
|
498
|
+
enabled: Whether custom DHCP DNS is enabled.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Updated network configuration.
|
|
502
|
+
"""
|
|
503
|
+
payload: dict[str, Any] = {"_id": network_id, "dhcpd_dns_enabled": enabled}
|
|
504
|
+
if not enabled:
|
|
505
|
+
payload.update({
|
|
506
|
+
"dhcpd_dns_1": "",
|
|
507
|
+
"dhcpd_dns_2": "",
|
|
508
|
+
"dhcpd_dns_3": "",
|
|
509
|
+
"dhcpd_dns_4": "",
|
|
510
|
+
})
|
|
511
|
+
else:
|
|
512
|
+
if dns1 is not None:
|
|
513
|
+
payload["dhcpd_dns_1"] = dns1
|
|
514
|
+
if dns2 is not None:
|
|
515
|
+
payload["dhcpd_dns_2"] = dns2
|
|
516
|
+
if dns3 is not None:
|
|
517
|
+
payload["dhcpd_dns_3"] = dns3
|
|
518
|
+
if dns4 is not None:
|
|
519
|
+
payload["dhcpd_dns_4"] = dns4
|
|
520
|
+
return await self.update_network(network_id, payload)
|
|
521
|
+
|
|
522
|
+
# ========== AP Groups (Broadcasting Groups) ==========
|
|
523
|
+
|
|
524
|
+
async def _ensure_cookies_loaded(self) -> None:
|
|
525
|
+
"""Ensure we have a valid session by making a simple API call."""
|
|
526
|
+
if self._api_key:
|
|
527
|
+
return # API key mode: no session needed
|
|
528
|
+
if not self._cookies:
|
|
529
|
+
# Make a simple call to trigger authentication
|
|
530
|
+
await self.get("/stat/health")
|
|
531
|
+
|
|
532
|
+
async def _v2_request(
|
|
533
|
+
self, method: str, endpoint: str, data: dict[str, Any] | None = None
|
|
534
|
+
) -> Any:
|
|
535
|
+
"""Make a request to the v2 API.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
539
|
+
endpoint: API endpoint path (without base URL)
|
|
540
|
+
data: Optional JSON payload
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Response JSON or True for successful DELETE
|
|
544
|
+
"""
|
|
545
|
+
await self._ensure_cookies_loaded()
|
|
546
|
+
|
|
547
|
+
url = f"{self.controller_url}/proxy/network/v2/api/site/{self.site}{endpoint}"
|
|
548
|
+
headers: dict[str, str] = {}
|
|
549
|
+
if self._api_key:
|
|
550
|
+
headers["X-API-KEY"] = self._api_key
|
|
551
|
+
elif self._csrf_token:
|
|
552
|
+
headers["x-csrf-token"] = self._csrf_token
|
|
553
|
+
if method in ("POST", "PUT"):
|
|
554
|
+
headers["Content-Type"] = "application/json"
|
|
555
|
+
|
|
556
|
+
# In API key mode: stateless, no cookies
|
|
557
|
+
client_kwargs: dict[str, Any] = {
|
|
558
|
+
"verify": self.verify_ssl,
|
|
559
|
+
"timeout": self.timeout,
|
|
560
|
+
}
|
|
561
|
+
if not self._api_key:
|
|
562
|
+
client_kwargs["cookies"] = self._cookies
|
|
563
|
+
|
|
564
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
565
|
+
if method == "GET":
|
|
566
|
+
response = await client.get(url, headers=headers)
|
|
567
|
+
elif method == "POST":
|
|
568
|
+
response = await client.post(url, headers=headers, json=data)
|
|
569
|
+
elif method == "PUT":
|
|
570
|
+
response = await client.put(url, headers=headers, json=data)
|
|
571
|
+
elif method == "DELETE":
|
|
572
|
+
response = await client.delete(url, headers=headers)
|
|
573
|
+
else:
|
|
574
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
575
|
+
|
|
576
|
+
if response.status_code == 401:
|
|
577
|
+
if self._api_key:
|
|
578
|
+
raise LocalAuthenticationError(_API_KEY_REJECTED_MSG)
|
|
579
|
+
raise LocalAuthenticationError("Session expired")
|
|
580
|
+
if not response.is_success:
|
|
581
|
+
raise LocalAPIError(
|
|
582
|
+
f"API error: {response.text}", status_code=response.status_code
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
if method == "DELETE":
|
|
586
|
+
return True
|
|
587
|
+
return response.json()
|
|
588
|
+
|
|
589
|
+
async def get_ap_groups(self) -> list[dict[str, Any]]:
|
|
590
|
+
"""Get all AP groups (broadcasting groups).
|
|
591
|
+
|
|
592
|
+
AP groups determine which WLANs are broadcast on which Access Points.
|
|
593
|
+
Uses the v2 API endpoint.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
List of AP group dicts with keys: _id, name, device_macs, for_wlanconf
|
|
597
|
+
"""
|
|
598
|
+
return await self._v2_request("GET", "/apgroups")
|
|
599
|
+
|
|
600
|
+
async def create_ap_group(
|
|
601
|
+
self, name: str, device_macs: list[str] | None = None
|
|
602
|
+
) -> dict[str, Any]:
|
|
603
|
+
"""Create a new AP group.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
name: Name for the new AP group
|
|
607
|
+
device_macs: Optional list of device MAC addresses to include
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
The created AP group dict
|
|
611
|
+
"""
|
|
612
|
+
payload = {"name": name, "device_macs": device_macs or []}
|
|
613
|
+
return await self._v2_request("POST", "/apgroups", payload)
|
|
614
|
+
|
|
615
|
+
async def update_ap_group(
|
|
616
|
+
self, group_id: str, name: str, device_macs: list[str]
|
|
617
|
+
) -> dict[str, Any]:
|
|
618
|
+
"""Update an existing AP group.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
group_id: The AP group ID to update
|
|
622
|
+
name: New name for the group
|
|
623
|
+
device_macs: List of device MAC addresses for the group
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
The updated AP group dict
|
|
627
|
+
"""
|
|
628
|
+
payload = {"name": name, "device_macs": device_macs}
|
|
629
|
+
return await self._v2_request("PUT", f"/apgroups/{group_id}", payload)
|
|
630
|
+
|
|
631
|
+
async def delete_ap_group(self, group_id: str) -> bool:
|
|
632
|
+
"""Delete an AP group.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
group_id: The AP group ID to delete
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
True if deletion was successful
|
|
639
|
+
"""
|
|
640
|
+
return await self._v2_request("DELETE", f"/apgroups/{group_id}")
|
|
641
|
+
|
|
642
|
+
async def get_firewall_rules(self) -> list[dict[str, Any]]:
|
|
643
|
+
"""Get all firewall rules."""
|
|
644
|
+
response = await self.get("/rest/firewallrule")
|
|
645
|
+
return response.get("data", [])
|
|
646
|
+
|
|
647
|
+
async def get_firewall_groups(self) -> list[dict[str, Any]]:
|
|
648
|
+
"""Get all firewall groups."""
|
|
649
|
+
response = await self.get("/rest/firewallgroup")
|
|
650
|
+
return response.get("data", [])
|
|
651
|
+
|
|
652
|
+
async def get_port_forwards(self) -> list[dict[str, Any]]:
|
|
653
|
+
"""Get all port forwarding rules."""
|
|
654
|
+
response = await self.get("/rest/portforward")
|
|
655
|
+
return response.get("data", [])
|
|
656
|
+
|
|
657
|
+
async def get_devices(self) -> list[dict[str, Any]]:
|
|
658
|
+
"""Get all device configurations and status."""
|
|
659
|
+
response = await self.get("/stat/device")
|
|
660
|
+
return response.get("data", [])
|
|
661
|
+
|
|
662
|
+
async def get_device(self, mac: str) -> dict[str, Any] | None:
|
|
663
|
+
"""Get a specific device by MAC address."""
|
|
664
|
+
mac = mac.lower().replace("-", ":")
|
|
665
|
+
devices = await self.get_devices()
|
|
666
|
+
for device in devices:
|
|
667
|
+
if device.get("mac", "").lower() == mac:
|
|
668
|
+
return device
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
async def restart_device(self, mac: str) -> bool:
|
|
672
|
+
"""Restart/reboot a device."""
|
|
673
|
+
mac = mac.lower().replace("-", ":")
|
|
674
|
+
response = await self.post("/cmd/devmgr", data={"cmd": "restart", "mac": mac})
|
|
675
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
676
|
+
|
|
677
|
+
async def upgrade_device(self, mac: str) -> bool:
|
|
678
|
+
"""Upgrade device firmware."""
|
|
679
|
+
mac = mac.lower().replace("-", ":")
|
|
680
|
+
response = await self.post("/cmd/devmgr", data={"cmd": "upgrade", "mac": mac})
|
|
681
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
682
|
+
|
|
683
|
+
async def locate_device(self, mac: str, enabled: bool = True) -> bool:
|
|
684
|
+
"""Enable/disable locate LED on device."""
|
|
685
|
+
mac = mac.lower().replace("-", ":")
|
|
686
|
+
response = await self.post(
|
|
687
|
+
"/cmd/devmgr",
|
|
688
|
+
data={"cmd": "set-locate", "mac": mac, "locate_enable": enabled},
|
|
689
|
+
)
|
|
690
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
691
|
+
|
|
692
|
+
async def adopt_device(self, mac: str) -> bool:
|
|
693
|
+
"""Adopt a device."""
|
|
694
|
+
mac = mac.lower().replace("-", ":")
|
|
695
|
+
response = await self.post("/cmd/devmgr", data={"cmd": "adopt", "mac": mac})
|
|
696
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
697
|
+
|
|
698
|
+
async def get_dhcp_reservations(self) -> list[dict[str, Any]]:
|
|
699
|
+
"""Get DHCP reservations (clients with fixed IPs)."""
|
|
700
|
+
# Fixed IPs are stored in user records with use_fixedip=True
|
|
701
|
+
response = await self.get("/rest/user")
|
|
702
|
+
users = response.get("data", [])
|
|
703
|
+
return [u for u in users if u.get("use_fixedip", False)]
|
|
704
|
+
|
|
705
|
+
async def set_client_fixed_ip(
|
|
706
|
+
self,
|
|
707
|
+
client_id: str,
|
|
708
|
+
fixed_ip: str | None = None,
|
|
709
|
+
network_id: str | None = None,
|
|
710
|
+
*,
|
|
711
|
+
use_fixedip: bool = True,
|
|
712
|
+
) -> bool:
|
|
713
|
+
"""Set or remove a fixed IP for a client.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
client_id: The _id of the client (user record)
|
|
717
|
+
fixed_ip: The IP address to assign (required if use_fixedip=True)
|
|
718
|
+
network_id: The network _id (optional, uses client's current network)
|
|
719
|
+
use_fixedip: True to enable fixed IP, False to disable
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
True on success
|
|
723
|
+
"""
|
|
724
|
+
payload: dict[str, Any] = {
|
|
725
|
+
"_id": client_id,
|
|
726
|
+
"use_fixedip": use_fixedip,
|
|
727
|
+
}
|
|
728
|
+
if use_fixedip:
|
|
729
|
+
if fixed_ip:
|
|
730
|
+
payload["fixed_ip"] = fixed_ip
|
|
731
|
+
if network_id:
|
|
732
|
+
payload["network_id"] = network_id
|
|
733
|
+
|
|
734
|
+
response = await self._request("PUT", f"/rest/user/{client_id}", data=payload)
|
|
735
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
736
|
+
|
|
737
|
+
async def get_traffic_rules(self) -> list[dict[str, Any]]:
|
|
738
|
+
"""Get traffic rules/schedules."""
|
|
739
|
+
response = await self.get("/rest/trafficrule")
|
|
740
|
+
return response.get("data", [])
|
|
741
|
+
|
|
742
|
+
async def get_routing(self) -> list[dict[str, Any]]:
|
|
743
|
+
"""Get static routes."""
|
|
744
|
+
response = await self.get("/rest/routing")
|
|
745
|
+
return response.get("data", [])
|
|
746
|
+
|
|
747
|
+
async def get_site_settings(self) -> list[dict[str, Any]]:
|
|
748
|
+
"""Get site settings."""
|
|
749
|
+
response = await self.get("/rest/setting")
|
|
750
|
+
return response.get("data", [])
|
|
751
|
+
|
|
752
|
+
async def get_running_config(self) -> dict[str, Any]:
|
|
753
|
+
"""Get full running configuration."""
|
|
754
|
+
config: dict[str, Any] = {}
|
|
755
|
+
|
|
756
|
+
# Fetch each section, handling errors gracefully
|
|
757
|
+
async def safe_fetch(name: str, func):
|
|
758
|
+
try:
|
|
759
|
+
config[name] = await func()
|
|
760
|
+
except LocalAPIError:
|
|
761
|
+
config[name] = [] # Empty list on error
|
|
762
|
+
|
|
763
|
+
await safe_fetch("networks", self.get_networks)
|
|
764
|
+
await safe_fetch("wireless", self.get_wlans)
|
|
765
|
+
await safe_fetch("firewall_rules", self.get_firewall_rules)
|
|
766
|
+
await safe_fetch("firewall_groups", self.get_firewall_groups)
|
|
767
|
+
await safe_fetch("port_forwards", self.get_port_forwards)
|
|
768
|
+
await safe_fetch("devices", self.get_devices)
|
|
769
|
+
await safe_fetch("dhcp_reservations", self.get_dhcp_reservations)
|
|
770
|
+
await safe_fetch("traffic_rules", self.get_traffic_rules)
|
|
771
|
+
await safe_fetch("routing", self.get_routing)
|
|
772
|
+
|
|
773
|
+
return config
|
|
774
|
+
|
|
775
|
+
# ========== Monitoring ==========
|
|
776
|
+
|
|
777
|
+
async def get_events(self, limit: int = 50) -> list[dict[str, Any]]:
|
|
778
|
+
"""Get recent events."""
|
|
779
|
+
response = await self.post("/stat/event", data={"_limit": limit, "_sort": "-time"})
|
|
780
|
+
return response.get("data", [])
|
|
781
|
+
|
|
782
|
+
async def get_alarms(self, archived: bool = False) -> list[dict[str, Any]]:
|
|
783
|
+
"""Get alarms. Set archived=True to include archived alarms."""
|
|
784
|
+
response = await self.get("/stat/alarm")
|
|
785
|
+
alarms = response.get("data", [])
|
|
786
|
+
if not archived:
|
|
787
|
+
alarms = [a for a in alarms if not a.get("archived", False)]
|
|
788
|
+
return alarms
|
|
789
|
+
|
|
790
|
+
async def archive_alarm(self, alarm_id: str) -> bool:
|
|
791
|
+
"""Archive an alarm by ID."""
|
|
792
|
+
response = await self.post(
|
|
793
|
+
"/cmd/evtmgr", data={"cmd": "archive-alarm", "_id": alarm_id}
|
|
794
|
+
)
|
|
795
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
796
|
+
|
|
797
|
+
async def get_health(self) -> list[dict[str, Any]]:
|
|
798
|
+
"""Get site health information."""
|
|
799
|
+
response = await self.get("/stat/health")
|
|
800
|
+
return response.get("data", [])
|
|
801
|
+
|
|
802
|
+
# ========== Vouchers ==========
|
|
803
|
+
|
|
804
|
+
async def get_vouchers(self) -> list[dict[str, Any]]:
|
|
805
|
+
"""Get all vouchers."""
|
|
806
|
+
response = await self.get("/stat/voucher")
|
|
807
|
+
return response.get("data", [])
|
|
808
|
+
|
|
809
|
+
async def create_voucher(
|
|
810
|
+
self,
|
|
811
|
+
count: int = 1,
|
|
812
|
+
duration: int = 1440, # minutes (24h default)
|
|
813
|
+
quota: int = 0, # MB (0 = unlimited)
|
|
814
|
+
up_limit: int = 0, # kbps (0 = unlimited)
|
|
815
|
+
down_limit: int = 0, # kbps (0 = unlimited)
|
|
816
|
+
multi_use: int = 1, # number of uses
|
|
817
|
+
note: str | None = None,
|
|
818
|
+
) -> list[dict[str, Any]]:
|
|
819
|
+
"""Create voucher(s).
|
|
820
|
+
|
|
821
|
+
Args:
|
|
822
|
+
count: Number of vouchers to create
|
|
823
|
+
duration: Duration in minutes
|
|
824
|
+
quota: Data quota in MB (0 = unlimited)
|
|
825
|
+
up_limit: Upload limit in kbps (0 = unlimited)
|
|
826
|
+
down_limit: Download limit in kbps (0 = unlimited)
|
|
827
|
+
multi_use: Number of uses per voucher
|
|
828
|
+
note: Optional note/description
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
List of created voucher data
|
|
832
|
+
"""
|
|
833
|
+
data: dict[str, Any] = {
|
|
834
|
+
"cmd": "create-voucher",
|
|
835
|
+
"n": count,
|
|
836
|
+
"expire": duration,
|
|
837
|
+
"quota": multi_use, # quota field is actually multi-use count
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if quota > 0:
|
|
841
|
+
data["bytes"] = quota # MB
|
|
842
|
+
|
|
843
|
+
if up_limit > 0:
|
|
844
|
+
data["up"] = up_limit
|
|
845
|
+
|
|
846
|
+
if down_limit > 0:
|
|
847
|
+
data["down"] = down_limit
|
|
848
|
+
|
|
849
|
+
if note:
|
|
850
|
+
data["note"] = note
|
|
851
|
+
|
|
852
|
+
response = await self.post("/cmd/hotspot", data=data)
|
|
853
|
+
return response.get("data", [])
|
|
854
|
+
|
|
855
|
+
async def revoke_voucher(self, voucher_id: str) -> bool:
|
|
856
|
+
"""Revoke/delete a voucher by ID."""
|
|
857
|
+
response = await self.post(
|
|
858
|
+
"/cmd/hotspot", data={"cmd": "delete-voucher", "_id": voucher_id}
|
|
859
|
+
)
|
|
860
|
+
return response.get("meta", {}).get("rc") == "ok"
|
|
861
|
+
|
|
862
|
+
# ========== DPI (Deep Packet Inspection) ==========
|
|
863
|
+
|
|
864
|
+
async def get_site_dpi(self) -> list[dict[str, Any]]:
|
|
865
|
+
"""Get site-level DPI statistics."""
|
|
866
|
+
response = await self.get("/stat/sitedpi")
|
|
867
|
+
return response.get("data", [])
|
|
868
|
+
|
|
869
|
+
async def get_client_dpi(self, mac: str) -> list[dict[str, Any]]:
|
|
870
|
+
"""Get DPI statistics for a specific client."""
|
|
871
|
+
mac = mac.lower().replace("-", ":")
|
|
872
|
+
response = await self.get(f"/stat/stadpi/{mac}")
|
|
873
|
+
return response.get("data", [])
|
|
874
|
+
|
|
875
|
+
# ========== Statistics ==========
|
|
876
|
+
|
|
877
|
+
async def get_daily_stats(self, days: int = 30) -> list[dict[str, Any]]:
|
|
878
|
+
"""Get daily site statistics."""
|
|
879
|
+
response = await self.post(
|
|
880
|
+
"/stat/report/daily.site",
|
|
881
|
+
data={
|
|
882
|
+
"attrs": ["time", "rx_bytes", "tx_bytes", "num_sta", "wan-rx_bytes", "wan-tx_bytes"],
|
|
883
|
+
"n": days,
|
|
884
|
+
},
|
|
885
|
+
)
|
|
886
|
+
return response.get("data", [])
|
|
887
|
+
|
|
888
|
+
async def get_hourly_stats(self, hours: int = 24) -> list[dict[str, Any]]:
|
|
889
|
+
"""Get hourly site statistics."""
|
|
890
|
+
response = await self.post(
|
|
891
|
+
"/stat/report/hourly.site",
|
|
892
|
+
data={
|
|
893
|
+
"attrs": ["time", "rx_bytes", "tx_bytes", "num_sta", "wan-rx_bytes", "wan-tx_bytes"],
|
|
894
|
+
"n": hours,
|
|
895
|
+
},
|
|
896
|
+
)
|
|
897
|
+
return response.get("data", [])
|