mirror of
https://github.com/home-assistant/core.git
synced 2025-07-08 13:57:10 +00:00
Change qBittorrent lib to qbittorrentapi (#113394)
* Change qBittorrent lib to qbittorrentapi * Fix tests * Convert qbittorrent service to new lib * Add missing translation key * Catch APIConnectionError in service call * Replace type ignore by Any typing * Remove last type: ignore * Use lib type for torrent_filter * Change import format * Fix remaining Any type --------- Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
parent
960d1289ef
commit
80b2b05bd8
@ -3,8 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from qbittorrent.client import LoginRequired
|
from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed
|
||||||
from requests.exceptions import RequestException
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
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_PASSWORD],
|
||||||
config_entry.data[CONF_VERIFY_SSL],
|
config_entry.data[CONF_VERIFY_SSL],
|
||||||
)
|
)
|
||||||
except LoginRequired as err:
|
except LoginFailed as err:
|
||||||
raise ConfigEntryNotReady("Invalid credentials") from err
|
raise ConfigEntryNotReady("Invalid credentials") from err
|
||||||
except RequestException as err:
|
except Forbidden403Error as err:
|
||||||
raise ConfigEntryNotReady("Failed to connect") from 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)
|
coordinator = QBittorrentDataCoordinator(hass, client)
|
||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
@ -5,8 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from qbittorrent.client import LoginRequired
|
from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed
|
||||||
from requests.exceptions import RequestException
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
@ -46,9 +45,9 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
user_input[CONF_PASSWORD],
|
user_input[CONF_PASSWORD],
|
||||||
user_input[CONF_VERIFY_SSL],
|
user_input[CONF_VERIFY_SSL],
|
||||||
)
|
)
|
||||||
except LoginRequired:
|
except (LoginFailed, Forbidden403Error):
|
||||||
errors = {"base": "invalid_auth"}
|
errors = {"base": "invalid_auth"}
|
||||||
except RequestException:
|
except APIConnectionError:
|
||||||
errors = {"base": "cannot_connect"}
|
errors = {"base": "cannot_connect"}
|
||||||
else:
|
else:
|
||||||
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
|
return self.async_create_entry(title=DEFAULT_NAME, data=user_input)
|
||||||
|
@ -4,10 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from qbittorrent import Client
|
from qbittorrentapi import (
|
||||||
from qbittorrent.client import LoginRequired
|
APIConnectionError,
|
||||||
|
Client,
|
||||||
|
Forbidden403Error,
|
||||||
|
LoginFailed,
|
||||||
|
SyncMainDataDictionary,
|
||||||
|
TorrentInfoList,
|
||||||
|
)
|
||||||
|
from qbittorrentapi.torrents import TorrentStatusesT
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@ -18,8 +24,8 @@ from .const import DOMAIN
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]):
|
||||||
"""Coordinator for updating qBittorrent data."""
|
"""Coordinator for updating QBittorrent data."""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, client: Client) -> None:
|
def __init__(self, hass: HomeAssistant, client: Client) -> None:
|
||||||
"""Initialize coordinator."""
|
"""Initialize coordinator."""
|
||||||
@ -39,22 +45,31 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
update_interval=timedelta(seconds=30),
|
update_interval=timedelta(seconds=30),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> SyncMainDataDictionary:
|
||||||
"""Async method to update QBittorrent data."""
|
|
||||||
try:
|
try:
|
||||||
return await self.hass.async_add_executor_job(self.client.sync_main_data)
|
return await self.hass.async_add_executor_job(self.client.sync_maindata)
|
||||||
except LoginRequired as exc:
|
except (LoginFailed, Forbidden403Error) 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:
|
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN, translation_key="login_error"
|
translation_domain=DOMAIN, translation_key="login_error"
|
||||||
) from exc
|
) 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
|
return torrents
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
"""Helper functions for qBittorrent."""
|
"""Helper functions for qBittorrent."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
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:
|
def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Client:
|
||||||
"""Create a qBittorrent client."""
|
"""Create a qBittorrent client."""
|
||||||
client = Client(url, verify=verify_ssl)
|
|
||||||
client.login(username, password)
|
client = Client(
|
||||||
# Get an arbitrary attribute to test if connection succeeds
|
url, username=username, password=password, VERIFY_WEBUI_CERTIFICATE=verify_ssl
|
||||||
client.get_alternative_speed_status()
|
)
|
||||||
|
client.auth_log_in(username, password)
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
@ -31,23 +32,24 @@ def format_unix_timestamp(timestamp) -> str:
|
|||||||
return dt_object.isoformat()
|
return dt_object.isoformat()
|
||||||
|
|
||||||
|
|
||||||
def format_progress(torrent) -> str:
|
def format_progress(torrent: TorrentDictionary) -> str:
|
||||||
"""Format the progress of a torrent."""
|
"""Format the progress of a torrent."""
|
||||||
progress = torrent["progress"]
|
progress = cast(float, torrent["progress"]) * 100
|
||||||
progress = float(progress) * 100
|
|
||||||
return f"{progress:.2f}"
|
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."""
|
"""Format a list of torrents."""
|
||||||
value = {}
|
value = {}
|
||||||
for torrent in torrents:
|
for torrent in torrents:
|
||||||
value[torrent["name"]] = format_torrent(torrent)
|
value[str(torrent["name"])] = format_torrent(torrent)
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def format_torrent(torrent) -> dict[str, Any]:
|
def format_torrent(torrent: TorrentDictionary) -> dict[str, Any]:
|
||||||
"""Format a single torrent."""
|
"""Format a single torrent."""
|
||||||
value = {}
|
value = {}
|
||||||
value["id"] = torrent["hash"]
|
value["id"] = torrent["hash"]
|
||||||
@ -55,6 +57,7 @@ def format_torrent(torrent) -> dict[str, Any]:
|
|||||||
value["percent_done"] = format_progress(torrent)
|
value["percent_done"] = format_progress(torrent)
|
||||||
value["status"] = torrent["state"]
|
value["status"] = torrent["state"]
|
||||||
value["eta"] = seconds_to_hhmmss(torrent["eta"])
|
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
|
return value
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["qbittorrent"],
|
"loggers": ["qbittorrent"],
|
||||||
"requirements": ["python-qbittorrent==0.4.3"]
|
"requirements": ["qbittorrent-api==2024.2.59"]
|
||||||
}
|
}
|
||||||
|
@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@ -35,8 +36,9 @@ SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents"
|
|||||||
|
|
||||||
def get_state(coordinator: QBittorrentDataCoordinator) -> str:
|
def get_state(coordinator: QBittorrentDataCoordinator) -> str:
|
||||||
"""Get current download/upload state."""
|
"""Get current download/upload state."""
|
||||||
upload = coordinator.data["server_state"]["up_info_speed"]
|
server_state = cast(Mapping, coordinator.data.get("server_state"))
|
||||||
download = coordinator.data["server_state"]["dl_info_speed"]
|
upload = cast(int, server_state.get("up_info_speed"))
|
||||||
|
download = cast(int, server_state.get("dl_info_speed"))
|
||||||
|
|
||||||
if upload > 0 and download > 0:
|
if upload > 0 and download > 0:
|
||||||
return STATE_UP_DOWN
|
return STATE_UP_DOWN
|
||||||
@ -47,6 +49,18 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str:
|
|||||||
return STATE_IDLE
|
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)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class QBittorrentSensorEntityDescription(SensorEntityDescription):
|
class QBittorrentSensorEntityDescription(SensorEntityDescription):
|
||||||
"""Entity description class for qBittorent sensors."""
|
"""Entity description class for qBittorent sensors."""
|
||||||
@ -69,9 +83,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
|
|||||||
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
|
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
|
||||||
suggested_display_precision=2,
|
suggested_display_precision=2,
|
||||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
|
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
|
||||||
value_fn=lambda coordinator: float(
|
value_fn=get_dl,
|
||||||
coordinator.data["server_state"]["dl_info_speed"]
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
QBittorrentSensorEntityDescription(
|
QBittorrentSensorEntityDescription(
|
||||||
key=SENSOR_TYPE_UPLOAD_SPEED,
|
key=SENSOR_TYPE_UPLOAD_SPEED,
|
||||||
@ -80,9 +92,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
|
|||||||
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
|
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
|
||||||
suggested_display_precision=2,
|
suggested_display_precision=2,
|
||||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
|
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
|
||||||
value_fn=lambda coordinator: float(
|
value_fn=get_up,
|
||||||
coordinator.data["server_state"]["up_info_speed"]
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
QBittorrentSensorEntityDescription(
|
QBittorrentSensorEntityDescription(
|
||||||
key=SENSOR_TYPE_ALL_TORRENTS,
|
key=SENSOR_TYPE_ALL_TORRENTS,
|
||||||
@ -165,16 +175,12 @@ def count_torrents_in_states(
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""Count the number of torrents in specified states."""
|
"""Count the number of torrents in specified states."""
|
||||||
# When torrents are not in the returned data, there are none, return 0.
|
# When torrents are not in the returned data, there are none, return 0.
|
||||||
if "torrents" not in coordinator.data:
|
try:
|
||||||
return 0
|
torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents"))
|
||||||
|
|
||||||
if not states:
|
if not states:
|
||||||
return len(coordinator.data["torrents"])
|
return len(torrents)
|
||||||
|
|
||||||
return len(
|
return len(
|
||||||
[
|
[torrent for torrent in torrents.values() if torrent.get("state") in states]
|
||||||
torrent
|
|
||||||
for torrent in coordinator.data["torrents"].values()
|
|
||||||
if torrent["state"] in states
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
except AttributeError:
|
||||||
|
return 0
|
||||||
|
@ -84,6 +84,9 @@
|
|||||||
},
|
},
|
||||||
"login_error": {
|
"login_error": {
|
||||||
"message": "A login error occured. Please check you username and password."
|
"message": "A login error occured. Please check you username and password."
|
||||||
|
},
|
||||||
|
"cannot_connect": {
|
||||||
|
"message": "Can't connect to QBittorrent, please check your configuration."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2302,9 +2302,6 @@ python-otbr-api==2.6.0
|
|||||||
# homeassistant.components.picnic
|
# homeassistant.components.picnic
|
||||||
python-picnic-api==1.1.0
|
python-picnic-api==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.qbittorrent
|
|
||||||
python-qbittorrent==0.4.3
|
|
||||||
|
|
||||||
# homeassistant.components.rabbitair
|
# homeassistant.components.rabbitair
|
||||||
python-rabbitair==0.0.8
|
python-rabbitair==0.0.8
|
||||||
|
|
||||||
@ -2420,6 +2417,9 @@ pyzbar==0.1.7
|
|||||||
# homeassistant.components.zerproc
|
# homeassistant.components.zerproc
|
||||||
pyzerproc==0.4.8
|
pyzerproc==0.4.8
|
||||||
|
|
||||||
|
# homeassistant.components.qbittorrent
|
||||||
|
qbittorrent-api==2024.2.59
|
||||||
|
|
||||||
# homeassistant.components.qingping
|
# homeassistant.components.qingping
|
||||||
qingping-ble==0.10.0
|
qingping-ble==0.10.0
|
||||||
|
|
||||||
|
@ -1799,9 +1799,6 @@ python-otbr-api==2.6.0
|
|||||||
# homeassistant.components.picnic
|
# homeassistant.components.picnic
|
||||||
python-picnic-api==1.1.0
|
python-picnic-api==1.1.0
|
||||||
|
|
||||||
# homeassistant.components.qbittorrent
|
|
||||||
python-qbittorrent==0.4.3
|
|
||||||
|
|
||||||
# homeassistant.components.rabbitair
|
# homeassistant.components.rabbitair
|
||||||
python-rabbitair==0.0.8
|
python-rabbitair==0.0.8
|
||||||
|
|
||||||
@ -1893,6 +1890,9 @@ pyyardian==1.1.1
|
|||||||
# homeassistant.components.zerproc
|
# homeassistant.components.zerproc
|
||||||
pyzerproc==0.4.8
|
pyzerproc==0.4.8
|
||||||
|
|
||||||
|
# homeassistant.components.qbittorrent
|
||||||
|
qbittorrent-api==2024.2.59
|
||||||
|
|
||||||
# homeassistant.components.qingping
|
# homeassistant.components.qingping
|
||||||
qingping-ble==0.10.0
|
qingping-ble==0.10.0
|
||||||
|
|
||||||
|
@ -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
|
# Test flow with wrong creds, fail with invalid_auth
|
||||||
with requests_mock.Mocker() as mock:
|
with requests_mock.Mocker() as mock:
|
||||||
mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/transfer/speedLimitsMode")
|
mock.head(USER_INPUT[CONF_URL])
|
||||||
mock.get(f"{USER_INPUT[CONF_URL]}/api/v2/app/preferences", status_code=403)
|
|
||||||
mock.post(
|
mock.post(
|
||||||
f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
|
f"{USER_INPUT[CONF_URL]}/api/v2/auth/login",
|
||||||
text="Wrong username/password",
|
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"}
|
assert result["errors"] == {"base": "invalid_auth"}
|
||||||
|
|
||||||
# Test flow with proper input, succeed
|
# Test flow with proper input, succeed
|
||||||
|
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 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"], USER_INPUT
|
result["flow_id"], USER_INPUT
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||||
|
|
||||||
assert result["data"] == {
|
assert result["data"] == {
|
||||||
CONF_URL: "http://localhost:8080",
|
CONF_URL: "http://localhost:8080",
|
||||||
CONF_USERNAME: "user",
|
CONF_USERNAME: "user",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user