diff --git a/.coveragerc b/.coveragerc index 4b831fc3d3c..2c06c7d0bb8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -938,6 +938,7 @@ omit = homeassistant/components/pushover/notify.py homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py + homeassistant/components/qbittorrent/__init__.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py diff --git a/CODEOWNERS b/CODEOWNERS index 1acd5f6c9f7..509f3e5f302 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -933,6 +933,7 @@ build.json @home-assistant/supervisor /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue /homeassistant/components/qbittorrent/ @geoffreylagaisse +/tests/components/qbittorrent/ @geoffreylagaisse /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index a5274f7a5a9..5154ae155ec 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -1 +1,54 @@ """The qbittorrent component.""" +import logging + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .helpers import setup_client + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up qBittorrent from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + setup_client, + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], + ) + except LoginRequired as err: + _LOGGER.error("Invalid credentials") + raise ConfigEntryNotReady from err + except RequestException as err: + _LOGGER.error("Failed to connect") + raise ConfigEntryNotReady from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload qBittorrent config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + return unload_ok diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py new file mode 100644 index 00000000000..54c47c53895 --- /dev/null +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for qBittorrent.""" +from __future__ import annotations + +import logging +from typing import Any + +from qbittorrent.client import LoginRequired +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN +from .helpers import setup_client + +_LOGGER = logging.getLogger(__name__) + +USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_VERIFY_SSL, default=True): bool, + } +) + + +class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the qBittorrent integration.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a user-initiated config flow.""" + errors = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + try: + await self.hass.async_add_executor_job( + setup_client, + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_VERIFY_SSL], + ) + except LoginRequired: + errors = {"base": "invalid_auth"} + except RequestException: + errors = {"base": "cannot_connect"} + else: + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + + schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + self._async_abort_entries_match({CONF_URL: config[CONF_URL]}) + return self.async_create_entry( + title=config.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_URL: config[CONF_URL], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_VERIFY_SSL: True, + }, + ) diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 5f9ad42f7fc..0a79c67f400 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -1,3 +1,7 @@ """Constants for qBittorrent.""" +from typing import Final + +DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" +DEFAULT_URL = "http://127.0.0.1:8080" diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py new file mode 100644 index 00000000000..7f7833e912a --- /dev/null +++ b/homeassistant/components/qbittorrent/helpers.py @@ -0,0 +1,11 @@ +"""Helper functions for qBittorrent.""" +from qbittorrent.client import Client + + +def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client: + """Create a qBittorrent client.""" + client = Client(url, verify=verify_ssl) + client.login(username, password) + # Get an arbitrary attribute to test if connection succeeds + client.get_alternative_speed_status() + return client diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 47090ab8b91..c56bb8102b8 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -2,6 +2,7 @@ "domain": "qbittorrent", "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index cafb8d8b21e..6b758daab0a 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -23,12 +24,12 @@ from homeassistant.const import ( UnitOfDataRate, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DEFAULT_NAME +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,31 +70,41 @@ 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 qBittorrent sensors.""" + """Set up the qBittorrent platform.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - try: - client = Client(config[CONF_URL]) - client.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - except RequestException as err: - _LOGGER.error("Connection failed") - raise PlatformNotReady from err - - name = config.get(CONF_NAME) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entites: AddEntitiesCallback, +) -> None: + """Set up qBittorrent sensor entries.""" + client: Client = hass.data[DOMAIN][config_entry.entry_id] entities = [ - QBittorrentSensor(description, client, name) for description in SENSOR_TYPES + QBittorrentSensor(description, client, config_entry) + for description in SENSOR_TYPES ] - - add_entities(entities, True) + async_add_entites(entities, True) def format_speed(speed): @@ -108,14 +119,15 @@ class QBittorrentSensor(SensorEntity): def __init__( self, description: SensorEntityDescription, - qbittorrent_client, - client_name, + qbittorrent_client: Client, + config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._attr_name = f"{client_name} {description.name}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" + self._attr_name = f"{config_entry.title} {description.name}" self._attr_available = False def update(self) -> None: diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json new file mode 100644 index 00000000000..24d1885a917 --- /dev/null +++ b/homeassistant/components/qbittorrent/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The qBittorrent YAML configuration is being removed", + "description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 37480904f9e..240e30ec860 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -339,6 +339,7 @@ FLOWS = { "pushover", "pvoutput", "pvpc_hourly_pricing", + "qbittorrent", "qingping", "qnap_qsw", "rachio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3e89f9d12d5..bc0e9e1c335 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4316,7 +4316,7 @@ "qbittorrent": { "name": "qBittorrent", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "qingping": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 514e653d346..5b2f1be04c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1507,6 +1507,9 @@ python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 +# homeassistant.components.qbittorrent +python-qbittorrent==0.4.2 + # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/qbittorrent/__init__.py b/tests/components/qbittorrent/__init__.py new file mode 100644 index 00000000000..2be020668c1 --- /dev/null +++ b/tests/components/qbittorrent/__init__.py @@ -0,0 +1 @@ +"""Tests for the qBittorrent integration.""" diff --git a/tests/components/qbittorrent/conftest.py b/tests/components/qbittorrent/conftest.py new file mode 100644 index 00000000000..448f68db81e --- /dev/null +++ b/tests/components/qbittorrent/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for testing qBittorrent component.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +import requests_mock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock qbittorrent entry setup.""" + with patch( + "homeassistant.components.qbittorrent.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_api() -> Generator[requests_mock.Mocker, None, None]: + """Mock the qbittorrent API.""" + with requests_mock.Mocker() as mocker: + mocker.get("http://localhost:8080/api/v2/app/preferences", status_code=403) + mocker.get("http://localhost:8080/api/v2/transfer/speedLimitsMode") + mocker.post("http://localhost:8080/api/v2/auth/login", text="Ok.") + yield mocker diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py new file mode 100644 index 00000000000..b7244ccef8d --- /dev/null +++ b/tests/components/qbittorrent/test_config_flow.py @@ -0,0 +1,136 @@ +"""Test the qBittorrent config flow.""" +import pytest +from requests.exceptions import RequestException +import requests_mock + +from homeassistant.components.qbittorrent.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_PASSWORD, + CONF_SOURCE, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +USER_INPUT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, +} + +YAML_IMPORT = { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} + + +async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> None: + """Test the user flow.""" + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with connection failure, fail with cannot_connect + with requests_mock.Mocker() as mock: + mock.get( + f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", + exc=RequestException, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Test flow with wrong creds, fail with invalid_auth + with requests_mock.Mocker() as mock: + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode") + mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403) + mock.post( + f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", + text="Wrong username/password", + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Test flow with proper input, succeed + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + # Open flow as USER with no input + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test flow with duplicate config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test import step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_URL: "http://localhost:8080", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_VERIFY_SSL: True, + } + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test import step already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=YAML_IMPORT, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured"