From 145c0f4a2c6d192363e254e4edb669e3e2824a68 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 20 Dec 2022 17:48:45 +0100 Subject: [PATCH] Handle updates sent via mDNS (#96) --- devolo_plc_api/device.py | 53 ++++++++++++++++++++++++---------------- docs/CHANGELOG.md | 6 +++++ tests/test_device.py | 21 +--------------- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index 206a233..8881ea2 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -41,9 +41,6 @@ def __init__( deviceapi: dict[str, Any] | None = None, zeroconf_instance: AsyncZeroconf | Zeroconf | None = None, ) -> None: - self.firmware_date = date.fromtimestamp(0) - self.firmware_version = "" - self.hostname = "" self.ip = ip self.mac = "" self.mt_number = 0 @@ -54,6 +51,7 @@ def __init__( self.device: DeviceApi | None = None self.plcnet: PlcNetApi | None = None + self._browser: dict[str, AsyncServiceBrowser] = {} self._connected = False self._info: dict[str, dict[str, Any]] = { PLCNETAPI: plcnetapi or EMPTY_INFO, @@ -87,6 +85,25 @@ def __enter__(self) -> Device: def __exit__(self, exc_type: type | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None: self.disconnect() + @property + def firmware_date(self) -> date: + """Date the firmware was built.""" + if DEVICEAPI in self._info: + return date.fromisoformat(self._info[DEVICEAPI]["properties"].get("FirmwareDate", "1970-01-01")[:10]) + return date.fromtimestamp(0) + + @property + def firmware_version(self) -> str: + if DEVICEAPI in self._info: + return self._info[DEVICEAPI]["properties"].get("FirmwareVersion", "") + return "" + + @property + def hostname(self) -> str: + if DEVICEAPI in self._info: + return self._info[DEVICEAPI].get("hostname", "") + return "" + @property def password(self) -> str: """The currently set device password.""" @@ -124,11 +141,14 @@ def connect(self) -> None: async def async_disconnect(self) -> None: """Disconnect from a device asynchronous.""" - if not self._zeroconf_instance: - await self._zeroconf.async_close() - if not self._session_instance: - await self._session.aclose() - self._connected = False + if self._connected: + for browser in self._browser.values(): + await browser.async_cancel() + if not self._zeroconf_instance: + await self._zeroconf.async_close() + if not self._session_instance: + await self._session.aclose() + self._connected = False def disconnect(self) -> None: """Disconnect from a device synchronous.""" @@ -141,11 +161,6 @@ async def _get_device_info(self) -> None: if not self._info[service_type]["properties"]: await self._retry_zeroconf_info(service_type=service_type) if self._info[service_type]["properties"]: - self.firmware_date = date.fromisoformat( - self._info[service_type]["properties"].get("FirmwareDate", "1970-01-01")[:10] - ) - self.firmware_version = self._info[service_type]["properties"].get("FirmwareVersion", "") - self.hostname = self._info[service_type].get("hostname", "") self.mt_number = self._info[service_type]["properties"].get("MT", 0) self.product = self._info[service_type]["properties"].get("Product", "") self.serial_number = self._info[service_type]["properties"]["SN"] @@ -167,14 +182,11 @@ async def _get_plcnet_info(self) -> None: async def _get_zeroconf_info(self, service_type: str) -> None: """Browse for the desired mDNS service types and query them.""" - if self._info[service_type]["properties"]: - return # No need to continue, if device info already exist - self._logger.debug("Browsing for %s", service_type) counter = 0 addr = None if self._multicast else self.ip question_type = DNSQuestionType.QM if self._multicast else DNSQuestionType.QU - browser = AsyncServiceBrowser( + self._browser[service_type] = AsyncServiceBrowser( zeroconf=self._zeroconf.zeroconf, type_=service_type, handlers=[self._state_change], @@ -184,7 +196,6 @@ async def _get_zeroconf_info(self, service_type: str) -> None: while not self._info[service_type]["properties"] and counter < 300: counter += 1 await asyncio.sleep(0.01) - await browser.async_cancel() async def _retry_zeroconf_info(self, service_type: str) -> None: """Retry getting the zeroconf info using multicast.""" @@ -196,9 +207,9 @@ async def _retry_zeroconf_info(self, service_type: str) -> None: def _state_change(self, zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange) -> None: """Evaluate the query result.""" - if state_change is not ServiceStateChange.Added: + if state_change == ServiceStateChange.Removed: return - asyncio.ensure_future(self._get_service_info(zeroconf, service_type, name)) + asyncio.create_task(self._get_service_info(zeroconf, service_type, name)) async def _get_service_info(self, zeroconf: Zeroconf, service_type: str, name: str) -> None: """Get service information, if IP matches.""" @@ -214,7 +225,7 @@ async def _get_service_info(self, zeroconf: Zeroconf, service_type: str, name: s ): return # No need to continue, if there are no relevant service information - self._logger.debug("Adding service info of %s to %s", service_type, service_info.server_key) + self._logger.debug("Updating service info of %s for %s", service_type, service_info.server_key) self._info[service_type] = self.info_from_service(service_info) @staticmethod diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8477094..6a6cb99 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.9.0] - 2022/12/20 + +### Added + +- Handle updates sent via mDNS + ## [v0.8.1] - 2022/10/18 ### Fixed diff --git a/tests/test_device.py b/tests/test_device.py index 332e99f..8c51c21 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -173,14 +173,6 @@ async def test__get_zeroconf_info_timeout(self, mock_device: Device): await mock_device._get_zeroconf_info("_http._tcp.local.") assert sleep.call_count == 300 - @pytest.mark.asyncio - async def test__get_zeroconf_info(self, test_data: TestData, mock_service_browser: MockServiceBrowser): - """Test that after getting zeroconf information browsing is stopped.""" - with patch("devolo_plc_api.device.Device._state_change", state_change): - mock_device = Device(ip=test_data.ip, plcnetapi=None, deviceapi=test_data.device_info[DEVICEAPI]) - await mock_device.async_connect() - assert mock_service_browser.async_cancel.call_count == 1 - @pytest.mark.asyncio async def test__get_zeroconf_info_device_info_exists(self, mock_device: Device, mock_service_browser: MockServiceBrowser): """Test early return if information already exist.""" @@ -198,17 +190,6 @@ async def test__state_change_no_service_info(self, test_data: TestData): await mock_device.async_connect() assert ifs.call_count == 0 - @pytest.mark.asyncio - @pytest.mark.usefixtures("mock_service_browser") - async def test__state_change_added(self, test_data: TestData): - """Test that service information are processed on state change to added.""" - with patch("devolo_plc_api.device.Device._get_service_info") as gsi, patch( - "devolo_plc_api.device.Device._retry_zeroconf_info" - ), patch("asyncio.sleep"): - mock_device = Device(ip=test_data.ip, plcnetapi=None, deviceapi=test_data.device_info[DEVICEAPI]) - await mock_device.async_connect() - assert gsi.call_count == 1 - @pytest.mark.asyncio # pylint: disable=protected-access async def test__state_change_removed(self, mock_device: Device): @@ -228,7 +209,7 @@ async def test__get_service_info(self, test_data: TestData): ), patch("devolo_plc_api.device.AsyncServiceInfo.async_request") as ar: mock_device = Device(ip=test_data.ip, plcnetapi=None, deviceapi=test_data.device_info[DEVICEAPI]) await mock_device.async_connect() - assert ar.call_count == 1 + assert ar.call_count == 2 assert mock_device.mac == test_data.device_info[PLCNETAPI]["properties"]["PlcMacAddress"] def test_info_from_service_no_address(self, mock_device: Device):