From d119610cf1b4289094e2aaf0df15b492c4013f0f Mon Sep 17 00:00:00 2001
From: Jon Maddox <jon@jonmaddox.com>
Date: Wed, 7 Mar 2018 03:33:13 -0500
Subject: [PATCH] Add a Media Player Component for Channels (#12937)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* add Channels media player

* add Channels' services

* style :lipstick:

* more :lipstick:

* make up your mind robot

* :lipstick: :lipstick: :lipstick:

* dump client and pull it in via a package

* ChannelsApp -> ChannelsPlayer

* load the lib

* add pychannels in requirements

* not using requests anymore

* extra line :lipstick:

* move this here

* move this up

* :fire:

* use constants for these

* add a platform schema

* force update here

* get defaults to None

* break out after finding it

* use None for state if offline or errored

* pull in CONF_NAME

* fix syntax

* update requirements_all.txt

* :lipstick::lipstick::lipstick:

* :lipstick:

* docs

* like this? ¯\(°_o)/¯
---
 .../components/media_player/channels.py       | 303 ++++++++++++++++++
 .../components/media_player/services.yaml     |  26 +-
 requirements_all.txt                          |   3 +
 3 files changed, 331 insertions(+), 1 deletion(-)
 create mode 100644 homeassistant/components/media_player/channels.py

diff --git a/homeassistant/components/media_player/channels.py b/homeassistant/components/media_player/channels.py
new file mode 100644
index 00000000000..eda47237b44
--- /dev/null
+++ b/homeassistant/components/media_player/channels.py
@@ -0,0 +1,303 @@
+"""
+Support for interfacing with an instance of Channels (https://getchannels.com).
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/media_player.channels/
+"""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+    MEDIA_TYPE_CHANNEL, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_EPISODE,
+    MEDIA_TYPE_VIDEO, SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP,
+    SUPPORT_VOLUME_MUTE, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK,
+    SUPPORT_PLAY_MEDIA, SUPPORT_SELECT_SOURCE, DOMAIN, PLATFORM_SCHEMA,
+    MediaPlayerDevice)
+
+from homeassistant.const import (
+    CONF_HOST, CONF_PORT, CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING,
+    ATTR_ENTITY_ID)
+
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_CHANNELS = 'channels'
+DEFAULT_NAME = 'Channels'
+DEFAULT_PORT = 57000
+
+FEATURE_SUPPORT = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \
+    SUPPORT_VOLUME_MUTE | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | \
+    SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+    vol.Required(CONF_HOST): cv.string,
+    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+    vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+SERVICE_SEEK_FORWARD = 'channels_seek_forward'
+SERVICE_SEEK_BACKWARD = 'channels_seek_backward'
+SERVICE_SEEK_BY = 'channels_seek_by'
+
+# Service call validation schemas
+ATTR_SECONDS = 'seconds'
+
+CHANNELS_SCHEMA = vol.Schema({
+    vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+CHANNELS_SEEK_BY_SCHEMA = CHANNELS_SCHEMA.extend({
+    vol.Required(ATTR_SECONDS): vol.Coerce(int),
+})
+
+REQUIREMENTS = ['pychannels==1.0.0']
+
+
+# pylint: disable=unused-argument, abstract-method
+# pylint: disable=too-many-instance-attributes
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    """Setup the Channels platform."""
+    device = ChannelsPlayer(
+        config.get('name', DEFAULT_NAME),
+        config.get(CONF_HOST),
+        config.get(CONF_PORT, DEFAULT_PORT)
+        )
+
+    if DATA_CHANNELS not in hass.data:
+        hass.data[DATA_CHANNELS] = []
+
+    add_devices([device], True)
+    hass.data[DATA_CHANNELS].append(device)
+
+    def service_handler(service):
+        """Handler for services."""
+        entity_ids = service.data.get(ATTR_ENTITY_ID)
+
+        if entity_ids:
+            devices = [device for device in hass.data[DATA_CHANNELS]
+                       if device.entity_id in entity_ids]
+        else:
+            devices = hass.data[DATA_CHANNELS]
+
+        for device in devices:
+            if service.service == SERVICE_SEEK_FORWARD:
+                device.seek_forward()
+            elif service.service == SERVICE_SEEK_BACKWARD:
+                device.seek_backward()
+            elif service.service == SERVICE_SEEK_BY:
+                seconds = service.data.get('seconds')
+                device.seek_by(seconds)
+
+    hass.services.register(
+        DOMAIN, SERVICE_SEEK_FORWARD, service_handler,
+        schema=CHANNELS_SCHEMA)
+
+    hass.services.register(
+        DOMAIN, SERVICE_SEEK_BACKWARD, service_handler,
+        schema=CHANNELS_SCHEMA)
+
+    hass.services.register(
+        DOMAIN, SERVICE_SEEK_BY, service_handler,
+        schema=CHANNELS_SEEK_BY_SCHEMA)
+
+
+class ChannelsPlayer(MediaPlayerDevice):
+    """Representation of a Channels instance."""
+
+    # pylint: disable=too-many-public-methods
+    def __init__(self, name, host, port):
+        """Initialize the Channels app."""
+        from pychannels import Channels
+
+        self._name = name
+        self._host = host
+        self._port = port
+
+        self.client = Channels(self._host, self._port)
+
+        self.status = None
+        self.muted = None
+
+        self.channel_number = None
+        self.channel_name = None
+        self.channel_image_url = None
+
+        self.now_playing_title = None
+        self.now_playing_episode_title = None
+        self.now_playing_season_number = None
+        self.now_playing_episode_number = None
+        self.now_playing_summary = None
+        self.now_playing_image_url = None
+
+        self.favorite_channels = []
+
+    def update_favorite_channels(self):
+        """Update the favorite channels from the client."""
+        self.favorite_channels = self.client.favorite_channels()
+
+    def update_state(self, state_hash):
+        """Update all the state properties with the passed in dictionary."""
+        self.status = state_hash.get('status', "stopped")
+        self.muted = state_hash.get('muted', False)
+
+        channel_hash = state_hash.get('channel')
+        np_hash = state_hash.get('now_playing')
+
+        if channel_hash:
+            self.channel_number = channel_hash.get('channel_number')
+            self.channel_name = channel_hash.get('channel_name')
+            self.channel_image_url = channel_hash.get('channel_image_url')
+        else:
+            self.channel_number = None
+            self.channel_name = None
+            self.channel_image_url = None
+
+        if np_hash:
+            self.now_playing_title = np_hash.get('title')
+            self.now_playing_episode_title = np_hash.get('episode_title')
+            self.now_playing_season_number = np_hash.get('season_number')
+            self.now_playing_episode_number = np_hash.get('episode_number')
+            self.now_playing_summary = np_hash.get('summary')
+            self.now_playing_image_url = np_hash.get('image_url')
+        else:
+            self.now_playing_title = None
+            self.now_playing_episode_title = None
+            self.now_playing_season_number = None
+            self.now_playing_episode_number = None
+            self.now_playing_summary = None
+            self.now_playing_image_url = None
+
+    @property
+    def name(self):
+        """Return the name of the player."""
+        return self._name
+
+    @property
+    def state(self):
+        """Return the state of the player."""
+        if self.status == 'stopped':
+            return STATE_IDLE
+
+        if self.status == 'paused':
+            return STATE_PAUSED
+
+        if self.status == 'playing':
+            return STATE_PLAYING
+
+        return None
+
+    def update(self):
+        """Retrieve latest state."""
+        self.update_favorite_channels()
+        self.update_state(self.client.status())
+
+    @property
+    def source_list(self):
+        """List of favorite channels."""
+        sources = [channel['name'] for channel in self.favorite_channels]
+        return sources
+
+    @property
+    def is_volume_muted(self):
+        """Boolean if volume is currently muted."""
+        return self.muted
+
+    @property
+    def media_content_id(self):
+        """Content ID of current playing channel."""
+        return self.channel_number
+
+    @property
+    def media_content_type(self):
+        """Content type of current playing media."""
+        return MEDIA_TYPE_CHANNEL
+
+    @property
+    def media_image_url(self):
+        """Image url of current playing media."""
+        if self.now_playing_image_url:
+            return self.now_playing_image_url
+        elif self.channel_image_url:
+            return self.channel_image_url
+
+        return 'https://getchannels.com/assets/img/icon-1024.png'
+
+    @property
+    def media_title(self):
+        """Title of current playing media."""
+        if self.state:
+            return self.now_playing_title
+
+        return None
+
+    @property
+    def supported_features(self):
+        """Flag of media commands that are supported."""
+        return FEATURE_SUPPORT
+
+    def mute_volume(self, mute):
+        """Mute (true) or unmute (false) player."""
+        if mute != self.muted:
+            response = self.client.toggle_muted()
+            self.update_state(response)
+
+    def media_stop(self):
+        """Send media_stop command to player."""
+        self.status = "stopped"
+        response = self.client.stop()
+        self.update_state(response)
+
+    def media_play(self):
+        """Send media_play command to player."""
+        response = self.client.resume()
+        self.update_state(response)
+
+    def media_pause(self):
+        """Send media_pause command to player."""
+        response = self.client.pause()
+        self.update_state(response)
+
+    def media_next_track(self):
+        """Seek ahead."""
+        response = self.client.skip_forward()
+        self.update_state(response)
+
+    def media_previous_track(self):
+        """Seek back."""
+        response = self.client.skip_backward()
+        self.update_state(response)
+
+    def select_source(self, source):
+        """Select a channel to tune to."""
+        for channel in self.favorite_channels:
+            if channel["name"] == source:
+                response = self.client.play_channel(channel["number"])
+                self.update_state(response)
+                break
+
+    def play_media(self, media_type, media_id, **kwargs):
+        """Send the play_media command to the player."""
+        if media_type == MEDIA_TYPE_CHANNEL:
+            response = self.client.play_channel(media_id)
+            self.update_state(response)
+        elif media_type in [MEDIA_TYPE_VIDEO, MEDIA_TYPE_EPISODE,
+                            MEDIA_TYPE_TVSHOW]:
+            response = self.client.play_recording(media_id)
+            self.update_state(response)
+
+    def seek_forward(self):
+        """Seek forward in the timeline."""
+        response = self.client.seek_forward()
+        self.update_state(response)
+
+    def seek_backward(self):
+        """Seek backward in the timeline."""
+        response = self.client.seek_backward()
+        self.update_state(response)
+
+    def seek_by(self, seconds):
+        """Seek backward in the timeline."""
+        response = self.client.seek(seconds)
+        self.update_state(response)
diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml
index 4d488a92300..beaea8a8ad0 100644
--- a/homeassistant/components/media_player/services.yaml
+++ b/homeassistant/components/media_player/services.yaml
@@ -242,6 +242,30 @@ sonos_set_option:
       description: Enable Speech Enhancement mode
       example: 'true'
 
+channels_seek_forward:
+  description: Seek forward by a set number of seconds.
+  fields:
+    entity_id:
+      description: Name of entity for the instance of Channels to seek in.
+      example: 'media_player.family_room_channels'
+
+channels_seek_backward:
+  description: Seek backward by a set number of seconds.
+  fields:
+    entity_id:
+      description: Name of entity for the instance of Channels to seek in.
+      example: 'media_player.family_room_channels'
+
+channels_seek_by:
+  description: Seek by an inputted number of seconds.
+  fields:
+    entity_id:
+      description: Name of entity for the instance of Channels to seek in.
+      example: 'media_player.family_room_channels'
+    seconds:
+      description: Number of seconds to seek by. Negative numbers seek backwards.
+      example: 120
+
 soundtouch_play_everywhere:
   description: Play on all Bose Soundtouch devices.
   fields:
@@ -367,7 +391,7 @@ bluesound_clear_sleep_timer:
 
 songpal_set_sound_setting:
   description: Change sound setting.
-  
+
   fields:
     entity_id:
       description: Target device.
diff --git a/requirements_all.txt b/requirements_all.txt
index 8b7e6c8c16d..4eae1e2688f 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -675,6 +675,9 @@ pybbox==0.0.5-alpha
 # homeassistant.components.device_tracker.bluetooth_tracker
 # pybluez==0.22
 
+# homeassistant.components.media_player.channels
+pychannels==1.0.0
+
 # homeassistant.components.media_player.cast
 pychromecast==2.0.0
 
-- 
GitLab