diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index df2a57c..2e12186 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -12,7 +12,7 @@ // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "poetry config virtualenvs.in-project true && poetry install --with dev --no-interaction && . .venv/bin/activate && pre-commit install", + "postCreateCommand": "poetry config virtualenvs.in-project true && poetry install --with dev --no-interaction && . .venv/bin/activate && pre-commit install && pre-commit install-hooks", // Configure tool-specific properties. "customizations": { "vscode": { diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..8209473 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://paypal.me/imick"] diff --git a/sagemcom_api/client.py b/sagemcom_api/client.py index 111b005..2d12263 100644 --- a/sagemcom_api/client.py +++ b/sagemcom_api/client.py @@ -3,11 +3,13 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import hashlib import json import math import random from types import TracebackType +from typing import Any import urllib.parse from aiohttp import ( @@ -27,6 +29,7 @@ DEFAULT_USER_AGENT, XMO_ACCESS_RESTRICTION_ERR, XMO_AUTHENTICATION_ERR, + XMO_INVALID_SESSION_ERR, XMO_LOGIN_RETRY_ERR, XMO_MAX_SESSION_COUNT_ERR, XMO_NO_ERR, @@ -40,6 +43,7 @@ AccessRestrictionException, AuthenticationException, BadRequestException, + InvalidSessionException, LoginRetryErrorException, LoginTimeoutException, MaximumSessionCountException, @@ -51,6 +55,11 @@ from .models import Device, DeviceInfo, PortMapping +async def retry_login(invocation: Mapping[str, Any]) -> None: + """Retry login via backoff if an exception occurs.""" + await invocation["args"][0].login() + + # pylint: disable=too-many-instance-attributes class SagemcomClient: """Client to communicate with the Sagemcom API.""" @@ -118,7 +127,7 @@ async def close(self) -> None: def __generate_nonce(self): """Generate pseudo random number (nonce) to avoid replay attacks.""" - self._current_nonce = math.floor(random.randrange(0, 1) * 500000) + self._current_nonce = math.floor(random.randrange(0, 500000)) def __generate_request_id(self): """Generate sequential request ID.""" @@ -187,6 +196,7 @@ def __get_response_value(self, response, index=0): (ClientConnectorError, ClientOSError, ServerDisconnectedError), max_tries=5, ) + # pylint: disable=too-many-branches async def __post(self, url, data): async with self.session.post(url, data=data) as response: if response.status == 400: @@ -207,6 +217,12 @@ async def __post(self, url, data): ): return result + if error["description"] == XMO_INVALID_SESSION_ERR: + self._session_id = 0 + self._server_nonce = "" + self._request_id = -1 + raise InvalidSessionException(error) + # Error in one of the actions if error["description"] == XMO_REQUEST_ACTION_ERR: # pylint:disable=fixme @@ -241,6 +257,17 @@ async def __post(self, url, data): return result + @backoff.on_exception( + backoff.expo, + ( + AuthenticationException, + LoginRetryErrorException, + LoginTimeoutException, + InvalidSessionException, + ), + max_tries=2, + on_backoff=retry_login, + ) async def __api_request_async(self, actions, priority=False): """Build request to the internal JSON-req API.""" self.__generate_request_id() @@ -252,7 +279,7 @@ async def __api_request_async(self, actions, priority=False): payload = { "request": { "id": self._request_id, - "session-id": str(self._session_id), + "session-id": int(self._session_id), "priority": priority, "actions": actions, "cnonce": self._current_nonce, @@ -435,7 +462,9 @@ async def get_device_info(self) -> DeviceInfo: async def get_hosts(self, only_active: bool | None = False) -> list[Device]: """Retrieve hosts connected to Sagemcom F@st device.""" - data = await self.get_value_by_xpath("Device/Hosts/Hosts") + data = await self.get_value_by_xpath( + "Device/Hosts/Hosts", options={"capability-flags": {"interface": True}} + ) devices = [Device(**d) for d in data] if only_active: diff --git a/sagemcom_api/const.py b/sagemcom_api/const.py index f0add30..8a96cc3 100644 --- a/sagemcom_api/const.py +++ b/sagemcom_api/const.py @@ -7,6 +7,7 @@ XMO_ACCESS_RESTRICTION_ERR = "XMO_ACCESS_RESTRICTION_ERR" XMO_AUTHENTICATION_ERR = "XMO_AUTHENTICATION_ERR" +XMO_INVALID_SESSION_ERR = "XMO_INVALID_SESSION_ERR" XMO_NON_WRITABLE_PARAMETER_ERR = "XMO_NON_WRITABLE_PARAMETER_ERR" XMO_NO_ERR = "XMO_NO_ERR" XMO_REQUEST_ACTION_ERR = "XMO_REQUEST_ACTION_ERR" diff --git a/sagemcom_api/exceptions.py b/sagemcom_api/exceptions.py index 3906745..4fb2933 100644 --- a/sagemcom_api/exceptions.py +++ b/sagemcom_api/exceptions.py @@ -1,43 +1,51 @@ """Exceptions for the Sagemcom F@st client.""" +class BaseSagemcomException(Exception): + """Base exception for Sagemcom F@st client.""" + + +# Broad exceptions provided by this library +class BadRequestException(BaseSagemcomException): + """Bad request.""" + + +class UnauthorizedException(BaseSagemcomException): + """Unauthorized.""" + + +class UnknownException(BaseSagemcomException): + """Unknown exception.""" + + # Exceptions provided by SagemCom API -class AccessRestrictionException(Exception): +class AccessRestrictionException(BaseSagemcomException): """Raised when current user has access restrictions.""" -class AuthenticationException(Exception): +class AuthenticationException(UnauthorizedException): """Raised when authentication is not correct.""" -class LoginRetryErrorException(Exception): +class InvalidSessionException(UnauthorizedException): + """Raised when session is invalid.""" + + +class LoginRetryErrorException(BaseSagemcomException): """Raised when too many login retries are attempted.""" -class LoginTimeoutException(Exception): +class LoginTimeoutException(BaseSagemcomException): """Raised when a timeout is encountered during login.""" -class NonWritableParameterException(Exception): +class NonWritableParameterException(BaseSagemcomException): """Raised when provided parameter is not writable.""" -class UnknownPathException(Exception): +class UnknownPathException(BaseSagemcomException): """Raised when provided path does not exist.""" -class MaximumSessionCountException(Exception): +class MaximumSessionCountException(BaseSagemcomException): """Raised when the maximum session count is reached.""" - - -# Broad exceptions provided by this library -class BadRequestException(Exception): - """TODO.""" - - -class UnauthorizedException(Exception): - """TODO.""" - - -class UnknownException(Exception): - """TODO."""