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 <joostlek@outlook.com>
This commit is contained in:
Joe Neuman 2023-12-27 00:48:52 -08:00 committed by GitHub
parent c7eab49c70
commit d33ad57dd3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 151 additions and 64 deletions

View File

@ -1020,8 +1020,8 @@ build.json @home-assistant/supervisor
/tests/components/pvoutput/ @frenck /tests/components/pvoutput/ @frenck
/homeassistant/components/pvpc_hourly_pricing/ @azogue /homeassistant/components/pvpc_hourly_pricing/ @azogue
/tests/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue
/homeassistant/components/qbittorrent/ @geoffreylagaisse /homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
/tests/components/qbittorrent/ @geoffreylagaisse /tests/components/qbittorrent/ @geoffreylagaisse @finder39
/homeassistant/components/qingping/ @bdraco @skgsergio /homeassistant/components/qingping/ @bdraco @skgsergio
/tests/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio
/homeassistant/components/qld_bushfire/ @exxamalte /homeassistant/components/qld_bushfire/ @exxamalte

View File

@ -19,21 +19,21 @@ from .const import DOMAIN
from .coordinator import QBittorrentDataCoordinator from .coordinator import QBittorrentDataCoordinator
from .helpers import setup_client from .helpers import setup_client
PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__) _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.""" """Set up qBittorrent from a config entry."""
hass.data.setdefault(DOMAIN, {})
try: try:
client = await hass.async_add_executor_job( client = await hass.async_add_executor_job(
setup_client, setup_client,
entry.data[CONF_URL], config_entry.data[CONF_URL],
entry.data[CONF_USERNAME], config_entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD], config_entry.data[CONF_PASSWORD],
entry.data[CONF_VERIFY_SSL], config_entry.data[CONF_VERIFY_SSL],
) )
except LoginRequired as err: except LoginRequired as err:
raise ConfigEntryNotReady("Invalid credentials") from 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) coordinator = QBittorrentDataCoordinator(hass, client)
await coordinator.async_config_entry_first_refresh() 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 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.""" """Unload qBittorrent config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if unload_ok := await hass.config_entries.async_unload_platforms(
del hass.data[DOMAIN][entry.entry_id] config_entry, PLATFORMS
):
del hass.data[DOMAIN][config_entry.entry_id]
if not hass.data[DOMAIN]: if not hass.data[DOMAIN]:
del hass.data[DOMAIN] del hass.data[DOMAIN]
return unload_ok return unload_ok

View File

@ -5,3 +5,7 @@ DOMAIN: Final = "qbittorrent"
DEFAULT_NAME = "qBittorrent" DEFAULT_NAME = "qBittorrent"
DEFAULT_URL = "http://127.0.0.1:8080" DEFAULT_URL = "http://127.0.0.1:8080"
STATE_UP_DOWN = "up_down"
STATE_SEEDING = "seeding"
STATE_DOWNLOADING = "downloading"

View File

@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""QBittorrent update coordinator.""" """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."""

View File

@ -1,7 +1,7 @@
{ {
"domain": "qbittorrent", "domain": "qbittorrent",
"name": "qBittorrent", "name": "qBittorrent",
"codeowners": ["@geoffreylagaisse"], "codeowners": ["@geoffreylagaisse", "@finder39"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/qbittorrent", "documentation": "https://www.home-assistant.io/integrations/qbittorrent",
"integration_type": "service", "integration_type": "service",

View File

@ -4,22 +4,21 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import Any
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.const import STATE_IDLE, UnitOfDataRate
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity 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 from .coordinator import QBittorrentDataCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -27,62 +26,94 @@ _LOGGER = logging.getLogger(__name__)
SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_CURRENT_STATUS = "current_status"
SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed"
SENSOR_TYPE_UPLOAD_SPEED = "upload_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) def get_state(coordinator: QBittorrentDataCoordinator) -> str:
class QBittorrentMixin: """Get current download/upload state."""
"""Mixin for required keys.""" upload = coordinator.data["server_state"]["up_info_speed"]
download = coordinator.data["server_state"]["dl_info_speed"]
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"]
if upload > 0 and download > 0: if upload > 0 and download > 0:
return "up_down" return STATE_UP_DOWN
if upload > 0 and download == 0: if upload > 0 and download == 0:
return "seeding" return STATE_SEEDING
if upload == 0 and download > 0: if upload == 0 and download > 0:
return "downloading" return STATE_DOWNLOADING
return STATE_IDLE return STATE_IDLE
def format_speed(speed): @dataclass(frozen=True, kw_only=True)
"""Return a bytes/s measurement as a human readable string.""" class QBittorrentSensorEntityDescription(SensorEntityDescription):
kb_spd = float(speed) / 1024 """Entity description class for qBittorent sensors."""
return round(kb_spd, 2 if kb_spd < 0.1 else 1)
value_fn: Callable[[QBittorrentDataCoordinator], StateType]
SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
QBittorrentSensorEntityDescription( QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_CURRENT_STATUS, key=SENSOR_TYPE_CURRENT_STATUS,
name="Status", translation_key="current_status",
value_fn=_get_qbittorrent_state, device_class=SensorDeviceClass.ENUM,
options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING],
value_fn=get_state,
), ),
QBittorrentSensorEntityDescription( QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_DOWNLOAD_SPEED, key=SENSOR_TYPE_DOWNLOAD_SPEED,
name="Down Speed", translation_key="download_speed",
icon="mdi:cloud-download", icon="mdi:cloud-download",
device_class=SensorDeviceClass.DATA_RATE, device_class=SensorDeviceClass.DATA_RATE,
native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2,
value_fn=lambda data: format_speed(data["server_state"]["dl_info_speed"]), suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
value_fn=lambda coordinator: float(
coordinator.data["server_state"]["dl_info_speed"]
),
), ),
QBittorrentSensorEntityDescription( QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_UPLOAD_SPEED, key=SENSOR_TYPE_UPLOAD_SPEED,
name="Up Speed", translation_key="upload_speed",
icon="mdi:cloud-upload", icon="mdi:cloud-upload",
device_class=SensorDeviceClass.DATA_RATE, device_class=SensorDeviceClass.DATA_RATE,
native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2,
value_fn=lambda data: format_speed(data["server_state"]["up_info_speed"]), 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
async_add_entites: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up qBittorrent sensor entries.""" """Set up qBittorrent sensor entries."""
coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] 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 for description in SENSOR_TYPES
] )
async_add_entites(entities)
class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity): class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity):
"""Representation of a qBittorrent sensor.""" """Representation of a qBittorrent sensor."""
_attr_has_entity_name = True
entity_description: QBittorrentSensorEntityDescription entity_description: QBittorrentSensorEntityDescription
def __init__( def __init__(
self, self,
description: QBittorrentSensorEntityDescription,
coordinator: QBittorrentDataCoordinator, coordinator: QBittorrentDataCoordinator,
config_entry: ConfigEntry, config_entry: ConfigEntry,
entity_description: QBittorrentSensorEntityDescription,
) -> None: ) -> None:
"""Initialize the qBittorrent sensor.""" """Initialize the qBittorrent sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = entity_description
self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_unique_id = f"{config_entry.entry_id}-{entity_description.key}"
self._attr_name = f"{config_entry.title} {description.name}" self._attr_device_info = DeviceInfo(
self._attr_available = False entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="QBittorrent",
)
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return value of sensor.""" """Return the value of the sensor."""
return self.entity_description.value_fn(self.coordinator.data) 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
]
)

View File

@ -17,5 +17,36 @@
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "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"
}
}
} }
} }