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/const.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Constants for the Bbox router API."""
|
|
2
|
+
|
|
3
|
+
API_PATH = "api/v1"
|
|
4
|
+
|
|
5
|
+
#: Default hostname of a Bbox on the local network.
|
|
6
|
+
DEFAULT_HOST = "bbox.lan"
|
|
7
|
+
|
|
8
|
+
#: Default IP address of a Bbox on the local network
|
|
9
|
+
#: (192.168.1.1 on recent models, 192.168.1.254 on older ones).
|
|
10
|
+
DEFAULT_IP = "192.168.1.1"
|
|
11
|
+
|
|
12
|
+
#: Alternate hostname the Bbox answers on the LAN: the router hijacks DNS for
|
|
13
|
+
#: it (mabbox.bytel.fr.lan -> its own LAN IP) and serves a valid certificate
|
|
14
|
+
#: for this name. It only works from the local network, NOT from the internet.
|
|
15
|
+
MABBOX_HOST = "mabbox.bytel.fr"
|
|
16
|
+
|
|
17
|
+
#: Remote (WAN) access goes through the router's public IP, not mabbox.bytel.fr:
|
|
18
|
+
#: https://<wan-ip>/api/v1, with the "remote access" option enabled on the box.
|
|
19
|
+
#: Discover the WAN IP from the LAN with ``get_wan_ip().ipaddress``.
|
|
20
|
+
|
|
21
|
+
DEFAULT_TIMEOUT = 10
|
|
22
|
+
|
|
23
|
+
#: Conservative validity assumed for a btoken when the router omits or
|
|
24
|
+
#: returns an unparseable "expires" (real tokens live ~20 min).
|
|
25
|
+
TOKEN_FALLBACK_TTL = 60
|
|
26
|
+
|
|
27
|
+
#: Endpoints that must be called without a ``btoken`` query parameter.
|
|
28
|
+
NO_TOKEN_ENDPOINTS = frozenset({"login", "logout", "device/token"})
|
|
29
|
+
|
|
30
|
+
#: Reasons returned by the API in its error envelope that are worth retrying.
|
|
31
|
+
TEMPORARY_ERROR_REASONS = frozenset({"cannot extract request parameters"})
|
bbox_api/exceptions.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Exceptions raised by the Bbox API client.
|
|
2
|
+
|
|
3
|
+
Typical mapping in an application: ``BboxInvalidCredentialsError`` /
|
|
4
|
+
``BboxSessionExpiredError`` -> "authentication failed", ``BboxConnectionError``
|
|
5
|
+
/ ``BboxTimeoutError`` -> "retry later / not ready".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BboxApiError(Exception):
|
|
14
|
+
"""Base exception for all Bbox API errors."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
*,
|
|
20
|
+
status_code: int | None = None,
|
|
21
|
+
errors: list[dict[str, Any]] | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Initialize the error with an optional HTTP status and API error list."""
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.errors = errors or []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BboxConnectionError(BboxApiError):
|
|
31
|
+
"""The router could not be reached (DNS failure, refused connection...)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BboxTimeoutError(BboxApiError):
|
|
35
|
+
"""A request to the router timed out."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str = "Request to the Bbox timed out") -> None:
|
|
38
|
+
"""Initialize the timeout error."""
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BboxAuthenticationError(BboxApiError):
|
|
43
|
+
"""Authentication with the router failed or is missing."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BboxInvalidCredentialsError(BboxAuthenticationError):
|
|
47
|
+
"""The admin password is wrong (HTTP 401 on /login)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str = "Invalid Bbox admin password") -> None:
|
|
50
|
+
"""Initialize the invalid credentials error."""
|
|
51
|
+
super().__init__(message, status_code=401)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BboxSessionExpiredError(BboxAuthenticationError):
|
|
55
|
+
"""The session cookie expired and re-authentication did not succeed."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, message: str = "Bbox session expired") -> None:
|
|
58
|
+
"""Initialize the session expired error."""
|
|
59
|
+
super().__init__(message, status_code=401)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BboxRateLimitError(BboxApiError):
|
|
63
|
+
"""Too many login attempts: the router temporarily blocks authentication."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str = "Too many login attempts, rate limited") -> None:
|
|
66
|
+
"""Initialize the rate limit error."""
|
|
67
|
+
super().__init__(message, status_code=429)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BboxNotFoundError(BboxApiError):
|
|
71
|
+
"""The requested endpoint does not exist on this router/firmware."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, message: str = "Endpoint not found on the Bbox") -> None:
|
|
74
|
+
"""Initialize the not found error."""
|
|
75
|
+
super().__init__(message, status_code=404)
|
bbox_api/models.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Typed data models for the Bbox router API responses.
|
|
2
|
+
|
|
3
|
+
All models are deliberately tolerant: unknown keys are preserved in ``raw``,
|
|
4
|
+
missing or malformed keys fall back to the field default, so a firmware update
|
|
5
|
+
changing the API payload is unlikely to break parsing. API keys are mapped to
|
|
6
|
+
snake_case attributes through the ``alias`` field metadata.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import types
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from functools import cache
|
|
16
|
+
from typing import Any, Self, Union, cast, get_args, get_origin, get_type_hints
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fix_mojibake(value: str) -> str:
|
|
20
|
+
"""Repair strings mis-decoded by the router (UTF-8 sent as latin-1)."""
|
|
21
|
+
try:
|
|
22
|
+
return value.encode("latin-1").decode("utf-8")
|
|
23
|
+
except (UnicodeDecodeError, UnicodeEncodeError):
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _to_bool(value: Any) -> bool:
|
|
28
|
+
if isinstance(value, bool):
|
|
29
|
+
return value
|
|
30
|
+
if isinstance(value, (int, float)):
|
|
31
|
+
return value != 0
|
|
32
|
+
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_int(value: Any) -> int:
|
|
36
|
+
return int(float(value))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _to_float(value: Any) -> float:
|
|
40
|
+
return float(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _to_datetime(value: Any) -> datetime:
|
|
44
|
+
if isinstance(value, datetime):
|
|
45
|
+
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
46
|
+
if isinstance(value, (int, float)):
|
|
47
|
+
return datetime.fromtimestamp(value, tz=UTC)
|
|
48
|
+
text = str(value).strip()
|
|
49
|
+
if text.endswith("Z"):
|
|
50
|
+
text = text[:-1] + "+00:00"
|
|
51
|
+
parsed = datetime.fromisoformat(text) # raises ValueError on garbage
|
|
52
|
+
if parsed.tzinfo is None:
|
|
53
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
54
|
+
return parsed
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _coerce(hint: Any, value: Any) -> Any:
|
|
58
|
+
"""Coerce a raw API value to the declared field type.
|
|
59
|
+
|
|
60
|
+
Raises ValueError/TypeError when the value cannot be coerced, so the
|
|
61
|
+
caller can fall back to the field default instead of crashing.
|
|
62
|
+
"""
|
|
63
|
+
if value is None or hint is Any:
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
origin = get_origin(hint)
|
|
67
|
+
|
|
68
|
+
if origin in (list, tuple):
|
|
69
|
+
args = get_args(hint)
|
|
70
|
+
inner = args[0] if args else Any
|
|
71
|
+
if not isinstance(value, (list, tuple)):
|
|
72
|
+
raise TypeError(f"expected a list, got {type(value).__name__}")
|
|
73
|
+
return [_coerce(inner, item) for item in value]
|
|
74
|
+
|
|
75
|
+
if origin is dict:
|
|
76
|
+
args = get_args(hint)
|
|
77
|
+
inner = args[1] if len(args) == 2 else Any
|
|
78
|
+
if not isinstance(value, dict):
|
|
79
|
+
raise TypeError(f"expected a dict, got {type(value).__name__}")
|
|
80
|
+
return {key: _coerce(inner, item) for key, item in value.items()}
|
|
81
|
+
|
|
82
|
+
if origin is Union or isinstance(hint, types.UnionType):
|
|
83
|
+
for arg in get_args(hint):
|
|
84
|
+
if arg is type(None):
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
return _coerce(arg, value)
|
|
88
|
+
except (ValueError, TypeError, KeyError):
|
|
89
|
+
continue
|
|
90
|
+
raise ValueError(f"cannot coerce {value!r} to {hint}")
|
|
91
|
+
|
|
92
|
+
if dataclasses.is_dataclass(hint):
|
|
93
|
+
if not isinstance(value, dict):
|
|
94
|
+
name = getattr(hint, "__name__", hint)
|
|
95
|
+
raise TypeError(f"expected a dict for {name}, got {type(value).__name__}")
|
|
96
|
+
return cast("type[BboxModel]", hint).from_dict(value)
|
|
97
|
+
|
|
98
|
+
if hint is bool:
|
|
99
|
+
return _to_bool(value)
|
|
100
|
+
if hint is int:
|
|
101
|
+
return _to_int(value)
|
|
102
|
+
if hint is float:
|
|
103
|
+
return _to_float(value)
|
|
104
|
+
if hint is datetime:
|
|
105
|
+
return _to_datetime(value)
|
|
106
|
+
if hint is str:
|
|
107
|
+
text = value if isinstance(value, str) else str(value)
|
|
108
|
+
fixed = fix_mojibake(text)
|
|
109
|
+
return fixed if fixed else None # the API uses "" for "no value"
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@cache
|
|
114
|
+
def _type_hints(cls: Any) -> dict[str, Any]:
|
|
115
|
+
return get_type_hints(cls)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _field_default(f: dataclasses.Field[Any]) -> Any:
|
|
119
|
+
if f.default is not dataclasses.MISSING:
|
|
120
|
+
return f.default
|
|
121
|
+
if f.default_factory is not dataclasses.MISSING:
|
|
122
|
+
return f.default_factory()
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class BboxModel:
|
|
128
|
+
"""Base model: tolerant ``from_dict`` plus the untouched raw payload."""
|
|
129
|
+
|
|
130
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
134
|
+
"""Build a model from a raw API payload.
|
|
135
|
+
|
|
136
|
+
Unknown keys are kept in ``raw``; missing keys keep their default;
|
|
137
|
+
keys that fail to coerce also fall back to the default.
|
|
138
|
+
"""
|
|
139
|
+
if not isinstance(data, dict):
|
|
140
|
+
raise TypeError(f"{cls.__name__}.from_dict expects a dict, got {type(data).__name__}")
|
|
141
|
+
hints = _type_hints(cast(Any, cls))
|
|
142
|
+
kwargs: dict[str, Any] = {"raw": dict(data)}
|
|
143
|
+
for f in dataclasses.fields(cls):
|
|
144
|
+
if f.name == "raw":
|
|
145
|
+
continue
|
|
146
|
+
alias = f.metadata.get("alias")
|
|
147
|
+
if alias is not None and alias in data:
|
|
148
|
+
value = data[alias]
|
|
149
|
+
elif f.name in data:
|
|
150
|
+
value = data[f.name]
|
|
151
|
+
else:
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
kwargs[f.name] = _coerce(hints.get(f.name, Any), value)
|
|
155
|
+
except (ValueError, TypeError, KeyError, OverflowError, AttributeError):
|
|
156
|
+
kwargs[f.name] = _field_default(f)
|
|
157
|
+
return cls(**kwargs)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# /device
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class FirmwareVersion(BboxModel):
|
|
167
|
+
"""Firmware version block of the router."""
|
|
168
|
+
|
|
169
|
+
version: str | None = None
|
|
170
|
+
date: datetime | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class ConnectionFlags(BboxModel):
|
|
175
|
+
"""WAN connection types supported by the router ('using' block)."""
|
|
176
|
+
|
|
177
|
+
ipv4: bool = False
|
|
178
|
+
ipv6: bool = False
|
|
179
|
+
ftth: bool = False
|
|
180
|
+
adsl: bool = False
|
|
181
|
+
vdsl: bool = False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class Display(BboxModel):
|
|
186
|
+
"""Front panel display state."""
|
|
187
|
+
|
|
188
|
+
luminosity: int = 0
|
|
189
|
+
luminosity_extender: int = 0
|
|
190
|
+
state: str | None = None
|
|
191
|
+
orientation: int | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class DeviceInfo(BboxModel):
|
|
196
|
+
"""Router information, from GET /device."""
|
|
197
|
+
|
|
198
|
+
modelname: str | None = None
|
|
199
|
+
modelclass: str | None = None
|
|
200
|
+
serialnumber: str | None = None
|
|
201
|
+
status: int = 0
|
|
202
|
+
numberofboots: int = 0
|
|
203
|
+
uptime: int = 0
|
|
204
|
+
now: datetime | None = None
|
|
205
|
+
firstusedate: datetime | None = None
|
|
206
|
+
main: FirmwareVersion | None = None
|
|
207
|
+
running: FirmwareVersion | None = None
|
|
208
|
+
reco: FirmwareVersion | None = None
|
|
209
|
+
spl: FirmwareVersion | None = None
|
|
210
|
+
tpl: FirmwareVersion | None = None
|
|
211
|
+
ldr1: FirmwareVersion | None = None
|
|
212
|
+
ldr2: FirmwareVersion | None = None
|
|
213
|
+
display: Display | None = None
|
|
214
|
+
using: ConnectionFlags | None = None
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def firmware_version(self) -> str | None:
|
|
218
|
+
"""Return the running firmware version, if known."""
|
|
219
|
+
if self.running and self.running.version:
|
|
220
|
+
return self.running.version
|
|
221
|
+
return self.main.version if self.main else None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# /hosts
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class IPv6Address(BboxModel):
|
|
231
|
+
"""An IPv6 address of a host."""
|
|
232
|
+
|
|
233
|
+
ipaddress: str | None = None
|
|
234
|
+
status: str | None = None
|
|
235
|
+
lastseen: datetime | None = None
|
|
236
|
+
lastscan: datetime | None = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class EthernetInfo(BboxModel):
|
|
241
|
+
"""Ethernet link details of a host."""
|
|
242
|
+
|
|
243
|
+
physicalport: int = 0
|
|
244
|
+
logicalport: int = 0
|
|
245
|
+
speed: int = 0 # Mbps
|
|
246
|
+
mode: str | None = None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class WirelessInfo(BboxModel):
|
|
251
|
+
"""Wi-Fi link details of a host."""
|
|
252
|
+
|
|
253
|
+
band: float | None = None
|
|
254
|
+
rssi_dbm: int | None = field(default=None, metadata={"alias": "rssi0"})
|
|
255
|
+
rate: int = 0 # Mbps
|
|
256
|
+
mcs: int = 0
|
|
257
|
+
tx_usage: int = field(default=0, metadata={"alias": "txUsage"})
|
|
258
|
+
rx_usage: int = field(default=0, metadata={"alias": "rxUsage"})
|
|
259
|
+
estimatedRate: int = 0
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def rssi(self) -> int | None:
|
|
263
|
+
"""Return the RSSI in dBm."""
|
|
264
|
+
return self.rssi_dbm
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass
|
|
268
|
+
class Host(BboxModel):
|
|
269
|
+
"""A device connected to the Bbox, from GET /hosts (device tracker)."""
|
|
270
|
+
|
|
271
|
+
id: int = 0
|
|
272
|
+
active: bool = False
|
|
273
|
+
hostname: str | None = None
|
|
274
|
+
ipaddress: str | None = None
|
|
275
|
+
macaddress: str | None = None
|
|
276
|
+
type: str | None = None # DHCP / Static
|
|
277
|
+
link: str | None = None # Ethernet, Wifi 2.4, Wifi 5...
|
|
278
|
+
lease: int = 0
|
|
279
|
+
firstseen: datetime | None = None
|
|
280
|
+
lastseen: int = 0
|
|
281
|
+
devicetype: str | None = None
|
|
282
|
+
duid: str | None = None
|
|
283
|
+
guest: bool = False
|
|
284
|
+
me: bool = False
|
|
285
|
+
serial_number: str | None = field(default=None, metadata={"alias": "serialNumber"})
|
|
286
|
+
ip6address: list[IPv6Address] = field(default_factory=list)
|
|
287
|
+
ethernet: EthernetInfo | None = None
|
|
288
|
+
wireless: WirelessInfo | None = None
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def is_connected(self) -> bool:
|
|
292
|
+
"""Return True if the host is currently active on the LAN."""
|
|
293
|
+
return self.active
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# /wan/ip and /wan/ip/stats
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class WANStats(BboxModel):
|
|
303
|
+
"""Traffic counters of one direction (rx or tx) of the WAN interface."""
|
|
304
|
+
|
|
305
|
+
packets: int = 0
|
|
306
|
+
bytes: int = 0
|
|
307
|
+
packetserrors: int = 0
|
|
308
|
+
packetsdiscards: int = 0
|
|
309
|
+
occupation: int = 0 # percent
|
|
310
|
+
bandwidth: int = 0 # kbit/s currently used
|
|
311
|
+
max_bandwidth: int = field(default=0, metadata={"alias": "maxBandwidth"}) # kbit/s
|
|
312
|
+
contractual_bandwidth: int = field(default=0, metadata={"alias": "contractualBandwidth"})
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def bandwidth_mbps(self) -> float:
|
|
316
|
+
"""Current bandwidth in Mbit/s."""
|
|
317
|
+
return self.bandwidth / 1000
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def max_bandwidth_mbps(self) -> float:
|
|
321
|
+
"""Maximum bandwidth in Mbit/s."""
|
|
322
|
+
return self.max_bandwidth / 1000
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def contractual_bandwidth_mbps(self) -> float:
|
|
326
|
+
"""Contractual bandwidth in Mbit/s."""
|
|
327
|
+
return self.contractual_bandwidth / 1000
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass
|
|
331
|
+
class WANIPStats(BboxModel):
|
|
332
|
+
"""WAN IP statistics, from GET /wan/ip/stats (bandwidth sensors)."""
|
|
333
|
+
|
|
334
|
+
rx: WANStats = field(default_factory=WANStats)
|
|
335
|
+
tx: WANStats = field(default_factory=WANStats)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@dataclass
|
|
339
|
+
class WANIP(BboxModel):
|
|
340
|
+
"""WAN IP configuration, from GET /wan/ip (the ``wan.ip`` block)."""
|
|
341
|
+
|
|
342
|
+
ipaddress: str | None = field(default=None, metadata={"alias": "address"})
|
|
343
|
+
subnet: str | None = None
|
|
344
|
+
gateway: str | None = None
|
|
345
|
+
state: str | None = None
|
|
346
|
+
dnsservers: str | None = None # comma-separated string on this API
|
|
347
|
+
dnsserversv6: str | None = None
|
|
348
|
+
mac: str | None = None
|
|
349
|
+
mtu: int = 0
|
|
350
|
+
portrange: str | None = None
|
|
351
|
+
cgnatenable: bool = False
|
|
352
|
+
maptenable: bool = False
|
|
353
|
+
ip6state: str | None = None
|
|
354
|
+
ip6address: list[IPv6Address] = field(default_factory=list)
|
|
355
|
+
ip6prefix: list[dict[str, Any]] = field(default_factory=list)
|
|
356
|
+
# Filled by the client from the sibling blocks wan.link / wan.internet:
|
|
357
|
+
link_type: str | None = None
|
|
358
|
+
internet_state: int | None = None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# /wireless
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@dataclass
|
|
367
|
+
class WirelessBand(BboxModel):
|
|
368
|
+
"""One Wi-Fi band SSID configuration (keyed '24', '5', '6', 'guest').
|
|
369
|
+
|
|
370
|
+
Warning: ``raw`` contains the ``security`` block, which includes the
|
|
371
|
+
Wi-Fi passphrase in clear text. Never expose ``raw`` in logs or API
|
|
372
|
+
responses.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
id: str | None = None # SSID name
|
|
376
|
+
enable: bool = False
|
|
377
|
+
hidden: bool = False
|
|
378
|
+
bssid: str | None = None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@dataclass
|
|
382
|
+
class WirelessRadio(BboxModel):
|
|
383
|
+
"""One Wi-Fi band radio state (keyed '24', '5', '6', 'guest')."""
|
|
384
|
+
|
|
385
|
+
enable: bool = False
|
|
386
|
+
state: int = 0
|
|
387
|
+
standard: str | None = None
|
|
388
|
+
channel: int = 0
|
|
389
|
+
current_channel: int | None = None
|
|
390
|
+
current_bandwidth: int | None = None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass
|
|
394
|
+
class Wireless(BboxModel):
|
|
395
|
+
"""Wi-Fi configuration, from GET /wireless (the ``wireless`` block).
|
|
396
|
+
|
|
397
|
+
``bands`` / ``radios`` are keyed by band name as returned by the API
|
|
398
|
+
('24', '5', '6', 'guest').
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
status: str | None = None
|
|
402
|
+
bands: dict[str, WirelessBand] = field(default_factory=dict, metadata={"alias": "ssid"})
|
|
403
|
+
radios: dict[str, WirelessRadio] = field(default_factory=dict, metadata={"alias": "radio"})
|
|
404
|
+
|
|
405
|
+
def band(self, name: str) -> WirelessBand | None:
|
|
406
|
+
"""Return the SSID configuration of one band ('24', '5', '6'...)."""
|
|
407
|
+
return self.bands.get(name)
|
|
408
|
+
|
|
409
|
+
def is_enabled(self, band: str) -> bool | None:
|
|
410
|
+
"""Return whether the radio of one band is enabled, if known."""
|
|
411
|
+
radio = self.radios.get(band)
|
|
412
|
+
return radio.enable if radio else None
|
bbox_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bbox-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the Bouygues Telecom Bbox router API
|
|
5
|
+
Project-URL: Homepage, https://github.com/xavier/bbox-api
|
|
6
|
+
Project-URL: Repository, https://github.com/xavier/bbox-api
|
|
7
|
+
Project-URL: Issues, https://github.com/xavier/bbox-api/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/xavier/bbox-api#readme
|
|
9
|
+
Author: rettelx
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: aiohttp,api,async,bbox,bouygues,bouygues-telecom,gateway,router
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: aiohttp>=3.9
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: aiohttp<3.14,>=3.9; extra == 'dev'
|
|
29
|
+
Requires-Dist: aioresponses>=0.7; extra == 'dev'
|
|
30
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=1; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
34
|
+
Provides-Extra: test
|
|
35
|
+
Requires-Dist: aiohttp<3.14,>=3.9; extra == 'test'
|
|
36
|
+
Requires-Dist: aioresponses>=0.7; extra == 'test'
|
|
37
|
+
Requires-Dist: pytest-asyncio>=1; extra == 'test'
|
|
38
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# bbox-api
|
|
42
|
+
|
|
43
|
+
Async client for the **Bouygues Telecom Bbox router API** (`/api/v1`), for any
|
|
44
|
+
asyncio app. Python >= 3.11, MIT, single dependency (`aiohttp`), fully typed
|
|
45
|
+
(`py.typed`, mypy-clean) with fault-tolerant dataclass models (unknown or
|
|
46
|
+
malformed payload keys never crash parsing; unknown keys stay in `.raw`).
|
|
47
|
+
|
|
48
|
+
- **Local**: `BboxClient(pw)` → `http://bbox.lan/api/v1` (also `192.168.1.1` or
|
|
49
|
+
`mabbox.bytel.fr` with `use_tls=True` — that name resolves only on the LAN,
|
|
50
|
+
the box hijacks DNS for it). **Remote**: `BboxClient.remote(pw, wan_ip)` →
|
|
51
|
+
`https://<wan-ip>/api/v1` ("remote access" enabled on the box; the WAN IP is
|
|
52
|
+
refused from the LAN itself).
|
|
53
|
+
- **Auth is automatic**: admin password → session cookie + cached `btoken`,
|
|
54
|
+
re-auth on 401, retries on transient errors.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install bbox-api
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import asyncio
|
|
66
|
+
from bbox_api import BboxClient
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def main():
|
|
70
|
+
async with BboxClient("admin-password") as client: # local: bbox.lan
|
|
71
|
+
device = await client.get_device() # model, firmware, uptime
|
|
72
|
+
stats = await client.get_wan_ip_stats() # rx/tx bandwidth_mbps
|
|
73
|
+
hosts = await client.get_hosts() # connected devices
|
|
74
|
+
await client.set_wireless(False) # all main Wi-Fi bands off
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Any endpoint not wrapped by a typed method: `await client.raw_request("alerts")`.
|
|
81
|
+
To share your own `aiohttp.ClientSession`, pass `session=` — the client then
|
|
82
|
+
never closes it.
|
|
83
|
+
|
|
84
|
+
## Errors
|
|
85
|
+
|
|
86
|
+
All inherit `BboxApiError`: `BboxInvalidCredentialsError`, `BboxSessionExpiredError`, `BboxRateLimitError`, `BboxNotFoundError`, `BboxConnectionError`, `BboxTimeoutError`.
|
|
87
|
+
|
|
88
|
+
## Verified endpoints (live-tested on a F@st5688b, fw 25.1.22)
|
|
89
|
+
|
|
90
|
+
GET endpoints returning HTTP 200, grouped by area:
|
|
91
|
+
|
|
92
|
+
- **device**: `device`, `device/cpu`, `device/mem`, `device/summary`, `device/log`, `device/led`
|
|
93
|
+
- **hosts**: `hosts`, `hosts/me`, `alerts`
|
|
94
|
+
- **wan**: `wan/ip`, `wan/ip/stats`, `wan/ftth/stats`, `wan/diags`, `cpl`
|
|
95
|
+
- **wireless**: `wireless`, `wireless/24`, `wireless/5`, `wireless/6`, `wireless/guest`,
|
|
96
|
+
`wireless/24/stats`, `wireless/5/stats`, `wireless/6/stats`, `wireless/wps`,
|
|
97
|
+
`wireless/repeater`, `wireless/scheduler`
|
|
98
|
+
- **lan / misc**: `summary`, `lan/ip`, `lan/stats`, `iptv`, `services`, `speedtest`,
|
|
99
|
+
`parentalcontrol`, `voip`, `voip/fullcalllog/<line-id>` (line id from `GET /voip`,
|
|
100
|
+
e.g. `voip/fullcalllog/1`)
|
|
101
|
+
|
|
102
|
+
GET endpoints returning 404 on this model/firmware (they raise `BboxNotFoundError`):
|
|
103
|
+
|
|
104
|
+
- `device/optimization`, `wan/ftth`, `wan/xdsl`, `wan/xdsl/stats`, `wan/cable`
|
|
105
|
+
(this is a FTTH box), `lan/ip6`, `ddns`, `remote`, `voip/voicemail`,
|
|
106
|
+
`voip/callforward`, `voip/fullcalllog` without a line id
|
|
107
|
+
|
|
108
|
+
Writes verified (PUT/POST/DELETE with `btoken`):
|
|
109
|
+
|
|
110
|
+
- `PUT /wireless` + `radio.enable=0/1` — toggles all main bands (2.4/5/6 GHz) at once
|
|
111
|
+
- `PUT /wireless/guestenable` + `enable=0/1` — guest Wi-Fi
|
|
112
|
+
- `POST` / `DELETE /wireless/wps` — start/stop a WPS session
|
|
113
|
+
|
|
114
|
+
Behavior notes from live testing:
|
|
115
|
+
|
|
116
|
+
- `/hosts` returns a **list of blocks**: the first is the main LAN, the following
|
|
117
|
+
ones are e.g. the guest network (entries flagged `guest=1`, 10.0.0.x subnet).
|
|
118
|
+
The guest block stays present even when guest Wi-Fi is off (its hosts just show
|
|
119
|
+
`active=False`). `get_hosts()` parses all blocks and excludes guests unless
|
|
120
|
+
`include_guests=True`; `get_guests()` returns only guest hosts.
|
|
121
|
+
|
|
122
|
+
Security warning: `GET /wireless` returns the **Wi-Fi passphrase in clear text**
|
|
123
|
+
(`raw["bands"]["24"]["security"]["passphrase"]`). Never expose a model's `raw`
|
|
124
|
+
dict in logs or API responses.
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
python -m venv .venv && .venv/Scripts/pip install -e ".[dev]"
|
|
130
|
+
.venv/Scripts/python -m pytest # 44 tests, mocked API (aiohttp<3.14: aioresponses limit)
|
|
131
|
+
.venv/Scripts/ruff check .
|
|
132
|
+
.venv/Scripts/python -m mypy
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
CI/CD (`.github/workflows/`): `ci.yml` runs pytest on Python 3.11–3.14 plus
|
|
136
|
+
ruff and mypy on every push/PR; `release.yml` builds the wheel/sdist with
|
|
137
|
+
hatchling and publishes to PyPI on `v*` tags via trusted publishing (OIDC —
|
|
138
|
+
configure the `bbox-api` project on PyPI with this repo as trusted publisher,
|
|
139
|
+
no API token needed).
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
bbox_api/__init__.py,sha256=dSJuJWPClsL_F1vx5uucgn0lkAsXPYVtXN1kzSP11r0,1161
|
|
2
|
+
bbox_api/client.py,sha256=o6MQXA2stUm3MxfmneBq-E3SP-ydeAr-NuTt7jbtdsY,26897
|
|
3
|
+
bbox_api/const.py,sha256=d5DSex_WmBLrp7gVlnseFX8MXRcAwMy_Vj9LtvMCCt0,1260
|
|
4
|
+
bbox_api/exceptions.py,sha256=2g4CB5LeLIVOza4wylSEuCTk2zONItyLTpbshYlC4xA,2488
|
|
5
|
+
bbox_api/models.py,sha256=M_EJut8uemwpIYwnWEuJnnqqMMQpm77VbgXwiZlMnOk,12891
|
|
6
|
+
bbox_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
bbox_api-0.1.0.dist-info/METADATA,sha256=j9lR5SgaPmqkdv7-gG0icXvPvYkHUTJQTA3EWzTd8Zk,5841
|
|
8
|
+
bbox_api-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
bbox_api-0.1.0.dist-info/licenses/LICENSE,sha256=aclySKxemqpUijBxTioUYDHBRtCDs-mmb30bR1sEjZQ,1064
|
|
10
|
+
bbox_api-0.1.0.dist-info/RECORD,,
|