bbox-api 0.1.0__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.
- bbox_api/__init__.py +60 -0
- bbox_api/client.py +670 -0
- bbox_api/const.py +31 -0
- bbox_api/exceptions.py +75 -0
- bbox_api/models.py +412 -0
- bbox_api/py.typed +0 -0
- bbox_api-0.1.0.dist-info/METADATA +143 -0
- bbox_api-0.1.0.dist-info/RECORD +10 -0
- bbox_api-0.1.0.dist-info/WHEEL +4 -0
- bbox_api-0.1.0.dist-info/licenses/LICENSE +21 -0
bbox_api/client.py
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
"""Async client for the Bouygues Telecom Bbox router API.
|
|
2
|
+
|
|
3
|
+
The router exposes a local REST API under ``/api/v1`` (see
|
|
4
|
+
https://api.bbox.fr/doc/apirouter/index.html). Authentication works with the
|
|
5
|
+
admin password: ``POST /api/v1/login`` sets a session cookie (``BBOX_ID``),
|
|
6
|
+
and every other call must carry a short-lived ``btoken`` query parameter
|
|
7
|
+
issued by ``GET /api/v1/device/token``.
|
|
8
|
+
|
|
9
|
+
Typical usage::
|
|
10
|
+
|
|
11
|
+
async with BboxClient("admin-password") as client:
|
|
12
|
+
device = await client.get_device()
|
|
13
|
+
hosts = await client.get_hosts()
|
|
14
|
+
|
|
15
|
+
To share an existing aiohttp session (e.g. one managed by your application),
|
|
16
|
+
pass it with ``session=`` — the client then never closes it::
|
|
17
|
+
|
|
18
|
+
client = BboxClient(password, host="192.168.1.1", session=my_session)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import socket
|
|
27
|
+
from datetime import UTC, datetime, timedelta
|
|
28
|
+
from typing import Any, Literal, Self
|
|
29
|
+
|
|
30
|
+
import aiohttp
|
|
31
|
+
|
|
32
|
+
from .const import (
|
|
33
|
+
API_PATH,
|
|
34
|
+
DEFAULT_HOST,
|
|
35
|
+
DEFAULT_TIMEOUT,
|
|
36
|
+
NO_TOKEN_ENDPOINTS,
|
|
37
|
+
TEMPORARY_ERROR_REASONS,
|
|
38
|
+
TOKEN_FALLBACK_TTL,
|
|
39
|
+
)
|
|
40
|
+
from .exceptions import (
|
|
41
|
+
BboxApiError,
|
|
42
|
+
BboxAuthenticationError,
|
|
43
|
+
BboxConnectionError,
|
|
44
|
+
BboxInvalidCredentialsError,
|
|
45
|
+
BboxNotFoundError,
|
|
46
|
+
BboxRateLimitError,
|
|
47
|
+
BboxSessionExpiredError,
|
|
48
|
+
BboxTimeoutError,
|
|
49
|
+
)
|
|
50
|
+
from .models import WANIP, DeviceInfo, Host, WANIPStats, Wireless
|
|
51
|
+
|
|
52
|
+
_LOGGER = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
_MAX_RETRIES = 3
|
|
55
|
+
_RETRY_DELAY = 1.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _unwrap(data: Any) -> Any:
|
|
59
|
+
"""Unwrap the API envelope: payloads are wrapped in a list of objects
|
|
60
|
+
(e.g. ``hosts`` returns one object for the LAN and one for extenders),
|
|
61
|
+
the first object carries the main payload."""
|
|
62
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
63
|
+
return data[0]
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BboxClient:
|
|
68
|
+
"""Async client for a Bbox router."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
password: str,
|
|
73
|
+
host: str = DEFAULT_HOST,
|
|
74
|
+
*,
|
|
75
|
+
use_tls: bool = False,
|
|
76
|
+
port: int | None = None,
|
|
77
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
78
|
+
session: aiohttp.ClientSession | None = None,
|
|
79
|
+
verify_ssl: bool = True,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Initialize the client.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
password: Router admin password (the one of the web interface).
|
|
85
|
+
host: Router hostname or IP, e.g. ``bbox.lan`` or ``192.168.1.1``.
|
|
86
|
+
use_tls: Use HTTPS instead of HTTP (required for remote access and
|
|
87
|
+
for ``mabbox.bytel.fr``).
|
|
88
|
+
port: Optional TCP port override.
|
|
89
|
+
timeout: Request timeout in seconds.
|
|
90
|
+
session: Optional shared aiohttp session. If omitted, one is
|
|
91
|
+
created and closed by the client.
|
|
92
|
+
verify_ssl: Verify TLS certificates when ``use_tls`` is enabled.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If the password is empty.
|
|
96
|
+
"""
|
|
97
|
+
if not password:
|
|
98
|
+
raise ValueError("password must not be empty")
|
|
99
|
+
if not host:
|
|
100
|
+
raise ValueError("host must not be empty")
|
|
101
|
+
if port is not None and not 1 <= port <= 65535:
|
|
102
|
+
raise ValueError(f"invalid port: {port}")
|
|
103
|
+
if timeout <= 0:
|
|
104
|
+
raise ValueError(f"timeout must be > 0, got {timeout}")
|
|
105
|
+
self._password = password
|
|
106
|
+
scheme = "https" if use_tls else "http"
|
|
107
|
+
netloc = f"{host}:{port}" if port else host
|
|
108
|
+
self._origin = f"{scheme}://{netloc}"
|
|
109
|
+
self._base_url = f"{self._origin}/{API_PATH}"
|
|
110
|
+
self._timeout = timeout
|
|
111
|
+
self._verify_ssl = verify_ssl
|
|
112
|
+
self._session = session
|
|
113
|
+
self._owns_session = session is None
|
|
114
|
+
self._authenticated = False
|
|
115
|
+
self._btoken: str | None = None
|
|
116
|
+
self._btoken_expires: datetime | None = None
|
|
117
|
+
# Serialise login and token renewal: concurrent calls must not
|
|
118
|
+
# trigger several logins (the router rate-limits them with HTTP 429).
|
|
119
|
+
self._auth_lock = asyncio.Lock()
|
|
120
|
+
self._token_lock = asyncio.Lock()
|
|
121
|
+
|
|
122
|
+
def __del__(self) -> None:
|
|
123
|
+
"""Warn if an owned session was never closed."""
|
|
124
|
+
try:
|
|
125
|
+
if self._session is not None and self._owns_session and not self._session.closed:
|
|
126
|
+
_LOGGER.warning(
|
|
127
|
+
"BboxClient for %s was garbage-collected with an "
|
|
128
|
+
"unclosed session; use 'async with' or close()",
|
|
129
|
+
self._origin,
|
|
130
|
+
)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def remote(cls, password: str, wan_ip: str, **kwargs: Any) -> Self:
|
|
136
|
+
"""Create a client for remote (WAN) access through the router's public IP.
|
|
137
|
+
|
|
138
|
+
Requires the "remote access" option enabled on the router and only
|
|
139
|
+
works from outside the LAN — from the LAN, use the default host
|
|
140
|
+
(``bbox.lan`` / ``mabbox.bytel.fr`` / its LAN IP) instead; the WAN IP
|
|
141
|
+
is typically refused (no NAT hairpinning).
|
|
142
|
+
|
|
143
|
+
The router's certificate is issued for ``mabbox.bytel.fr``, not for
|
|
144
|
+
its IP, so TLS verification is disabled by default here.
|
|
145
|
+
|
|
146
|
+
Discover the WAN IP from the LAN first:
|
|
147
|
+
``wan_ip = (await client.get_wan_ip()).ipaddress``.
|
|
148
|
+
"""
|
|
149
|
+
kwargs.pop("host", None)
|
|
150
|
+
kwargs.pop("use_tls", None)
|
|
151
|
+
kwargs.setdefault("verify_ssl", False)
|
|
152
|
+
return cls(password, host=wan_ip, use_tls=True, **kwargs)
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Session / authentication
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def __aenter__(self) -> Self:
|
|
159
|
+
"""Authenticate and return the client (closes the session on failure)."""
|
|
160
|
+
try:
|
|
161
|
+
await self.login()
|
|
162
|
+
except BaseException:
|
|
163
|
+
await self.close()
|
|
164
|
+
raise
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
async def __aexit__(self, *_exc_info: Any) -> None:
|
|
168
|
+
"""Close the underlying HTTP session if owned by this client."""
|
|
169
|
+
await self.close()
|
|
170
|
+
|
|
171
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
172
|
+
if self._session is None:
|
|
173
|
+
# unsafe=True is required so the session cookie is kept when the
|
|
174
|
+
# router is reached by IP address instead of a hostname.
|
|
175
|
+
self._session = aiohttp.ClientSession(
|
|
176
|
+
connector=aiohttp.TCPConnector(
|
|
177
|
+
family=socket.AF_INET, use_dns_cache=True, verify_ssl=self._verify_ssl
|
|
178
|
+
),
|
|
179
|
+
cookie_jar=aiohttp.CookieJar(unsafe=True),
|
|
180
|
+
)
|
|
181
|
+
self._owns_session = True
|
|
182
|
+
return self._session
|
|
183
|
+
|
|
184
|
+
async def login(self) -> None:
|
|
185
|
+
"""Authenticate against the router with the admin password.
|
|
186
|
+
|
|
187
|
+
Safe to call concurrently and repeatedly: only one login request is
|
|
188
|
+
ever in flight.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
BboxInvalidCredentialsError: Wrong password.
|
|
192
|
+
BboxRateLimitError: Too many attempts, the router blocks login.
|
|
193
|
+
BboxConnectionError: Router unreachable.
|
|
194
|
+
BboxTimeoutError: Router did not answer in time.
|
|
195
|
+
"""
|
|
196
|
+
if self._authenticated:
|
|
197
|
+
return
|
|
198
|
+
async with self._auth_lock:
|
|
199
|
+
if self._authenticated:
|
|
200
|
+
return
|
|
201
|
+
await self.request(
|
|
202
|
+
"POST",
|
|
203
|
+
"login",
|
|
204
|
+
data={"password": self._password, "remember": "1"},
|
|
205
|
+
skip_token=True,
|
|
206
|
+
)
|
|
207
|
+
self._authenticated = True
|
|
208
|
+
_LOGGER.debug("Authenticated to %s", self._origin)
|
|
209
|
+
|
|
210
|
+
async def logout(self) -> None:
|
|
211
|
+
"""Invalidate the session on the router."""
|
|
212
|
+
try:
|
|
213
|
+
await self.request("POST", "logout", skip_token=True)
|
|
214
|
+
finally:
|
|
215
|
+
self._authenticated = False
|
|
216
|
+
async with self._token_lock:
|
|
217
|
+
self._btoken = None
|
|
218
|
+
self._btoken_expires = None
|
|
219
|
+
|
|
220
|
+
async def close(self) -> None:
|
|
221
|
+
"""Close the HTTP session if this client owns it, and detach from it.
|
|
222
|
+
|
|
223
|
+
A shared (injected) session is never closed; the client simply stops
|
|
224
|
+
using it and will create its own session on the next request.
|
|
225
|
+
"""
|
|
226
|
+
if self._session is not None and self._owns_session:
|
|
227
|
+
await self._session.close()
|
|
228
|
+
self._session = None
|
|
229
|
+
self._owns_session = False
|
|
230
|
+
self._authenticated = False
|
|
231
|
+
|
|
232
|
+
def _token_valid(self) -> bool:
|
|
233
|
+
return (
|
|
234
|
+
self._btoken is not None
|
|
235
|
+
and self._btoken_expires is not None
|
|
236
|
+
and datetime.now(UTC) < self._btoken_expires
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def _get_token(self) -> str:
|
|
240
|
+
"""Return a valid ``btoken``, fetching or renewing it if needed."""
|
|
241
|
+
if self._token_valid():
|
|
242
|
+
return self._btoken # type: ignore[return-value]
|
|
243
|
+
|
|
244
|
+
await self.login()
|
|
245
|
+
async with self._token_lock:
|
|
246
|
+
if self._token_valid(): # another coroutine renewed it meanwhile
|
|
247
|
+
return self._btoken # type: ignore[return-value]
|
|
248
|
+
data = _unwrap(await self.request("GET", "device/token", skip_token=True))
|
|
249
|
+
device = data.get("device", {}) if isinstance(data, dict) else {}
|
|
250
|
+
token = device.get("token")
|
|
251
|
+
if not token:
|
|
252
|
+
raise BboxApiError("Router did not return a btoken")
|
|
253
|
+
self._btoken = str(token)
|
|
254
|
+
expires = device.get("expires")
|
|
255
|
+
try:
|
|
256
|
+
self._btoken_expires = (
|
|
257
|
+
datetime.fromisoformat(str(expires).replace("Z", "+00:00")) if expires else None
|
|
258
|
+
)
|
|
259
|
+
except ValueError:
|
|
260
|
+
self._btoken_expires = None
|
|
261
|
+
if self._btoken_expires is None:
|
|
262
|
+
# Missing/unparseable expiry: assume a short conservative TTL
|
|
263
|
+
# instead of refetching the token on every single call.
|
|
264
|
+
self._btoken_expires = datetime.now(UTC) + timedelta(seconds=TOKEN_FALLBACK_TTL)
|
|
265
|
+
return self._btoken
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
# Low-level request
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
async def request(
|
|
272
|
+
self,
|
|
273
|
+
method: str,
|
|
274
|
+
path: str,
|
|
275
|
+
*,
|
|
276
|
+
params: dict[str, Any] | None = None,
|
|
277
|
+
json_body: Any = None,
|
|
278
|
+
data: Any = None,
|
|
279
|
+
skip_token: bool = False,
|
|
280
|
+
_retried_auth: bool = False,
|
|
281
|
+
_attempt: int = 1,
|
|
282
|
+
) -> Any:
|
|
283
|
+
"""Perform a request against the router API and return parsed JSON.
|
|
284
|
+
|
|
285
|
+
This is the escape hatch for endpoints not covered by the typed
|
|
286
|
+
methods of this client.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
BboxApiError: (or a subclass) on any API/transport failure.
|
|
290
|
+
"""
|
|
291
|
+
session = self._ensure_session()
|
|
292
|
+
path = path.strip("/")
|
|
293
|
+
url = f"{self._base_url}/{path}"
|
|
294
|
+
|
|
295
|
+
if not skip_token and path not in NO_TOKEN_ENDPOINTS:
|
|
296
|
+
await self.login()
|
|
297
|
+
params = {**(params or {}), "btoken": await self._get_token()}
|
|
298
|
+
|
|
299
|
+
headers = {"Referer": f"{self._base_url}/", "Origin": self._origin}
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
async with asyncio.timeout(self._timeout):
|
|
303
|
+
async with session.request(
|
|
304
|
+
method,
|
|
305
|
+
url,
|
|
306
|
+
params=params,
|
|
307
|
+
json=json_body,
|
|
308
|
+
data=data,
|
|
309
|
+
headers=headers,
|
|
310
|
+
allow_redirects=False,
|
|
311
|
+
ssl=self._verify_ssl,
|
|
312
|
+
) as response:
|
|
313
|
+
body = await response.read()
|
|
314
|
+
|
|
315
|
+
if 300 <= response.status < 400:
|
|
316
|
+
# e.g. http://bbox.lan redirects to https://mabbox.bytel.fr
|
|
317
|
+
location = response.headers.get("Location", "?")
|
|
318
|
+
raise BboxApiError(
|
|
319
|
+
f"Router answered {response.status} redirect to {location} "
|
|
320
|
+
f"on {path}; check host/use_tls (mabbox.bytel.fr is LAN-only)",
|
|
321
|
+
status_code=response.status,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if response.status == 401 and path != "login" and not _retried_auth:
|
|
325
|
+
# Session cookie expired: re-authenticate and retry once.
|
|
326
|
+
_LOGGER.debug("401 on %s, re-authenticating", path)
|
|
327
|
+
self._authenticated = False
|
|
328
|
+
async with self._token_lock:
|
|
329
|
+
self._btoken = None
|
|
330
|
+
self._btoken_expires = None
|
|
331
|
+
await self.login()
|
|
332
|
+
return await self.request(
|
|
333
|
+
method,
|
|
334
|
+
path,
|
|
335
|
+
params=params,
|
|
336
|
+
json_body=json_body,
|
|
337
|
+
data=data,
|
|
338
|
+
skip_token=skip_token,
|
|
339
|
+
_retried_auth=True,
|
|
340
|
+
_attempt=_attempt,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if response.status >= 400:
|
|
344
|
+
error = self._build_error(response.status, body, path)
|
|
345
|
+
if _attempt < _MAX_RETRIES and any(
|
|
346
|
+
str(err.get("reason", "")).lower() in TEMPORARY_ERROR_REASONS
|
|
347
|
+
for err in error.errors
|
|
348
|
+
):
|
|
349
|
+
_LOGGER.debug(
|
|
350
|
+
"Temporary API error on %s, retry %s/%s",
|
|
351
|
+
path,
|
|
352
|
+
_attempt,
|
|
353
|
+
_MAX_RETRIES,
|
|
354
|
+
)
|
|
355
|
+
await asyncio.sleep(_RETRY_DELAY)
|
|
356
|
+
return await self.request(
|
|
357
|
+
method,
|
|
358
|
+
path,
|
|
359
|
+
params=params,
|
|
360
|
+
json_body=json_body,
|
|
361
|
+
data=data,
|
|
362
|
+
skip_token=skip_token,
|
|
363
|
+
_retried_auth=_retried_auth,
|
|
364
|
+
_attempt=_attempt + 1,
|
|
365
|
+
)
|
|
366
|
+
raise error
|
|
367
|
+
|
|
368
|
+
return self._parse_body(body)
|
|
369
|
+
except TimeoutError as err:
|
|
370
|
+
return await self._retry_or_raise(
|
|
371
|
+
method,
|
|
372
|
+
path,
|
|
373
|
+
BboxTimeoutError(f"Timeout after {self._timeout}s on {method} {path}"),
|
|
374
|
+
err,
|
|
375
|
+
params,
|
|
376
|
+
json_body,
|
|
377
|
+
data,
|
|
378
|
+
skip_token,
|
|
379
|
+
_retried_auth,
|
|
380
|
+
_attempt,
|
|
381
|
+
)
|
|
382
|
+
except aiohttp.ClientError as err:
|
|
383
|
+
return await self._retry_or_raise(
|
|
384
|
+
method,
|
|
385
|
+
path,
|
|
386
|
+
BboxConnectionError(f"Connection error on {method} {path}: {err}"),
|
|
387
|
+
err,
|
|
388
|
+
params,
|
|
389
|
+
json_body,
|
|
390
|
+
data,
|
|
391
|
+
skip_token,
|
|
392
|
+
_retried_auth,
|
|
393
|
+
_attempt,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
async def _retry_or_raise(
|
|
397
|
+
self,
|
|
398
|
+
method: str,
|
|
399
|
+
path: str,
|
|
400
|
+
error: BboxApiError,
|
|
401
|
+
cause: BaseException,
|
|
402
|
+
params: dict[str, Any] | None,
|
|
403
|
+
json_body: Any,
|
|
404
|
+
data: Any,
|
|
405
|
+
skip_token: bool,
|
|
406
|
+
retried_auth: bool,
|
|
407
|
+
attempt: int,
|
|
408
|
+
) -> Any:
|
|
409
|
+
"""Retry idempotent GETs on transport errors, raise otherwise."""
|
|
410
|
+
if method.upper() == "GET" and attempt < _MAX_RETRIES:
|
|
411
|
+
_LOGGER.debug("Transport error on GET %s, retry %s/%s", path, attempt, _MAX_RETRIES)
|
|
412
|
+
await asyncio.sleep(_RETRY_DELAY)
|
|
413
|
+
return await self.request(
|
|
414
|
+
method,
|
|
415
|
+
path,
|
|
416
|
+
params=params,
|
|
417
|
+
json_body=json_body,
|
|
418
|
+
data=data,
|
|
419
|
+
skip_token=skip_token,
|
|
420
|
+
_retried_auth=retried_auth,
|
|
421
|
+
_attempt=attempt + 1,
|
|
422
|
+
)
|
|
423
|
+
raise error from cause
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _parse_body(body: bytes) -> Any:
|
|
427
|
+
text = body.decode("utf-8", errors="replace").lstrip("\ufeff")
|
|
428
|
+
if not text:
|
|
429
|
+
return None
|
|
430
|
+
try:
|
|
431
|
+
return json.loads(text)
|
|
432
|
+
except json.JSONDecodeError:
|
|
433
|
+
return text
|
|
434
|
+
|
|
435
|
+
def _build_error(self, status: int, body: bytes, path: str) -> BboxApiError:
|
|
436
|
+
"""Translate an HTTP error into the matching exception."""
|
|
437
|
+
detail: dict[str, Any] = {}
|
|
438
|
+
try:
|
|
439
|
+
parsed = json.loads(body.decode("utf-8", errors="replace"))
|
|
440
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("exception"), dict):
|
|
441
|
+
detail = parsed["exception"]
|
|
442
|
+
except json.JSONDecodeError:
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
errors = detail.get("errors")
|
|
446
|
+
if not isinstance(errors, list):
|
|
447
|
+
errors = []
|
|
448
|
+
reasons = [
|
|
449
|
+
str(err.get("reason")) for err in errors if isinstance(err, dict) and err.get("reason")
|
|
450
|
+
]
|
|
451
|
+
suffix = f": {', '.join(reasons)}" if reasons else ""
|
|
452
|
+
message = f"Bbox API error on {path} (HTTP {status}){suffix}"
|
|
453
|
+
|
|
454
|
+
if status == 401:
|
|
455
|
+
if path == "login":
|
|
456
|
+
return BboxInvalidCredentialsError()
|
|
457
|
+
return BboxSessionExpiredError(message)
|
|
458
|
+
if status == 403:
|
|
459
|
+
return BboxAuthenticationError(message, status_code=status, errors=errors)
|
|
460
|
+
if status == 429:
|
|
461
|
+
return BboxRateLimitError(message)
|
|
462
|
+
if status == 404:
|
|
463
|
+
return BboxNotFoundError(message)
|
|
464
|
+
return BboxApiError(message, status_code=status, errors=errors)
|
|
465
|
+
|
|
466
|
+
async def raw_request(
|
|
467
|
+
self,
|
|
468
|
+
path: str,
|
|
469
|
+
*,
|
|
470
|
+
method: str = "GET",
|
|
471
|
+
params: dict[str, Any] | None = None,
|
|
472
|
+
json_body: Any = None,
|
|
473
|
+
) -> Any:
|
|
474
|
+
"""Call any API endpoint and return the JSON payload.
|
|
475
|
+
|
|
476
|
+
The API wraps most payloads in a list of objects; the first object
|
|
477
|
+
(the main payload) is returned. Endpoints that genuinely return a
|
|
478
|
+
list of entries should be called through :meth:`request` instead.
|
|
479
|
+
"""
|
|
480
|
+
return _unwrap(await self.request(method, path, params=params, json_body=json_body))
|
|
481
|
+
|
|
482
|
+
# ------------------------------------------------------------------
|
|
483
|
+
# Device
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
async def get_device(self) -> DeviceInfo:
|
|
487
|
+
"""Return router information (GET /device)."""
|
|
488
|
+
data = _unwrap(await self.request("GET", "device"))
|
|
489
|
+
return DeviceInfo.from_dict(data.get("device", data))
|
|
490
|
+
|
|
491
|
+
async def reboot(self) -> None:
|
|
492
|
+
"""Reboot the router (POST /device/reboot)."""
|
|
493
|
+
await self.request("POST", "device/reboot")
|
|
494
|
+
|
|
495
|
+
async def get_device_cpu(self) -> dict[str, Any]:
|
|
496
|
+
"""Return CPU usage details (GET /device/cpu)."""
|
|
497
|
+
return _unwrap(await self.request("GET", "device/cpu"))
|
|
498
|
+
|
|
499
|
+
async def get_device_memory(self) -> dict[str, Any]:
|
|
500
|
+
"""Return memory usage details (GET /device/mem)."""
|
|
501
|
+
return _unwrap(await self.request("GET", "device/mem"))
|
|
502
|
+
|
|
503
|
+
# ------------------------------------------------------------------
|
|
504
|
+
# Hosts (device tracker)
|
|
505
|
+
# ------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
async def get_hosts(self, *, include_guests: bool = False) -> list[Host]:
|
|
508
|
+
"""Return the devices known to the router (GET /hosts).
|
|
509
|
+
|
|
510
|
+
The payload is a list of blocks: the first block is the main LAN,
|
|
511
|
+
the following ones are e.g. the guest network (their entries are
|
|
512
|
+
flagged ``guest=1``). Guest hosts are excluded unless
|
|
513
|
+
``include_guests`` is True. Verified on F@st5688b fw 25.1.22: the
|
|
514
|
+
guest block stays present even when the guest Wi-Fi is disabled
|
|
515
|
+
(its hosts then show ``active=False``).
|
|
516
|
+
"""
|
|
517
|
+
data = await self.request("GET", "hosts")
|
|
518
|
+
elements = data if isinstance(data, list) else [data]
|
|
519
|
+
hosts: list[Host] = []
|
|
520
|
+
for element in elements:
|
|
521
|
+
if not isinstance(element, dict):
|
|
522
|
+
continue
|
|
523
|
+
block = element.get("hosts")
|
|
524
|
+
entries = block.get("list", []) if isinstance(block, dict) else []
|
|
525
|
+
for entry in entries:
|
|
526
|
+
if not isinstance(entry, dict):
|
|
527
|
+
continue
|
|
528
|
+
host = Host.from_dict(entry)
|
|
529
|
+
if host.guest and not include_guests:
|
|
530
|
+
continue
|
|
531
|
+
hosts.append(host)
|
|
532
|
+
return hosts
|
|
533
|
+
|
|
534
|
+
async def get_guests(self) -> list[Host]:
|
|
535
|
+
"""Return the hosts connected through the guest network (10.0.0.x)."""
|
|
536
|
+
return [host for host in await self.get_hosts(include_guests=True) if host.guest]
|
|
537
|
+
|
|
538
|
+
async def get_hosts_me(self) -> Host:
|
|
539
|
+
"""Return the router itself as a host (GET /hosts/me, key ``host``)."""
|
|
540
|
+
data = _unwrap(await self.request("GET", "hosts/me"))
|
|
541
|
+
me: Any = data.get("host") if isinstance(data, dict) else None
|
|
542
|
+
if not isinstance(me, dict) and isinstance(data, dict):
|
|
543
|
+
hosts = data.get("hosts")
|
|
544
|
+
me = hosts.get("me") if isinstance(hosts, dict) else None
|
|
545
|
+
if not isinstance(me, dict):
|
|
546
|
+
me = {}
|
|
547
|
+
return Host.from_dict(me)
|
|
548
|
+
|
|
549
|
+
# ------------------------------------------------------------------
|
|
550
|
+
# WAN
|
|
551
|
+
# ------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
async def get_wan_ip(self) -> WANIP:
|
|
554
|
+
"""Return the WAN IP configuration (GET /wan/ip, block ``wan.ip``)."""
|
|
555
|
+
data = _unwrap(await self.request("GET", "wan/ip"))
|
|
556
|
+
wan = data.get("wan", {}) if isinstance(data, dict) else {}
|
|
557
|
+
if not isinstance(wan, dict):
|
|
558
|
+
wan = {}
|
|
559
|
+
ip = dict(wan.get("ip") or {})
|
|
560
|
+
link = wan.get("link")
|
|
561
|
+
internet = wan.get("internet")
|
|
562
|
+
if isinstance(link, dict):
|
|
563
|
+
ip.setdefault("link_type", link.get("type"))
|
|
564
|
+
if isinstance(internet, dict):
|
|
565
|
+
ip.setdefault("internet_state", internet.get("state"))
|
|
566
|
+
return WANIP.from_dict(ip)
|
|
567
|
+
|
|
568
|
+
async def get_wan_ip_stats(self) -> WANIPStats:
|
|
569
|
+
"""Return WAN bandwidth statistics (GET /wan/ip/stats)."""
|
|
570
|
+
data = _unwrap(await self.request("GET", "wan/ip/stats"))
|
|
571
|
+
wan = data.get("wan") if isinstance(data, dict) else None
|
|
572
|
+
ip = wan.get("ip") if isinstance(wan, dict) else None
|
|
573
|
+
stats = ip.get("stats") if isinstance(ip, dict) else None
|
|
574
|
+
if not isinstance(stats, dict):
|
|
575
|
+
stats = ip if isinstance(ip, dict) else {}
|
|
576
|
+
return WANIPStats.from_dict(stats)
|
|
577
|
+
|
|
578
|
+
async def get_wan_ftth_stats(self) -> dict[str, Any]:
|
|
579
|
+
"""Return FTTH line statistics (GET /wan/ftth/stats)."""
|
|
580
|
+
return _unwrap(await self.request("GET", "wan/ftth/stats"))
|
|
581
|
+
|
|
582
|
+
async def get_wan_xdsl_stats(self) -> dict[str, Any]:
|
|
583
|
+
"""Return xDSL line statistics (GET /wan/xdsl/stats)."""
|
|
584
|
+
return _unwrap(await self.request("GET", "wan/xdsl/stats"))
|
|
585
|
+
|
|
586
|
+
# ------------------------------------------------------------------
|
|
587
|
+
# Wireless
|
|
588
|
+
# ------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
async def get_wireless(self) -> Wireless:
|
|
591
|
+
"""Return the Wi-Fi configuration (GET /wireless, block ``wireless``).
|
|
592
|
+
|
|
593
|
+
Warning: the raw payload of this endpoint contains the Wi-Fi
|
|
594
|
+
passphrase in clear text (``security.passphrase``); never expose
|
|
595
|
+
``raw`` to end users.
|
|
596
|
+
"""
|
|
597
|
+
data = _unwrap(await self.request("GET", "wireless"))
|
|
598
|
+
block = data.get("wireless", data) if isinstance(data, dict) else {}
|
|
599
|
+
return Wireless.from_dict(block if isinstance(block, dict) else {})
|
|
600
|
+
|
|
601
|
+
async def get_wireless_stats(self, band: Literal["24", "5", "6"]) -> dict[str, Any]:
|
|
602
|
+
"""Return Wi-Fi statistics for one band (GET /wireless/<band>/stats)."""
|
|
603
|
+
return _unwrap(await self.request("GET", f"wireless/{band}/stats"))
|
|
604
|
+
|
|
605
|
+
async def set_wireless(self, enabled: bool) -> None:
|
|
606
|
+
"""Enable or disable the main Wi-Fi radios (PUT /wireless, radio.enable).
|
|
607
|
+
|
|
608
|
+
Verified on F@st5688b fw 25.1.22: this toggles all main bands
|
|
609
|
+
(2.4/5/6 GHz) at once and leaves the guest network untouched.
|
|
610
|
+
"""
|
|
611
|
+
await self.request("PUT", "wireless", params={"radio.enable": int(enabled)})
|
|
612
|
+
|
|
613
|
+
async def set_guest_wifi(self, enabled: bool) -> None:
|
|
614
|
+
"""Enable or disable the guest Wi-Fi (PUT /wireless/guestenable, enable).
|
|
615
|
+
|
|
616
|
+
Verified on F@st5688b fw 25.1.22: the ``enable`` parameter is the
|
|
617
|
+
one that works on recent firmwares (``radio.enable`` has no effect).
|
|
618
|
+
"""
|
|
619
|
+
await self.request("PUT", "wireless/guestenable", params={"enable": int(enabled)})
|
|
620
|
+
|
|
621
|
+
async def get_wps(self) -> dict[str, Any]:
|
|
622
|
+
"""Return the WPS state (GET /wireless/wps)."""
|
|
623
|
+
return _unwrap(await self.request("GET", "wireless/wps"))
|
|
624
|
+
|
|
625
|
+
async def start_wps(self) -> None:
|
|
626
|
+
"""Start a WPS session (POST /wireless/wps)."""
|
|
627
|
+
await self.request("POST", "wireless/wps")
|
|
628
|
+
|
|
629
|
+
async def stop_wps(self) -> None:
|
|
630
|
+
"""Stop the WPS session (DELETE /wireless/wps)."""
|
|
631
|
+
await self.request("DELETE", "wireless/wps")
|
|
632
|
+
|
|
633
|
+
# ------------------------------------------------------------------
|
|
634
|
+
# Misc
|
|
635
|
+
# ------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
async def get_summary(self) -> dict[str, Any]:
|
|
638
|
+
"""Return the router summary (GET /summary)."""
|
|
639
|
+
return _unwrap(await self.request("GET", "summary"))
|
|
640
|
+
|
|
641
|
+
async def get_lan_ip(self) -> dict[str, Any]:
|
|
642
|
+
"""Return the LAN IP configuration (GET /lan/ip)."""
|
|
643
|
+
return _unwrap(await self.request("GET", "lan/ip"))
|
|
644
|
+
|
|
645
|
+
async def get_voip(self) -> dict[str, Any]:
|
|
646
|
+
"""Return the VoIP state (GET /voip)."""
|
|
647
|
+
return _unwrap(await self.request("GET", "voip"))
|
|
648
|
+
|
|
649
|
+
async def get_call_log(self, line: str | None = None) -> list[dict[str, Any]]:
|
|
650
|
+
"""Return the VoIP call log (GET /voip/fullcalllog/<line>).
|
|
651
|
+
|
|
652
|
+
If ``line`` is omitted, the first line declared by GET /voip is used
|
|
653
|
+
(verified on F@st5688b: lines carry an ``id``, the call log lives at
|
|
654
|
+
``voip/fullcalllog/<id>`` and returns ``{"calllog": [...]}``).
|
|
655
|
+
"""
|
|
656
|
+
if line is None:
|
|
657
|
+
voip = _unwrap(await self.request("GET", "voip"))
|
|
658
|
+
lines = voip.get("voip", []) if isinstance(voip, dict) else voip
|
|
659
|
+
if isinstance(lines, list) and lines and isinstance(lines[0], dict):
|
|
660
|
+
line = str(lines[0].get("id", "1"))
|
|
661
|
+
else:
|
|
662
|
+
line = "1"
|
|
663
|
+
data = _unwrap(await self.request("GET", f"voip/fullcalllog/{line}"))
|
|
664
|
+
if isinstance(data, dict) and isinstance(data.get("calllog"), list):
|
|
665
|
+
return data["calllog"]
|
|
666
|
+
if isinstance(data, dict):
|
|
667
|
+
entries = data.get("voip", {})
|
|
668
|
+
entries = entries.get("fullcalllog", []) if isinstance(entries, dict) else entries
|
|
669
|
+
return entries if isinstance(entries, list) else [entries]
|
|
670
|
+
return data if isinstance(data, list) else [data]
|