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/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", [])