From 15d7febffdb31beb8dd9bc0f969b834321fb3205 Mon Sep 17 00:00:00 2001 From: Eniot <146570523+Eniot666@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:44:47 +0100 Subject: [PATCH] feat(transmission): add session and cumulative stats sensors (#166134) --- .../components/transmission/icons.json | 18 + .../components/transmission/sensor.py | 76 +++- .../components/transmission/strings.json | 18 + tests/components/transmission/conftest.py | 9 +- .../transmission/snapshots/test_sensor.ambr | 356 ++++++++++++++++++ tests/components/transmission/test_sensor.py | 72 +++- 6 files changed, 545 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/transmission/icons.json b/homeassistant/components/transmission/icons.json index 3193c0f85aa..551ba07766f 100644 --- a/homeassistant/components/transmission/icons.json +++ b/homeassistant/components/transmission/icons.json @@ -21,12 +21,30 @@ "paused_torrents": { "default": "mdi:counter" }, + "session_download": { + "default": "mdi:download" + }, + "session_ratio": { + "default": "mdi:swap-vertical" + }, + "session_upload": { + "default": "mdi:upload" + }, "started_torrents": { "default": "mdi:counter" }, + "total_download": { + "default": "mdi:download-multiple" + }, + "total_ratio": { + "default": "mdi:swap-vertical-bold" + }, "total_torrents": { "default": "mdi:counter" }, + "total_upload": { + "default": "mdi:upload-multiple" + }, "transmission_status": { "default": "mdi:information-outline" }, diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index adf778c0158..2d678b4275d 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -11,8 +11,9 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) -from homeassistant.const import STATE_IDLE, UnitOfDataRate +from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -40,6 +41,18 @@ class TransmissionSensorEntityDescription(SensorEntityDescription): extra_state_attr_func: Callable[[Any], dict[str, str]] | None = None +def _compute_ratio(uploaded: int | None, downloaded: int | None) -> float | None: + """Compute upload/download ratio. + + Returns None when data is unavailable or downloaded == 0. + """ + if uploaded is None or downloaded is None: + return None + if downloaded == 0: + return None + return uploaded / downloaded + + SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( TransmissionSensorEntityDescription( key="download", @@ -112,6 +125,66 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( coordinator=coordinator, key="started" ), ), + TransmissionSensorEntityDescription( + key="session_download", + translation_key="session_download", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + val_func=lambda coordinator: coordinator.data.current_stats.downloaded_bytes, + ), + TransmissionSensorEntityDescription( + key="session_upload", + translation_key="session_upload", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + val_func=lambda coordinator: coordinator.data.current_stats.uploaded_bytes, + ), + TransmissionSensorEntityDescription( + key="total_download", + translation_key="total_download", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + val_func=lambda coordinator: coordinator.data.cumulative_stats.downloaded_bytes, + ), + TransmissionSensorEntityDescription( + key="total_upload", + translation_key="total_upload", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + val_func=lambda coordinator: coordinator.data.cumulative_stats.uploaded_bytes, + ), + TransmissionSensorEntityDescription( + key="session_ratio", + translation_key="session_ratio", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + val_func=lambda coordinator: _compute_ratio( + coordinator.data.current_stats.uploaded_bytes, + coordinator.data.current_stats.downloaded_bytes, + ), + ), + TransmissionSensorEntityDescription( + key="total_ratio", + translation_key="total_ratio", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=3, + val_func=lambda coordinator: _compute_ratio( + coordinator.data.cumulative_stats.uploaded_bytes, + coordinator.data.cumulative_stats.downloaded_bytes, + ), + ), ) @@ -121,7 +194,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Transmission sensors.""" - coordinator = config_entry.runtime_data async_add_entities( diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 588deb3f76c..73dd986a8e0 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -66,14 +66,32 @@ "name": "Paused torrents", "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, + "session_download": { + "name": "Session download" + }, + "session_ratio": { + "name": "Session ratio" + }, + "session_upload": { + "name": "Session upload" + }, "started_torrents": { "name": "Started torrents", "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, + "total_download": { + "name": "Total download" + }, + "total_ratio": { + "name": "Total ratio" + }, "total_torrents": { "name": "Total torrents", "unit_of_measurement": "[%key:component::transmission::entity::sensor::active_torrents::unit_of_measurement%]" }, + "total_upload": { + "name": "Total upload" + }, "transmission_status": { "name": "Status", "state": { diff --git a/tests/components/transmission/conftest.py b/tests/components/transmission/conftest.py index 17071fe179f..c49d2e2510d 100644 --- a/tests/components/transmission/conftest.py +++ b/tests/components/transmission/conftest.py @@ -55,6 +55,14 @@ def mock_transmission_client() -> Generator[AsyncMock]: "activeTorrentCount": 0, "pausedTorrentCount": 0, "torrentCount": 0, + "current-stats": { + "uploadedBytes": 5368709120, + "downloadedBytes": 10737418240, + }, + "cumulative-stats": { + "uploadedBytes": 85899345920, + "downloadedBytes": 107374182400, + }, } client.session_stats.return_value = SessionStats(fields=session_stats_data) @@ -62,7 +70,6 @@ def mock_transmission_client() -> Generator[AsyncMock]: client.get_session.return_value = Session(fields=session_data) client.get_torrents.return_value = [] - client.port_test.return_value = True yield mock_client_class diff --git a/tests/components/transmission/snapshots/test_sensor.ambr b/tests/components/transmission/snapshots/test_sensor.ambr index 899246d4010..9e642c43ebd 100644 --- a/tests/components/transmission/snapshots/test_sensor.ambr +++ b/tests/components/transmission/snapshots/test_sensor.ambr @@ -216,6 +216,184 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.transmission_session_download-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_session_download', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Session download', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session download', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'session_download', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-session_download', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.transmission_session_download-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Transmission Session download', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.transmission_session_download', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_sensors[sensor.transmission_session_ratio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_session_ratio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Session ratio', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Session ratio', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'session_ratio', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-session_ratio', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.transmission_session_ratio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Session ratio', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.transmission_session_ratio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.5', + }) +# --- +# name: test_sensors[sensor.transmission_session_upload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_session_upload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Session upload', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Session upload', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'session_upload', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-session_upload', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.transmission_session_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Transmission Session upload', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.transmission_session_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- # name: test_sensors[sensor.transmission_started_torrents-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -333,6 +511,123 @@ 'state': 'up_down', }) # --- +# name: test_sensors[sensor.transmission_total_download-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_total_download', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total download', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total download', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_download', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-total_download', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.transmission_total_download-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Transmission Total download', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.transmission_total_download', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensors[sensor.transmission_total_ratio-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_total_ratio', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total ratio', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total ratio', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_ratio', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-total_ratio', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.transmission_total_ratio-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Total ratio', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.transmission_total_ratio', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.8', + }) +# --- # name: test_sensors[sensor.transmission_total_torrents-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -386,6 +681,67 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.transmission_total_upload-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_total_upload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total upload', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total upload', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_upload', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-total_upload', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.transmission_total_upload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Transmission Total upload', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.transmission_total_upload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80.0', + }) +# --- # name: test_sensors[sensor.transmission_upload_speed-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/transmission/test_sensor.py b/tests/components/transmission/test_sensor.py index cd7cb9f59c9..7ec0c4a4550 100644 --- a/tests/components/transmission/test_sensor.py +++ b/tests/components/transmission/test_sensor.py @@ -2,9 +2,16 @@ from unittest.mock import AsyncMock, patch +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.transmission.const import ( + STATE_DOWNLOADING, + STATE_SEEDING, + STATE_UP_DOWN, +) +from homeassistant.components.transmission.sensor import _compute_ratio, get_state +from homeassistant.const import STATE_IDLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -25,3 +32,66 @@ async def test_sensors( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_stats_sensors( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test session and cumulative stats sensors.""" + with patch("homeassistant.components.transmission.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + # Session download: 10 GiB = 10.0 GiB + state = hass.states.get("sensor.transmission_session_download") + assert state is not None + assert float(state.state) == pytest.approx(10.0, rel=1e-3) + + # Session upload: 5 GiB = 5.0 GiB + state = hass.states.get("sensor.transmission_session_upload") + assert state is not None + assert float(state.state) == pytest.approx(5.0, rel=1e-3) + + # Total download: 100 GiB = 100.0 GiB + state = hass.states.get("sensor.transmission_total_download") + assert state is not None + assert float(state.state) == pytest.approx(100.0, rel=1e-3) + + # Total upload: 80 GiB = 80.0 GiB + state = hass.states.get("sensor.transmission_total_upload") + assert state is not None + assert float(state.state) == pytest.approx(80.0, rel=1e-3) + + # Session ratio: 5 GiB / 10 GiB = 0.5 + state = hass.states.get("sensor.transmission_session_ratio") + assert state is not None + assert float(state.state) == pytest.approx(0.5, rel=1e-3) + + # Total ratio: 80 GiB / 100 GiB = 0.8 + state = hass.states.get("sensor.transmission_total_ratio") + assert state is not None + assert float(state.state) == pytest.approx(0.8, rel=1e-3) + + +def test_get_state_combinations() -> None: + """Test get_state with all upload/download combinations.""" + + assert get_state(1, 1) == STATE_UP_DOWN + assert get_state(1, 0) == STATE_SEEDING + assert get_state(0, 1) == STATE_DOWNLOADING + assert get_state(0, 0) == STATE_IDLE + + +def test_helper_functions() -> None: + """Test helper functions directly.""" + + # _compute_ratio - zero download + assert _compute_ratio(100, 0) is None + + # _compute_ratio - None values + assert _compute_ratio(None, 100) is None + assert _compute_ratio(100, None) is None + + # _compute_ratio - normal + assert _compute_ratio(500, 1000) == 0.5