diff --git a/.coveragerc b/.coveragerc index fefd9205b05..bba6eb584c5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1061,7 +1061,6 @@ omit = homeassistant/components/pushbullet/sensor.py homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py - homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/coordinator.py homeassistant/components/qbittorrent/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index f3a33c394ca..fa8db6628ce 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1100,6 +1100,8 @@ build.json @home-assistant/supervisor /tests/components/pvoutput/ @frenck /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue +/homeassistant/components/pyload/ @tr4nt0r +/tests/components/pyload/ @tr4nt0r /homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 /tests/components/qbittorrent/ @geoffreylagaisse @finder39 /homeassistant/components/qingping/ @bdraco diff --git a/homeassistant/components/pyload/const.py b/homeassistant/components/pyload/const.py new file mode 100644 index 00000000000..a7d155d8b33 --- /dev/null +++ b/homeassistant/components/pyload/const.py @@ -0,0 +1,7 @@ +"""Constants for the pyLoad integration.""" + +DOMAIN = "pyload" + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "pyLoad" +DEFAULT_PORT = 8000 diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 6cb641f6ead..90d750ff9b8 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -1,7 +1,10 @@ { "domain": "pyload", "name": "pyLoad", - "codeowners": [], + "codeowners": ["@tr4nt0r"], "documentation": "https://www.home-assistant.io/integrations/pyload", - "iot_class": "local_polling" + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["pyloadapi"], + "requirements": ["PyLoadAPI==1.1.0"] } diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index b7d4d1f461b..c21e74b18a7 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -5,7 +5,10 @@ from __future__ import annotations from datetime import timedelta import logging -import requests +from aiohttp import CookieJar +from pyloadapi.api import PyLoadAPI +from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError +from pyloadapi.types import StatusServerResponse import voluptuous as vol from homeassistant.components.sensor import ( @@ -22,22 +25,22 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_USERNAME, - CONTENT_TYPE_JSON, UnitOfDataRate, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = "localhost" -DEFAULT_NAME = "pyLoad" -DEFAULT_PORT = 8000 -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) +SCAN_INTERVAL = timedelta(seconds=15) SENSOR_TYPES = { "speed": SensorEntityDescription( @@ -63,10 +66,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the pyLoad sensors.""" @@ -77,16 +80,26 @@ def setup_platform( username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) monitored_types = config[CONF_MONITORED_VARIABLES] - url = f"{protocol}://{host}:{port}/api/" + url = f"{protocol}://{host}:{port}/" + session = async_create_clientsession( + hass, + verify_ssl=False, + cookie_jar=CookieJar(unsafe=True), + ) + pyloadapi = PyLoadAPI(session, api_url=url, username=username, password=password) try: - pyloadapi = PyLoadAPI(api_url=url, username=username, password=password) - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as conn_err: - _LOGGER.error("Error setting up pyLoad API: %s", conn_err) - return + await pyloadapi.login() + except CannotConnect as conn_err: + raise PlatformNotReady( + "Unable to connect and retrieve data from pyLoad API" + ) from conn_err + except ParserError as e: + raise PlatformNotReady("Unable to parse data from pyLoad API") from e + except InvalidAuth as e: + raise PlatformNotReady( + f"Authentication failed for {config[CONF_USERNAME]}, check your login credentials" + ) from e devices = [] for ng_type in monitored_types: @@ -95,7 +108,7 @@ def setup_platform( ) devices.append(new_sensor) - add_entities(devices, True) + async_add_entities(devices, True) class PyLoadSensor(SensorEntity): @@ -109,64 +122,33 @@ class PyLoadSensor(SensorEntity): self.type = sensor_type.key self.api = api self.entity_description = sensor_type + self.data: StatusServerResponse - def update(self) -> None: + async def async_update(self) -> None: """Update state of sensor.""" try: - self.api.update() - except requests.exceptions.ConnectionError: - # Error calling the API, already logged in api.update() - return + self.data = await self.api.get_status() + except InvalidAuth: + _LOGGER.info("Authentication failed, trying to reauthenticate") + try: + await self.api.login() + except InvalidAuth as e: + raise PlatformNotReady( + f"Authentication failed for {self.api.username}, check your login credentials" + ) from e + else: + raise UpdateFailed( + "Unable to retrieve data due to cookie expiration but re-authentication was successful." + ) + except CannotConnect as e: + raise UpdateFailed( + "Unable to connect and retrieve data from pyLoad API" + ) from e + except ParserError as e: + raise UpdateFailed("Unable to parse data from pyLoad API") from e - if self.api.status is None: - _LOGGER.debug( - "Update of %s requested, but no status is available", self.name - ) - return - - if (value := self.api.status.get(self.type)) is None: - _LOGGER.warning("Unable to locate value for %s", self.type) - return + value = getattr(self.data, self.type) if "speed" in self.type and value > 0: # Convert download rate from Bytes/s to MBytes/s self._attr_native_value = round(value / 2**20, 2) - else: - self._attr_native_value = value - - -class PyLoadAPI: - """Simple wrapper for pyLoad's API.""" - - def __init__(self, api_url, username=None, password=None): - """Initialize pyLoad API and set headers needed later.""" - self.api_url = api_url - self.status = None - self.headers = {"Content-Type": CONTENT_TYPE_JSON} - - if username is not None and password is not None: - self.payload = {"username": username, "password": password} - self.login = requests.post(f"{api_url}login", data=self.payload, timeout=5) - self.update() - - def post(self): - """Send a POST request and return the response as a dict.""" - try: - response = requests.post( - f"{self.api_url}statusServer", - cookies=self.login.cookies, - headers=self.headers, - timeout=5, - ) - response.raise_for_status() - _LOGGER.debug("JSON Response: %s", response.json()) - return response.json() - - except requests.exceptions.ConnectionError as conn_exc: - _LOGGER.error("Failed to update pyLoad status. Error: %s", conn_exc) - raise - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update cached response.""" - self.status = self.post() diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7f2f4292748..425702562d0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4775,7 +4775,7 @@ }, "pyload": { "name": "pyLoad", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "local_polling" }, diff --git a/requirements_all.txt b/requirements_all.txt index 8bfbce89514..b7fccc8d6f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -59,6 +59,9 @@ PyFlume==0.6.5 # homeassistant.components.fronius PyFronius==0.7.3 +# homeassistant.components.pyload +PyLoadAPI==1.1.0 + # homeassistant.components.mvglive PyMVGLive==1.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d62837452b3..be404d99447 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -50,6 +50,9 @@ PyFlume==0.6.5 # homeassistant.components.fronius PyFronius==0.7.3 +# homeassistant.components.pyload +PyLoadAPI==1.1.0 + # homeassistant.components.met_eireann PyMetEireann==2021.8.0 diff --git a/tests/components/pyload/__init__.py b/tests/components/pyload/__init__.py new file mode 100644 index 00000000000..5ba1e4f9337 --- /dev/null +++ b/tests/components/pyload/__init__.py @@ -0,0 +1 @@ +"""Tests for the pyLoad component.""" diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py new file mode 100644 index 00000000000..31f251c6e85 --- /dev/null +++ b/tests/components/pyload/conftest.py @@ -0,0 +1,74 @@ +"""Fixtures for pyLoad integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyloadapi.types import LoginResponse, StatusServerResponse +import pytest + +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.helpers.typing import ConfigType + + +@pytest.fixture +def pyload_config() -> ConfigType: + """Mock pyload configuration entry.""" + return { + "sensor": { + CONF_PLATFORM: "pyload", + CONF_HOST: "localhost", + CONF_PORT: 8000, + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_SSL: True, + CONF_MONITORED_VARIABLES: ["speed"], + CONF_NAME: "pyload", + } + } + + +@pytest.fixture +def mock_pyloadapi() -> Generator[AsyncMock, None, None]: + """Mock PyLoadAPI.""" + with ( + patch( + "homeassistant.components.pyload.sensor.PyLoadAPI", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + client.username = "username" + client.login.return_value = LoginResponse.from_dict( + { + "_permanent": True, + "authenticated": True, + "id": 2, + "name": "username", + "role": 0, + "perms": 0, + "template": "default", + "_flashes": [["message", "Logged in successfully"]], + } + ) + client.get_status.return_value = StatusServerResponse.from_dict( + { + "pause": False, + "active": 1, + "queue": 6, + "total": 37, + "speed": 5405963.0, + "download": True, + "reconnect": False, + "captcha": False, + } + ) + yield client diff --git a/tests/components/pyload/snapshots/test_sensor.ambr b/tests/components/pyload/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..384a59b78b2 --- /dev/null +++ b/tests/components/pyload/snapshots/test_sensor.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'pyload Speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pyload_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.16', + }) +# --- diff --git a/tests/components/pyload/test_sensor.py b/tests/components/pyload/test_sensor.py new file mode 100644 index 00000000000..54f15deb313 --- /dev/null +++ b/tests/components/pyload/test_sensor.py @@ -0,0 +1,84 @@ +"""Tests for the pyLoad Sensors.""" + +from unittest.mock import AsyncMock + +from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sensor import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_setup( + hass: HomeAssistant, + pyload_config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of the pyload sensor platform.""" + + assert await async_setup_component(hass, DOMAIN, pyload_config) + await hass.async_block_till_done() + + result = hass.states.get("sensor.pyload_speed") + assert result == snapshot + + +@pytest.mark.parametrize( + ("exception", "expected_exception"), + [ + (CannotConnect, "Unable to connect and retrieve data from pyLoad API"), + (ParserError, "Unable to parse data from pyLoad API"), + ( + InvalidAuth, + "Authentication failed for username, check your login credentials", + ), + ], +) +async def test_setup_exceptions( + hass: HomeAssistant, + pyload_config: ConfigType, + mock_pyloadapi: AsyncMock, + exception: Exception, + expected_exception: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exceptions during setup up pyLoad platform.""" + + mock_pyloadapi.login.side_effect = exception + + assert await async_setup_component(hass, DOMAIN, pyload_config) + await hass.async_block_till_done() + + assert len(hass.states.async_all(DOMAIN)) == 0 + assert expected_exception in caplog.text + + +@pytest.mark.parametrize( + ("exception", "expected_exception"), + [ + (CannotConnect, "UpdateFailed"), + (ParserError, "UpdateFailed"), + (InvalidAuth, "UpdateFailed"), + ], +) +async def test_sensor_update_exceptions( + hass: HomeAssistant, + pyload_config: ConfigType, + mock_pyloadapi: AsyncMock, + exception: Exception, + expected_exception: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test exceptions during update of pyLoad sensor.""" + + mock_pyloadapi.get_status.side_effect = exception + + assert await async_setup_component(hass, DOMAIN, pyload_config) + await hass.async_block_till_done() + + assert len(hass.states.async_all(DOMAIN)) == 0 + assert expected_exception in caplog.text