1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 23:53:49 +01:00

Add event entity to Transmission (#166686)

This commit is contained in:
Andrew Jackson
2026-03-28 16:06:11 +00:00
committed by GitHub
parent 116fa57903
commit fbe4195ae0
7 changed files with 301 additions and 24 deletions

View File

@@ -40,7 +40,7 @@ from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR, Platform.SWITCH]
MIGRATION_NAME_TO_KEY = {
# Sensors

View File

@@ -55,6 +55,10 @@ EVENT_STARTED_TORRENT = "transmission_started_torrent"
EVENT_REMOVED_TORRENT = "transmission_removed_torrent"
EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent"
EVENT_TYPE_STARTED = "started"
EVENT_TYPE_REMOVED = "removed"
EVENT_TYPE_DOWNLOADED = "downloaded"
STATE_UP_DOWN = "up_down"
STATE_SEEDING = "seeding"
STATE_DOWNLOADING = "downloading"

View File

@@ -1,19 +1,24 @@
"""Coordinator for transmssion integration."""
"""Coordinator for transmission integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from functools import partial
import logging
import transmission_rpc
from transmission_rpc.session import SessionStats
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.const import ATTR_ID, ATTR_NAME, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_DOWNLOAD_PATH,
ATTR_LABELS,
CONF_LIMIT,
CONF_ORDER,
DEFAULT_LIMIT,
@@ -23,13 +28,28 @@ from .const import (
EVENT_DOWNLOADED_TORRENT,
EVENT_REMOVED_TORRENT,
EVENT_STARTED_TORRENT,
EVENT_TYPE_DOWNLOADED,
EVENT_TYPE_REMOVED,
EVENT_TYPE_STARTED,
)
_LOGGER = logging.getLogger(__name__)
type EventCallback = Callable[[TransmissionEventData], None]
type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator]
@dataclass
class TransmissionEventData:
"""Data for a single event."""
event_type: str
name: str
id: int
download_path: str
labels: list[str]
class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
"""Transmission dataupdate coordinator class."""
@@ -49,6 +69,7 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
self._all_torrents: list[transmission_rpc.Torrent] = []
self._completed_torrents: list[transmission_rpc.Torrent] = []
self._started_torrents: list[transmission_rpc.Torrent] = []
self._event_listeners: dict[str, EventCallback] = {}
self.torrents: list[transmission_rpc.Torrent] = []
super().__init__(
hass,
@@ -68,9 +89,32 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
"""Return order."""
return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) # type: ignore[no-any-return]
@callback
def async_add_event_listener(
self, update_callback: EventCallback, target_event_id: str
) -> Callable[[], None]:
"""Listen for updates."""
self._event_listeners[target_event_id] = update_callback
return partial(self.__async_remove_listener_internal, target_event_id)
def __async_remove_listener_internal(self, listener_id: str) -> None:
self._event_listeners.pop(listener_id, None)
@callback
def _async_notify_event_listeners(self, event: TransmissionEventData) -> None:
"""Notify event listeners in the event loop."""
for listener in list(self._event_listeners.values()):
listener(event)
async def _async_update_data(self) -> SessionStats:
"""Update transmission data."""
return await self.hass.async_add_executor_job(self.update)
data = await self.hass.async_add_executor_job(self.update)
self.check_completed_torrent()
self.check_started_torrent()
self.check_removed_torrent()
return data
def update(self) -> SessionStats:
"""Get the latest data from Transmission instance."""
@@ -82,10 +126,6 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
except transmission_rpc.TransmissionError as err:
raise UpdateFailed("Unable to connect to Transmission client") from err
self.check_completed_torrent()
self.check_started_torrent()
self.check_removed_torrent()
return data
def init_torrent_list(self) -> None:
@@ -108,15 +148,24 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in current_completed_torrents:
if torrent.id not in old_completed_torrents:
self.hass.bus.fire(
# Once event triggers are out of labs we can remove the bus event
self.hass.bus.async_fire(
EVENT_DOWNLOADED_TORRENT,
{
"name": torrent.name,
"id": torrent.id,
"download_path": torrent.download_dir,
"labels": torrent.labels,
ATTR_NAME: torrent.name,
ATTR_ID: torrent.id,
ATTR_DOWNLOAD_PATH: torrent.download_dir,
ATTR_LABELS: torrent.labels,
},
)
event = TransmissionEventData(
event_type=EVENT_TYPE_DOWNLOADED,
name=torrent.name,
id=torrent.id,
download_path=torrent.download_dir or "",
labels=torrent.labels,
)
self._async_notify_event_listeners(event)
self._completed_torrents = current_completed_torrents
@@ -130,15 +179,24 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in current_started_torrents:
if torrent.id not in old_started_torrents:
self.hass.bus.fire(
# Once event triggers are out of labs we can remove the bus event
self.hass.bus.async_fire(
EVENT_STARTED_TORRENT,
{
"name": torrent.name,
"id": torrent.id,
"download_path": torrent.download_dir,
"labels": torrent.labels,
ATTR_NAME: torrent.name,
ATTR_ID: torrent.id,
ATTR_DOWNLOAD_PATH: torrent.download_dir,
ATTR_LABELS: torrent.labels,
},
)
event = TransmissionEventData(
event_type=EVENT_TYPE_STARTED,
name=torrent.name,
id=torrent.id,
download_path=torrent.download_dir or "",
labels=torrent.labels,
)
self._async_notify_event_listeners(event)
self._started_torrents = current_started_torrents
@@ -148,15 +206,24 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]):
for torrent in self._all_torrents:
if torrent.id not in current_torrents:
self.hass.bus.fire(
# Once event triggers are out of labs we can remove the bus event
self.hass.bus.async_fire(
EVENT_REMOVED_TORRENT,
{
"name": torrent.name,
"id": torrent.id,
"download_path": torrent.download_dir,
"labels": torrent.labels,
ATTR_NAME: torrent.name,
ATTR_ID: torrent.id,
ATTR_DOWNLOAD_PATH: torrent.download_dir,
ATTR_LABELS: torrent.labels,
},
)
event = TransmissionEventData(
event_type=EVENT_TYPE_REMOVED,
name=torrent.name,
id=torrent.id,
download_path=torrent.download_dir or "",
labels=torrent.labels,
)
self._async_notify_event_listeners(event)
self._all_torrents = self.torrents.copy()

View File

@@ -0,0 +1,85 @@
"""Define events for the Transmission integration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.const import ATTR_ID, ATTR_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_DOWNLOAD_PATH,
ATTR_LABELS,
EVENT_TYPE_DOWNLOADED,
EVENT_TYPE_REMOVED,
EVENT_TYPE_STARTED,
)
from .coordinator import TransmissionConfigEntry, TransmissionEventData
from .entity import TransmissionEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TransmissionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Transmission event platform."""
coordinator = config_entry.runtime_data
description = EventEntityDescription(
key="torrent",
translation_key="torrent",
event_types=[
EVENT_TYPE_STARTED,
EVENT_TYPE_DOWNLOADED,
EVENT_TYPE_REMOVED,
],
)
async_add_entities([TransmissionEvent(coordinator, description)])
class TransmissionEvent(TransmissionEntity, EventEntity):
"""Representation of a Transmission event entity."""
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
if TYPE_CHECKING:
assert self._attr_unique_id
self.async_on_remove(
self.coordinator.async_add_event_listener(
self._handle_event, self._attr_unique_id
)
)
@callback
def _handle_event(self, event_data: TransmissionEventData) -> None:
"""Handle the torrent events."""
event_type = event_data.event_type
if event_type not in self.event_types:
_LOGGER.warning("Event type %s is not known", event_type)
return
self._trigger_event(
event_type,
{
ATTR_NAME: event_data.name,
ATTR_ID: event_data.id,
ATTR_DOWNLOAD_PATH: event_data.download_path,
ATTR_LABELS: event_data.labels,
},
)
self.async_write_ha_state()

View File

@@ -8,6 +8,11 @@
}
}
},
"event": {
"torrent": {
"default": "mdi:folder-file-outline"
}
},
"sensor": {
"active_torrents": {
"default": "mdi:counter"

View File

@@ -50,6 +50,20 @@
}
}
},
"event": {
"torrent": {
"name": "Torrent",
"state_attributes": {
"event_type": {
"state": {
"downloaded": "Downloaded",
"removed": "Removed",
"started": "Started"
}
}
}
}
},
"sensor": {
"active_torrents": {
"name": "Active torrents",

View File

@@ -0,0 +1,102 @@
"""Tests for the Transmission event platform."""
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES
from homeassistant.components.transmission.const import (
DEFAULT_SCAN_INTERVAL,
EVENT_TYPE_DOWNLOADED,
EVENT_TYPE_REMOVED,
EVENT_TYPE_STARTED,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_event_entity_setup(
hass: HomeAssistant,
mock_transmission_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the event entity is created with expected capabilities."""
with patch("homeassistant.components.transmission.PLATFORMS", [Platform.EVENT]):
await setup_integration(hass, mock_config_entry)
await hass.async_block_till_done()
state = hass.states.get("event.transmission_torrent")
assert state is not None
assert state.state == "unknown"
assert state.attributes[ATTR_EVENT_TYPE] is None
assert state.attributes[ATTR_EVENT_TYPES] == [
EVENT_TYPE_STARTED,
EVENT_TYPE_DOWNLOADED,
EVENT_TYPE_REMOVED,
]
@pytest.mark.parametrize(
("hass_event", "expected_event_type"),
[
(EVENT_TYPE_STARTED, EVENT_TYPE_STARTED),
(EVENT_TYPE_DOWNLOADED, EVENT_TYPE_DOWNLOADED),
(EVENT_TYPE_REMOVED, EVENT_TYPE_REMOVED),
],
)
async def test_event_updates_state(
hass: HomeAssistant,
mock_transmission_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
hass_event: str,
expected_event_type: str,
) -> None:
"""Test Transmission events update the entity state and attributes."""
with patch("homeassistant.components.transmission.PLATFORMS", [Platform.EVENT]):
await setup_integration(hass, mock_config_entry)
await hass.async_block_till_done()
client = mock_transmission_client.return_value
torrent_status = {
EVENT_TYPE_STARTED: "downloading",
EVENT_TYPE_DOWNLOADED: "seeding",
EVENT_TYPE_REMOVED: "stopped",
}[hass_event]
torrent = SimpleNamespace(
id=1,
name="Test",
status=torrent_status,
download_dir="/downloads",
labels=[],
)
torrents_sequence = {
EVENT_TYPE_STARTED: [[torrent]],
EVENT_TYPE_DOWNLOADED: [[torrent]],
EVENT_TYPE_REMOVED: [[torrent], []],
}[hass_event]
client.get_torrents.side_effect = torrents_sequence
for _ in torrents_sequence:
freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("event.transmission_torrent")
assert state is not None
assert state.attributes[ATTR_EVENT_TYPE] == expected_event_type
assert state.attributes["id"] == 1
assert state.attributes["name"] == "Test"
assert state.attributes["download_path"] == "/downloads"
assert state.attributes["labels"] == []
assert dt_util.parse_datetime(state.state) is not None