Skip to content

Commit 5e6531a

Browse files
committed
fix: prefer stable FritzBox device info pages (home, boxinfo)
Try data.lua pages home and boxinfo before overview for device info, as overview returns HTML on some firmware versions. Refactors shared data.lua fetching into _get_data_page helper. Closes #183
1 parent 87db64a commit 5e6531a

File tree

2 files changed

+87
-62
lines changed

2 files changed

+87
-62
lines changed

app/fritzbox.py

Lines changed: 46 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,43 @@
1111
_TR064_NS = {"tr64": "urn:dslforum-org:device-1-0"}
1212

1313

14+
def _get_data_page(url: str, sid: str, page: str) -> dict:
15+
"""Fetch a FritzBox data.lua page and return its data payload."""
16+
r = requests.post(
17+
f"{url}/data.lua",
18+
data={
19+
"xhr": 1,
20+
"sid": sid,
21+
"lang": "de",
22+
"page": page,
23+
"xhrId": "all",
24+
"no_sidrenew": "",
25+
},
26+
timeout=10,
27+
)
28+
r.raise_for_status()
29+
return r.json().get("data", {})
30+
31+
32+
def _parse_fritzos_device_info(data: dict) -> dict:
33+
"""Extract model/version/uptime from a FritzBox fritzos object."""
34+
fritzos = data.get("fritzos", {})
35+
if not fritzos:
36+
return {}
37+
38+
result = {
39+
"model": fritzos.get("Productname", "FRITZ!Box"),
40+
"sw_version": fritzos.get("nspver", ""),
41+
}
42+
uptime = fritzos.get("Uptime")
43+
if uptime is not None:
44+
try:
45+
result["uptime_seconds"] = int(uptime)
46+
except (ValueError, TypeError):
47+
pass
48+
return result
49+
50+
1451
def login(url: str, user: str, password: str) -> str:
1552
"""Authenticate to FritzBox and return session ID."""
1653
r = requests.get(
@@ -51,53 +88,18 @@ def login(url: str, user: str, password: str) -> str:
5188

5289
def get_docsis_data(url: str, sid: str) -> dict:
5390
"""Query DOCSIS channel data from FritzBox."""
54-
r = requests.post(
55-
f"{url}/data.lua",
56-
data={
57-
"xhr": 1,
58-
"sid": sid,
59-
"lang": "de",
60-
"page": "docInfo",
61-
"xhrId": "all",
62-
"no_sidrenew": "",
63-
},
64-
timeout=10,
65-
)
66-
r.raise_for_status()
67-
return r.json().get("data", {})
91+
return _get_data_page(url, sid, "docInfo")
6892

6993

7094
def get_device_info(url: str, sid: str) -> dict:
7195
"""Try to get FritzBox model info."""
72-
try:
73-
r = requests.post(
74-
f"{url}/data.lua",
75-
data={
76-
"xhr": 1,
77-
"sid": sid,
78-
"lang": "de",
79-
"page": "overview",
80-
"xhrId": "all",
81-
"no_sidrenew": "",
82-
},
83-
timeout=10,
84-
)
85-
r.raise_for_status()
86-
data = r.json().get("data", {})
87-
fritzos = data.get("fritzos", {})
88-
result = {
89-
"model": fritzos.get("Productname", "FRITZ!Box"),
90-
"sw_version": fritzos.get("nspver", ""),
91-
}
92-
uptime = fritzos.get("Uptime")
93-
if uptime is not None:
94-
try:
95-
result["uptime_seconds"] = int(uptime)
96-
except (ValueError, TypeError):
97-
pass
98-
return result
99-
except Exception as e:
100-
log.debug("FritzBox overview device info unavailable, trying TR-064 fallback: %s", e)
96+
for page in ("home", "boxinfo", "overview"):
97+
try:
98+
result = _parse_fritzos_device_info(_get_data_page(url, sid, page))
99+
if result:
100+
return result
101+
except Exception as e:
102+
log.debug("FritzBox %s device info unavailable: %s", page, e)
101103

102104
try:
103105
r = requests.get(f"{url}/tr064/tr64desc.xml", timeout=10)
@@ -119,20 +121,7 @@ def get_device_info(url: str, sid: str) -> dict:
119121
def get_connection_info(url: str, sid: str) -> dict:
120122
"""Get internet connection info (speeds, type) from netMoni page."""
121123
try:
122-
r = requests.post(
123-
f"{url}/data.lua",
124-
data={
125-
"xhr": 1,
126-
"sid": sid,
127-
"lang": "de",
128-
"page": "netMoni",
129-
"xhrId": "all",
130-
"no_sidrenew": "",
131-
},
132-
timeout=10,
133-
)
134-
r.raise_for_status()
135-
data = r.json().get("data", {})
124+
data = _get_data_page(url, sid, "netMoni")
136125
conns = data.get("connections", [])
137126
if not conns:
138127
return {}

tests/test_fritzbox_api.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
class TestGetDeviceInfo:
2323
@patch("app.fritzbox.requests.post")
24-
def test_uses_overview_json_when_available(self, mock_post):
24+
def test_uses_home_json_when_available(self, mock_post):
2525
response = MagicMock()
2626
response.raise_for_status = MagicMock()
2727
response.json.return_value = {
@@ -42,15 +42,46 @@ def test_uses_overview_json_when_available(self, mock_post):
4242
"sw_version": "8.02",
4343
"uptime_seconds": 1234,
4444
}
45+
assert mock_post.call_args.kwargs["data"]["page"] == "home"
46+
47+
@patch("app.fritzbox.requests.post")
48+
def test_falls_back_to_boxinfo_when_home_is_not_json(self, mock_post):
49+
home_response = MagicMock()
50+
home_response.raise_for_status = MagicMock()
51+
home_response.json.side_effect = ValueError("not json")
52+
53+
boxinfo_response = MagicMock()
54+
boxinfo_response.raise_for_status = MagicMock()
55+
boxinfo_response.json.return_value = {
56+
"data": {
57+
"fritzos": {
58+
"Productname": "FRITZ!Box 6690 Cable",
59+
"nspver": "8.21",
60+
}
61+
}
62+
}
63+
64+
mock_post.side_effect = [home_response, boxinfo_response]
65+
66+
info = fb.get_device_info("http://fritz.box", "sid123")
67+
68+
assert info == {
69+
"model": "FRITZ!Box 6690 Cable",
70+
"sw_version": "8.21",
71+
}
72+
assert [call.kwargs["data"]["page"] for call in mock_post.call_args_list] == [
73+
"home",
74+
"boxinfo",
75+
]
4576

4677
@patch("app.fritzbox.requests.get")
4778
@patch("app.fritzbox.requests.post")
48-
def test_falls_back_to_tr064_when_overview_returns_html(self, mock_post, mock_get):
79+
def test_falls_back_to_tr064_when_data_pages_return_html(self, mock_post, mock_get):
4980
html_response = MagicMock()
5081
html_response.raise_for_status = MagicMock()
5182
html_response.json.side_effect = ValueError("not json")
5283
html_response.text = "<html>login</html>"
53-
mock_post.return_value = html_response
84+
mock_post.side_effect = [html_response, html_response, html_response]
5485

5586
tr064_response = MagicMock()
5687
tr064_response.raise_for_status = MagicMock()
@@ -63,14 +94,19 @@ def test_falls_back_to_tr064_when_overview_returns_html(self, mock_post, mock_ge
6394
"model": "FRITZ!Box 6690 Cable",
6495
"sw_version": "267.08.21",
6596
}
97+
assert [call.kwargs["data"]["page"] for call in mock_post.call_args_list] == [
98+
"home",
99+
"boxinfo",
100+
"overview",
101+
]
66102

67103
@patch("app.fritzbox.requests.get")
68104
@patch("app.fritzbox.requests.post")
69-
def test_returns_generic_fallback_when_overview_and_tr064_fail(self, mock_post, mock_get):
105+
def test_returns_generic_fallback_when_all_sources_fail(self, mock_post, mock_get):
70106
post_response = MagicMock()
71107
post_response.raise_for_status = MagicMock()
72108
post_response.json.side_effect = ValueError("not json")
73-
mock_post.return_value = post_response
109+
mock_post.side_effect = [post_response, post_response, post_response]
74110

75111
mock_get.side_effect = RuntimeError("network down")
76112

0 commit comments

Comments
 (0)