diff --git a/CODEOWNERS b/CODEOWNERS index be3a7755e33..547845f9421 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1697,8 +1697,8 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_train/ @gjohansson-ST /homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST /tests/components/trafikverket_weatherstation/ @gjohansson-ST -/homeassistant/components/transmission/ @engrbm87 @JPHutchins -/tests/components/transmission/ @engrbm87 @JPHutchins +/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp +/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp /homeassistant/components/trend/ @jpbede /tests/components/trend/ @jpbede /homeassistant/components/triggercmd/ @rvmey diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index c232f26cefd..da4cc6ed5c8 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -40,9 +40,12 @@ STATE_ATTR_TORRENT_INFO = "torrent_info" ATTR_DELETE_DATA = "delete_data" ATTR_TORRENT = "torrent" +ATTR_TORRENTS = "torrents" ATTR_DOWNLOAD_PATH = "download_path" +ATTR_TORRENT_FILTER = "torrent_filter" SERVICE_ADD_TORRENT = "add_torrent" +SERVICE_GET_TORRENTS = "get_torrents" SERVICE_REMOVE_TORRENT = "remove_torrent" SERVICE_START_TORRENT = "start_torrent" SERVICE_STOP_TORRENT = "stop_torrent" @@ -54,3 +57,14 @@ EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent" STATE_UP_DOWN = "up_down" STATE_SEEDING = "seeding" STATE_DOWNLOADING = "downloading" + +FILTER_MODES: dict[str, list[str] | None] = { + "started": ["downloading"], + "completed": ["seeding"], + "paused": ["stopped"], + "active": [ + "seeding", + "downloading", + ], + "all": None, +} diff --git a/homeassistant/components/transmission/helpers.py b/homeassistant/components/transmission/helpers.py new file mode 100644 index 00000000000..4a3ddc28b27 --- /dev/null +++ b/homeassistant/components/transmission/helpers.py @@ -0,0 +1,45 @@ +"""Helper functions for Transmission.""" + +from typing import Any + +from transmission_rpc.torrent import Torrent + + +def format_torrent(torrent: Torrent) -> dict[str, Any]: + """Format a single torrent.""" + value: dict[str, Any] = {} + + value["id"] = torrent.id + value["name"] = torrent.name + value["status"] = torrent.status.value + value["percent_done"] = f"{torrent.percent_done * 100:.2f}%" + value["ratio"] = f"{torrent.ratio:.2f}" + value["eta"] = str(torrent.eta) if torrent.eta else None + value["added_date"] = torrent.added_date.isoformat() + value["done_date"] = torrent.done_date.isoformat() if torrent.done_date else None + value["download_dir"] = torrent.download_dir + value["labels"] = torrent.labels + + return value + + +def filter_torrents( + torrents: list[Torrent], statuses: list[str] | None = None +) -> list[Torrent]: + """Filter torrents based on the statuses provided.""" + return [ + torrent + for torrent in torrents + if statuses is None or torrent.status in statuses + ] + + +def format_torrents( + torrents: list[Torrent], +) -> dict[str, dict[str, Any]]: + """Format a list of torrents.""" + value = {} + for torrent in torrents: + value[torrent.name] = format_torrent(torrent) + + return value diff --git a/homeassistant/components/transmission/icons.json b/homeassistant/components/transmission/icons.json index 287f9f501b0..20b296e9fc0 100644 --- a/homeassistant/components/transmission/icons.json +++ b/homeassistant/components/transmission/icons.json @@ -42,6 +42,9 @@ "add_torrent": { "service": "mdi:download" }, + "get_torrents": { + "service": "mdi:file-arrow-up-down-outline" + }, "remove_torrent": { "service": "mdi:download-off" }, diff --git a/homeassistant/components/transmission/manifest.json b/homeassistant/components/transmission/manifest.json index 69ed258f511..6c6d18517db 100644 --- a/homeassistant/components/transmission/manifest.json +++ b/homeassistant/components/transmission/manifest.json @@ -1,7 +1,7 @@ { "domain": "transmission", "name": "Transmission", - "codeowners": ["@engrbm87", "@JPHutchins"], + "codeowners": ["@engrbm87", "@JPHutchins", "@andrew-codechimp"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/transmission", "integration_type": "service", diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index f6a0c0f9066..adf778c0158 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -7,8 +7,6 @@ from contextlib import suppress from dataclasses import dataclass from typing import Any -from transmission_rpc.torrent import Torrent - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -20,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( + FILTER_MODES, STATE_ATTR_TORRENT_INFO, STATE_DOWNLOADING, STATE_SEEDING, @@ -28,20 +27,10 @@ from .const import ( ) from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator from .entity import TransmissionEntity +from .helpers import filter_torrents PARALLEL_UPDATES = 0 -MODES: dict[str, list[str] | None] = { - "started_torrents": ["downloading"], - "completed_torrents": ["seeding"], - "paused_torrents": ["stopped"], - "active_torrents": [ - "seeding", - "downloading", - ], - "total_torrents": None, -} - @dataclass(frozen=True, kw_only=True) class TransmissionSensorEntityDescription(SensorEntityDescription): @@ -84,7 +73,7 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( translation_key="active_torrents", val_func=lambda coordinator: coordinator.data.active_torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( - coordinator=coordinator, key="active_torrents" + coordinator=coordinator, key="active" ), ), TransmissionSensorEntityDescription( @@ -92,7 +81,7 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( translation_key="paused_torrents", val_func=lambda coordinator: coordinator.data.paused_torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( - coordinator=coordinator, key="paused_torrents" + coordinator=coordinator, key="paused" ), ), TransmissionSensorEntityDescription( @@ -100,27 +89,27 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( translation_key="total_torrents", val_func=lambda coordinator: coordinator.data.torrent_count, extra_state_attr_func=lambda coordinator: _torrents_info_attr( - coordinator=coordinator, key="total_torrents" + coordinator=coordinator, key="total" ), ), TransmissionSensorEntityDescription( key="completed_torrents", translation_key="completed_torrents", val_func=lambda coordinator: len( - _filter_torrents(coordinator.torrents, MODES["completed_torrents"]) + filter_torrents(coordinator.torrents, FILTER_MODES["completed"]) ), extra_state_attr_func=lambda coordinator: _torrents_info_attr( - coordinator=coordinator, key="completed_torrents" + coordinator=coordinator, key="completed" ), ), TransmissionSensorEntityDescription( key="started_torrents", translation_key="started_torrents", val_func=lambda coordinator: len( - _filter_torrents(coordinator.torrents, MODES["started_torrents"]) + filter_torrents(coordinator.torrents, FILTER_MODES["started"]) ), extra_state_attr_func=lambda coordinator: _torrents_info_attr( - coordinator=coordinator, key="started_torrents" + coordinator=coordinator, key="started" ), ), ) @@ -169,21 +158,11 @@ def get_state(upload: int, download: int) -> str: return STATE_IDLE -def _filter_torrents( - torrents: list[Torrent], statuses: list[str] | None = None -) -> list[Torrent]: - return [ - torrent - for torrent in torrents - if statuses is None or torrent.status in statuses - ] - - def _torrents_info_attr( coordinator: TransmissionDataUpdateCoordinator, key: str ) -> dict[str, Any]: infos = {} - torrents = _filter_torrents(coordinator.torrents, MODES[key]) + torrents = filter_torrents(coordinator.torrents, FILTER_MODES.get(key)) torrents = SUPPORTED_ORDER_MODES[coordinator.order](torrents) for torrent in torrents[: coordinator.limit]: info = infos[torrent.name] = { diff --git a/homeassistant/components/transmission/services.py b/homeassistant/components/transmission/services.py index ff03583e470..bcce2e10c15 100644 --- a/homeassistant/components/transmission/services.py +++ b/homeassistant/components/transmission/services.py @@ -1,14 +1,16 @@ """Define services for the Transmission integration.""" +from enum import StrEnum from functools import partial import logging -from typing import cast +from typing import Any, cast +from transmission_rpc import Torrent import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ID -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, selector @@ -16,18 +18,34 @@ from .const import ( ATTR_DELETE_DATA, ATTR_DOWNLOAD_PATH, ATTR_TORRENT, + ATTR_TORRENT_FILTER, + ATTR_TORRENTS, CONF_ENTRY_ID, DEFAULT_DELETE_DATA, DOMAIN, + FILTER_MODES, SERVICE_ADD_TORRENT, + SERVICE_GET_TORRENTS, SERVICE_REMOVE_TORRENT, SERVICE_START_TORRENT, SERVICE_STOP_TORRENT, ) from .coordinator import TransmissionDataUpdateCoordinator +from .helpers import filter_torrents, format_torrents _LOGGER = logging.getLogger(__name__) + +class TorrentFilter(StrEnum): + """TorrentFilter model.""" + + ALL = "all" + STARTED = "started" + COMPLETED = "completed" + PAUSED = "paused" + ACTIVE = "active" + + SERVICE_BASE_SCHEMA = vol.Schema( { vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector( @@ -45,6 +63,16 @@ SERVICE_ADD_TORRENT_SCHEMA = vol.All( ), ) +SERVICE_GET_TORRENTS_SCHEMA = vol.All( + SERVICE_BASE_SCHEMA.extend( + { + vol.Required(ATTR_TORRENT_FILTER): vol.In( + [x.lower() for x in TorrentFilter] + ), + } + ), +) + SERVICE_REMOVE_TORRENT_SCHEMA = vol.All( SERVICE_BASE_SCHEMA.extend( { @@ -111,6 +139,24 @@ async def _async_add_torrent(service: ServiceCall) -> None: await coordinator.async_request_refresh() +async def _async_get_torrents(service: ServiceCall) -> dict[str, Any] | None: + """Get torrents.""" + coordinator = _get_coordinator_from_service_data(service) + torrent_filter: str = service.data[ATTR_TORRENT_FILTER] + + def get_filtered_torrents() -> list[Torrent]: + """Filter torrents based on the filter provided.""" + all_torrents = coordinator.api.get_torrents() + return filter_torrents(all_torrents, FILTER_MODES[torrent_filter]) + + torrents = await service.hass.async_add_executor_job(get_filtered_torrents) + + info = format_torrents(torrents) + return { + ATTR_TORRENTS: info, + } + + async def _async_start_torrent(service: ServiceCall) -> None: """Start torrent.""" coordinator = _get_coordinator_from_service_data(service) @@ -149,6 +195,14 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_ADD_TORRENT_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_TORRENTS, + _async_get_torrents, + schema=SERVICE_GET_TORRENTS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_REMOVE_TORRENT, diff --git a/homeassistant/components/transmission/services.yaml b/homeassistant/components/transmission/services.yaml index cadfbee2f63..3afc870337e 100644 --- a/homeassistant/components/transmission/services.yaml +++ b/homeassistant/components/transmission/services.yaml @@ -16,6 +16,27 @@ add_torrent: selector: text: +get_torrents: + fields: + entry_id: + required: true + selector: + config_entry: + integration: transmission + torrent_filter: + required: true + example: "all" + default: "all" + selector: + select: + options: + - "all" + - "active" + - "started" + - "paused" + - "completed" + translation_key: torrent_filter + remove_torrent: fields: entry_id: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 903f48885ea..6eeadb3dca2 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -120,6 +120,15 @@ "oldest_first": "Oldest first", "worst_ratio_first": "Worst ratio first" } + }, + "torrent_filter": { + "options": { + "active": "Active", + "all": "All", + "completed": "Completed", + "paused": "Paused", + "started": "Started" + } } }, "services": { @@ -141,6 +150,20 @@ }, "name": "Add torrent" }, + "get_torrents": { + "description": "Get a list of current torrents", + "fields": { + "entry_id": { + "description": "[%key:component::transmission::services::add_torrent::fields::entry_id::description%]", + "name": "[%key:component::transmission::services::add_torrent::fields::entry_id::name%]" + }, + "torrent_filter": { + "description": "What kind of torrents you want to return, such as All or Active.", + "name": "Torrent filter" + } + }, + "name": "Get torrents" + }, "remove_torrent": { "description": "Removes a torrent.", "fields": { diff --git a/tests/components/transmission/conftest.py b/tests/components/transmission/conftest.py index 0390981db92..2adb1bf67b2 100644 --- a/tests/components/transmission/conftest.py +++ b/tests/components/transmission/conftest.py @@ -87,16 +87,15 @@ def mock_torrent(): torrent_data = { "id": torrent_id, "name": name, - "percentDone": percent_done, "status": status, - "rateDownload": 0, - "rateUpload": 0, - "downloadDir": download_dir, + "percentDone": percent_done, + "uploadRatio": ratio, + "ratio": ratio, "eta": eta, "addedDate": int(added_date.timestamp()), - "uploadRatio": ratio, - "error": 0, - "errorString": "", + "doneDate": int(added_date.timestamp()) if percent_done >= 1.0 else 0, + "downloadDir": download_dir, + "labels": [], } return Torrent(fields=torrent_data) diff --git a/tests/components/transmission/test_services.py b/tests/components/transmission/test_services.py index 52ff3e2aaef..d7868bdd824 100644 --- a/tests/components/transmission/test_services.py +++ b/tests/components/transmission/test_services.py @@ -8,9 +8,12 @@ from homeassistant.components.transmission.const import ( ATTR_DELETE_DATA, ATTR_DOWNLOAD_PATH, ATTR_TORRENT, + ATTR_TORRENT_FILTER, + ATTR_TORRENTS, CONF_ENTRY_ID, DOMAIN, SERVICE_ADD_TORRENT, + SERVICE_GET_TORRENTS, SERVICE_REMOVE_TORRENT, SERVICE_START_TORRENT, SERVICE_STOP_TORRENT, @@ -252,3 +255,69 @@ async def test_remove_torrent_service_with_delete_data( ) client.remove_torrent.assert_called_once_with(789, delete_data=True) + + +@pytest.mark.parametrize( + ("filter_mode", "expected_statuses", "expected_torrents"), + [ + ("all", ["seeding", "downloading", "stopped"], [1, 2, 3]), + ("started", ["downloading"], [1]), + ("completed", ["seeding"], [2]), + ("paused", ["stopped"], [3]), + ("active", ["seeding", "downloading"], [1, 2]), + ], +) +async def test_get_torrents_service( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_torrent, + filter_mode: str, + expected_statuses: list[str], + expected_torrents: list[int], +) -> None: + """Test get torrents service with various filter modes.""" + client = mock_transmission_client.return_value + + downloading_torrent = mock_torrent(torrent_id=1, name="Downloading", status=4) + seeding_torrent = mock_torrent(torrent_id=2, name="Seeding", status=6) + stopped_torrent = mock_torrent(torrent_id=3, name="Stopped", status=0) + + client.get_torrents.return_value = [ + downloading_torrent, + seeding_torrent, + stopped_torrent, + ] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_TORRENTS, + { + CONF_ENTRY_ID: mock_config_entry.entry_id, + ATTR_TORRENT_FILTER: filter_mode, + }, + blocking=True, + return_response=True, + ) + + assert response is not None + assert ATTR_TORRENTS in response + torrents = response[ATTR_TORRENTS] + assert isinstance(torrents, dict) + + assert len(torrents) == len(expected_statuses) + + for torrent_name, torrent_data in torrents.items(): + assert isinstance(torrent_data, dict) + assert "id" in torrent_data + assert "name" in torrent_data + assert "status" in torrent_data + assert torrent_data["name"] == torrent_name + assert torrent_data["id"] in expected_torrents + expected_torrents.remove(int(torrent_data["id"])) + + assert len(expected_torrents) == 0