diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 7b1a38b7e31..84f080c4d49 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -1,29 +1,111 @@ """The qbittorrent component.""" import logging +from typing import Any from qbittorrent.client import LoginRequired from requests.exceptions import RequestException from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_DEVICE_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import ( + DOMAIN, + SERVICE_GET_ALL_TORRENTS, + SERVICE_GET_TORRENTS, + STATE_ATTR_ALL_TORRENTS, + STATE_ATTR_TORRENTS, + TORRENT_FILTER, +) from .coordinator import QBittorrentDataCoordinator -from .helpers import setup_client +from .helpers import format_torrents, setup_client _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + PLATFORMS = [Platform.SENSOR] +CONF_ENTRY = "entry" + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up qBittorrent services.""" + + async def handle_get_torrents(service_call: ServiceCall) -> dict[str, Any] | None: + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(service_call.data[ATTR_DEVICE_ID]) + + if device_entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device", + translation_placeholders={ + "device_id": service_call.data[ATTR_DEVICE_ID] + }, + ) + + entry_id = None + + for key, value in device_entry.identifiers: + if key == DOMAIN: + entry_id = value + break + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry_id", + translation_placeholders={"device_id": entry_id or ""}, + ) + + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][entry_id] + items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER]) + info = format_torrents(items) + return { + STATE_ATTR_TORRENTS: info, + } + + hass.services.async_register( + DOMAIN, + SERVICE_GET_TORRENTS, + handle_get_torrents, + supports_response=SupportsResponse.ONLY, + ) + + async def handle_get_all_torrents( + service_call: ServiceCall, + ) -> dict[str, Any] | None: + torrents = {} + + for key, value in hass.data[DOMAIN].items(): + coordinator: QBittorrentDataCoordinator = value + items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER]) + torrents[key] = format_torrents(items) + + return { + STATE_ATTR_ALL_TORRENTS: torrents, + } + + hass.services.async_register( + DOMAIN, + SERVICE_GET_ALL_TORRENTS, + handle_get_all_torrents, + supports_response=SupportsResponse.ONLY, + ) + + return True + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up qBittorrent from a config entry.""" diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index d8fe2c012a3..73e29d06f40 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -7,6 +7,13 @@ DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" DEFAULT_URL = "http://127.0.0.1:8080" +STATE_ATTR_TORRENTS = "torrents" +STATE_ATTR_ALL_TORRENTS = "all_torrents" + STATE_UP_DOWN = "up_down" STATE_SEEDING = "seeding" STATE_DOWNLOADING = "downloading" + +SERVICE_GET_TORRENTS = "get_torrents" +SERVICE_GET_ALL_TORRENTS = "get_all_torrents" +TORRENT_FILTER = "torrent_filter" diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 32ce4cf9711..850bcf15ca2 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -10,7 +10,7 @@ from qbittorrent import Client from qbittorrent.client import LoginRequired from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -19,11 +19,18 @@ _LOGGER = logging.getLogger(__name__) class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Coordinator for updating QBittorrent data.""" + """Coordinator for updating qBittorrent data.""" def __init__(self, hass: HomeAssistant, client: Client) -> None: """Initialize coordinator.""" self.client = client + # self.main_data: dict[str, int] = {} + self.total_torrents: dict[str, int] = {} + self.active_torrents: dict[str, int] = {} + self.inactive_torrents: dict[str, int] = {} + self.paused_torrents: dict[str, int] = {} + self.seeding_torrents: dict[str, int] = {} + self.started_torrents: dict[str, int] = {} super().__init__( hass, @@ -33,7 +40,21 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) async def _async_update_data(self) -> dict[str, Any]: + """Async method to update QBittorrent data.""" try: return await self.hass.async_add_executor_job(self.client.sync_main_data) except LoginRequired as exc: - raise ConfigEntryError("Invalid authentication") from 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( + translation_domain=DOMAIN, translation_key="login_error" + ) from exc + + return torrents diff --git a/homeassistant/components/qbittorrent/helpers.py b/homeassistant/components/qbittorrent/helpers.py index b9c29675473..bbe53765f8b 100644 --- a/homeassistant/components/qbittorrent/helpers.py +++ b/homeassistant/components/qbittorrent/helpers.py @@ -1,5 +1,8 @@ """Helper functions for qBittorrent.""" +from datetime import UTC, datetime +from typing import Any + from qbittorrent.client import Client @@ -10,3 +13,48 @@ def setup_client(url: str, username: str, password: str, verify_ssl: bool) -> Cl # Get an arbitrary attribute to test if connection succeeds client.get_alternative_speed_status() return client + + +def seconds_to_hhmmss(seconds) -> str: + """Convert seconds to HH:MM:SS format.""" + if seconds == 8640000: + return "None" + + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + return f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}" + + +def format_unix_timestamp(timestamp) -> str: + """Format a UNIX timestamp to a human-readable date.""" + dt_object = datetime.fromtimestamp(timestamp, tz=UTC) + return dt_object.isoformat() + + +def format_progress(torrent) -> str: + """Format the progress of a torrent.""" + progress = torrent["progress"] + progress = float(progress) * 100 + return f"{progress:.2f}" + + +def format_torrents(torrents: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """Format a list of torrents.""" + value = {} + for torrent in torrents: + value[torrent["name"]] = format_torrent(torrent) + + return value + + +def format_torrent(torrent) -> dict[str, Any]: + """Format a single torrent.""" + value = {} + value["id"] = torrent["hash"] + value["added_date"] = format_unix_timestamp(torrent["added_on"]) + value["percent_done"] = format_progress(torrent) + value["status"] = torrent["state"] + value["eta"] = seconds_to_hhmmss(torrent["eta"]) + value["ratio"] = "{:.2f}".format(float(torrent["ratio"])) + + return value diff --git a/homeassistant/components/qbittorrent/icons.json b/homeassistant/components/qbittorrent/icons.json index bb458c751e1..68fc1020dae 100644 --- a/homeassistant/components/qbittorrent/icons.json +++ b/homeassistant/components/qbittorrent/icons.json @@ -8,5 +8,9 @@ "default": "mdi:cloud-upload" } } + }, + "services": { + "get_torrents": "mdi:file-arrow-up-down-outline", + "get_all_torrents": "mdi:file-arrow-up-down-outline" } } diff --git a/homeassistant/components/qbittorrent/services.yaml b/homeassistant/components/qbittorrent/services.yaml new file mode 100644 index 00000000000..f7fc6b95f64 --- /dev/null +++ b/homeassistant/components/qbittorrent/services.yaml @@ -0,0 +1,35 @@ +get_torrents: + fields: + device_id: + required: true + selector: + device: + integration: qbittorrent + torrent_filter: + required: true + example: "all" + default: "all" + selector: + select: + options: + - "active" + - "inactive" + - "paused" + - "all" + - "seeding" + - "started" +get_all_torrents: + fields: + torrent_filter: + required: true + example: "all" + default: "all" + selector: + select: + options: + - "active" + - "inactive" + - "paused" + - "all" + - "seeding" + - "started" diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 8b20a3354dd..5376e929429 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -48,5 +48,42 @@ "name": "All torrents" } } + }, + "services": { + "get_torrents": { + "name": "Get torrents", + "description": "Gets a list of current torrents", + "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "Which service to grab the list from" + }, + "torrent_filter": { + "name": "Torrent filter", + "description": "What kind of torrents you want to return, such as All or Active." + } + } + }, + "get_all_torrents": { + "name": "Get all torrents", + "description": "Gets a list of current torrents from all instances of qBittorrent", + "fields": { + "torrent_filter": { + "name": "Torrent filter", + "description": "What kind of torrents you want to return, such as All or Active." + } + } + } + }, + "exceptions": { + "invalid_device": { + "message": "No device with id {device_id} was found" + }, + "invalid_entry_id": { + "message": "No entry with id {device_id} was found" + }, + "login_error": { + "message": "A login error occured. Please check you username and password." + } } } diff --git a/tests/components/qbittorrent/test_helpers.py b/tests/components/qbittorrent/test_helpers.py new file mode 100644 index 00000000000..b308cd33aec --- /dev/null +++ b/tests/components/qbittorrent/test_helpers.py @@ -0,0 +1,108 @@ +"""Test the qBittorrent helpers.""" + +from homeassistant.components.qbittorrent.helpers import ( + format_progress, + format_torrent, + format_torrents, + format_unix_timestamp, + seconds_to_hhmmss, +) +from homeassistant.core import HomeAssistant + + +async def test_seconds_to_hhmmss( + hass: HomeAssistant, +) -> None: + """Test the seconds_to_hhmmss function.""" + assert seconds_to_hhmmss(8640000) == "None" + assert seconds_to_hhmmss(3661) == "01:01:01" + + +async def test_format_unix_timestamp( + hass: HomeAssistant, +) -> None: + """Test the format_unix_timestamp function.""" + assert format_unix_timestamp(1640995200) == "2022-01-01T00:00:00+00:00" + + +async def test_format_progress( + hass: HomeAssistant, +) -> None: + """Test the format_progress function.""" + assert format_progress({"progress": 0.5}) == "50.00" + + +async def test_format_torrents( + hass: HomeAssistant, +) -> None: + """Test the format_torrents function.""" + torrents_data = [ + { + "name": "torrent1", + "hash": "hash1", + "added_on": 1640995200, + "progress": 0.5, + "state": "paused", + "eta": 86400, + "ratio": 1.0, + }, + { + "name": "torrent2", + "hash": "hash1", + "added_on": 1640995200, + "progress": 0.5, + "state": "paused", + "eta": 86400, + "ratio": 1.0, + }, + ] + + expected_result = { + "torrent1": { + "id": "hash1", + "added_date": "2022-01-01T00:00:00+00:00", + "percent_done": "50.00", + "status": "paused", + "eta": "24:00:00", + "ratio": "1.00", + }, + "torrent2": { + "id": "hash1", + "added_date": "2022-01-01T00:00:00+00:00", + "percent_done": "50.00", + "status": "paused", + "eta": "24:00:00", + "ratio": "1.00", + }, + } + + result = format_torrents(torrents_data) + + assert result == expected_result + + +async def test_format_torrent( + hass: HomeAssistant, +) -> None: + """Test the format_torrent function.""" + torrent_data = { + "hash": "hash1", + "added_on": 1640995200, + "progress": 0.5, + "state": "paused", + "eta": 86400, + "ratio": 1.0, + } + + expected_result = { + "id": "hash1", + "added_date": "2022-01-01T00:00:00+00:00", + "percent_done": "50.00", + "status": "paused", + "eta": "24:00:00", + "ratio": "1.00", + } + + result = format_torrent(torrent_data) + + assert result == expected_result