diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 84f080c4d49..fb781dd1a0c 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -3,8 +3,7 @@ import logging from typing import Any -from qbittorrent.client import LoginRequired -from requests.exceptions import RequestException +from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -118,10 +117,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_PASSWORD], config_entry.data[CONF_VERIFY_SSL], ) - except LoginRequired as err: + except LoginFailed as err: raise ConfigEntryNotReady("Invalid credentials") from err - except RequestException as err: - raise ConfigEntryNotReady("Failed to connect") from err + except Forbidden403Error as err: + raise ConfigEntryNotReady("Fail to log in, banned user ?") from err + except APIConnectionError as exc: + raise ConfigEntryNotReady("Fail to connect to qBittorrent") from exc + coordinator = QBittorrentDataCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py index c17c842529b..fb9bde4805f 100644 --- a/homeassistant/components/qbittorrent/config_flow.py +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -5,8 +5,7 @@ from __future__ import annotations import logging from typing import Any -from qbittorrent.client import LoginRequired -from requests.exceptions import RequestException +from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -46,9 +45,9 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_PASSWORD], user_input[CONF_VERIFY_SSL], ) - except LoginRequired: + except (LoginFailed, Forbidden403Error): errors = {"base": "invalid_auth"} - except RequestException: + except APIConnectionError: errors = {"base": "cannot_connect"} else: return self.async_create_entry(title=DEFAULT_NAME, data=user_input) diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 850bcf15ca2..0ef36d2a954 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -4,10 +4,16 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any -from qbittorrent import Client -from qbittorrent.client import LoginRequired +from qbittorrentapi import ( + APIConnectionError, + Client, + Forbidden403Error, + LoginFailed, + SyncMainDataDictionary, + TorrentInfoList, +) +from qbittorrentapi.torrents import TorrentStatusesT from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -18,8 +24,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Coordinator for updating qBittorrent data.""" +class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): + """Coordinator for updating QBittorrent data.""" def __init__(self, hass: HomeAssistant, client: Client) -> None: """Initialize coordinator.""" @@ -39,22 +45,31 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(seconds=30), ) - async def _async_update_data(self) -> dict[str, Any]: - """Async method to update QBittorrent data.""" + async def _async_update_data(self) -> SyncMainDataDictionary: try: - return await self.hass.async_add_executor_job(self.client.sync_main_data) - except LoginRequired as exc: - raise HomeAssistantError(str(exc)) from exc - - async def get_torrents(self, torrent_filter: str) -> list[dict[str, Any]]: - """Async method to get QBittorrent torrents.""" - try: - torrents = await self.hass.async_add_executor_job( - lambda: self.client.torrents(filter=torrent_filter) - ) - except LoginRequired as exc: + return await self.hass.async_add_executor_job(self.client.sync_maindata) + except (LoginFailed, Forbidden403Error) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="login_error" ) from exc + except APIConnectionError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from exc + + async def get_torrents(self, torrent_filter: TorrentStatusesT) -> TorrentInfoList: + """Async method to get QBittorrent torrents.""" + try: + torrents = await self.hass.async_add_executor_job( + lambda: self.client.torrents_info(torrent_filter) + ) + except (LoginFailed, Forbidden403Error) as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="login_error" + ) from exc + except APIConnectionError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from exc return torrents diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py index bbe53765f8b..fac0a6033fa 100644 --- a/homeassistant/components/qbittorrent/helpers.py +++ b/homeassistant/components/qbittorrent/helpers.py @@ -1,17 +1,18 @@ """Helper functions for qBittorrent.""" from datetime import UTC, datetime -from typing import Any +from typing import Any, cast -from qbittorrent.client import Client +from qbittorrentapi import Client, TorrentDictionary, TorrentInfoList 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() + + client = Client( + url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=verify_ssl + ) + client.auth_log_in(username, password) return client @@ -31,23 +32,24 @@ def format_unix_timestamp(timestamp) -> str: return dt_object.isoformat() -def format_progress(torrent) -> str: +def format_progress(torrent: TorrentDictionary) -> str: """Format the progress of a torrent.""" - progress = torrent["progress"] - progress = float(progress) * 100 + progress = cast(float, torrent["progress"]) * 100 return f"{progress:.2f}" -def format_torrents(torrents: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: +def format_torrents( + torrents: TorrentInfoList, +) -> dict[str, dict[str, Any]]: """Format a list of torrents.""" value = {} for torrent in torrents: - value[torrent["name"]] = format_torrent(torrent) + value[str(torrent["name"])] = format_torrent(torrent) return value -def format_torrent(torrent) -> dict[str, Any]: +def format_torrent(torrent: TorrentDictionary) -> dict[str, Any]: """Format a single torrent.""" value = {} value["id"] = torrent["hash"] @@ -55,6 +57,7 @@ def format_torrent(torrent) -> dict[str, Any]: value["percent_done"] = format_progress(torrent) value["status"] = torrent["state"] value["eta"] = seconds_to_hhmmss(torrent["eta"]) - value["ratio"] = "{:.2f}".format(float(torrent["ratio"])) + ratio = cast(float, torrent["ratio"]) + value["ratio"] = f"{ratio:.2f}" return value diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index fb51f177081..bd9897aa6ba 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], - "requirements": ["python-qbittorrent==0.4.3"] + "requirements": ["qbittorrent-api==2024.2.59"] } diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 84eac7d28cf..cd65fb766e4 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass import logging +from typing import Any, cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -35,8 +36,9 @@ SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents" def get_state(coordinator: QBittorrentDataCoordinator) -> str: """Get current download/upload state.""" - upload = coordinator.data["server_state"]["up_info_speed"] - download = coordinator.data["server_state"]["dl_info_speed"] + server_state = cast(Mapping, coordinator.data.get("server_state")) + upload = cast(int, server_state.get("up_info_speed")) + download = cast(int, server_state.get("dl_info_speed")) if upload > 0 and download > 0: return STATE_UP_DOWN @@ -47,6 +49,18 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str: return STATE_IDLE +def get_dl(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("dl_info_speed")) + + +def get_up(coordinator: QBittorrentDataCoordinator) -> int: + """Get current upload speed.""" + server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) + return cast(int, server_state.get("up_info_speed")) + + @dataclass(frozen=True, kw_only=True) class QBittorrentSensorEntityDescription(SensorEntityDescription): """Entity description class for qBittorent sensors.""" @@ -69,9 +83,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=lambda coordinator: float( - coordinator.data["server_state"]["dl_info_speed"] - ), + value_fn=get_dl, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, @@ -80,9 +92,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=lambda coordinator: float( - coordinator.data["server_state"]["up_info_speed"] - ), + value_fn=get_up, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ALL_TORRENTS, @@ -165,16 +175,12 @@ def count_torrents_in_states( ) -> int: """Count the number of torrents in specified states.""" # When torrents are not in the returned data, there are none, return 0. - if "torrents" not in coordinator.data: + try: + torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents")) + if not states: + return len(torrents) + return len( + [torrent for torrent in torrents.values() if torrent.get("state") in states] + ) + except AttributeError: return 0 - - if not states: - return len(coordinator.data["torrents"]) - - return len( - [ - torrent - for torrent in coordinator.data["torrents"].values() - if torrent["state"] in states - ] - ) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 5376e929429..948e9dca8e9 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -84,6 +84,9 @@ }, "login_error": { "message": "A login error occured. Please check you username and password." + }, + "cannot_connect": { + "message": "Can't connect to QBittorrent, please check your configuration." } } } diff --git a/requirements_all.txt b/requirements_all.txt index cd382d9b1c7..730f4c32633 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2302,9 +2302,6 @@ python-otbr-api==2.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 -# homeassistant.components.qbittorrent -python-qbittorrent==0.4.3 - # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -2420,6 +2417,9 @@ pyzbar==0.1.7 # homeassistant.components.zerproc pyzerproc==0.4.8 +# homeassistant.components.qbittorrent +qbittorrent-api==2024.2.59 + # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e84a8a345ed..a7fc37a5473 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1799,9 +1799,6 @@ python-otbr-api==2.6.0 # homeassistant.components.picnic python-picnic-api==1.1.0 -# homeassistant.components.qbittorrent -python-qbittorrent==0.4.3 - # homeassistant.components.rabbitair python-rabbitair==0.0.8 @@ -1893,6 +1890,9 @@ pyyardian==1.1.1 # homeassistant.components.zerproc pyzerproc==0.4.8 +# homeassistant.components.qbittorrent +qbittorrent-api==2024.2.59 + # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py index c52762f24d3..abf64713f50 100644 --- a/tests/components/qbittorrent/test_config_flow.py +++ b/tests/components/qbittorrent/test_config_flow.py @@ -59,8 +59,7 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> # 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.head(USER_INPUT[CONF_URL]) mock.post( f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", text="Wrong username/password", @@ -74,11 +73,18 @@ async def test_flow_user(hass: HomeAssistant, mock_api: requests_mock.Mocker) -> 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"] is FlowResultType.CREATE_ENTRY + with requests_mock.Mocker() as mock: + mock.head(USER_INPUT[CONF_URL]) + mock.post( + f"{USER_INPUT[CONF_URL]}/api/v2/auth/login", + text="Ok.", + ) + 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",