diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e05a7b96..3515adeb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.7.3 + rev: v0.7.4 hooks: - id: ruff args: diff --git a/changelog.md b/changelog.md index 15938191..ea761277 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,7 @@ # Version 2024.11.2 (2024-11-15) - Ignore unknown interfaces +- Remove clients for not available interfaces # Version 2024.11.1 (2024-11-14) diff --git a/hahomematic/central/__init__.py b/hahomematic/central/__init__.py index eb6672a9..1e436f54 100644 --- a/hahomematic/central/__init__.py +++ b/hahomematic/central/__init__.py @@ -45,6 +45,7 @@ IGNORE_FOR_UN_IGNORE_PARAMETERS, IP_ANY_V4, PORT_ANY, + PRIMARY_CLIENT_CANDIDATE_INTERFACES, UN_IGNORE_WILDCARD, BackendSystemEvent, DataPointCategory, @@ -350,9 +351,9 @@ async def start(self) -> None: if self._started: _LOGGER.debug("START: Central %s already started", self.name) return - if self._config.interface_configs and ( + if self._config.enabled_interface_configs and ( ip_addr := await self._identify_ip_addr( - port=tuple(self._config.interface_configs)[0].port + port=tuple(self._config.enabled_interface_configs)[0].port ) ): self._callback_ip_addr = ip_addr @@ -512,60 +513,94 @@ async def _create_clients(self) -> bool: self.name, ) return False - if len(self._config.interface_configs) == 0: + if len(self._config.enabled_interface_configs) == 0: _LOGGER.warning( "CREATE_CLIENTS failed: No Interfaces for %s defined", self.name, ) return False - for interface_config in self._config.interface_configs: - try: - if client := await hmcl.create_client( - central=self, - interface_config=interface_config, + # create primary clients + for interface_config in self._config.enabled_interface_configs: + if interface_config.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES: + await self._create_client(interface_config=interface_config) + + # create secondary clients + for interface_config in self._config.enabled_interface_configs: + if interface_config.interface not in PRIMARY_CLIENT_CANDIDATE_INTERFACES: + if ( + self.primary_client is not None + and interface_config.interface + not in self.primary_client.system_information.available_interfaces ): - if ( - available_interfaces := client.system_information.available_interfaces - ) and (interface_config.interface not in available_interfaces): - _LOGGER.debug( - "CREATE_CLIENTS failed: Interface: %s is not available for backend", - interface_config.interface, - ) - continue - _LOGGER.debug( - "CREATE_CLIENTS: Adding client %s to %s", - client.interface_id, + _LOGGER.warning( + "CREATE_CLIENTS failed: Interface: %s is not available for backend %s", + interface_config.interface, self.name, ) - self._clients[client.interface_id] = client - except BaseHomematicException as ex: - self.fire_interface_event( - interface_id=interface_config.interface_id, - interface_event_type=InterfaceEventType.PROXY, - data={EventKey.AVAILABLE: False}, - ) - _LOGGER.warning( - "CREATE_CLIENTS failed: No connection to interface %s [%s]", - interface_config.interface_id, - reduce_args(args=ex.args), - ) + interface_config.disable() + continue + await self._create_client(interface_config=interface_config) - if self.has_clients: + if self.has_all_enabled_clients: _LOGGER.debug( "CREATE_CLIENTS: All clients successfully created for %s", self.name, ) return True + if self.primary_client is not None: + _LOGGER.warning( + "CREATE_CLIENTS: Created %i of %i clients", + len(self._clients), + len(self._config.enabled_interface_configs), + ) + return True + _LOGGER.debug("CREATE_CLIENTS failed for %s", self.name) return False + async def _create_client(self, interface_config: hmcl.InterfaceConfig) -> None: + """Create a client.""" + try: + if client := await hmcl.create_client( + central=self, + interface_config=interface_config, + ): + _LOGGER.debug( + "CREATE_CLIENT: Adding client %s to %s", + client.interface_id, + self.name, + ) + self._clients[client.interface_id] = client + except BaseHomematicException as ex: + self.fire_interface_event( + interface_id=interface_config.interface_id, + interface_event_type=InterfaceEventType.PROXY, + data={EventKey.AVAILABLE: False}, + ) + + _LOGGER.warning( + "CREATE_CLIENT failed: No connection to interface %s [%s]", + interface_config.interface_id, + reduce_args(args=ex.args), + ) + async def _init_clients(self) -> None: """Init clients of control unit, and start connection checker.""" for client in self._clients.values(): + if client.interface not in self.system_information.available_interfaces: + _LOGGER.debug( + "INIT_CLIENTS failed: Interface: %s is not available for backend %s", + client.interface, + self.name, + ) + del self._clients[client.interface_id] + continue if await client.proxy_init() == ProxyInitState.INIT_SUCCESS: - _LOGGER.debug("INIT_CLIENTS: client for %s initialized", client.interface_id) + _LOGGER.debug( + "INIT_CLIENTS: client %s initialized for %s", client.interface_id, self.name + ) async def _de_init_clients(self) -> None: """De-init clients.""" @@ -634,13 +669,24 @@ def _stop_connection_checker(self) -> None: async def validate_config_and_get_system_information(self) -> SystemInformation: """Validate the central configuration.""" - if len(self._config.interface_configs) == 0: + if len(self._config.enabled_interface_configs) == 0: raise NoClientsException("validate_config: No clients defined.") system_information = SystemInformation() - for interface_config in self._config.interface_configs: - client = await hmcl.create_client(central=self, interface_config=interface_config) - if not system_information.serial: + for interface_config in self._config.enabled_interface_configs: + try: + client = await hmcl.create_client(central=self, interface_config=interface_config) + except BaseHomematicException as ex: + _LOGGER.error( + "VALIDATE_CONFIG_AND_GET_SYSTEM_INFORMATION failed for client %s: %s", + interface_config.interface, + reduce_args(args=ex.args), + ) + raise + if ( + client.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES + and not system_information.serial + ): system_information = client.system_information return system_information @@ -701,7 +747,7 @@ def _get_primary_client(self) -> hmcl.Client | None: """Return the client by interface_id or the first with a virtual remote.""" client: hmcl.Client | None = None for client in self._clients.values(): - if client.interface in (Interface.HMIP_RF, Interface.BIDCOS_RF) and client.available: + if client.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES and client.available: return client return client @@ -741,11 +787,15 @@ def has_client(self, interface_id: str) -> bool: return interface_id in self._clients @property - def has_clients(self) -> bool: + def has_all_enabled_clients(self) -> bool: """Check if all configured clients exists in central.""" count_client = len(self._clients) - count_client_defined = len(self._config.interface_configs) - return count_client > 0 and count_client == count_client_defined + return count_client > 0 and count_client == len(self._config.enabled_interface_configs) + + @property + def has_clients(self) -> bool: + """Check if clients exists in central.""" + return len(self._clients) > 0 async def _load_caches(self) -> None: """Load files to caches.""" @@ -754,8 +804,12 @@ async def _load_caches(self) -> None: await self._paramset_descriptions.load() await self._device_details.load() await self._data_cache.load() - except orjson.JSONDecodeError: # pragma: no cover - _LOGGER.warning("LOAD_CACHES failed: Unable to load caches for %s", self.name) + except orjson.JSONDecodeError as ex: # pragma: no cover + _LOGGER.warning( + "LOAD_CACHES failed: Unable to load caches for %s: %s", + self.name, + reduce_args(args=ex.args), + ) await self.clear_caches() async def _create_devices(self, new_device_addresses: dict[str, set[str]]) -> None: @@ -1412,7 +1466,7 @@ async def _check_connection(self) -> None: self._central.name, ) try: - if not self._central.has_clients: + if not self._central.has_all_enabled_clients: _LOGGER.warning( "CHECK_CONNECTION failed: No clients exist. " "Trying to create clients for server %s", @@ -1487,7 +1541,7 @@ def __init__( self.host: Final = host self.include_internal_programs: Final = include_internal_programs self.include_internal_sysvars: Final = include_internal_sysvars - self.interface_configs: Final = interface_configs + self._interface_configs: Final = interface_configs self.json_port: Final = json_port self.listen_ip_addr: Final = listen_ip_addr self.listen_port: Final = listen_port @@ -1524,6 +1578,11 @@ def load_un_ignore(self) -> bool: """Return if un_ignore should be loaded.""" return self.start_direct is False + @property + def enabled_interface_configs(self) -> tuple[hmcl.InterfaceConfig, ...]: + """Return the interface configs.""" + return tuple(ic for ic in self._interface_configs if ic.enabled is True) + @property def use_caches(self) -> bool: """Return if caches should be used.""" @@ -1555,6 +1614,7 @@ def check_config(self) -> None: callback_host=self.callback_host, callback_port=self.callback_port, json_port=self.json_port, + interface_configs=self._interface_configs, ): failures = ", ".join(config_failures) raise HaHomematicConfigException(failures) diff --git a/hahomematic/client/__init__.py b/hahomematic/client/__init__.py index be68ae98..c1a16009 100644 --- a/hahomematic/client/__init__.py +++ b/hahomematic/client/__init__.py @@ -186,9 +186,8 @@ async def proxy_init(self) -> ProxyInitState: """Init the proxy has to tell the CCU / Homegear where to send the events.""" if not self.supports_xml_rpc: - device_descriptions = await self.list_devices() await self.central.add_new_devices( - interface_id=self.interface_id, device_descriptions=device_descriptions + interface_id=self.interface_id, device_descriptions=await self.list_devices() ) return ProxyInitState.INIT_SUCCESS try: @@ -1647,6 +1646,7 @@ def __init__( self.port: Final = port self.remote_path: Final = remote_path self._init_validate() + self._enabled: bool = True def _init_validate(self) -> None: """Validate the client_config.""" @@ -1657,6 +1657,15 @@ def _init_validate(self) -> None: ", ".join(list(Interface)), ) + @property + def enabled(self) -> bool: + """Return if the interface config is enabled.""" + return self._enabled + + def disable(self) -> None: + """Disable the interface config.""" + self._enabled = False + async def create_client( central: hmcu.CentralUnit, diff --git a/hahomematic/client/xml_rpc.py b/hahomematic/client/xml_rpc.py index c75107db..1f0d149f 100644 --- a/hahomematic/client/xml_rpc.py +++ b/hahomematic/client/xml_rpc.py @@ -163,7 +163,7 @@ async def __async_request(self, *args, **kwargs): # type: ignore[no-untyped-def if not self._connection_state.has_issue(issuer=self, iid=self.interface_id): if per.errmsg == "Unauthorized": raise AuthFailure(per) from per - raise NoConnectionException(per) from per + raise NoConnectionException(per.errmsg) from per except Exception as ex: raise ClientException(ex) from ex diff --git a/hahomematic/const.py b/hahomematic/const.py index bd0938f8..00eb8c7f 100644 --- a/hahomematic/const.py +++ b/hahomematic/const.py @@ -526,6 +526,8 @@ class ParameterType(StrEnum): DataPointCategory.UPDATE, ) +PRIMARY_CLIENT_CANDIDATE_INTERFACES: Final = (Interface.HMIP_RF, Interface.BIDCOS_RF) + RELEVANT_INIT_PARAMETERS: Final[tuple[Parameter, ...]] = ( Parameter.CONFIG_PENDING, Parameter.STICKY_UN_REACH, diff --git a/hahomematic/support.py b/hahomematic/support.py index 9bdb415b..4f551f20 100644 --- a/hahomematic/support.py +++ b/hahomematic/support.py @@ -3,7 +3,7 @@ from __future__ import annotations import base64 -from collections.abc import Collection +from collections.abc import Collection, Set as AbstractSet import contextlib from dataclasses import dataclass from datetime import datetime @@ -18,6 +18,7 @@ import sys from typing import Any, Final +from hahomematic import client as hmcl from hahomematic.config import TIMEOUT from hahomematic.const import ( ALLOWED_HOSTNAME_PATTERN, @@ -32,6 +33,7 @@ INIT_DATETIME, MAX_CACHE_AGE, NO_CACHE_ENTRY, + PRIMARY_CLIENT_CANDIDATE_INTERFACES, CommandRxMode, ParamsetKey, RxMode, @@ -83,6 +85,7 @@ def check_config( callback_host: str | None, callback_port: int | None, json_port: int | None, + interface_configs: AbstractSet[hmcl.InterfaceConfig] | None = None, ) -> list[str]: """Check config. Throws BaseHomematicException on failure.""" config_failures: list[str] = [] @@ -109,10 +112,22 @@ def check_config( config_failures.append("Invalid callback port") if json_port and not is_port(port=json_port): config_failures.append("Invalid json port") + if interface_configs and not has_primary_client(interface_configs=interface_configs): + config_failures.append( + f"No primary interface ({", ".join(PRIMARY_CLIENT_CANDIDATE_INTERFACES)}) defined" + ) return config_failures +def has_primary_client(interface_configs: AbstractSet[hmcl.InterfaceConfig]) -> bool: + """Check if all configured clients exists in central.""" + for interface_config in interface_configs: + if interface_config.interface in PRIMARY_CLIENT_CANDIDATE_INTERFACES: + return True + return False + + def delete_file(folder: str, file_name: str) -> None: """Delete the file.""" file_path = os.path.join(folder, file_name) diff --git a/requirements.txt b/requirements.txt index 9ec82a61..3625ed48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -aiohttp>=3.10.10 +aiohttp>=3.11.2 orjson>=3.10.11 python-slugify>=8.0.4 voluptuous>=0.15.2 diff --git a/requirements_test.txt b/requirements_test.txt index ab37a97e..45077281 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ -r requirements.txt -r requirements_test_pre_commit.txt -coverage==7.6.5 +coverage==7.6.7 freezegun==1.5.1 mypy-dev==1.14.0a2 pip==24.3.1 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index b9cdca78..e7bf8d10 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,4 +1,4 @@ bandit==1.7.10 codespell==2.3.0 -ruff==0.7.3 +ruff==0.7.4 yamllint==1.35.1 diff --git a/tests/test_central.py b/tests/test_central.py index b4c563c2..6bfc4bb8 100644 --- a/tests/test_central.py +++ b/tests/test_central.py @@ -865,7 +865,7 @@ async def test_central_without_interface_config(factory: helper.Factory) -> None """Test central other methods.""" central = await factory.get_raw_central(interface_config=None) try: - assert central.has_clients is False + assert central.has_all_enabled_clients is False with pytest.raises(NoClientsException): await central.validate_config_and_get_system_information() @@ -874,7 +874,7 @@ async def test_central_without_interface_config(factory: helper.Factory) -> None central.get_client("NOT_A_VALID_INTERFACE_ID") await central.start() - assert central.has_clients is False + assert central.has_all_enabled_clients is False assert central.available is True assert central.system_information.serial is None