From 0c58c5b45b19768c909aa4ac94e0128bb6204cee Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 28 Aug 2020 09:33:48 +0200 Subject: [PATCH 01/13] Add Dependabot We rely on packages that recommend version pinning. Dependabot will hopefully tell us, if new versions can be reviewed. --- .github/dependabot.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4eaf559 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" # Location of package manifests + schedule: + interval: "daily" + target-branch: "development" From 13bd1fbf567e8d59056f8b6ad3b5a18bf6112aa3 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 30 Nov 2020 16:45:09 +0100 Subject: [PATCH 02/13] Set password via attribute --- devolo_plc_api/clients/protobuf.py | 7 ++++--- devolo_plc_api/device.py | 18 ++++++++++++++---- devolo_plc_api/device_api/deviceapi.py | 12 ++++++------ devolo_plc_api/plcnet_api/plcnetapi.py | 6 ++++-- docs/CHANGELOG.md | 6 ++++++ example_async.py | 4 +++- example_sync.py | 4 +++- tests/fixtures/device_api.py | 2 +- tests/stubs/protobuf.py | 3 ++- 9 files changed, 43 insertions(+), 19 deletions(-) diff --git a/devolo_plc_api/clients/protobuf.py b/devolo_plc_api/clients/protobuf.py index 64c17e3..9decc99 100644 --- a/devolo_plc_api/clients/protobuf.py +++ b/devolo_plc_api/clients/protobuf.py @@ -21,8 +21,9 @@ def __init__(self): self._loop = asyncio.get_running_loop() self._logger = logging.getLogger(self.__class__.__name__) + self.password: str + self._ip: str - self._password: str self._path: str self._port: int self._session: AsyncClient @@ -50,7 +51,7 @@ async def _async_get(self, sub_url: str, timeout: float = TIMEOUT) -> Response: url = f"{self.url}{sub_url}" self._logger.debug("Getting from %s", url) try: - return await self._session.get(url, auth=DigestAuth(self._user, self._password), timeout=timeout) + return await self._session.get(url, auth=DigestAuth(self._user, self.password), timeout=timeout) except TypeError: raise DevicePasswordProtected("The used password is wrong.") from None @@ -59,7 +60,7 @@ async def _async_post(self, sub_url: str, content: bytes, timeout: float = TIMEO url = f"{self.url}{sub_url}" self._logger.debug("Posting to %s", url) try: - return await self._session.post(url, auth=DigestAuth(self._user, self._password), content=content, timeout=timeout) + return await self._session.post(url, auth=DigestAuth(self._user, self.password), content=content, timeout=timeout) except TypeError: raise DevicePasswordProtected("The used password is wrong.") from None diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index c429b8a..be5cd54 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -18,17 +18,15 @@ class Device: Representing object for your devolo PLC device. It stores all properties and functionalities discovered during setup. :param ip: IP address of the device to communicate with. - :param password: Password of the Web-UI, if it is protected :param zeroconf_instance: Zeroconf instance to be potentially reused. """ - def __init__(self, ip: str, password: Optional[str] = None, zeroconf_instance: Optional[Zeroconf] = None): + def __init__(self, ip: str, zeroconf_instance: Optional[Zeroconf] = None): self.firmware_date = date.fromtimestamp(0) self.firmware_version = "" self.ip = ip self.mac = "" self.mt_number = 0 - self.password = password self.product = "" self.technology = "" self.serial_number = 0 @@ -41,6 +39,7 @@ def __init__(self, ip: str, password: Optional[str] = None, zeroconf_instance: O "_dvl-deviceapi._tcp.local.": {}, } self._logger = logging.getLogger(self.__class__.__name__) + self._password = "" self._zeroconf_instance = zeroconf_instance self._loop: asyncio.AbstractEventLoop @@ -66,6 +65,17 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._loop.run_until_complete(self.__aexit__(exc_type, exc_val, exc_tb)) self._loop.close() + @property + def password(self): + """ The currently set device password. """ + return self._password + + @password.setter + def password(self, password): + """ Change the currently set device password. """ + self._password = password + self.device.password = password + async def _get_device_info(self): """ Get information from the devolo Device API. """ service_type = "_dvl-deviceapi._tcp.local." @@ -80,7 +90,7 @@ async def _get_device_info(self): self.mt_number = self._info[service_type].get("MT", 0) self.product = self._info[service_type].get("Product", "") - self.device = DeviceApi(ip=self.ip, session=self._session, info=self._info[service_type], password=self.password) + self.device = DeviceApi(ip=self.ip, session=self._session, info=self._info[service_type]) async def _get_plcnet_info(self): """ Get information from the devolo PlcNet API. """ diff --git a/devolo_plc_api/device_api/deviceapi.py b/devolo_plc_api/device_api/deviceapi.py index da3d930..e7705c0 100644 --- a/devolo_plc_api/device_api/deviceapi.py +++ b/devolo_plc_api/device_api/deviceapi.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Optional +from typing import Callable, Dict from httpx import AsyncClient @@ -16,21 +16,21 @@ class DeviceApi(Protobuf): :param ip: IP address of the device to communicate with :param session: HTTP client session :param info: Information collected from the mDNS query - :param password: Password of the device """ - def __init__(self, ip: str, session: AsyncClient, info: Dict, password: Optional[str]): + def __init__(self, ip: str, session: AsyncClient, info: Dict): super().__init__() + self._ip = ip + self._path = info['Path'] self._port = info['Port'] self._session = session - self._path = info['Path'] - self._version = info['Version'] self._user = "devolo" - self._password = password or "" + self._version = info['Version'] features = info.get("Features", "") self.features = features.split(",") if features else ['reset', 'update', 'led', 'intmtg'] + self.password = "" def _feature(feature: str): # type: ignore # pylint: disable=no-self-argument """ Decorator to filter unsupported features before querying the device. """ diff --git a/devolo_plc_api/plcnet_api/plcnetapi.py b/devolo_plc_api/plcnet_api/plcnetapi.py index c6c07af..d42dd01 100644 --- a/devolo_plc_api/plcnet_api/plcnetapi.py +++ b/devolo_plc_api/plcnet_api/plcnetapi.py @@ -19,14 +19,16 @@ class PlcNetApi(Protobuf): def __init__(self, ip: str, session: AsyncClient, info: Dict): super().__init__() + self._ip = ip self._mac = info['PlcMacAddress'] self._path = info['Path'] self._port = info['Port'] self._session = session - self._version = info['Version'] self._user = "" # PLC API is not password protected. - self._password = "" # PLC API is not password protected. + self._version = info['Version'] + + self.password = "" # PLC API is not password protected. async def async_get_network_overview(self) -> dict: """ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e1ee690..fa500e2 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). +## [unreleased] + +### Changed + +- **BREAKING**: The device password must be specified by setting an attribute now + ## [v0.2.0] - 14.09.2020 ### Added diff --git a/example_async.py b/example_async.py index e2ad754..8097dc6 100644 --- a/example_async.py +++ b/example_async.py @@ -11,7 +11,9 @@ async def run(): - async with Device(ip=IP, password=PASSWORD) as dpa: + async with Device(ip=IP) as dpa: + # Set the password + dpa.password = PASSWORD # Get LED settings of the device. The state might be LED_ON or LED_OFF. # {'state': 'LED_ON'} diff --git a/example_sync.py b/example_sync.py index c21139f..35035ea 100644 --- a/example_sync.py +++ b/example_sync.py @@ -9,7 +9,9 @@ def run(): - with Device(ip=IP, password=PASSWORD) as dpa: + with Device(ip=IP) as dpa: + # Set the password + dpa.password = PASSWORD # Get LED settings of the device. The state might be LED_ON or LED_OFF. # {'state': 'LED_ON'} diff --git a/tests/fixtures/device_api.py b/tests/fixtures/device_api.py index 5cc1ecd..d0bd45f 100644 --- a/tests/fixtures/device_api.py +++ b/tests/fixtures/device_api.py @@ -19,7 +19,7 @@ def device_api(request, feature): patch("asyncio.get_running_loop", asyncio.new_event_loop): asyncio.new_event_loop() request.cls.device_info["_dvl-deviceapi._tcp.local."]["Features"] = feature - yield DeviceApi(request.cls.ip, AsyncClient(), request.cls.device_info["_dvl-deviceapi._tcp.local."], "password") + yield DeviceApi(request.cls.ip, AsyncClient(), request.cls.device_info["_dvl-deviceapi._tcp.local."]) @pytest.fixture() diff --git a/tests/stubs/protobuf.py b/tests/stubs/protobuf.py index d96b1d6..3493e65 100644 --- a/tests/stubs/protobuf.py +++ b/tests/stubs/protobuf.py @@ -20,4 +20,5 @@ def __init__(self): self._path = test_data["device_info"]["_dvl-plcnetapi._tcp.local."]["Path"] self._version = test_data["device_info"]["_dvl-plcnetapi._tcp.local."]["Version"] self._user = "user" - self._password = "password" + + self.password = "password" From 3abbae223ebb2d5a33f550c5c7da800a14f5765d Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 30 Nov 2020 20:10:07 +0100 Subject: [PATCH 03/13] Reuse API data if possible --- devolo_plc_api/device.py | 46 +++++++++++++++++--------- devolo_plc_api/device_api/deviceapi.py | 10 +++--- devolo_plc_api/plcnet_api/plcnetapi.py | 8 ++--- docs/CHANGELOG.md | 4 +++ tests/fixtures/device.py | 4 +-- tests/fixtures/device_api.py | 2 +- tests/mocks/mock_devices.py | 10 ++++++ tests/mocks/mock_zeroconf.py | 6 ++++ tests/stubs/protobuf.py | 4 +-- tests/test_data.json | 32 ++++++++++-------- tests/test_device.py | 34 ++++++++++++------- tests/test_profobuf.py | 4 +-- 12 files changed, 106 insertions(+), 58 deletions(-) create mode 100644 tests/mocks/mock_devices.py diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index be5cd54..088e25f 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -12,16 +12,26 @@ from .exceptions.device import DeviceNotFound from .plcnet_api.plcnetapi import PlcNetApi +EMPTY_INFO: Dict = { + "properties": {} +} + class Device: """ Representing object for your devolo PLC device. It stores all properties and functionalities discovered during setup. :param ip: IP address of the device to communicate with. + :param plcnetapi: Reuse externally gathered data for the plcnet API + :param deviceapi: Reuse externally gathered data for the device API :param zeroconf_instance: Zeroconf instance to be potentially reused. """ - def __init__(self, ip: str, zeroconf_instance: Optional[Zeroconf] = None): + def __init__(self, + ip: str, + plcnetapi: Optional[Dict] = None, + deviceapi: Optional[Dict] = None, + zeroconf_instance: Optional[Zeroconf] = None): self.firmware_date = date.fromtimestamp(0) self.firmware_version = "" self.ip = ip @@ -35,8 +45,8 @@ def __init__(self, ip: str, zeroconf_instance: Optional[Zeroconf] = None): self.plcnet = None self._info: Dict = { - "_dvl-plcnetapi._tcp.local.": {}, - "_dvl-deviceapi._tcp.local.": {}, + "_dvl-plcnetapi._tcp.local.": plcnetapi or EMPTY_INFO, + "_dvl-deviceapi._tcp.local.": deviceapi or EMPTY_INFO, } self._logger = logging.getLogger(self.__class__.__name__) self._password = "" @@ -66,15 +76,16 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._loop.close() @property - def password(self): + def password(self) -> str: """ The currently set device password. """ return self._password @password.setter - def password(self, password): + def password(self, password: str): """ Change the currently set device password. """ self._password = password - self.device.password = password + if self.device: + self.device.password = password async def _get_device_info(self): """ Get information from the devolo Device API. """ @@ -84,11 +95,11 @@ async def _get_device_info(self): except asyncio.TimeoutError: return - self.firmware_date = date.fromisoformat(self._info[service_type].get("FirmwareDate", "1970-01-01")) - self.firmware_version = self._info[service_type].get("FirmwareVersion", "") - self.serial_number = self._info[service_type].get("SN", 0) - self.mt_number = self._info[service_type].get("MT", 0) - self.product = self._info[service_type].get("Product", "") + self.firmware_date = date.fromisoformat(self._info[service_type]["properties"].get("FirmwareDate", "1970-01-01")) + self.firmware_version = self._info[service_type]["properties"].get("FirmwareVersion", "") + self.serial_number = self._info[service_type]["properties"].get("SN", 0) + self.mt_number = self._info[service_type]["properties"].get("MT", 0) + self.product = self._info[service_type]["properties"].get("Product", "") self.device = DeviceApi(ip=self.ip, session=self._session, info=self._info[service_type]) @@ -100,16 +111,19 @@ async def _get_plcnet_info(self): except asyncio.TimeoutError: return - self.mac = self._info[service_type]['PlcMacAddress'] - self.technology = self._info[service_type].get("PlcTechnology", "") + self.mac = self._info[service_type]["properties"]["PlcMacAddress"] + self.technology = self._info[service_type]["properties"].get("PlcTechnology", "") self.plcnet = PlcNetApi(ip=self.ip, session=self._session, info=self._info[service_type]) async def _get_zeroconf_info(self, service_type: str): """ Browse for the desired mDNS service types and query them. """ + if self._info[service_type]["properties"]: + return # Early return, if device info already exist + self._logger.debug("Browsing for %s", service_type) browser = ServiceBrowser(self._zeroconf, service_type, [self._state_change]) - while not self._info[service_type]: + while not self._info[service_type]["properties"]: await asyncio.sleep(0.1) browser.cancel() @@ -128,7 +142,7 @@ def _state_change(self, zeroconf: Zeroconf, service_type: str, name: str, state_ self.ip in [socket.inet_ntoa(address) for address in service_info.addresses]: self._logger.debug("Adding service info of %s", service_type) - self._info[service_type]['Port'] = service_info.port + self._info[service_type]["port"] = service_info.port # The answer is a byte string, that concatenates key-value pairs with their length as two byte hex value. total_length = len(service_info.text) @@ -136,5 +150,5 @@ def _state_change(self, zeroconf: Zeroconf, service_type: str, name: str, state_ while offset < total_length: parsed_length, = struct.unpack_from("!B", service_info.text, offset) key_value = service_info.text[offset + 1:offset + 1 + parsed_length].decode("UTF-8").split("=") - self._info[service_type][key_value[0]] = key_value[1] + self._info[service_type]["properties"][key_value[0]] = key_value[1] offset += parsed_length + 1 diff --git a/devolo_plc_api/device_api/deviceapi.py b/devolo_plc_api/device_api/deviceapi.py index e7705c0..9c834e5 100644 --- a/devolo_plc_api/device_api/deviceapi.py +++ b/devolo_plc_api/device_api/deviceapi.py @@ -22,14 +22,14 @@ def __init__(self, ip: str, session: AsyncClient, info: Dict): super().__init__() self._ip = ip - self._path = info['Path'] - self._port = info['Port'] + self._path = info["properties"]["Path"] + self._port = info["port"] self._session = session self._user = "devolo" - self._version = info['Version'] + self._version = info["properties"]["Version"] - features = info.get("Features", "") - self.features = features.split(",") if features else ['reset', 'update', 'led', 'intmtg'] + features = info["properties"].get("Features", "") + self.features = features.split(",") if features else ["reset", "update", "led", "intmtg"] self.password = "" def _feature(feature: str): # type: ignore # pylint: disable=no-self-argument diff --git a/devolo_plc_api/plcnet_api/plcnetapi.py b/devolo_plc_api/plcnet_api/plcnetapi.py index d42dd01..1633638 100644 --- a/devolo_plc_api/plcnet_api/plcnetapi.py +++ b/devolo_plc_api/plcnet_api/plcnetapi.py @@ -21,12 +21,12 @@ def __init__(self, ip: str, session: AsyncClient, info: Dict): super().__init__() self._ip = ip - self._mac = info['PlcMacAddress'] - self._path = info['Path'] - self._port = info['Port'] + self._mac = info["properties"]["PlcMacAddress"] + self._path = info["properties"]["Path"] + self._port = info["port"] self._session = session self._user = "" # PLC API is not password protected. - self._version = info['Version'] + self._version = info["properties"]["Version"] self.password = "" # PLC API is not password protected. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fa500e2..82361da 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [unreleased] +### Added + +- If API data is discovered externally, it can be reused + ### Changed - **BREAKING**: The device password must be specified by setting an attribute now diff --git a/tests/fixtures/device.py b/tests/fixtures/device.py index c7f9ab8..8f71a0b 100644 --- a/tests/fixtures/device.py +++ b/tests/fixtures/device.py @@ -5,7 +5,7 @@ from devolo_plc_api.device import Device -from ..mocks.mock_zeroconf import MockZeroconf +from ..mocks.mock_zeroconf import MockServiceBrowser, MockZeroconf @pytest.fixture() @@ -19,7 +19,7 @@ def mock_device(request): @pytest.fixture() def mock_service_browser(mocker): - mocker.patch("zeroconf.ServiceBrowser.__init__", return_value=None) + mocker.patch("zeroconf.ServiceBrowser.__init__", MockServiceBrowser.__init__) mocker.patch("zeroconf.ServiceBrowser.cancel", return_value=None) diff --git a/tests/fixtures/device_api.py b/tests/fixtures/device_api.py index d0bd45f..69cb0f0 100644 --- a/tests/fixtures/device_api.py +++ b/tests/fixtures/device_api.py @@ -18,7 +18,7 @@ def device_api(request, feature): patch("devolo_plc_api.clients.protobuf.Protobuf._async_post", new=AsyncMock(return_value=Response)), \ patch("asyncio.get_running_loop", asyncio.new_event_loop): asyncio.new_event_loop() - request.cls.device_info["_dvl-deviceapi._tcp.local."]["Features"] = feature + request.cls.device_info["_dvl-deviceapi._tcp.local."]["properties"]["Features"] = feature yield DeviceApi(request.cls.ip, AsyncClient(), request.cls.device_info["_dvl-deviceapi._tcp.local."]) diff --git a/tests/mocks/mock_devices.py b/tests/mocks/mock_devices.py new file mode 100644 index 0000000..8aa26b0 --- /dev/null +++ b/tests/mocks/mock_devices.py @@ -0,0 +1,10 @@ +import json +import pathlib + +file = pathlib.Path(__file__).parent / ".." / "test_data.json" +with file.open("r") as fh: + test_data = json.load(fh) + + +def state_change(self): + self._info = test_data["device_info"] diff --git a/tests/mocks/mock_zeroconf.py b/tests/mocks/mock_zeroconf.py index 34857eb..6a583fc 100644 --- a/tests/mocks/mock_zeroconf.py +++ b/tests/mocks/mock_zeroconf.py @@ -16,3 +16,9 @@ def get_service_info(self, service_type, name): service_info.addresses = [socket.inet_aton(test_data['ip'])] service_info.text = b"\x09new=value" return service_info + + +class MockServiceBrowser: + + def __init__(self, zc, st, sc): + sc[0]() diff --git a/tests/stubs/protobuf.py b/tests/stubs/protobuf.py index 3493e65..8e203ba 100644 --- a/tests/stubs/protobuf.py +++ b/tests/stubs/protobuf.py @@ -17,8 +17,8 @@ def __init__(self): self._loop = asyncio.new_event_loop() self._ip = test_data["ip"] self._port = 14791 - self._path = test_data["device_info"]["_dvl-plcnetapi._tcp.local."]["Path"] - self._version = test_data["device_info"]["_dvl-plcnetapi._tcp.local."]["Version"] + self._path = test_data["device_info"]["_dvl-plcnetapi._tcp.local."]["properties"]["Path"] + self._version = test_data["device_info"]["_dvl-plcnetapi._tcp.local."]["properties"]["Version"] self._user = "user" self.password = "password" diff --git a/tests/test_data.json b/tests/test_data.json index 796683d..9b29edc 100644 --- a/tests/test_data.json +++ b/tests/test_data.json @@ -1,22 +1,26 @@ { "device_info": { "_dvl-deviceapi._tcp.local.": { - "FirmwareDate": "2020-06-29", - "FirmwareVersion": "5.5.1", - "SN": "1234567890123456", - "MT": "2730", - "Product": "dLAN pro 1200+ WiFi ac", - "Path": "1234567890abcdef", - "Port": 80, - "Version": "v0", - "Features": "wifi1" + "port": 80, + "properties": { + "FirmwareDate": "2020-06-29", + "FirmwareVersion": "5.5.1", + "SN": "1234567890123456", + "MT": "2730", + "Product": "dLAN pro 1200+ WiFi ac", + "Path": "1234567890abcdef", + "Version": "v0", + "Features": "wifi1" + } }, "_dvl-plcnetapi._tcp.local.": { - "Path": "1234567890abcdef", - "PlcMacAddress": "AABBCCDDEEFF", - "PlcTechnology": "hpav", - "Port": 80, - "Version": "v0" + "port": 80, + "properties": { + "Path": "1234567890abcdef", + "PlcMacAddress": "AABBCCDDEEFF", + "PlcTechnology": "hpav", + "Version": "v0" + } } }, "ip": "192.168.0.10" diff --git a/tests/test_device.py b/tests/test_device.py index 1ea5b0d..f373766 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -10,10 +10,13 @@ except ImportError: from asynctest import CoroutineMock as AsyncMock +from devolo_plc_api.device import EMPTY_INFO from devolo_plc_api.device_api.deviceapi import DeviceApi from devolo_plc_api.exceptions.device import DeviceNotFound from devolo_plc_api.plcnet_api.plcnetapi import PlcNetApi +from .mocks.mock_devices import state_change + class TestDevice: @@ -23,11 +26,11 @@ async def test__get_device_info(self, mock_device): with patch("devolo_plc_api.device.Device._get_zeroconf_info", new=AsyncMock()): device_info = self.device_info['_dvl-deviceapi._tcp.local.'] await mock_device._get_device_info() - assert mock_device.firmware_date == date.fromisoformat(device_info['FirmwareDate']) - assert mock_device.firmware_version == device_info['FirmwareVersion'] - assert mock_device.serial_number == device_info['SN'] - assert mock_device.mt_number == device_info['MT'] - assert mock_device.product == device_info['Product'] + assert mock_device.firmware_date == date.fromisoformat(device_info["properties"]["FirmwareDate"]) + assert mock_device.firmware_version == device_info["properties"]["FirmwareVersion"] + assert mock_device.serial_number == device_info["properties"]["SN"] + assert mock_device.mt_number == device_info["properties"]["MT"] + assert mock_device.product == device_info["properties"]["Product"] assert type(mock_device.device) == DeviceApi @pytest.mark.asyncio @@ -42,10 +45,10 @@ async def test__get_device_info_timeout(self, mock_device): @pytest.mark.usefixtures("mock_plcnet_api") async def test__get_plcnet_info(self, mock_device): with patch("devolo_plc_api.device.Device._get_zeroconf_info", new=AsyncMock()): - device_info = self.device_info['_dvl-plcnetapi._tcp.local.'] + device_info = self.device_info["_dvl-plcnetapi._tcp.local."] await mock_device._get_plcnet_info() - assert mock_device.mac == device_info['PlcMacAddress'] - assert mock_device.technology == device_info['PlcTechnology'] + assert mock_device.mac == device_info["properties"]["PlcMacAddress"] + assert mock_device.technology == device_info["properties"]["PlcTechnology"] assert type(mock_device.plcnet) == PlcNetApi @pytest.mark.asyncio @@ -59,11 +62,18 @@ async def test__get_plcnet_info_timeout(self, mock_device): @pytest.mark.asyncio @pytest.mark.usefixtures("mock_service_browser") async def test__get_zeroconf_info(self, mocker, mock_device): + with patch("devolo_plc_api.device.Device._state_change", state_change): + mock_device._info["_dvl-plcnetapi._tcp.local."] = EMPTY_INFO + spy_cancel = mocker.spy(ServiceBrowser, "cancel") + await mock_device._get_zeroconf_info("_dvl-plcnetapi._tcp.local.") + assert spy_cancel.call_count == 1 + + @pytest.mark.asyncio + @pytest.mark.usefixtures("mock_service_browser") + async def test__get_zeroconf_info_early_return(self, mocker, mock_device): spy_cancel = mocker.spy(ServiceBrowser, "cancel") - spy_sleep = mocker.spy(asyncio, "sleep") await mock_device._get_zeroconf_info("_dvl-plcnetapi._tcp.local.") - assert spy_cancel.call_count == 1 - assert spy_sleep.call_count == 0 + assert spy_cancel.call_count == 0 @pytest.mark.asyncio async def test__setup_device(self, mocker, mock_device): @@ -88,7 +98,7 @@ async def test__setup_device_not_found(self, mock_device): def test__state_change_added(self, mock_device, mock_zeroconf): service_type = "_dvl-plcnetapi._tcp.local." mock_device._state_change(mock_zeroconf, service_type, service_type, ServiceStateChange.Added) - assert mock_device._info[service_type]['new'] == "value" + assert mock_device._info[service_type]["properties"]["new"] == "value" @pytest.mark.asyncio def test__state_change_removed(self, mock_device, mock_zeroconf): diff --git a/tests/test_profobuf.py b/tests/test_profobuf.py index c509285..8869101 100644 --- a/tests/test_profobuf.py +++ b/tests/test_profobuf.py @@ -14,8 +14,8 @@ def test_attribute_error(self, mock_protobuf): def test_url(self, request, mock_protobuf): ip = request.cls.ip - path = request.cls.device_info['_dvl-plcnetapi._tcp.local.']['Path'] - version = request.cls.device_info['_dvl-plcnetapi._tcp.local.']['Version'] + path = request.cls.device_info["_dvl-plcnetapi._tcp.local."]["properties"]["Path"] + version = request.cls.device_info["_dvl-plcnetapi._tcp.local."]["properties"]["Version"] assert mock_protobuf.url == f"http://{ip}:14791/{path}/{version}/" @pytest.mark.asyncio From 5d026ed60d9b44d780894d7a72cced2f88f2f5c1 Mon Sep 17 00:00:00 2001 From: Markus Bong <2Fake1987@gmail.com> Date: Tue, 1 Dec 2020 07:19:48 +0100 Subject: [PATCH 04/13] make functions for calling without a context manger --- devolo_plc_api/device.py | 6 ++++++ example_async.py | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index af2fc49..fa5ddc0 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -40,6 +40,12 @@ def __init__(self, ip: str, password: Optional[str] = None, zeroconf_instance: O self._logger = logging.getLogger(self.__class__.__name__) self._zeroconf_instance = zeroconf_instance + async def connect(self): + return await self.__aenter__() + + async def disconnect(self): + await self.__aexit__() + async def __aenter__(self): self._session = httpx.AsyncClient() self._zeroconf = self._zeroconf_instance or Zeroconf() diff --git a/example_async.py b/example_async.py index 8b6e31b..22f1b05 100644 --- a/example_async.py +++ b/example_async.py @@ -121,6 +121,12 @@ async def run(): # Set the user device name. If the name was changed successfully, True is returned, otherwise False. print("success" if await dpa.plcnet.async_set_user_device_name(name="New name") else "failed") +async def without_context_manager(): + dpa = Device(ip=IP, password=PASSWORD) + await dpa.connect() + print(await dpa.device.async_get_led_setting()) if __name__ == "__main__": - asyncio.run(run()) + # asyncio.run(run()) + asyncio.run(without_context_manager()) + \ No newline at end of file From b2cda1e38e3412cff5a32063d93d1910eb555e47 Mon Sep 17 00:00:00 2001 From: Markus Bong <2Fake1987@gmail.com> Date: Tue, 1 Dec 2020 07:32:35 +0100 Subject: [PATCH 05/13] move code from aenter/aexit to connect/disconnect --- devolo_plc_api/device.py | 14 +++++++------- example_async.py | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index fa5ddc0..7f4eec1 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -41,23 +41,23 @@ def __init__(self, ip: str, password: Optional[str] = None, zeroconf_instance: O self._zeroconf_instance = zeroconf_instance async def connect(self): - return await self.__aenter__() - - async def disconnect(self): - await self.__aexit__() - - async def __aenter__(self): self._session = httpx.AsyncClient() self._zeroconf = self._zeroconf_instance or Zeroconf() loop = asyncio.get_running_loop() await loop.create_task(self._gather_apis()) return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def disconnect(self): if not self._zeroconf_instance: self._zeroconf.close() await self._session.aclose() + async def __aenter__(self): + return await self.connect() + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.disconnect() + def __enter__(self): self._session = httpx.Client() self._zeroconf = self._zeroconf_instance or Zeroconf() diff --git a/example_async.py b/example_async.py index 22f1b05..9544e23 100644 --- a/example_async.py +++ b/example_async.py @@ -125,6 +125,7 @@ async def without_context_manager(): dpa = Device(ip=IP, password=PASSWORD) await dpa.connect() print(await dpa.device.async_get_led_setting()) + await dpa.disconnect() if __name__ == "__main__": # asyncio.run(run()) From 4c0509ba40c857f9d26be695307365496c3087cc Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 1 Dec 2020 13:45:04 +0100 Subject: [PATCH 06/13] Fix CI --- devolo_plc_api/__init__.py | 43 ------------------------------ devolo_plc_api/network/__init__.py | 42 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 43 deletions(-) create mode 100644 devolo_plc_api/network/__init__.py diff --git a/devolo_plc_api/__init__.py b/devolo_plc_api/__init__.py index 09d2c2e..d3ec452 100644 --- a/devolo_plc_api/__init__.py +++ b/devolo_plc_api/__init__.py @@ -1,44 +1 @@ -import asyncio -import time - -from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf - -from .device import Device - __version__ = "0.2.0" - - -async def async_discover_network(): - devices = {} - - def add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): - """ Create a device object to each matching device. """ - if state_change is ServiceStateChange.Added: - service_info = Device.info_from_service(zeroconf.get_service_info(service_type, name)) - devices[service_info["properties"]["SN"]] = Device(ip=service_info["address"], - deviceapi=service_info, - zeroconf_instance=zeroconf) - - browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [add]) - await asyncio.sleep(3) - browser.cancel() - await asyncio.gather(*[device.async_connect() for device in devices.values()]) - return devices - - -def discover_network(): - devices = {} - - def add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): - """ Create a device object to each matching device. """ - if state_change is ServiceStateChange.Added: - service_info = Device.info_from_service(zeroconf.get_service_info(service_type, name)) - devices[service_info["properties"]["SN"]] = Device(ip=service_info["address"], - deviceapi=service_info, - zeroconf_instance=zeroconf) - - browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [add]) - time.sleep(3) - browser.cancel() - [device.connect() for device in devices.values()] - return devices diff --git a/devolo_plc_api/network/__init__.py b/devolo_plc_api/network/__init__.py new file mode 100644 index 0000000..e4f1c1c --- /dev/null +++ b/devolo_plc_api/network/__init__.py @@ -0,0 +1,42 @@ +import asyncio +import time + +from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf + +from ..device import Device + + +async def async_discover_network(): + devices = {} + + def add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): + """ Create a device object to each matching device. """ + if state_change is ServiceStateChange.Added: + service_info = Device.info_from_service(zeroconf.get_service_info(service_type, name)) + devices[service_info["properties"]["SN"]] = Device(ip=service_info["address"], + deviceapi=service_info, + zeroconf_instance=zeroconf) + + browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [add]) + await asyncio.sleep(3) + browser.cancel() + await asyncio.gather(*[device.async_connect() for device in devices.values()]) + return devices + + +def discover_network(): + devices = {} + + def add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): + """ Create a device object to each matching device. """ + if state_change is ServiceStateChange.Added: + service_info = Device.info_from_service(zeroconf.get_service_info(service_type, name)) + devices[service_info["properties"]["SN"]] = Device(ip=service_info["address"], + deviceapi=service_info, + zeroconf_instance=zeroconf) + + browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [add]) + time.sleep(3) + browser.cancel() + [device.connect() for device in devices.values()] + return devices From 3552c7ee73358e511a5c9b65af73ddbb711144df Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 1 Dec 2020 13:58:09 +0100 Subject: [PATCH 07/13] Ease querying a single device --- devolo_plc_api/device.py | 5 ++--- tests/mocks/mock_zeroconf.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index 9919d7d..abeb93f 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -148,7 +148,7 @@ async def _get_zeroconf_info(self, service_type: str): return # Early return, if device info already exist self._logger.debug("Browsing for %s", service_type) - browser = ServiceBrowser(self._zeroconf, service_type, [self._state_change]) + browser = ServiceBrowser(self._zeroconf, service_type, [self._state_change], addr=self.ip) while not self._info[service_type]["properties"]: await asyncio.sleep(0.1) browser.cancel() @@ -156,8 +156,7 @@ async def _get_zeroconf_info(self, service_type: str): def _state_change(self, zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): """ Evaluate the query result. """ service_info = zeroconf.get_service_info(service_type, name) - if service_info and state_change is ServiceStateChange.Added and \ - self.ip in [socket.inet_ntoa(address) for address in service_info.addresses]: + if state_change is ServiceStateChange.Added: self._logger.debug("Adding service info of %s", service_type) self._info[service_type] = self.info_from_service(service_info) diff --git a/tests/mocks/mock_zeroconf.py b/tests/mocks/mock_zeroconf.py index 6a583fc..b714ba9 100644 --- a/tests/mocks/mock_zeroconf.py +++ b/tests/mocks/mock_zeroconf.py @@ -20,5 +20,5 @@ def get_service_info(self, service_type, name): class MockServiceBrowser: - def __init__(self, zc, st, sc): + def __init__(self, zc, st, sc, addr): sc[0]() From b708d8452d8790c9ae3111844b10ae89b0afd502 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 1 Dec 2020 14:12:44 +0100 Subject: [PATCH 08/13] Make pylint happy --- devolo_plc_api/device.py | 5 ++--- devolo_plc_api/network/__init__.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index abeb93f..e1d43f7 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -1,7 +1,6 @@ import asyncio import ipaddress import logging -import socket import struct from datetime import date from typing import Dict, Optional @@ -145,7 +144,7 @@ async def _get_plcnet_info(self): async def _get_zeroconf_info(self, service_type: str): """ Browse for the desired mDNS service types and query them. """ if self._info[service_type]["properties"]: - return # Early return, if device info already exist + return # No need to continue, if device info already exist self._logger.debug("Browsing for %s", service_type) browser = ServiceBrowser(self._zeroconf, service_type, [self._state_change], addr=self.ip) @@ -165,7 +164,7 @@ def info_from_service(service_info): """Return prepared info from mDNS entries.""" properties = {} if not service_info.addresses: - return None + return None # No need to continue, if there is no IP address to contact the device total_length = len(service_info.text) offset = 0 diff --git a/devolo_plc_api/network/__init__.py b/devolo_plc_api/network/__init__.py index e4f1c1c..38fba95 100644 --- a/devolo_plc_api/network/__init__.py +++ b/devolo_plc_api/network/__init__.py @@ -38,5 +38,5 @@ def add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceS browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [add]) time.sleep(3) browser.cancel() - [device.connect() for device in devices.values()] + [device.connect() for device in devices.values()] # pylint: disable=expression-not-assigned return devices From f7068e19205f0f99ae9cd435934011f64b377b8f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 1 Dec 2020 18:00:18 +0100 Subject: [PATCH 09/13] Reduce duplication --- devolo_plc_api/device.py | 8 +++-- devolo_plc_api/network/__init__.py | 52 ++++++++++++++---------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index e1d43f7..0adcbe3 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -6,7 +6,7 @@ from typing import Dict, Optional import httpx -from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf +from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf from .device_api.deviceapi import DeviceApi from .exceptions.device import DeviceNotFound @@ -155,12 +155,16 @@ async def _get_zeroconf_info(self, service_type: str): def _state_change(self, zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): """ Evaluate the query result. """ service_info = zeroconf.get_service_info(service_type, name) + + if service_info is None: + return # No need to continue, if there are no service information + if state_change is ServiceStateChange.Added: self._logger.debug("Adding service info of %s", service_type) self._info[service_type] = self.info_from_service(service_info) @staticmethod - def info_from_service(service_info): + def info_from_service(service_info: ServiceInfo) -> Optional[Dict]: """Return prepared info from mDNS entries.""" properties = {} if not service_info.addresses: diff --git a/devolo_plc_api/network/__init__.py b/devolo_plc_api/network/__init__.py index 38fba95..f8f9b80 100644 --- a/devolo_plc_api/network/__init__.py +++ b/devolo_plc_api/network/__init__.py @@ -1,42 +1,40 @@ import asyncio import time +from typing import Dict from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf from ..device import Device +_devices: Dict[str, + Device] = {} -async def async_discover_network(): - devices = {} - def add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): - """ Create a device object to each matching device. """ - if state_change is ServiceStateChange.Added: - service_info = Device.info_from_service(zeroconf.get_service_info(service_type, name)) - devices[service_info["properties"]["SN"]] = Device(ip=service_info["address"], - deviceapi=service_info, - zeroconf_instance=zeroconf) - - browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [add]) +async def async_discover_network() -> Dict[str, Device]: + browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [_add]) await asyncio.sleep(3) browser.cancel() - await asyncio.gather(*[device.async_connect() for device in devices.values()]) - return devices - + await asyncio.gather(*[device.async_connect() for device in _devices.values()]) + return _devices -def discover_network(): - devices = {} - def add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): - """ Create a device object to each matching device. """ - if state_change is ServiceStateChange.Added: - service_info = Device.info_from_service(zeroconf.get_service_info(service_type, name)) - devices[service_info["properties"]["SN"]] = Device(ip=service_info["address"], - deviceapi=service_info, - zeroconf_instance=zeroconf) - - browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [add]) +def discover_network() -> Dict[str, Device]: + browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [_add]) time.sleep(3) browser.cancel() - [device.connect() for device in devices.values()] # pylint: disable=expression-not-assigned - return devices + [device.connect() for device in _devices.values()] # pylint: disable=expression-not-assigned + return _devices + + +def _add(zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange): + """" Create a device object to each matching device. """ + if state_change is ServiceStateChange.Added: + service_info = zeroconf.get_service_info(service_type, name) + if service_info is None: + return + + info = Device.info_from_service(service_info) + if info is None: + return + + _devices[info["properties"]["SN"]] = Device(ip=info["address"], deviceapi=info, zeroconf_instance=zeroconf) From a3b1b746f032fe95b422897972dc9f791e5660fc Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 2 Dec 2020 09:16:50 +0100 Subject: [PATCH 10/13] Restore test coverage --- tests/fixtures/device.py | 11 ++++- tests/mocks/mock_zeroconf.py | 2 +- tests/test_device.py | 82 ++++++++++++++++++++++++++---------- tests/test_network.py | 67 +++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 tests/test_network.py diff --git a/tests/fixtures/device.py b/tests/fixtures/device.py index 3ce3c9f..6842c08 100644 --- a/tests/fixtures/device.py +++ b/tests/fixtures/device.py @@ -4,6 +4,11 @@ import pytest from zeroconf import Zeroconf +try: + from unittest.mock import AsyncMock +except ImportError: + from asynctest import CoroutineMock as AsyncMock + from devolo_plc_api.device import Device from ..mocks.mock_zeroconf import MockServiceBrowser, MockZeroconf @@ -15,8 +20,10 @@ def mock_device(mocker, request): device._info = deepcopy(request.cls.device_info) device._loop = Mock() device._loop.is_running = lambda: False - device._session = None - device._zeroconf = None + device._session = Mock() + device._session.aclose = AsyncMock() + device._zeroconf = Mock() + device._zeroconf.close = lambda: None return device diff --git a/tests/mocks/mock_zeroconf.py b/tests/mocks/mock_zeroconf.py index b714ba9..aa060fa 100644 --- a/tests/mocks/mock_zeroconf.py +++ b/tests/mocks/mock_zeroconf.py @@ -20,5 +20,5 @@ def get_service_info(self, service_type, name): class MockServiceBrowser: - def __init__(self, zc, st, sc, addr): + def __init__(self, zc, st, sc, addr=None): sc[0]() diff --git a/tests/test_device.py b/tests/test_device.py index 062767d..de86ada 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -1,9 +1,9 @@ import asyncio from datetime import date -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest -from zeroconf import ServiceBrowser, ServiceStateChange +from zeroconf import ServiceBrowser, ServiceStateChange, Zeroconf try: from unittest.mock import AsyncMock @@ -20,6 +20,51 @@ class TestDevice: + @pytest.mark.parametrize("feature", [""]) + def test_set_password(self, mock_device, device_api): + mock_device.device = device_api + mock_device.password = "super_secret" + assert mock_device.device.password == "super_secret" + + @pytest.mark.asyncio + async def test_async_connect(self, mocker, mock_device): + with patch("devolo_plc_api.device.Device._get_device_info", new=AsyncMock()), \ + patch("devolo_plc_api.device.Device._get_plcnet_info", new=AsyncMock()): + spy_device_info = mocker.spy(mock_device, "_get_device_info") + spy_plcnet_info = mocker.spy(mock_device, "_get_plcnet_info") + mock_device.device = object + mock_device.plcnet = object + await mock_device.async_connect() + assert spy_device_info.call_count == 1 + assert spy_plcnet_info.call_count == 1 + + @pytest.mark.asyncio + async def test_async_connect_not_found(self, mock_device): + with patch("devolo_plc_api.device.Device._get_device_info", new=AsyncMock()), \ + patch("devolo_plc_api.device.Device._get_plcnet_info", new=AsyncMock()), \ + pytest.raises(DeviceNotFound): + await mock_device.async_connect() + + def test_connect(self, mocker, mock_device): + with patch("devolo_plc_api.device.Device.async_connect", new=AsyncMock()): + spy_connect = mocker.spy(mock_device, "async_connect") + mock_device.connect() + assert spy_connect.call_count == 1 + + @pytest.mark.asyncio + async def test_async_disconnect(self, mocker, mock_device): + spy_zeroconf = mocker.spy(mock_device._zeroconf, "close") + spy_session = mocker.spy(mock_device._session, "aclose") + await mock_device.async_disconnect() + assert spy_zeroconf.call_count == 1 + assert spy_session.call_count == 1 + + def test_disconnect(self, mocker, mock_device): + with patch("devolo_plc_api.device.Device.async_disconnect", new=AsyncMock()): + spy_connect = mocker.spy(mock_device, "async_disconnect") + mock_device.disconnect() + assert spy_connect.call_count == 1 + @pytest.mark.asyncio @pytest.mark.usefixtures("mock_device_api") async def test__get_device_info(self, mock_device): @@ -70,38 +115,29 @@ async def test__get_zeroconf_info(self, mocker, mock_device): @pytest.mark.asyncio @pytest.mark.usefixtures("mock_service_browser") - async def test__get_zeroconf_info_early_return(self, mocker, mock_device): + async def test__get_zeroconf_info_device_info_exists(self, mocker, mock_device): spy_cancel = mocker.spy(ServiceBrowser, "cancel") await mock_device._get_zeroconf_info("_dvl-plcnetapi._tcp.local.") assert spy_cancel.call_count == 0 - @pytest.mark.asyncio - async def test_async_connect(self, mocker, mock_device): - with patch("devolo_plc_api.device.Device._get_device_info", new=AsyncMock()), \ - patch("devolo_plc_api.device.Device._get_plcnet_info", new=AsyncMock()): - spy_device_info = mocker.spy(mock_device, "_get_device_info") - spy_plcnet_info = mocker.spy(mock_device, "_get_plcnet_info") - mock_device.device = object - mock_device.plcnet = object - await mock_device.async_connect() - assert spy_device_info.call_count == 1 - assert spy_plcnet_info.call_count == 1 - - @pytest.mark.asyncio - async def test_async_connect_not_found(self, mock_device): - with patch("devolo_plc_api.device.Device._get_device_info", new=AsyncMock()), \ - patch("devolo_plc_api.device.Device._get_plcnet_info", new=AsyncMock()), \ - pytest.raises(DeviceNotFound): - await mock_device.async_connect() + def test__state_change_no_service_info(self, mocker, mock_device): + with patch("zeroconf.Zeroconf.get_service_info", return_value=None): + service_type = "_dvl-plcnetapi._tcp.local." + spy_service_info = mocker.spy(mock_device, "info_from_service") + mock_device._state_change(Zeroconf(), service_type, service_type, ServiceStateChange.Added) + assert spy_service_info.call_count == 0 - @pytest.mark.asyncio def test__state_change_added(self, mock_device, mock_zeroconf): service_type = "_dvl-plcnetapi._tcp.local." mock_device._state_change(mock_zeroconf, service_type, service_type, ServiceStateChange.Added) assert mock_device._info[service_type]["properties"]["new"] == "value" - @pytest.mark.asyncio def test__state_change_removed(self, mock_device, mock_zeroconf): service_type = "_dvl-plcnetapi._tcp.local." mock_device._state_change(mock_zeroconf, service_type, service_type, ServiceStateChange.Removed) assert "new" not in mock_device._info[service_type] + + def test_info_from_service_no_address(self, mock_device): + service_info = Mock() + service_info.addresses = None + assert mock_device.info_from_service(service_info) is None diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..e6285b4 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,67 @@ +from unittest.mock import Mock, patch + +import pytest +from zeroconf import ServiceStateChange, Zeroconf + +try: + from unittest.mock import AsyncMock +except ImportError: + from asynctest import CoroutineMock as AsyncMock + +import devolo_plc_api.network as network +from devolo_plc_api.device import Device + + +class TestNetwork: + + @pytest.mark.asyncio + async def test_async_discover_network(self, mocker): + device = { + "1234567890123456": Device(ip="123.123.123.123") + } + with patch("zeroconf.Zeroconf", new=Mock()), \ + patch("asyncio.sleep", new=AsyncMock()), \ + patch("devolo_plc_api.device.Device.async_connect", new=AsyncMock()): + network._devices = device + spy_connect = mocker.spy(Device, "async_connect") + discovered = await network.async_discover_network() + assert spy_connect.call_count == 1 + assert discovered == device + + def test_discover_network(self, mocker): + device = { + "1234567890123456": Device(ip="123.123.123.123") + } + with patch("zeroconf.ServiceBrowser", new=Mock()), \ + patch("time.sleep", new=Mock()), \ + patch("devolo_plc_api.device.Device.connect", new=Mock()): + network._devices = device + spy_connect = mocker.spy(Device, "connect") + discovered = network.discover_network() + assert spy_connect.call_count == 1 + assert discovered == device + + def test__add(self, mocker): + service_info = { + "properties": { + "SN": "1234567890123456" + }, + "address": "123.123.123.123", + } + with patch("zeroconf.Zeroconf.get_service_info", return_value="service_info"), \ + patch("devolo_plc_api.device.Device.info_from_service", return_value=service_info): + network._add(Zeroconf(), "_dvl-deviceapi._tcp.local.", "_dvl-deviceapi._tcp.local.", ServiceStateChange.Added) + assert "1234567890123456" in network._devices + + def test__add_no_device(self, mocker): + with patch("zeroconf.Zeroconf.get_service_info", return_value=None): + spy_info = mocker.spy(Device, "info_from_service") + network._add(Zeroconf(), "_dvl-deviceapi._tcp.local.", "_dvl-deviceapi._tcp.local.", ServiceStateChange.Added) + assert spy_info.call_count == 0 + + def test__add_no_info(self, mocker): + with patch("zeroconf.Zeroconf.get_service_info", return_value="service_info"), \ + patch("devolo_plc_api.device.Device.info_from_service", return_value=None): + spy_device = mocker.spy(Device, "__init__") + network._add(Zeroconf(), "_dvl-deviceapi._tcp.local.", "_dvl-deviceapi._tcp.local.", ServiceStateChange.Added) + assert spy_device.call_count == 0 From 73bb96467076f9e6fa747858bf3a54b23bddea7b Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 2 Dec 2020 09:28:18 +0100 Subject: [PATCH 11/13] Update README and CHANGELOG --- README.md | 22 +++++++++++++++++++++- devolo_plc_api/__init__.py | 2 +- docs/CHANGELOG.md | 4 +++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 99c7472..bbf9f20 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,27 @@ pytest ## Usage -All features we currently support are shown in our examples. If you want to use the package asynchronously, please check [example_async.py](https://github.com/2Fake/devolo_plc_api/blob/master/example_async.py). If you want to use it synchronously, please check [example_sync.py](https://github.com/2Fake/devolo_plc_api/blob/master/example_sync.py). +All features we currently support on device basis are shown in our examples. If you want to use the package asynchronously, please check [example_async.py](https://github.com/2Fake/devolo_plc_api/blob/master/example_async.py). If you want to use it synchronously, please check [example_sync.py](https://github.com/2Fake/devolo_plc_api/blob/master/example_sync.py). + +If you don't know the IP addresses of your devices, you can discover them. You will get a dictionary with the device's serial number as key. The connections to the devices will be already established, but you will have to take about disconnecting. + +```python +from devolo_plc_api.network import async_discover_network + +devices = await async_discover_network() +# Do your magic +await asyncio.gather(*[device.async_disconnect() for device in devices.values()]) +``` + +Or in a synchronious setup: + +```python +from devolo_plc_api.network import discover_network + +devices = discover_network() +# Do your magic +[device.disconnect() for device in devices.values()] +``` ## Supported device diff --git a/devolo_plc_api/__init__.py b/devolo_plc_api/__init__.py index d3ec452..493f741 100644 --- a/devolo_plc_api/__init__.py +++ b/devolo_plc_api/__init__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 82361da..d094d6b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,11 +4,13 @@ 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). -## [unreleased] +## [v0.3.0] - 02.12.2020 ### Added - If API data is discovered externally, it can be reused +- The devices can be accessed without context manager +- If the network topology is unknown, it can be discovered now ### Changed From cf7074c2ca2149485f927238e3635e9f450f26b1 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 2 Dec 2020 10:51:30 +0100 Subject: [PATCH 12/13] Change requested --- README.md | 2 +- devolo_plc_api/device.py | 2 +- devolo_plc_api/network/__init__.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bbf9f20..2f1dfd4 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ devices = await async_discover_network() await asyncio.gather(*[device.async_disconnect() for device in devices.values()]) ``` -Or in a synchronious setup: +Or in a synchronous setup: ```python from devolo_plc_api.network import discover_network diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index 0adcbe3..127084e 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -168,7 +168,7 @@ def info_from_service(service_info: ServiceInfo) -> Optional[Dict]: """Return prepared info from mDNS entries.""" properties = {} if not service_info.addresses: - return None # No need to continue, if there is no IP address to contact the device + return # No need to continue, if there is no IP address to contact the device total_length = len(service_info.text) offset = 0 diff --git a/devolo_plc_api/network/__init__.py b/devolo_plc_api/network/__init__.py index f8f9b80..c278dc7 100644 --- a/devolo_plc_api/network/__init__.py +++ b/devolo_plc_api/network/__init__.py @@ -22,7 +22,8 @@ def discover_network() -> Dict[str, Device]: browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [_add]) time.sleep(3) browser.cancel() - [device.connect() for device in _devices.values()] # pylint: disable=expression-not-assigned + for device in _devices.values(): + device.connect() return _devices From d1744fddd728861e49c5b5f77a7c2b727088383d Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 2 Dec 2020 11:57:41 +0100 Subject: [PATCH 13/13] Cosmetic changes --- devolo_plc_api/device.py | 4 ++-- devolo_plc_api/network/__init__.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index 127084e..2915020 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -165,10 +165,10 @@ def _state_change(self, zeroconf: Zeroconf, service_type: str, name: str, state_ @staticmethod def info_from_service(service_info: ServiceInfo) -> Optional[Dict]: - """Return prepared info from mDNS entries.""" + """ Return prepared info from mDNS entries. """ properties = {} if not service_info.addresses: - return # No need to continue, if there is no IP address to contact the device + return None # No need to continue, if there is no IP address to contact the device total_length = len(service_info.text) offset = 0 diff --git a/devolo_plc_api/network/__init__.py b/devolo_plc_api/network/__init__.py index c278dc7..c58f3c2 100644 --- a/devolo_plc_api/network/__init__.py +++ b/devolo_plc_api/network/__init__.py @@ -11,6 +11,11 @@ async def async_discover_network() -> Dict[str, Device]: + """ + Discover all devices that expose the devolo device API via mDNS asynchronous. + + :return: Devices accessible via serial number. + """ browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [_add]) await asyncio.sleep(3) browser.cancel() @@ -19,6 +24,11 @@ async def async_discover_network() -> Dict[str, Device]: def discover_network() -> Dict[str, Device]: + """ + Discover devices that expose the devolo device API via mDNS synchronous. + + :return: Devices accessible via serial number. + """ browser = ServiceBrowser(Zeroconf(), "_dvl-deviceapi._tcp.local.", [_add]) time.sleep(3) browser.cancel()