Add config flow to qBittorrent (#82560)
* qbittorrent: implement config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: add English translations Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: create sensors with config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: set unique_id and icon Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: add tests for config_flow Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: detect duplicate config entries Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: import YAML config Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: update coveragerc Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * qbittorrent: delete translations file * create `deprecated_yaml` issue in `setup_platform` * move qbittorrent test fixtures to conftest.py * improve code quality & remove wrong unique_id * keep PLATFORM_SCHEMA until YAML support is removed * remove CONF_NAME in config entry, fix setup_entry * improve test suite * clean up QBittorrentSensor class * improve user flow tests * explicit result assertion & minor tweaks in tests Co-authored-by: epenet <epenet@users.noreply.github.com> * implement entry unloading Co-authored-by: epenet <epenet@users.noreply.github.com> * add type hints * tweak config_flow data handling --------- Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Co-authored-by: epenet <epenet@users.noreply.github.com>pull/90481/head
parent
5bc9545b81
commit
7c778847e7
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
)
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -339,6 +339,7 @@ FLOWS = {
|
|||
"pushover",
|
||||
"pvoutput",
|
||||
"pvpc_hourly_pricing",
|
||||
"qbittorrent",
|
||||
"qingping",
|
||||
"qnap_qsw",
|
||||
"rachio",
|
||||
|
|
|
@ -4316,7 +4316,7 @@
|
|||
"qbittorrent": {
|
||||
"name": "qBittorrent",
|
||||
"integration_type": "service",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"qingping": {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the qBittorrent integration."""
|
|
@ -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
|
|
@ -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"
|
Loading…
Reference in New Issue