Skip to content

Commit 5e1433c

Browse files
mr-milesclaude
andcommitted
Handle missing paths in GetDeviceInfo
TalkTalk (UK) branded F5364 does not recognise the ModelNumber attribute requested in GetDeviceInfo, which caused an exception because the response handler threw for any ActionError. As a result HA could not connect to the router to retrieve useful info, even though ModelNumber is not essential. Fixed by: - Lifting ActionError handling out of the low-level POST into a dedicated ActionErrorHandler, so callers can react appropriately (and multiple actions can be handled individually, addressing the previous TODO). - Adding a suppress_action_errors option so get_value(s)_by_xpath can ignore unknown-path errors and return None for missing values, while still raising genuine errors (auth, etc.). - Using that option in get_device_info's fallback so missing DeviceInfo attributes are tolerated. Existing error-raising behaviour is retained for all other calls, which now invoke ActionErrorHandler.throw_if_error explicitly. Tests cover the suppression behaviour of get_value(s)_by_xpath, the end-to-end get_device_info fallback for the reported F5364 case, and the ActionErrorHandler in isolation (throw_if_error / throw_if_error_at / from_error_description). Rebased onto upstream main, preserving the XPath-escaping fix (#476), get_logs, and the speed-test additions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d44f9b0 commit 5e1433c

8 files changed

Lines changed: 649 additions & 48 deletions
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Logic to spot and create ActionErrorExceptions."""
2+
3+
from .const import (
4+
XMO_ACCESS_RESTRICTION_ERR,
5+
XMO_AUTHENTICATION_ERR,
6+
XMO_LOGIN_RETRY_ERR,
7+
XMO_MAX_SESSION_COUNT_ERR,
8+
XMO_NO_ERR,
9+
XMO_NON_WRITABLE_PARAMETER_ERR,
10+
XMO_REQUEST_ACTION_ERR,
11+
XMO_UNKNOWN_PATH_ERR,
12+
)
13+
from .exceptions import (
14+
AccessRestrictionException,
15+
AuthenticationException,
16+
LoginRetryErrorException,
17+
MaximumSessionCountException,
18+
NonWritableParameterException,
19+
UnknownException,
20+
UnknownPathException,
21+
)
22+
23+
24+
class ActionErrorHandler:
25+
"""Raised when a requested action has an error."""
26+
27+
KNOWN_EXCEPTIONS = (
28+
XMO_AUTHENTICATION_ERR,
29+
XMO_ACCESS_RESTRICTION_ERR,
30+
XMO_NON_WRITABLE_PARAMETER_ERR,
31+
XMO_UNKNOWN_PATH_ERR,
32+
XMO_MAX_SESSION_COUNT_ERR,
33+
XMO_LOGIN_RETRY_ERR,
34+
)
35+
36+
@staticmethod
37+
def throw_if_error(response, ignore_unknown_path: bool = False) -> None:
38+
"""Raise the first action-level error, or do nothing if all actions succeeded.
39+
40+
:param ignore_unknown_path: if True, silently ignore UnknownPathException
41+
"""
42+
if response["reply"]["error"]["description"] != XMO_REQUEST_ACTION_ERR:
43+
return
44+
45+
for action in response["reply"]["actions"]:
46+
action_error = action["error"]
47+
action_error_desc = action_error["description"]
48+
if action_error_desc != XMO_NO_ERR:
49+
exc = ActionErrorHandler.from_error_description(action_error, action_error_desc)
50+
if ignore_unknown_path and isinstance(exc, UnknownPathException):
51+
continue
52+
raise exc
53+
54+
@staticmethod
55+
def throw_if_error_at(response, index: int, ignore_unknown_path: bool = False) -> None:
56+
"""Raise the error for a specific action, or do nothing if it succeeded.
57+
58+
:param ignore_unknown_path: if True, silently ignore UnknownPathException
59+
"""
60+
try:
61+
action_error = response["reply"]["actions"][index]["error"]
62+
except (KeyError, IndexError):
63+
return
64+
65+
action_error_desc = action_error["description"]
66+
if action_error_desc == XMO_NO_ERR:
67+
return
68+
69+
exc = ActionErrorHandler.from_error_description(action_error, action_error_desc)
70+
if ignore_unknown_path and isinstance(exc, UnknownPathException):
71+
return
72+
raise exc
73+
74+
@staticmethod
75+
def from_error_description(action_error, action_error_desc):
76+
"""Create the correct exception from an error, for the caller to throw."""
77+
# pylint: disable=too-many-return-statements
78+
79+
if action_error_desc == XMO_AUTHENTICATION_ERR:
80+
return AuthenticationException(action_error)
81+
82+
if action_error_desc == XMO_ACCESS_RESTRICTION_ERR:
83+
return AccessRestrictionException(action_error)
84+
85+
if action_error_desc == XMO_NON_WRITABLE_PARAMETER_ERR:
86+
return NonWritableParameterException(action_error)
87+
88+
if action_error_desc == XMO_UNKNOWN_PATH_ERR:
89+
return UnknownPathException(action_error)
90+
91+
if action_error_desc == XMO_MAX_SESSION_COUNT_ERR:
92+
return MaximumSessionCountException(action_error)
93+
94+
if action_error_desc == XMO_LOGIN_RETRY_ERR:
95+
return LoginRetryErrorException(action_error)
96+
97+
return UnknownException(action_error)

sagemcom_api/client.py

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -22,33 +22,24 @@
2222
TCPConnector,
2323
)
2424

25+
from .action_error_exception_handler import ActionErrorHandler
2526
from .const import (
2627
API_ENDPOINT,
2728
DEFAULT_TIMEOUT,
2829
DEFAULT_USER_AGENT,
2930
UINT_MAX,
30-
XMO_ACCESS_RESTRICTION_ERR,
31-
XMO_AUTHENTICATION_ERR,
3231
XMO_INVALID_SESSION_ERR,
33-
XMO_LOGIN_RETRY_ERR,
34-
XMO_MAX_SESSION_COUNT_ERR,
35-
XMO_NO_ERR,
36-
XMO_NON_WRITABLE_PARAMETER_ERR,
3732
XMO_REQUEST_ACTION_ERR,
3833
XMO_REQUEST_NO_ERR,
39-
XMO_UNKNOWN_PATH_ERR,
4034
)
4135
from .enums import EncryptionMethod
4236
from .exceptions import (
43-
AccessRestrictionException,
4437
AuthenticationException,
4538
BadRequestException,
4639
InvalidSessionException,
4740
LoginConnectionException,
4841
LoginRetryErrorException,
4942
LoginTimeoutException,
50-
MaximumSessionCountException,
51-
NonWritableParameterException,
5243
UnauthorizedException,
5344
UnknownException,
5445
UnknownPathException,
@@ -240,37 +231,12 @@ async def __post(self, url, data):
240231
self._request_id = -1
241232
raise InvalidSessionException(error)
242233

243-
# Error in one of the actions
234+
# Error in one or more of the actions. Leave this to the layer
235+
# above (via ActionErrorHandler), since a request may contain
236+
# multiple actions and the caller may want to react per-action
237+
# (e.g. suppress unknown-path errors for optional values).
244238
if error["description"] == XMO_REQUEST_ACTION_ERR:
245-
# pylint:disable=fixme
246-
# TODO How to support multiple actions + error handling?
247-
actions = result["reply"]["actions"]
248-
for action in actions:
249-
action_error = action["error"]
250-
action_error_desc = action_error["description"]
251-
252-
if action_error_desc == XMO_NO_ERR:
253-
continue
254-
255-
if action_error_desc == XMO_AUTHENTICATION_ERR:
256-
raise AuthenticationException(action_error)
257-
258-
if action_error_desc == XMO_ACCESS_RESTRICTION_ERR:
259-
raise AccessRestrictionException(action_error)
260-
261-
if action_error_desc == XMO_NON_WRITABLE_PARAMETER_ERR:
262-
raise NonWritableParameterException(action_error)
263-
264-
if action_error_desc == XMO_UNKNOWN_PATH_ERR:
265-
raise UnknownPathException(action_error)
266-
267-
if action_error_desc == XMO_MAX_SESSION_COUNT_ERR:
268-
raise MaximumSessionCountException(action_error)
269-
270-
if action_error_desc == XMO_LOGIN_RETRY_ERR:
271-
raise LoginRetryErrorException(action_error)
272-
273-
raise UnknownException(action_error)
239+
pass
274240

275241
return result
276242

@@ -339,6 +305,8 @@ async def login(self):
339305
except (ClientConnectorError, ClientOSError) as exception:
340306
raise LoginConnectionException("Unable to connect to the device. Please check the host address.") from exception
341307

308+
ActionErrorHandler.throw_if_error(response)
309+
342310
data = self.__get_response(response)
343311

344312
if data["id"] is not None and data["nonce"] is not None:
@@ -352,7 +320,8 @@ async def logout(self):
352320
"""Log out of the Sagemcom F@st device."""
353321
actions = {"id": 0, "method": "logOut"}
354322

355-
await self.__api_request_async([actions], False)
323+
response = await self.__api_request_async([actions], False)
324+
ActionErrorHandler.throw_if_error(response)
356325

357326
self._session_id = -1
358327
self._server_nonce = ""
@@ -381,13 +350,20 @@ async def get_encryption_method(self):
381350

382351
return None
383352

384-
async def get_value_by_xpath(self, xpath: str, options: dict | None = None) -> dict:
353+
async def get_value_by_xpath(
354+
self,
355+
xpath: str,
356+
options: dict | None = None,
357+
suppress_action_errors: bool = False,
358+
) -> Any:
385359
"""Retrieve raw value from router using XPath.
386360
387361
:param xpath: path expression
388362
:param options: optional options
363+
:param suppress_action_errors: if True, return None instead of raising
364+
when the path is unknown (other action errors are still raised)
389365
"""
390-
result = await self.get_values_by_xpaths({"value": xpath}, options)
366+
result = await self.get_values_by_xpaths({"value": xpath}, options, suppress_action_errors)
391367
return result["value"]
392368

393369
@backoff.on_exception(
@@ -401,11 +377,18 @@ async def get_value_by_xpath(self, xpath: str, options: dict | None = None) -> d
401377
max_tries=1,
402378
on_backoff=retry_login,
403379
)
404-
async def get_values_by_xpaths(self, xpaths: dict[str, str], options: dict | None = None) -> dict:
380+
async def get_values_by_xpaths(
381+
self,
382+
xpaths: dict[str, str],
383+
options: dict | None = None,
384+
suppress_action_errors: bool = False,
385+
) -> dict:
405386
"""Retrieve raw values from router using XPath.
406387
407388
:param xpaths: Dict of key to xpath expression
408389
:param options: optional options
390+
:param suppress_action_errors: if True, unknown-path actions return None
391+
instead of raising, while other action errors are still raised
409392
"""
410393
actions = [
411394
{
@@ -418,7 +401,16 @@ async def get_values_by_xpaths(self, xpaths: dict[str, str], options: dict | Non
418401
]
419402

420403
response = await self.__api_request_async(actions, False)
421-
values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
404+
405+
if not suppress_action_errors:
406+
ActionErrorHandler.throw_if_error(response)
407+
values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
408+
else:
409+
values = []
410+
for i in range(len(xpaths)):
411+
ActionErrorHandler.throw_if_error_at(response, i, ignore_unknown_path=True)
412+
values.append(self.__get_response_value(response, i))
413+
422414
data = dict(zip(xpaths.keys(), values, strict=True))
423415

424416
return data
@@ -461,6 +453,8 @@ async def set_values_by_xpaths(self, xpaths: dict[str, str], options: dict | Non
461453
]
462454

463455
response = await self.__api_request_async(actions, False)
456+
ActionErrorHandler.throw_if_error(response)
457+
464458
return response
465459

466460
@backoff.on_exception(
@@ -488,7 +482,9 @@ async def get_device_info(self) -> DeviceInfo:
488482
"product_class": "Device/DeviceInfo/ProductClass",
489483
"serial_number": "Device/DeviceInfo/SerialNumber",
490484
"software_version": "Device/DeviceInfo/SoftwareVersion",
491-
}
485+
},
486+
# missing values are returned as None when action errors are suppressed
487+
suppress_action_errors=True,
492488
)
493489
data["manufacturer"] = "Sagemcom"
494490

@@ -554,6 +550,8 @@ async def get_logs(self) -> str:
554550
}
555551

556552
response = await self.__api_request_async([actions], False)
553+
ActionErrorHandler.throw_if_error(response)
554+
557555
log_path = response["reply"]["actions"][0]["callbacks"][0]["parameters"]["uri"]
558556

559557
log_uri = f"{self.protocol}://{self.host}{log_path}"
@@ -571,6 +569,8 @@ async def reboot(self):
571569
}
572570

573571
response = await self.__api_request_async([action], False)
572+
ActionErrorHandler.throw_if_error(response)
573+
574574
data = self.__get_response_value(response)
575575

576576
return data
@@ -585,7 +585,10 @@ async def run_speed_test(self, block_traffic: bool = False):
585585
"parameters": {"BlockTraffic": block_traffic},
586586
}
587587
]
588-
return await self.__api_request_async(actions, False)
588+
response = await self.__api_request_async(actions, False)
589+
ActionErrorHandler.throw_if_error(response)
590+
591+
return response
589592

590593
async def get_speed_test_results(self) -> list[SpeedTestResult]:
591594
"""Retrieve Speed Test results from Sagemcom F@st device."""

tests/conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,29 @@ def xpath_value_response() -> dict[str, Any]:
6464
return load_fixture("xpath_value.json")
6565

6666

67+
@pytest.fixture
68+
def xpath_unknown_path_error_response() -> dict[str, Any]:
69+
"""Mock response for XPath query that returns XMO_UNKNOWN_PATH_ERR."""
70+
return load_fixture("xpath_unknown_path_error.json")
71+
72+
73+
@pytest.fixture
74+
def xpaths_mixed_errors_response() -> dict[str, Any]:
75+
"""Mock response for multi-XPath query with one success and one unknown-path error."""
76+
return load_fixture("xpaths_mixed_errors.json")
77+
78+
79+
@pytest.fixture
80+
def device_info_fallback_partial_response() -> dict[str, Any]:
81+
"""Mock response for the get_device_info fallback where ModelNumber is unknown.
82+
83+
Mirrors the TalkTalk F5364 case: individual DeviceInfo attributes are queried
84+
after the aggregate Device/DeviceInfo path fails, and ModelNumber returns
85+
XMO_UNKNOWN_PATH_ERR while the other attributes succeed.
86+
"""
87+
return load_fixture("device_info_fallback_partial.json")
88+
89+
6790
@pytest.fixture
6891
def mock_session_factory():
6992
"""Create a factory for mock aiohttp ClientSession.

0 commit comments

Comments
 (0)