diff --git a/devolo_plc_api/__init__.py b/devolo_plc_api/__init__.py index 6a9beea..3d18726 100644 --- a/devolo_plc_api/__init__.py +++ b/devolo_plc_api/__init__.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/devolo_plc_api/clients/protobuf.py b/devolo_plc_api/clients/protobuf.py index 9decc99..b9f4692 100644 --- a/devolo_plc_api/clients/protobuf.py +++ b/devolo_plc_api/clients/protobuf.py @@ -4,11 +4,11 @@ from typing import Callable from google.protobuf.json_format import MessageToDict -from httpx import AsyncClient, DigestAuth, Response +from httpx import AsyncClient, ConnectTimeout, DigestAuth, Response -from ..exceptions.device import DevicePasswordProtected +from ..exceptions.device import DevicePasswordProtected, DeviceUnavailable -TIMEOUT = 3.0 +TIMEOUT = 5.0 class Protobuf(ABC): @@ -19,7 +19,7 @@ class Protobuf(ABC): @abstractclassmethod def __init__(self): self._loop = asyncio.get_running_loop() - self._logger = logging.getLogger(self.__class__.__name__) + self._logger = logging.getLogger(f"{self.__class__.__module__}.{self.__class__.__name__}") self.password: str @@ -54,6 +54,8 @@ async def _async_get(self, sub_url: str, timeout: float = TIMEOUT) -> Response: 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 + except ConnectTimeout: + raise DeviceUnavailable("The device is currenctly not available. Maybe on standby?") from None async def _async_post(self, sub_url: str, content: bytes, timeout: float = TIMEOUT) -> Response: """ Post data asynchronously. """ @@ -63,6 +65,8 @@ async def _async_post(self, sub_url: str, content: bytes, timeout: float = TIMEO 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 + except ConnectTimeout: + raise DeviceUnavailable("The device is currenctly not available. Maybe on standby?") from None @staticmethod def _message_to_dict(message) -> dict: diff --git a/devolo_plc_api/device.py b/devolo_plc_api/device.py index 7c9ef9a..48995f1 100644 --- a/devolo_plc_api/device.py +++ b/devolo_plc_api/device.py @@ -45,11 +45,12 @@ def __init__(self, self.device = None self.plcnet = None + self._connected = False self._info: Dict = { "_dvl-plcnetapi._tcp.local.": plcnetapi or EMPTY_INFO, "_dvl-deviceapi._tcp.local.": deviceapi or EMPTY_INFO, } - self._logger = logging.getLogger(self.__class__.__name__) + self._logger = logging.getLogger(f"{self.__class__.__module__}.{self.__class__.__name__}") self._password = "" self._session_instance: Optional[httpx.AsyncClient] = None self._zeroconf_instance = zeroconf_instance @@ -60,7 +61,7 @@ def __init__(self, self._zeroconf: Zeroconf def __del__(self): - if self._loop.is_running(): + if self._connected and self._session_instance is None: self._logger.warning("Please disconnect properly from the device.") async def __aenter__(self): @@ -102,6 +103,7 @@ async def async_connect(self, session_instance: Optional[httpx.AsyncClient] = No await asyncio.gather(self._get_device_info(), self._get_plcnet_info()) if not self.device and not self.plcnet: raise DeviceNotFound(f"The device {self.ip} did not answer.") + self._connected = True def connect(self): """ Connect to a device synchronous. """ @@ -114,6 +116,7 @@ async def async_disconnect(self): self._zeroconf.close() if not self._session_instance: await self._session.aclose() + self._connected = False def disconnect(self): """ Disconnect from a device asynchronous. """ diff --git a/devolo_plc_api/exceptions/device.py b/devolo_plc_api/exceptions/device.py index 846e902..dbc907a 100644 --- a/devolo_plc_api/exceptions/device.py +++ b/devolo_plc_api/exceptions/device.py @@ -2,5 +2,9 @@ class DeviceNotFound(Exception): """ The device was not found. """ +class DeviceUnavailable(Exception): + """ The device is not available, e.g. in standby. """ + + class DevicePasswordProtected(Exception): """ The device is passwort protected. """ diff --git a/devolo_plc_api/network/__init__.py b/devolo_plc_api/network/__init__.py index 61b34d5..f080273 100644 --- a/devolo_plc_api/network/__init__.py +++ b/devolo_plc_api/network/__init__.py @@ -36,13 +36,15 @@ def discover_network() -> Dict[str, Device]: 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 + if state_change is not ServiceStateChange.Added: + return - info = Device.info_from_service(service_info) - if info is None or info["properties"]["MT"] in ("2600", "2601"): - return # Don't react on devolo Home Control central units + service_info = zeroconf.get_service_info(service_type, name) + if service_info is None: + return - _devices[info["properties"]["SN"]] = Device(ip=info["address"], deviceapi=info, zeroconf_instance=zeroconf) + info = Device.info_from_service(service_info) + if info is None or info["properties"]["MT"] in ("2600", "2601"): + return # Don't react on devolo Home Control central units + + _devices[info["properties"]["SN"]] = Device(ip=info["address"], deviceapi=info, zeroconf_instance=zeroconf) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3f39064..a7aa791 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,18 @@ 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.5.0] - 2020/12/21 + +### Changed + +- Increase read timeout to better handle busy devices +- If a device is unavailable (e.g. in standby), DeviceUnavailable is raised +- Loggers now contain the module name + +### Fixed + +- Sometime a warning popped up to properly close the connection to the device although it was properly closed + ## [v0.4.0] - 2020/12/08 ### Added diff --git a/tests/fixtures/device.py b/tests/fixtures/device.py index 6842c08..954474e 100644 --- a/tests/fixtures/device.py +++ b/tests/fixtures/device.py @@ -19,12 +19,12 @@ def mock_device(mocker, request): device = Device(ip=request.cls.ip) device._info = deepcopy(request.cls.device_info) device._loop = Mock() - device._loop.is_running = lambda: False device._session = Mock() device._session.aclose = AsyncMock() device._zeroconf = Mock() device._zeroconf.close = lambda: None - return device + yield device + device.disconnect() @pytest.fixture() diff --git a/tests/fixtures/protobuf.py b/tests/fixtures/protobuf.py index 3b19622..7690eaf 100644 --- a/tests/fixtures/protobuf.py +++ b/tests/fixtures/protobuf.py @@ -1,4 +1,5 @@ import pytest +from httpx import ConnectTimeout try: from unittest.mock import AsyncMock @@ -23,6 +24,12 @@ def mock_post(mocker): mocker.patch("httpx.AsyncClient.post", new=AsyncMock()) +@pytest.fixture() +def mock_device_unavailable(mocker): + mocker.patch("httpx.AsyncClient.get", side_effect=ConnectTimeout(message="", request="")) + mocker.patch("httpx.AsyncClient.post", side_effect=ConnectTimeout(message="", request="")) + + @pytest.fixture() def mock_wrong_password(mocker): mocker.patch("httpx.AsyncClient.get", side_effect=TypeError()) diff --git a/tests/test_device.py b/tests/test_device.py index de86ada..21694f9 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -37,6 +37,7 @@ async def test_async_connect(self, mocker, mock_device): await mock_device.async_connect() assert spy_device_info.call_count == 1 assert spy_plcnet_info.call_count == 1 + assert mock_device._connected @pytest.mark.asyncio async def test_async_connect_not_found(self, mock_device): @@ -44,6 +45,7 @@ async def test_async_connect_not_found(self, mock_device): patch("devolo_plc_api.device.Device._get_plcnet_info", new=AsyncMock()), \ pytest.raises(DeviceNotFound): await mock_device.async_connect() + assert not mock_device._connected def test_connect(self, mocker, mock_device): with patch("devolo_plc_api.device.Device.async_connect", new=AsyncMock()): @@ -58,6 +60,7 @@ async def test_async_disconnect(self, mocker, mock_device): await mock_device.async_disconnect() assert spy_zeroconf.call_count == 1 assert spy_session.call_count == 1 + assert not mock_device._connected def test_disconnect(self, mocker, mock_device): with patch("devolo_plc_api.device.Device.async_disconnect", new=AsyncMock()): diff --git a/tests/test_network.py b/tests/test_network.py index 8180be3..9c6fdbd 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -50,6 +50,13 @@ def test__add(self, mocker): network._add(Zeroconf(), "_dvl-deviceapi._tcp.local.", "_dvl-deviceapi._tcp.local.", ServiceStateChange.Added) assert "1234567890123456" in network._devices + def test__add_wrong_state(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.Removed) + assert spy_device.call_count == 0 + 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") diff --git a/tests/test_profobuf.py b/tests/test_profobuf.py index 8869101..edb0ee1 100644 --- a/tests/test_profobuf.py +++ b/tests/test_profobuf.py @@ -3,7 +3,7 @@ import pytest from devolo_plc_api.device_api.devolo_idl_proto_deviceapi_ledsettings_pb2 import LedSettingsSetResponse -from devolo_plc_api.exceptions.device import DevicePasswordProtected +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable class TestProtobuf: @@ -33,6 +33,13 @@ async def test__async_get_wrong_password(self, mock_protobuf): with pytest.raises(DevicePasswordProtected): await mock_protobuf._async_get("LedSettingsGet") + @pytest.mark.asyncio + @pytest.mark.usefixtures("mock_device_unavailable") + async def test__async_get_device_unavailable(self, mock_protobuf): + mock_protobuf._session = httpx.AsyncClient() + with pytest.raises(DeviceUnavailable): + await mock_protobuf._async_get("LedSettingsGet") + def test__message_to_dict(self, mocker, mock_protobuf): spy = mocker.spy(google.protobuf.json_format._Printer, "_MessageToJsonObject") mock_protobuf._message_to_dict(LedSettingsSetResponse()) @@ -52,3 +59,10 @@ async def test__async_post_wrong_password(self, mock_protobuf): mock_protobuf._session = httpx.AsyncClient() with pytest.raises(DevicePasswordProtected): await mock_protobuf._async_post("LedSettingsGet", "") + + @pytest.mark.asyncio + @pytest.mark.usefixtures("mock_device_unavailable") + async def test__async_post_device_unavailable(self, mock_protobuf): + mock_protobuf._session = httpx.AsyncClient() + with pytest.raises(DeviceUnavailable): + await mock_protobuf._async_post("LedSettingsGet", "")