From d33ad57dd3d4ff32af6982eebd38d1e6936c3960 Mon Sep 17 00:00:00 2001 From: Joe Neuman Date: Wed, 27 Dec 2023 00:48:52 -0800 Subject: [PATCH] Add qBittorrent torrent sensors (#105781) * Upgrade QBittorrent integration to show torrents This brings the QBittorrent integration to be more in line with the Transmission integration. It updates how the integration is written, along with adding sensors for Active Torrents, Inactive Torrents, Paused Torrents, Total Torrents, Seeding Torrents, Started Torrents. * Remove unused stuff * Fix codeowners * Correct name in comments * Update __init__.py * Make get torrents a service with a response * Update sensor.py * Update sensor.py * Update sensor.py * Add new sensors * remove service * more removes * more * Address comments * cleanup * Update coordinator.py * Fix most lint issues * Update sensor.py * Update sensor.py * Update manifest.json * Update sensor class * Update sensor.py * Fix lint issue with sensor class * Adding codeowners * Update homeassistant/components/qbittorrent/__init__.py --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 4 +- .../components/qbittorrent/__init__.py | 29 ++-- homeassistant/components/qbittorrent/const.py | 4 + .../components/qbittorrent/coordinator.py | 2 +- .../components/qbittorrent/manifest.json | 2 +- .../components/qbittorrent/sensor.py | 143 ++++++++++++------ .../components/qbittorrent/strings.json | 31 ++++ 7 files changed, 151 insertions(+), 64 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b50af486033..b0dcda5ce27 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1020,8 +1020,8 @@ build.json @home-assistant/supervisor /tests/components/pvoutput/ @frenck /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue -/homeassistant/components/qbittorrent/ @geoffreylagaisse -/tests/components/qbittorrent/ @geoffreylagaisse +/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 +/tests/components/qbittorrent/ @geoffreylagaisse @finder39 /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index fd9577f5c73..84315186097 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -19,21 +19,21 @@ from .const import DOMAIN from .coordinator import QBittorrentDataCoordinator from .helpers import setup_client -PLATFORMS = [Platform.SENSOR] - _LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up qBittorrent from a config entry.""" - hass.data.setdefault(DOMAIN, {}) + try: client = await hass.async_add_executor_job( setup_client, - entry.data[CONF_URL], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_VERIFY_SSL], + config_entry.data[CONF_URL], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data[CONF_VERIFY_SSL], ) except LoginRequired as err: raise ConfigEntryNotReady("Invalid credentials") from err @@ -42,16 +42,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = QBittorrentDataCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_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 unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + del hass.data[DOMAIN][config_entry.entry_id] if not hass.data[DOMAIN]: del hass.data[DOMAIN] return unload_ok diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 0a79c67f400..96c60e9b380 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -5,3 +5,7 @@ DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" DEFAULT_URL = "http://127.0.0.1:8080" + +STATE_UP_DOWN = "up_down" +STATE_SEEDING = "seeding" +STATE_DOWNLOADING = "downloading" diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 8363a764d0a..11467ce62f4 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """QBittorrent update coordinator.""" + """Coordinator for updating QBittorrent data.""" def __init__(self, hass: HomeAssistant, client: Client) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index e2c1526e4f8..fb51f177081 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -1,7 +1,7 @@ { "domain": "qbittorrent", "name": "qBittorrent", - "codeowners": ["@geoffreylagaisse"], + "codeowners": ["@geoffreylagaisse", "@finder39"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 0e6bc071125..a51ff58405c 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -4,22 +4,21 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, - SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, STATE_DOWNLOADING, STATE_SEEDING, STATE_UP_DOWN from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -27,62 +26,94 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" +SENSOR_TYPE_ALL_TORRENTS = "all_torrents" +SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" +SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" +SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents" -@dataclass(frozen=True) -class QBittorrentMixin: - """Mixin for required keys.""" - - value_fn: Callable[[dict[str, Any]], StateType] - - -@dataclass(frozen=True) -class QBittorrentSensorEntityDescription(SensorEntityDescription, QBittorrentMixin): - """Describes QBittorrent sensor entity.""" - - -def _get_qbittorrent_state(data: dict[str, Any]) -> str: - download = data["server_state"]["dl_info_speed"] - upload = data["server_state"]["up_info_speed"] +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"] if upload > 0 and download > 0: - return "up_down" + return STATE_UP_DOWN if upload > 0 and download == 0: - return "seeding" + return STATE_SEEDING if upload == 0 and download > 0: - return "downloading" + return STATE_DOWNLOADING return STATE_IDLE -def format_speed(speed): - """Return a bytes/s measurement as a human readable string.""" - kb_spd = float(speed) / 1024 - return round(kb_spd, 2 if kb_spd < 0.1 else 1) +@dataclass(frozen=True, kw_only=True) +class QBittorrentSensorEntityDescription(SensorEntityDescription): + """Entity description class for qBittorent sensors.""" + + value_fn: Callable[[QBittorrentDataCoordinator], StateType] SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( QBittorrentSensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, - name="Status", - value_fn=_get_qbittorrent_state, + translation_key="current_status", + device_class=SensorDeviceClass.ENUM, + options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], + value_fn=get_state, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, - name="Down Speed", + translation_key="download_speed", icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: format_speed(data["server_state"]["dl_info_speed"]), + 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"] + ), ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, - name="Up Speed", + translation_key="upload_speed", icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: format_speed(data["server_state"]["up_info_speed"]), + 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"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALL_TORRENTS, + translation_key="all_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states(coordinator, []), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ACTIVE_TORRENTS, + translation_key="active_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["downloading", "uploading"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_INACTIVE_TORRENTS, + translation_key="inactive_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["stalledDL", "stalledUP"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_PAUSED_TORRENTS, + translation_key="paused_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["pausedDL", "pausedUP"] + ), ), ) @@ -90,36 +121,54 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entites: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities = [ - QBittorrentSensor(description, coordinator, config_entry) + + async_add_entities( + QBittorrentSensor(coordinator, config_entry, description) for description in SENSOR_TYPES - ] - async_add_entites(entities) + ) class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity): """Representation of a qBittorrent sensor.""" + _attr_has_entity_name = True entity_description: QBittorrentSensorEntityDescription def __init__( self, - description: QBittorrentSensorEntityDescription, coordinator: QBittorrentDataCoordinator, config_entry: ConfigEntry, + entity_description: QBittorrentSensorEntityDescription, ) -> None: """Initialize the qBittorrent sensor.""" super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" - self._attr_name = f"{config_entry.title} {description.name}" - self._attr_available = False + self.entity_description = entity_description + self._attr_unique_id = f"{config_entry.entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="QBittorrent", + ) @property def native_value(self) -> StateType: - """Return value of sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator) + + +def count_torrents_in_states( + coordinator: QBittorrentDataCoordinator, states: list[str] +) -> int: + """Count the number of torrents in specified states.""" + 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 66c9430911e..8b20a3354dd 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -17,5 +17,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + }, + "transmission_status": { + "name": "Status", + "state": { + "idle": "[%key:common::state::idle%]", + "up_down": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading" + } + }, + "active_torrents": { + "name": "Active torrents" + }, + "inactive_torrents": { + "name": "Inactive torrents" + }, + "paused_torrents": { + "name": "Paused torrents" + }, + "all_torrents": { + "name": "All torrents" + } + } } }