"""Module to discover Velux KLF200 devices on the network.""" import asyncio from asyncio import Event, Future, Task from dataclasses import dataclass from typing import Any, Set from zeroconf import IPVersion from zeroconf.asyncio import ( AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf) SERVICE_STARTS_WITH: str = "VELUX_KLF_LAN" SERVICE_TYPE: str = "_http._tcp.local." @dataclass class VeluxHost(): """Class to store Velux KLF200 host information.""" hostname: str ip_address: str def sanitize_hostname(hostname: str) -> str: """Sanitize KLF200 hostname.""" hostname = hostname.upper() hostname = hostname.replace("._HTTP._TCP.LOCAL.", "") # KLF report it's hostname within Zeroconf ServiceInfo with the "LAN_" prefix, on DHCP requests without hostname = hostname.replace("LAN_", "") return hostname class VeluxDiscovery(): """Class to discover Velux KLF200 devices on the network.""" hosts: list[VeluxHost] = [] infos: list[AsyncServiceInfo | None] = [] def __init__(self, zeroconf: AsyncZeroconf,) -> None: """Initialize VeluxDiscovery object.""" self.zc: AsyncZeroconf = zeroconf async def _async_discover_hosts(self, min_wait_time: float, expected_hosts: int | None) -> None: """Listen for zeroconf ServiceInfo.""" self.hosts.clear() service_names: list[str] = [] tasks: Set[Task] = set() got_host: Event = Event() def add_info_and_host(fut: Future) -> None: info: AsyncServiceInfo = fut.result() self.infos.append(info) hostname = sanitize_hostname(info.name) host = VeluxHost( hostname=hostname, ip_address=info.parsed_addresses(version=IPVersion.V4Only)[0], ) self.hosts.append(host) got_host.set() def handler(name: str, **kwargs: Any) -> None: # pylint: disable=W0613:unused-argument if name.startswith(SERVICE_STARTS_WITH): if name not in service_names: service_names.append(name) task = asyncio.create_task(self.zc.async_get_service_info(type_=SERVICE_TYPE, name=name)) tasks.add(task) task.add_done_callback(add_info_and_host) task.add_done_callback(tasks.remove) browser: AsyncServiceBrowser = AsyncServiceBrowser(self.zc.zeroconf, SERVICE_TYPE, handlers=[handler]) if expected_hosts: while len(self.hosts) < expected_hosts: await got_host.wait() got_host.clear() while not self.hosts: await asyncio.sleep(min_wait_time) await browser.async_cancel() if tasks: await asyncio.gather(*tasks) async def async_discover_hosts( self, timeout: float = 10, min_wait_time: float = 3, expected_hosts: int | None = None ) -> bool: """Return true if Velux KLF200 devices found on the network. This function creates a zeroconf AsyncServiceBrowser and waits min_wait_time (seconds) for ServiceInfos from hosts. Some devices may take some time to respond (i.e. if they currently have a high CPU load). If one or more Hosts are found, the function cancels the ServiceBrowser and returns true. If expected_hosts is set, the function ignores min_wait_time and returns true once expected_hosts are found. If timeout (seconds) is exceeded, the function returns false. """ try: async with asyncio.timeout(timeout): await self._async_discover_hosts(min_wait_time, expected_hosts) except TimeoutError: return False return True