Tautulli (PlexPy) Custom Component

Hey all - My Plex Streams sensor has been throwing some certificate errors and I thought it would be fun to see if I could get Tautulli/PlexPy setup as an alternative/replacement.

This is currently setup to use the new Version 2 / Tautulli but could probably very easily be back ported to v1.

Here is the custom component (which was modeled off of the Pi-Hole Sensor):

"""
Support for getting statistical data from a Tautulli system.


"""
import logging
import json
from datetime import timedelta

import voluptuous as vol

import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
    CONF_NAME, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL, CONF_TOKEN, CONF_MONITORED_CONDITIONS)

_LOGGER = logging.getLogger(__name__)
_ENDPOINT = '/api/v2'

DEFAULT_HOST = 'localhost'
DEFAULT_NAME = 'Tautulli'
DEFAULT_SSL = False
DEFAULT_VERIFY_SSL = True

SCAN_INTERVAL = timedelta(minutes=1)

MONITORED_CONDITIONS = {
    'stream_count': ['Total',
                     'streams', 'mdi:basket-unfill'],
    'stream_count_transcode': ['Transcode',
                               'streams', 'mdi:basket-unfill'],
    'stream_count_direct_play': ['Direct Play',
                                 'streams', 'mdi:basket-unfill'],
    'stream_count_direct_stream': ['Direct Stream',
                                   'streams', 'mdi:basket-unfill'],
    'total_bandwidth': ['Total Bandwidth',
                        'Mbps', 'mdi:basket-unfill'],


}

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
    vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
    vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
    vol.Optional(CONF_TOKEN): cv.string,
    vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS):
    vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]),
})


def setup_platform(hass, config, add_devices, discovery_info=None):
    """Set up the Tautulli sensor."""
    name = config.get(CONF_NAME)
    host = config.get(CONF_HOST)
    use_ssl = config.get(CONF_SSL)
    token = config.get(CONF_TOKEN)
    verify_ssl = config.get(CONF_VERIFY_SSL)

    api = TautulliAPI('{}'.format(host), use_ssl, verify_ssl, token)

    sensors = [TautulliSensor(hass, api, name, condition)
               for condition in config[CONF_MONITORED_CONDITIONS]]

    add_devices(sensors, True)


class TautulliSensor(Entity):
    """Representation of a Tautulli sensor."""

    def __init__(self, hass, api, name, variable):
        """Initialize a Tautulli sensor."""
        self._hass = hass
        self._api = api
        self._name = name
        self._var_id = variable

        variable_info = MONITORED_CONDITIONS[variable]
        self._var_name = variable_info[0]
        self._var_units = variable_info[1]
        self._var_icon = variable_info[2]

    @property
    def name(self):
        """Return the name of the sensor."""
        return "{} {}".format(self._name, self._var_name)

    @property
    def icon(self):
        """Icon to use in the frontend, if any."""
        return self._var_icon

    @property
    def unit_of_measurement(self):
        """Return the unit the value is expressed in."""
        return self._var_units

    # pylint: disable=no-member
    @property
    def state(self):
        """Return the state of the device."""
        try:
            return_value = self._api.data['response']['data'][self._var_id]
            if self._var_id == 'total_bandwidth':
                return_value = round((return_value / 1000), 2)

            return return_value
        except TypeError:
            return self._api.data['response']['data'][self._var_id]

    # pylint: disable=no-member
    @property
    def device_state_attributes(self):
        """Return the state attributes of the Tautulli."""
        attributes = {}

        if self._var_id == 'total_bandwidth':
            attributes['wan_bandwidth'] = round(
                (self._api.data['response']['data']['wan_bandwidth'] / 1000), 2)
            attributes['lan_bandwidth'] = round(
                (self._api.data['response']['data']['lan_bandwidth'] / 1000), 2)
            # attributes[ATTR_TOTAL_BANDWIDTH] = self._api.data['response']['data']['total_bandwidth']
        else:
            for session in self._api.data['response']['data']['sessions']:
                if self._var_id == 'stream_count':
                    attributes[session['friendly_name']
                               ] = session['full_title']
                elif self._var_id == 'stream_count_transcode' and session['transcode_decision'] == "transcode":
                    attributes[session['friendly_name']
                               ] = session['full_title']
                elif self._var_id == 'stream_count_direct_stream' and session['transcode_decision'] == "copy":
                    attributes[session['friendly_name']
                               ] = session['full_title']
                elif self._var_id == 'stream_count_direct_play' and session['transcode_decision'] == "direct play":
                    attributes[session['friendly_name']
                               ] = session['full_title']

        return attributes

    @property
    def available(self):
        """Could the device be accessed during the last update call."""
        return self._api.available

    def update(self):
        """Get the latest data from the Tautulli API."""
        self._api.update()


