Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f830234
qbittorrent: implement config_flow
chrisx8 Nov 22, 2022
406ab31
qbittorrent: add English translations
chrisx8 Nov 22, 2022
190cb01
qbittorrent: create sensors with config_flow
chrisx8 Nov 22, 2022
9d17737
qbittorrent: set unique_id and icon
chrisx8 Nov 22, 2022
cd480a6
qbittorrent: add tests for config_flow
chrisx8 Nov 23, 2022
95102e8
qbittorrent: detect duplicate config entries
chrisx8 Nov 23, 2022
fe5921c
qbittorrent: import YAML config
chrisx8 Nov 23, 2022
1625d4a
qbittorrent: update coveragerc
chrisx8 Nov 23, 2022
d43e01f
qbittorrent: delete translations file
chrisx8 Mar 23, 2023
c716e32
create `deprecated_yaml` issue in `setup_platform`
chrisx8 Mar 24, 2023
9e136a0
move qbittorrent test fixtures to conftest.py
chrisx8 Mar 24, 2023
14e0a25
improve code quality & remove wrong unique_id
chrisx8 Mar 25, 2023
dd9ca3c
keep PLATFORM_SCHEMA until YAML support is removed
chrisx8 Mar 25, 2023
6f1ab54
remove CONF_NAME in config entry, fix setup_entry
chrisx8 Mar 25, 2023
f0bbb7d
improve test suite
chrisx8 Mar 25, 2023
e0b559f
clean up QBittorrentSensor class
chrisx8 Mar 26, 2023
9546e2a
improve user flow tests
chrisx8 Mar 26, 2023
988a2c0
merge upstream/dev & resolve merge conflict
chrisx8 Mar 26, 2023
1066ffc
explicit result assertion & minor tweaks in tests
chrisx8 Mar 27, 2023
b949e31
implement entry unloading
chrisx8 Mar 27, 2023
46b54f4
add type hints
chrisx8 Mar 28, 2023
5985480
tweak config_flow data handling
chrisx8 Mar 28, 2023
ac36131
Merge remote-tracking branch 'upstream/dev' into qbittorrent
chrisx8 Mar 28, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,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
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions homeassistant/components/qbittorrent/__init__.py
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
chrisx8 marked this conversation as resolved.
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")
Comment thread
MartinHjelmare marked this conversation as resolved.
raise ConfigEntryNotReady from err
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Time to implement reauth flow...

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
76 changes: 76 additions & 0 deletions homeassistant/components/qbittorrent/config_flow.py
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
MartinHjelmare marked this conversation as resolved.
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,
},
)
4 changes: 4 additions & 0 deletions homeassistant/components/qbittorrent/const.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
"""Constants for qBittorrent."""
Comment thread
chrisx8 marked this conversation as resolved.
from typing import Final

DOMAIN: Final = "qbittorrent"

DEFAULT_NAME = "qBittorrent"
DEFAULT_URL = "http://127.0.0.1:8080"
11 changes: 11 additions & 0 deletions homeassistant/components/qbittorrent/helpers.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions homeassistant/components/qbittorrent/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"domain": "qbittorrent",
"name": "qBittorrent",
"codeowners": ["@geoffreylagaisse"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/qbittorrent",
"integration_type": "service",
Comment thread
chrisx8 marked this conversation as resolved.
"iot_class": "local_polling",
Expand Down
60 changes: 36 additions & 24 deletions homeassistant/components/qbittorrent/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
Expand All @@ -23,12 +24,12 @@
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__)

Expand Down Expand Up @@ -69,31 +70,41 @@
)


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."""

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)

"""Set up the qBittorrent platform."""
hass.async_create_task(
Comment thread
chrisx8 marked this conversation as resolved.
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",
)


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):
Expand All @@ -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}"
Copy link
Copy Markdown
Contributor

@epenet epenet Mar 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should now look at adding DeviceInfo and set _attr_has_entity_name accordingly

self._attr_available = False

def update(self) -> None:
Expand Down
27 changes: 27 additions & 0 deletions homeassistant/components/qbittorrent/strings.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
1 change: 1 addition & 0 deletions homeassistant/generated/config_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@
"pushover",
"pvoutput",
"pvpc_hourly_pricing",
"qbittorrent",
"qingping",
"qnap_qsw",
"rachio",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/generated/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -4316,7 +4316,7 @@
"qbittorrent": {
"name": "qBittorrent",
"integration_type": "service",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"qingping": {
Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/components/qbittorrent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the qBittorrent integration."""
25 changes: 25 additions & 0 deletions tests/components/qbittorrent/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Loading