diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0f09e3e..35dce1e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ // See https://aka.ms/vscode-remote/devcontainer.json for format details. { - "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", "name": "ha-sagemcom-fast", "forwardPorts": [ 8123 @@ -12,9 +12,9 @@ } }, "features": { - "ghcr.io/devcontainers-contrib/features/ffmpeg-apt-get:1": {} + "ghcr.io/devcontainers-contrib/features/ffmpeg-apt-get:1": {} // required for Home Assistant }, - "postCreateCommand": "pip install -r requirements_dev.txt && pre-commit install && pre-commit install-hooks", + "postCreateCommand": "pip install -r requirements_dev.txt && pre-commit install && pre-commit install-hooks && sudo apt-get update && sudo apt-get install -y libpcap-dev libturbojpeg0", "containerEnv": { "DEVCONTAINER": "1" }, @@ -22,12 +22,9 @@ "customizations": { "vscode": { "extensions": [ - "ms-python.vscode-pylance", "ms-python.python", - "redhat.vscode-yaml", - "esbenp.prettier-vscode", - "GitHub.vscode-pull-request-github", - "GitHub.copilot" + "GitHub.copilot", + "GitHub.copilot-chat" ], "settings": { "python.pythonPath": "/usr/local/bin/python", diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04c8674..b66277a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.31.1 + rev: v3.17.0 hooks: - id: pyupgrade args: [--py37-plus] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 24.8.0 hooks: - id: black args: @@ -13,16 +13,16 @@ repos: - --quiet files: ^((custom_components)/.+)?[^/]+\.py$ - repo: https://github.com/codespell-project/codespell - rev: v2.1.0 + rev: v2.3.0 hooks: - id: codespell args: - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing - - --skip="./.*,*.csv,*.json,*.md" + - --skip="./.*,*.csv,*.json,*.md",setup.cfg - --quiet-level=2 exclude_types: [csv, json] - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 7.1.1 hooks: - id: flake8 additional_dependencies: @@ -30,10 +30,10 @@ repos: - pydocstyle==5.1.1 files: ^(custom_components)/.+\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort - repo: https://github.com/adrienverge/yamllint.git - rev: v1.26.3 + rev: v1.35.1 hooks: - id: yamllint diff --git a/README.md b/README.md index 71a8cb8..d31e4c1 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ https://github.com/imicknl/ha-sagemcom-fast This integration can only be configured via the Config Flow. Go to `Configuration -> Integrations -> Add Integration` and choose Sagemcom F@st. The prompt will ask you for your credentials. Please note that some routers require authentication, where others can login with `guest` username and an empty password. -The encryption method differs per device. Please refer to the table below to understand which option to select. If your device is not listed, please try both methods one by one. +The first login might take a longer time (up to a minute), since we will try to retrieve the encryption method used by your router. ## Supported devices diff --git a/custom_components/sagemcom_fast/__init__.py b/custom_components/sagemcom_fast/__init__.py index 6264535..33d873d 100644 --- a/custom_components/sagemcom_fast/__init__.py +++ b/custom_components/sagemcom_fast/__init__.py @@ -1,4 +1,5 @@ """The Sagemcom F@st integration.""" + from __future__ import annotations from dataclasses import dataclass @@ -23,6 +24,7 @@ from sagemcom_api.exceptions import ( AccessRestrictionException, AuthenticationException, + LoginRetryErrorException, MaximumSessionCountException, UnauthorizedException, ) @@ -57,11 +59,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): session = aiohttp_client.async_get_clientsession(hass, verify_ssl=verify_ssl) client = SagemcomClient( - host, - username, - password, - EncryptionMethod(encryption_method), - session, + host=host, + username=username, + password=password, + authentication_method=EncryptionMethod(encryption_method), + session=session, ssl=ssl, ) @@ -73,12 +75,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): except (AuthenticationException, UnauthorizedException) as exception: LOGGER.error("Invalid_auth") raise ConfigEntryAuthFailed("Invalid credentials") from exception - except (TimeoutError, ClientError) as exception: + except (TimeoutError, ClientError, ConnectionError) as exception: LOGGER.error("Failed to connect") raise ConfigEntryNotReady("Failed to connect") from exception except MaximumSessionCountException as exception: LOGGER.error("Maximum session count reached") raise ConfigEntryNotReady("Maximum session count reached") from exception + except LoginRetryErrorException as exception: + LOGGER.error("Too many login attempts. Retry later.") + raise ConfigEntryNotReady( + "Too many login attempts. Retry later." + ) from exception except Exception as exception: # pylint: disable=broad-except LOGGER.exception(exception) return False @@ -98,8 +105,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): update_interval=timedelta(seconds=update_interval), ) - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSagemcomFastData( coordinator=coordinator, gateway=gateway ) @@ -118,6 +123,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): configuration_url=f"{'https' if ssl else 'http'}://{host}", ) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/custom_components/sagemcom_fast/button.py b/custom_components/sagemcom_fast/button.py index 03f33cf..048e4e5 100644 --- a/custom_components/sagemcom_fast/button.py +++ b/custom_components/sagemcom_fast/button.py @@ -1,4 +1,5 @@ """Support for Sagencom F@st buttons.""" + from __future__ import annotations from homeassistant.components.button import ButtonDeviceClass, ButtonEntity diff --git a/custom_components/sagemcom_fast/config_flow.py b/custom_components/sagemcom_fast/config_flow.py index 4b64225..6bbfa82 100644 --- a/custom_components/sagemcom_fast/config_flow.py +++ b/custom_components/sagemcom_fast/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Sagemcom integration.""" -import logging from aiohttp import ClientError from homeassistant import config_entries @@ -13,33 +12,19 @@ from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from sagemcom_api.client import SagemcomClient -from sagemcom_api.enums import EncryptionMethod from sagemcom_api.exceptions import ( AccessRestrictionException, AuthenticationException, + LoginRetryErrorException, LoginTimeoutException, MaximumSessionCountException, + UnsupportedHostException, ) import voluptuous as vol -from .const import CONF_ENCRYPTION_METHOD, DOMAIN +from .const import CONF_ENCRYPTION_METHOD, DOMAIN, LOGGER from .options_flow import OptionsFlow -_LOGGER = logging.getLogger(__name__) - -ENCRYPTION_METHODS = [item.value for item in EncryptionMethod] - -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_USERNAME): str, - vol.Optional(CONF_PASSWORD): str, - vol.Required(CONF_ENCRYPTION_METHOD): vol.In(ENCRYPTION_METHODS), - vol.Required(CONF_SSL, default=False): bool, - vol.Required(CONF_VERIFY_SSL, default=False): bool, - } -) - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Sagemcom.""" @@ -47,30 +32,36 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + _host: str | None = None + _username: str | None = None + async def async_validate_input(self, user_input): """Validate user credentials.""" - username = user_input.get(CONF_USERNAME) or "" + self._username = user_input.get(CONF_USERNAME) or "" password = user_input.get(CONF_PASSWORD) or "" - host = user_input[CONF_HOST] - encryption_method = user_input[CONF_ENCRYPTION_METHOD] + self._host = user_input[CONF_HOST] ssl = user_input[CONF_SSL] session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) client = SagemcomClient( - host, - username, - password, - EncryptionMethod(encryption_method), - session, + host=self._host, + username=self._username, + password=password, + session=session, ssl=ssl, ) + user_input[CONF_ENCRYPTION_METHOD] = await client.get_encryption_method() + LOGGER.debug( + "Detected encryption method: %s", user_input[CONF_ENCRYPTION_METHOD] + ) + await client.login() await client.logout() return self.async_create_entry( - title=host, + title=self._host, data=user_input, ) @@ -89,18 +80,32 @@ async def async_step_user(self, user_input=None): errors["base"] = "access_restricted" except AuthenticationException: errors["base"] = "invalid_auth" - except (TimeoutError, ClientError): + except (TimeoutError, ClientError, ConnectionError): errors["base"] = "cannot_connect" except LoginTimeoutException: errors["base"] = "login_timeout" except MaximumSessionCountException: errors["base"] = "maximum_session_count" + except LoginRetryErrorException: + errors["base"] = "login_retry_error" + except UnsupportedHostException: + errors["base"] = "unsupported_host" except Exception as exception: # pylint: disable=broad-except errors["base"] = "unknown" - _LOGGER.exception(exception) + LOGGER.exception(exception) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self._host): str, + vol.Optional(CONF_USERNAME, default=self._username): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(CONF_SSL, default=False): bool, + vol.Required(CONF_VERIFY_SSL, default=False): bool, + } + ), + errors=errors, ) @staticmethod diff --git a/custom_components/sagemcom_fast/const.py b/custom_components/sagemcom_fast/const.py index b7b5404..9775360 100644 --- a/custom_components/sagemcom_fast/const.py +++ b/custom_components/sagemcom_fast/const.py @@ -1,4 +1,5 @@ """Constants for the Sagemcom F@st integration.""" + from __future__ import annotations import logging diff --git a/custom_components/sagemcom_fast/coordinator.py b/custom_components/sagemcom_fast/coordinator.py index 533b68c..c6e9204 100644 --- a/custom_components/sagemcom_fast/coordinator.py +++ b/custom_components/sagemcom_fast/coordinator.py @@ -1,13 +1,24 @@ """Helpers to help coordinate updates.""" + from __future__ import annotations +import asyncio from datetime import timedelta import logging +from aiohttp.client_exceptions import ClientError import async_timeout from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from sagemcom_api.client import SagemcomClient +from sagemcom_api.exceptions import ( + AccessRestrictionException, + AuthenticationException, + LoginRetryErrorException, + MaximumSessionCountException, + UnauthorizedException, +) from sagemcom_api.models import Device @@ -33,6 +44,7 @@ def __init__( self.data = {} self.hosts: dict[str, Device] = {} self.client = client + self.logger = logger async def _async_update_data(self) -> dict[str, Device]: """Update hosts data.""" @@ -40,6 +52,7 @@ async def _async_update_data(self) -> dict[str, Device]: async with async_timeout.timeout(10): try: await self.client.login() + await asyncio.sleep(1) hosts = await self.client.get_hosts(only_active=True) finally: await self.client.logout() @@ -52,5 +65,18 @@ async def _async_update_data(self) -> dict[str, Device]: self.hosts[host.id] = host return self.hosts + except AccessRestrictionException as exception: + raise ConfigEntryAuthFailed("Access restricted") from exception + except (AuthenticationException, UnauthorizedException) as exception: + raise ConfigEntryAuthFailed("Invalid credentials") from exception + except (TimeoutError, ClientError, ConnectionError) as exception: + raise UpdateFailed("Failed to connect") from exception + except LoginRetryErrorException as exception: + raise UpdateFailed( + "Too many login attempts. Retrying later." + ) from exception + except MaximumSessionCountException as exception: + raise UpdateFailed("Maximum session count reached") from exception except Exception as exception: - raise UpdateFailed(f"Error communicating with API: {exception}") + self.logger.exception(exception) + raise UpdateFailed(f"Error communicating with API: {str(exception)}") diff --git a/custom_components/sagemcom_fast/device_tracker.py b/custom_components/sagemcom_fast/device_tracker.py index 70b49f8..b0ffe10 100644 --- a/custom_components/sagemcom_fast/device_tracker.py +++ b/custom_components/sagemcom_fast/device_tracker.py @@ -1,4 +1,5 @@ """Support for device tracking of client router.""" + from __future__ import annotations from homeassistant.components.device_tracker import SourceType diff --git a/custom_components/sagemcom_fast/diagnostics.py b/custom_components/sagemcom_fast/diagnostics.py index 83a2e5d..14cb899 100644 --- a/custom_components/sagemcom_fast/diagnostics.py +++ b/custom_components/sagemcom_fast/diagnostics.py @@ -1,4 +1,5 @@ """Provides diagnostics for Overkiz.""" + from __future__ import annotations from typing import Any diff --git a/custom_components/sagemcom_fast/manifest.json b/custom_components/sagemcom_fast/manifest.json index 448e581..bed095a 100644 --- a/custom_components/sagemcom_fast/manifest.json +++ b/custom_components/sagemcom_fast/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/imicknl/ha-sagemcom-fast/issues", "requirements": [ - "sagemcom_api==1.1.0" + "sagemcom_api==1.3.2" ], "ssdp": [ { diff --git a/custom_components/sagemcom_fast/strings.json b/custom_components/sagemcom_fast/strings.json index 69d6746..62eeceb 100644 --- a/custom_components/sagemcom_fast/strings.json +++ b/custom_components/sagemcom_fast/strings.json @@ -9,7 +9,9 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", "login_timeout": "Request timed-out. This could be caused by using the wrong encryption method, or using a (non) SSL connection.", - "maximum_session_count": "Maximum session count reached." + "maximum_session_count": "Maximum session count reached.", + "login_retry_error": "Too many login attempts. Retry later.", + "unsupported_host": "Your host does not support the Sagemcom API endpoint used by this integration. Make sure you have provided the correct host and that your router is supported." }, "step": { "user": { @@ -17,11 +19,10 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", - "encryption_method": "Encryption Method", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, - "description": "Enter your credentials for accessing the routers web interface. Depending on the router model, Sagemcom is using different encryption methods for authentication, which can be found in the [supported devices](https://github.com/iMicknl/ha-sagemcom-fast#supported-devices) list." + "description": "Enter your credentials for accessing the router's web interface. The first login may take longer (up to a minute) as we retrieve the encryption method used by your router. For more information, see the [supported devices](https://github.com/iMicknl/ha-sagemcom-fast#supported-devices)." } } }, diff --git a/custom_components/sagemcom_fast/translations/en.json b/custom_components/sagemcom_fast/translations/en.json index 65c452c..5b8b759 100644 --- a/custom_components/sagemcom_fast/translations/en.json +++ b/custom_components/sagemcom_fast/translations/en.json @@ -9,7 +9,9 @@ "invalid_auth": "Invalid authentication", "unknown": "Unexpected error", "login_timeout": "Request timed-out. This could be caused by using the wrong encryption method, or using a (non) SSL connection.", - "maximum_session_count": "Maximum session count reached." + "maximum_session_count": "Maximum session count reached.", + "login_retry_error": "Too many login attempts. Retry later.", + "unsupported_host": "Your host does not support the Sagemcom API endpoint used by this integration. Make sure you have provided the correct host and that your router is supported." }, "step": { "user": { @@ -17,11 +19,10 @@ "host": "Host", "password": "Password", "username": "Username", - "encryption_method": "Encryption Method", "ssl": "Uses an SSL certificate", "verify_ssl": "Verify SSL certificate" }, - "description": "Enter your credentials for accessing the routers web interface. Depending on the router model, Sagemcom is using different encryption methods for authentication, which can be found in the [supported devices](https://github.com/iMicknl/ha-sagemcom-fast#supported-devices) list." + "description": "Enter your credentials for accessing the router's web interface. The first login may take longer (up to a minute) as we retrieve the encryption method used by your router. For more information, see the [supported devices](https://github.com/iMicknl/ha-sagemcom-fast#supported-devices)." } } }, diff --git a/hacs.json b/hacs.json index 971aa28..01964d2 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "Sagemcom F@st", - "homeassistant": "2024.1.2", + "homeassistant": "2024.7.0", "render_readme": true } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e1a95d9..c44a363 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -sagemcom_api==1.2.1 \ No newline at end of file +sagemcom_api==1.3.2 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 7a9769d..97c0cb1 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ -r requirements.txt -homeassistant==2024.7.2 +homeassistant==2024.8.0 pre-commit \ No newline at end of file