class TautulliAPI(object):
    """Get the latest data and update the states."""

    def __init__(self, host, use_ssl, verify_ssl, token):
        """Initialize the data object."""
        from homeassistant.components.sensor.rest import RestData

        uri_scheme = 'https://' if use_ssl else 'http://'
        resource = "{}{}{}?cmd=get_activity&apikey={}".format(
            uri_scheme, host, _ENDPOINT, token)

        self._rest = RestData('GET', resource, None, None, None, verify_ssl)
        self.data = None
        self.available = True
        self.update()

    def update(self):
        """Get the latest data from the Tautulli."""
        try:
            self._rest.update()
            self.data = json.loads(self._rest.data)
            self.available = True
        except TypeError:
            _LOGGER.error("Unable to fetch data from Tautulli")
            self.available = False


this should go into custom_components\sensor\ and the configuration.yaml entry should look like:

    - platform: tautulli
      host: [host_ip_address]:[port]
      ssl: false
      verify_ssl: true
      token: 'APIKEY'
      monitored_conditions:
        - stream_count
### Below are only compatible with Tautulli - if you are still on PlexPy, remove them.
        - stream_count_transcode
        - stream_count_direct_play
        - stream_count_direct_stream
        - total_bandwidth

stream_count is the total
total_bandwidth is the total “Streaming Brain Estimate”

The others should be self-explanatory.

For the stream sensors, you will get attributes showing the friendly_name and full_title of the stream specific to the sensor (i.e. Total shows all, transcode only shows the items that are transcoding).

total_bandwidth will have attributes showing the lan and wan “Streaming Brain Estimate” bandwidth.

Some screenshots:

Standard sensors:
image

Group:
image

using this config:

  group:
    tautulli:
      name: Tautulli
      entities:
        - sensor.tautulli_total
        - sensor.tautulli_transcode
        - sensor.tautulli_direct_play
        - sensor.tautulli_direct_stream
        - sensor.tautulli_total_bandwidth

Bandwidth:
image

Streams:
image

Given positive feedback and any bugs being squashed I may submit a PR for this.

Let me know if you have any questions and ENJOY!!!

EDIT:
Fixed Mbps calculation.
Added comment about PlexPy supported conditions.

20 Likes

Fantastic work!!

I shall be giving this a shot once i’ve got shiggins8/tautulli set up seeing as there’s not an official docker container for it yet

Awesome job! Works great in Hass.io

Very nice but how did you install Tautulli 2 ?? I use Plexpy since along time but I don’t see any update to go at version 2 and nothing too on website linked on Plex website :frowning:

@motoolfan Thanks but I don’t need url of PlexPy/Tautulli website I have that already since few years :wink: looking how to install version 2 ? as there is no mention of v2 on PlexPy/Tautulli website and github !

Sorry about that, Here ya go,

3 Likes

Thanks a lot :wink: upgraded my Plexpy setup to Tautulli and now going to install your component in my HA :slight_smile:

All credit goes to @kylerw, not I.

Hello, sorry for the question, which is the file name that I have to save sensor part?

tautulli.py

Something is not working for me… I get this error:

