diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index fe6eeb9e303..339ec6eb895 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -1,7 +1,7 @@ """The Tautulli integration.""" from __future__ import annotations -from pytautulli import PyTautulli, PyTautulliHostConfiguration +from pytautulli import PyTautulli, PyTautulliApiUser, PyTautulliHostConfiguration from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform @@ -50,14 +50,18 @@ class TautulliEntity(CoordinatorEntity[TautulliDataUpdateCoordinator]): self, coordinator: TautulliDataUpdateCoordinator, description: EntityDescription, + user: PyTautulliApiUser | None = None, ) -> None: """Initialize the Tautulli entity.""" super().__init__(coordinator) + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = f"{entry_id}_{description.key}" self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self.user = user self._attr_device_info = DeviceInfo( configuration_url=coordinator.host_configuration.base_url, entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, user.user_id if user else entry_id)}, manufacturer=DEFAULT_NAME, + name=user.username if user else DEFAULT_NAME, ) diff --git a/homeassistant/components/tautulli/const.py b/homeassistant/components/tautulli/const.py index 5c0a1b56cda..49b86ec6ef7 100644 --- a/homeassistant/components/tautulli/const.py +++ b/homeassistant/components/tautulli/const.py @@ -1,6 +1,8 @@ """Constants for the Tautulli integration.""" from logging import Logger, getLogger +ATTR_TOP_USER = "top_user" + CONF_MONITORED_USERS = "monitored_users" DEFAULT_NAME = "Tautulli" DEFAULT_PATH = "" diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index b1af6e3ce47..5981992c946 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,14 +1,23 @@ """A platform which allows you to get information from Tautulli.""" from __future__ import annotations -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast +from pytautulli import ( + PyTautulliApiActivity, + PyTautulliApiHomeStats, + PyTautulliApiSession, + PyTautulliApiUser, +) import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -20,14 +29,18 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, + DATA_KILOBITS, + PERCENTAGE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityCategory, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from . import TautulliEntity from .const import ( + ATTR_TOP_USER, CONF_MONITORED_USERS, DEFAULT_NAME, DEFAULT_PATH, @@ -53,12 +66,188 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +def get_top_stats( + home_stats: PyTautulliApiHomeStats, activity: PyTautulliApiActivity, key: str +) -> str | None: + """Get top statistics.""" + value = None + for stat in home_stats: + if stat.rows and stat.stat_id == key: + value = stat.rows[0].title + elif stat.rows and stat.stat_id == "top_users" and key == ATTR_TOP_USER: + value = stat.rows[0].user + return value + + +@dataclass +class TautulliSensorEntityMixin: + """Mixin for Tautulli sensor.""" + + value_fn: Callable[[PyTautulliApiHomeStats, PyTautulliApiActivity, str], StateType] + + +@dataclass +class TautulliSensorEntityDescription( + SensorEntityDescription, TautulliSensorEntityMixin +): + """Describes a Tautulli sensor.""" + + +SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( + TautulliSensorEntityDescription( icon="mdi:plex", key="watching_count", name="Tautulli", native_unit_of_measurement="Watching", + value_fn=lambda home_stats, activity, _: cast(int, activity.stream_count), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_direct_play", + name="Direct Plays", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_direct_play + ), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_direct_stream", + name="Direct Streams", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_direct_stream + ), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_transcode", + name="Transcodes", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_transcode + ), + ), + TautulliSensorEntityDescription( + key="total_bandwidth", + name="Total Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.total_bandwidth), + ), + TautulliSensorEntityDescription( + key="lan_bandwidth", + name="LAN Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.lan_bandwidth), + ), + TautulliSensorEntityDescription( + key="wan_bandwidth", + name="WAN Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.wan_bandwidth), + ), + TautulliSensorEntityDescription( + icon="mdi:movie-open", + key="top_movies", + name="Top Movie", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), + TautulliSensorEntityDescription( + icon="mdi:television", + key="top_tv", + name="Top TV Show", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), + TautulliSensorEntityDescription( + icon="mdi:walk", + key=ATTR_TOP_USER, + name="Top User", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), +) + + +@dataclass +class TautulliSessionSensorEntityMixin: + """Mixin for Tautulli session sensor.""" + + value_fn: Callable[[PyTautulliApiSession], StateType] + + +@dataclass +class TautulliSessionSensorEntityDescription( + SensorEntityDescription, TautulliSessionSensorEntityMixin +): + """Describes a Tautulli session sensor.""" + + +SESSION_SENSOR_TYPES: tuple[TautulliSessionSensorEntityDescription, ...] = ( + TautulliSessionSensorEntityDescription( + icon="mdi:plex", + key="state", + name="State", + value_fn=lambda session: cast(str, session.state), + ), + TautulliSessionSensorEntityDescription( + key="full_title", + name="Full Title", + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.full_title), + ), + TautulliSessionSensorEntityDescription( + icon="mdi:progress-clock", + key="progress", + name="Progress", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.progress_percent), + ), + TautulliSessionSensorEntityDescription( + key="stream_resolution", + name="Stream Resolution", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.stream_video_resolution), + ), + TautulliSessionSensorEntityDescription( + icon="mdi:plex", + key="transcode_decision", + name="Transcode Decision", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.transcode_decision), + ), + TautulliSessionSensorEntityDescription( + key="session_thumb", + name="session Thumbnail", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.user_thumb), + ), + TautulliSessionSensorEntityDescription( + key="video_resolution", + name="Video Resolution", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.video_resolution), ), ) @@ -82,62 +271,64 @@ async def async_setup_entry( ) -> None: """Set up Tautulli sensor.""" coordinator: TautulliDataUpdateCoordinator = hass.data[DOMAIN] - async_add_entities( + entities: list[TautulliSensor | TautulliSessionSensor] = [ TautulliSensor( coordinator, description, ) for description in SENSOR_TYPES - ) + ] + if coordinator.users: + entities.extend( + TautulliSessionSensor( + coordinator, + description, + user, + ) + for description in SESSION_SENSOR_TYPES + for user in coordinator.users + if user.username != "Local" + ) + async_add_entities(entities) class TautulliSensor(TautulliEntity, SensorEntity): """Representation of a Tautulli sensor.""" + entity_description: TautulliSensorEntityDescription + @property def native_value(self) -> StateType: """Return the state of the sensor.""" - if not self.coordinator.activity: - return 0 - return self.coordinator.activity.stream_count or 0 + return self.entity_description.value_fn( + self.coordinator.home_stats, + self.coordinator.activity, + self.entity_description.key, + ) + + +class TautulliSessionSensor(TautulliEntity, SensorEntity): + """Representation of a Tautulli session sensor.""" + + entity_description: TautulliSessionSensorEntityDescription + + def __init__( + self, + coordinator: TautulliDataUpdateCoordinator, + description: EntityDescription, + user: PyTautulliApiUser, + ) -> None: + """Initialize the Tautulli entity.""" + super().__init__(coordinator, description, user) + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = f"{entry_id}_{user.user_id}_{description.key}" + self._attr_name = f"{user.username} {description.name}" @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return attributes for the sensor.""" - if ( - not self.coordinator.activity - or not self.coordinator.home_stats - or not self.coordinator.users - ): - return None - - _attributes = { - "stream_count": self.coordinator.activity.stream_count, - "stream_count_direct_play": self.coordinator.activity.stream_count_direct_play, - "stream_count_direct_stream": self.coordinator.activity.stream_count_direct_stream, - "stream_count_transcode": self.coordinator.activity.stream_count_transcode, - "total_bandwidth": self.coordinator.activity.total_bandwidth, - "lan_bandwidth": self.coordinator.activity.lan_bandwidth, - "wan_bandwidth": self.coordinator.activity.wan_bandwidth, - } - - for stat in self.coordinator.home_stats: - if stat.stat_id == "top_movies": - _attributes["Top Movie"] = stat.rows[0].title if stat.rows else None - elif stat.stat_id == "top_tv": - _attributes["Top TV Show"] = stat.rows[0].title if stat.rows else None - elif stat.stat_id == "top_users": - _attributes["Top User"] = stat.rows[0].user if stat.rows else None - - for user in self.coordinator.users: - if user.username == "Local": - continue - _attributes.setdefault(user.username, {})["Activity"] = None - - for session in self.coordinator.activity.sessions: - if not _attributes.get(session.username) or "null" in session.state: - continue - - _attributes[session.username]["Activity"] = session.state - - return _attributes + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if self.coordinator.activity: + for session in self.coordinator.activity.sessions: + if self.user and session.user_id == self.user.user_id: + return self.entity_description.value_fn(session) + return None