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/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]