2018-01-21 10:14:36 ERROR (MainThread) [homeassistant.components.sensor] Error while setting up platform tautulli
Traceback (most recent call last):
File “/srv/homeassistant/lib/python3.5/site-packages/homeassistant/helpers/entity_component.py”, line 171, in _async_setup_platform
SLOW_SETUP_MAX_WAIT, loop=self.hass.loop)
File “/usr/lib/python3.5/asyncio/tasks.py”, line 400, in wait_for
return fut.result()
File “/usr/lib/python3.5/asyncio/futures.py”, line 293, in result
raise self._exception
File “/usr/lib/python3.5/concurrent/futures/thread.py”, line 55, in run
result = self.fn(*self.args, **self.kwargs)
File “/home/homeassistant/.homeassistant/custom_components/sensor/tautulli.py”, line 62, in setup_platform
api = TautulliAPI(’{}’.format(host), use_ssl, verify_ssl, token)
File “/home/homeassistant/.homeassistant/custom_components/sensor/tautulli.py”, line 157, in init
self.update()
File “/home/homeassistant/.homeassistant/custom_components/sensor/tautulli.py”, line 163, in update
self.data = json.loads(self._rest.data)
File “/usr/lib/python3.5/json/init.py”, line 319, in loads
return _default_decoder.decode(s)
File “/usr/lib/python3.5/json/decoder.py”, line 339, in decode
obj, end = self.raw_decode(s, idx=_w(s, 0).end())
File “/usr/lib/python3.5/json/decoder.py”, line 357, in raw_decode
raise JSONDecodeError(“Expecting value”, s, err.value) from None

Still trying to figure out where I should create the custom_components directory in a Hassbian system :frowning:

Figured out how to setup the whole thing but your component triggers that error in my system:

Log Details (ERROR)

Sun Jan 21 2018 12:05:03 GMT+0100 (CET)

Error doing job: Task exception was never retrieved

Traceback (most recent call last):
  File "/usr/lib/python3.5/asyncio/tasks.py", line 241, in _step
result = coro.throw(exc)
  File "/srv/homeassistant/lib/python3.5/site-packages/homeassistant/setup.py", line 60, in async_setup_component
return (yield from task)
  File "/usr/lib/python3.5/asyncio/futures.py", line 380, in __iter__
yield self  # This tells Task to wait for completion.
  File "/usr/lib/python3.5/asyncio/tasks.py", line 304, in _wakeup
future.result()
  File "/usr/lib/python3.5/asyncio/futures.py", line 293, in result
raise self._exception
  File "/usr/lib/python3.5/asyncio/tasks.py", line 239, in _step
result = coro.send(None)
  File "/srv/homeassistant/lib/python3.5/site-packages/homeassistant/setup.py", line 159, in _async_setup_component
conf_util.async_process_component_config(hass, config, domain)
  File "/srv/homeassistant/lib/python3.5/site-packages/homeassistant/config.py", line 624, in async_process_component_config
platform = get_platform(domain, p_name)
  File "/srv/homeassistant/lib/python3.5/site-packages/homeassistant/loader.py", line 104, in get_platform
return get_component(PLATFORM_FORMAT.format(domain, platform))
  File "/srv/homeassistant/lib/python3.5/site-packages/homeassistant/loader.py", line 142, in get_component
module = importlib.import_module(path)
  File "/usr/lib/python3.5/importlib/__init__.py", line 126, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 986, in _gcd_import
  File "<frozen importlib._bootstrap>", line 969, in _find_and_load
  File "<frozen importlib._bootstrap>", line 958, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 673, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 673, in exec_module
  File "<frozen importlib._bootstrap>", line 222, in _call_with_frames_removed
  File "/home/homeassistant/.homeassistant/custom_components/sensor/tautulli.py", line 7, in <module>
_LOGGER = logging.getLogger(__name__)
NameError: name 'logging' is not defined

Small update in the OP to properly calculate Mbps.

I’m not familiar with hassbian nuances - the error seems to be throwing up on import logging which is a standard import statement.

I notice both erros show you each on Python 3.5 - I’m on 3.6. Not sure if the error indicates differences in Python versions…

I’ll see to update

Yep ! Which version of HA are you running ? 0.61.1 here !

Same, I’m on 0.61.1

Any ideas what’s wrong with your code ? so I can fix it and try to get it working correctly here !