Skip to content

Handle missing paths in GetDeviceInfo#386

Open
mr-miles wants to merge 3 commits intoiMicknl:mainfrom
mr-miles:talktalk
Open

Handle missing paths in GetDeviceInfo#386
mr-miles wants to merge 3 commits intoiMicknl:mainfrom
mr-miles:talktalk

Conversation

@mr-miles
Copy link
Copy Markdown

@mr-miles mr-miles commented Feb 1, 2025

TalkTalk (UK) branded F5364 does not recognise the ModelNumber attribute requested in GetDeviceInfo
This causes an exception to be thrown, because GetResponse throws for any ActionError.
End result is that HA cant connect to the router to retrieve the useful info, even though the ModelNumber is not essential info.

Fixed by:

  • Lifting behaviour around ActionErrors to higher level, so caller can react appropriately
  • Specifically avoid throwing exceptions on missing path errors when getting DeviceInfo

There was a TODO around how to deal with multiple ActionErrors, which this PR also addresses - since errors can now be thrown at the point of reading the value from the response if required.

For calls other than GetDeviceInfo, the existing behaviour is retained.

@mr-miles mr-miles force-pushed the talktalk branch 3 times, most recently from 7d1b9c6 to f176611 Compare February 3, 2025 18:19
mr-miles and others added 2 commits April 6, 2026 22:42
Lift behaviour to higher level so caller can specify the behaviour

Specifically to avoid throwing exceptions on missing path errors when getting DeviceInfo
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts how action-level errors returned by the router are handled so that GetDeviceInfo can tolerate missing optional fields (notably ModelNumber) without preventing Home Assistant from connecting and retrieving other device details.

Changes:

  • Introduces ActionErrorHandler to centralize mapping action error descriptions to exceptions.
  • Stops __post from throwing on XMO_REQUEST_ACTION_ERR, moving that decision to higher-level call sites.
  • Adds suppress_action_errors to XPath getters and uses it in get_device_info fallback path.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.

File Description
sagemcom_api/client.py Moves action-error raising out of __post, adds optional suppression for XPath getters, and applies suppression in get_device_info.
sagemcom_api/action_error_exception_handler.py New helper that maps action error descriptions to the existing exception types and provides a “throw if action error” utility.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

xpath: str,
options: dict | None = None,
suppress_action_errors: bool = False,
) -> dict:
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_value_by_xpath is annotated to return dict, but it returns self.__get_response_value(...) which can be a scalar (e.g., the unit test expects a string). This makes the public API typing misleading; consider changing the return type to something like Any / object (and similarly for other value getters) so type checkers and callers have accurate expectations.

Suggested change
) -> dict:
) -> Any:

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: return type has been changed from to , which correctly reflects that the method can return scalars, dicts, lists, or depending on the path requested.

Comment on lines +390 to +391
if not suppress_action_errors:
ActionErrorHandler.throw_if(response)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suppress_action_errors=True skips action-error handling entirely. That can silently swallow critical action-level errors (e.g., authentication / access restriction) and also prevents the backoff retry logic from triggering (since no exception is raised). Consider only suppressing specific error types (e.g., unknown-path) while still raising for other action errors.

Suggested change
if not suppress_action_errors:
ActionErrorHandler.throw_if(response)
try:
ActionErrorHandler.throw_if(response)
except UnknownPathException:
if not suppress_action_errors:
raise

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: The blanket if not suppress_action_errors: throw_if() pattern has been replaced with throw_if_error(response, ignore_unknown_path=suppress_action_errors). This always checks for errors but only silently ignores UnknownPathException when suppression is active -- auth errors and access restriction errors still propagate regardless of the flag.

Comment on lines 432 to 433

values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as get_value_by_xpath: when suppress_action_errors=True, this method will return None values for any action-level error (including auth failures), which can hide real failures and bypass the backoff retry mechanism. A safer approach is to ignore only the intended error(s) per-action (e.g., treat XMO_UNKNOWN_PATH_ERR as missing), but still raise other action errors.

Suggested change
values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
else:
values = []
for i in range(len(xpaths)):
try:
values.append(self.__get_response_value(response, i))
except UnknownPathException:
values.append(None)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: The suppress path now does per-action error checking using throw_if_error_at(response, i, ignore_unknown_path=True) in a loop over each action result. Non-unknown-path errors (auth failures, access restrictions, etc.) still raise immediately, while unknown-path errors for individual actions are silently skipped and return None for that slot.

Comment on lines 486 to 497
data = await self.get_values_by_xpaths(
{
"mac_address": "Device/DeviceInfo/MACAddress",
"model_name": "Device/DeviceInfo/ModelNumber",
"model_number": "Device/DeviceInfo/ProductClass",
"product_class": "Device/DeviceInfo/ProductClass",
"serial_number": "Device/DeviceInfo/SerialNumber",
"software_version": "Device/DeviceInfo/SoftwareVersion",
}
},
# missing values converted to empty string
suppress_action_errors=True,
)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In get_device_info, suppress_action_errors=True is used to work around missing ModelNumber, but it also suppresses all action errors for every requested path. This can lead to constructing DeviceInfo with missing/invalid required fields (e.g., mac_address) and can mask authentication/access issues. Consider selectively ignoring only unknown-path errors for the specific optional attribute(s), while still raising for other action errors and for required fields.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: This is now covered by the fix to get_values_by_xpaths -- the suppress path uses per-action error checking with throw_if_error_at(response, i, ignore_unknown_path=True), so auth failures and access restriction errors still propagate even when suppress_action_errors=True. Only UnknownPathException (e.g. for ModelNumber on some firmware versions) is silently skipped.

"software_version": "Device/DeviceInfo/SoftwareVersion",
}
},
# missing values converted to empty string
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "missing values converted to empty string", but __get_response_value returns None on missing value (KeyError/IndexError). Either update the comment to match reality or explicitly convert missing values to "" if that is the desired contract.

Suggested change
# missing values converted to empty string
# missing values are returned as None when action errors are suppressed

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: The comment has been updated from "missing values converted to empty string" to "missing values returned as None when action errors are suppressed", which accurately reflects the actual behaviour of __get_response_value.

Comment on lines +3 to +21
from sagemcom_api.const import (
XMO_ACCESS_RESTRICTION_ERR,
XMO_AUTHENTICATION_ERR,
XMO_LOGIN_RETRY_ERR,
XMO_MAX_SESSION_COUNT_ERR,
XMO_NO_ERR,
XMO_NON_WRITABLE_PARAMETER_ERR,
XMO_REQUEST_ACTION_ERR,
XMO_UNKNOWN_PATH_ERR,
)
from sagemcom_api.exceptions import (
AccessRestrictionException,
AuthenticationException,
LoginRetryErrorException,
MaximumSessionCountException,
NonWritableParameterException,
UnknownException,
UnknownPathException,
)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module uses absolute imports (from sagemcom_api...) while the rest of the package (e.g., client.py) uses relative imports (from .const, from .exceptions). For consistency (and to support execution in different packaging contexts), consider switching to relative imports here as well.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: Changed to relative imports throughout action_error_exception_handler.py -- from sagemcom_api.const import ... is now from .const import ... and from sagemcom_api.exceptions import ... is now from .exceptions import ..., consistent with the rest of the package.

Comment on lines +52 to +55
@staticmethod
def is_unknown_exception(desc):
"""True/false if the ActionError is one of our known types."""
return False if desc == XMO_NO_ERR else desc not in ActionErrorHandler.KNOWN_EXCEPTIONS
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ActionErrorHandler.is_unknown_exception is introduced but not used anywhere in the codebase. If it’s not needed, removing it would reduce surface area; if it is intended for callers (e.g., selective suppression), consider wiring it into the new suppress_action_errors behavior and adding coverage for it.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: is_unknown_exception() has been removed entirely. It was never called anywhere in the codebase (confirmed by a full-codebase search), so it served no purpose and only added unnecessary surface area.

Comment on lines 429 to 433
response = await self.__api_request_async(actions, False)
if not suppress_action_errors:
ActionErrorHandler.throw_if(response)

values = [self.__get_response_value(response, i) for i in range(len(xpaths))]
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions improved handling for multiple action errors by deferring errors until reading values, but get_values_by_xpaths still calls ActionErrorHandler.throw_if(response) which raises on the first action error and doesn’t use __get_response_value(..., throw_on_action_error=True) at all. If the intent is per-action handling, consider either (a) removing the throw_if call for multi-action requests and handling errors per index when reading values, or (b) updating the PR description to match the implemented behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: The implementation now genuinely does per-action error handling in the suppress path, matching the PR description intent. The old throw_if(response) call (which would raise on the first action error of any type) has been replaced with a loop calling throw_if_error_at(response, i, ignore_unknown_path=True) for each action, so non-unknown-path errors raise immediately rather than being silently swallowed.

Comment on lines +408 to +413
async def get_values_by_xpaths(
self,
xpaths,
options: dict | None = None,
suppress_action_errors: bool = False,
) -> dict:
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior (suppress_action_errors) and the new ActionErrorHandler logic are not covered by unit tests. Given existing unit tests for action-level authentication errors, add tests that (1) verify unknown-path errors can be suppressed for optional fields, and (2) verify non-unknown-path action errors (e.g., authentication) still raise even when suppression is enabled for other fields.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: Added 5 new unit tests in tests/unit/test_client_basic.py covering:

  1. Unknown-path errors are suppressed and return None (single xpath)
  2. Unknown-path errors raise when suppress_action_errors=False
  3. Auth errors still raise even when suppress_action_errors=True (single xpath)
  4. Per-action suppression: a mix of success + unknown-path returns value + None
  5. Auth errors still raise even when suppress_action_errors=True (multi xpath)

As a bonus improvement, the throw_if() pattern (raise then catch at call site) was replaced with throw_if_error() and throw_if_error_at() methods that raise directly, avoiding the cost of constructing and catching exceptions at every call site.

- Fix relative imports in action_error_exception_handler.py (was absolute)
- Remove dead code: is_unknown_exception() was never called
- Fix get_value_by_xpath return type annotation: dict -> Any
- Fix inaccurate comment in get_device_info (values are None, not empty string)
- Replace throw_if() raise/catch pattern with throw_if_error() and
  throw_if_error_at() which raise directly, eliminating expensive
  exception construction and catching at call sites
- Both methods accept ignore_unknown_path=False so callers can suppress
  UnknownPathException without swallowing auth/access errors
- get_values_by_xpaths suppress path now does per-action error checking
  so non-unknown-path errors (e.g. auth failures) still propagate
- Add unit tests covering suppress_action_errors behaviour: unknown-path
  suppression returns None, auth errors still raise when suppressed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants