2017-02-07 08:55:19 +00:00
|
|
|
"""
|
|
|
|
Support for Apple TV.
|
|
|
|
|
|
|
|
For more details about this platform, please refer to the documentation at
|
|
|
|
https://home-assistant.io/components/media_player.apple_tv/
|
|
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import logging
|
|
|
|
import hashlib
|
|
|
|
|
2017-02-09 21:25:06 +00:00
|
|
|
import aiohttp
|
2017-02-07 08:55:19 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
2017-02-15 17:10:48 +00:00
|
|
|
from homeassistant.core import callback
|
2017-02-07 08:55:19 +00:00
|
|
|
from homeassistant.components.media_player import (
|
|
|
|
SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
|
2017-02-15 17:10:48 +00:00
|
|
|
SUPPORT_STOP, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_TURN_ON,
|
|
|
|
SUPPORT_TURN_OFF, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC,
|
|
|
|
MEDIA_TYPE_VIDEO, MEDIA_TYPE_TVSHOW)
|
2017-02-07 08:55:19 +00:00
|
|
|
from homeassistant.const import (
|
|
|
|
STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, CONF_HOST,
|
2017-02-15 17:10:48 +00:00
|
|
|
STATE_OFF, CONF_NAME)
|
2017-02-09 22:07:46 +00:00
|
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
2017-02-07 08:55:19 +00:00
|
|
|
import homeassistant.helpers.config_validation as cv
|
|
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
|
|
|
|
|
2017-02-12 03:25:20 +00:00
|
|
|
REQUIREMENTS = ['pyatv==0.1.4']
|
2017-02-07 08:55:19 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
CONF_LOGIN_ID = 'login_id'
|
2017-02-15 17:10:48 +00:00
|
|
|
CONF_START_OFF = 'start_off'
|
2017-02-07 08:55:19 +00:00
|
|
|
|
|
|
|
DEFAULT_NAME = 'Apple TV'
|
|
|
|
|
|
|
|
DATA_APPLE_TV = 'apple_tv'
|
|
|
|
|
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
|
|
|
vol.Required(CONF_HOST): cv.string,
|
|
|
|
vol.Required(CONF_LOGIN_ID): cv.string,
|
|
|
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
2017-02-15 17:10:48 +00:00
|
|
|
vol.Optional(CONF_START_OFF, default=False): cv.boolean
|
2017-02-07 08:55:19 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
@asyncio.coroutine
|
|
|
|
def async_setup_platform(hass, config, async_add_entities,
|
|
|
|
discovery_info=None):
|
|
|
|
"""Setup the Apple TV platform."""
|
|
|
|
import pyatv
|
|
|
|
|
2017-02-08 11:17:23 +00:00
|
|
|
if discovery_info is not None:
|
|
|
|
name = discovery_info['name']
|
|
|
|
host = discovery_info['host']
|
|
|
|
login_id = discovery_info['hsgid']
|
2017-02-15 17:10:48 +00:00
|
|
|
start_off = False
|
2017-02-08 11:17:23 +00:00
|
|
|
else:
|
|
|
|
name = config.get(CONF_NAME)
|
|
|
|
host = config.get(CONF_HOST)
|
|
|
|
login_id = config.get(CONF_LOGIN_ID)
|
2017-02-15 17:10:48 +00:00
|
|
|
start_off = config.get(CONF_START_OFF)
|
2017-02-08 11:17:23 +00:00
|
|
|
|
2017-02-07 08:55:19 +00:00
|
|
|
if DATA_APPLE_TV not in hass.data:
|
|
|
|
hass.data[DATA_APPLE_TV] = []
|
|
|
|
|
2017-02-08 11:17:23 +00:00
|
|
|
if host in hass.data[DATA_APPLE_TV]:
|
2017-02-07 08:55:19 +00:00
|
|
|
return False
|
2017-02-08 11:17:23 +00:00
|
|
|
hass.data[DATA_APPLE_TV].append(host)
|
2017-02-07 08:55:19 +00:00
|
|
|
|
|
|
|
details = pyatv.AppleTVDevice(name, host, login_id)
|
2017-02-09 22:07:46 +00:00
|
|
|
session = async_get_clientsession(hass)
|
|
|
|
atv = pyatv.connect_to_apple_tv(details, hass.loop, session=session)
|
2017-02-15 17:10:48 +00:00
|
|
|
entity = AppleTvDevice(atv, name, start_off)
|
2017-02-07 08:55:19 +00:00
|
|
|
|
|
|
|
yield from async_add_entities([entity], update_before_add=True)
|
|
|
|
|
|
|
|
|
|
|
|
class AppleTvDevice(MediaPlayerDevice):
|
|
|
|
"""Representation of an Apple TV device."""
|
|
|
|
|
2017-02-15 17:10:48 +00:00
|
|
|
def __init__(self, atv, name, is_off):
|
2017-02-07 08:55:19 +00:00
|
|
|
"""Initialize the Apple TV device."""
|
|
|
|
self._atv = atv
|
2017-02-15 17:10:48 +00:00
|
|
|
self._name = name
|
|
|
|
self._is_off = is_off
|
|
|
|
self._playing = None
|
|
|
|
self._artwork_hash = None
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def _set_power_off(self, is_off):
|
2017-02-07 08:55:19 +00:00
|
|
|
self._playing = None
|
|
|
|
self._artwork_hash = None
|
2017-02-15 17:10:48 +00:00
|
|
|
self._is_off = is_off
|
2017-02-07 08:55:19 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def name(self):
|
|
|
|
"""Return the name of the device."""
|
|
|
|
return self._name
|
|
|
|
|
|
|
|
@property
|
|
|
|
def state(self):
|
|
|
|
"""Return the state of the device."""
|
2017-02-15 17:10:48 +00:00
|
|
|
if self._is_off:
|
|
|
|
return STATE_OFF
|
|
|
|
|
2017-02-07 08:55:19 +00:00
|
|
|
if self._playing is not None:
|
|
|
|
from pyatv import const
|
|
|
|
state = self._playing.play_state
|
|
|
|
if state == const.PLAY_STATE_NO_MEDIA:
|
|
|
|
return STATE_IDLE
|
|
|
|
elif state == const.PLAY_STATE_PLAYING or \
|
|
|
|
state == const.PLAY_STATE_LOADING:
|
|
|
|
return STATE_PLAYING
|
|
|
|
elif state == const.PLAY_STATE_PAUSED or \
|
|
|
|
state == const.PLAY_STATE_FAST_FORWARD or \
|
|
|
|
state == const.PLAY_STATE_FAST_BACKWARD:
|
|
|
|
# Catch fast forward/backward here so "play" is default action
|
|
|
|
return STATE_PAUSED
|
|
|
|
else:
|
|
|
|
return STATE_STANDBY # Bad or unknown state?
|
|
|
|
|
|
|
|
@asyncio.coroutine
|
|
|
|
def async_update(self):
|
|
|
|
"""Retrieve latest state."""
|
2017-02-15 17:10:48 +00:00
|
|
|
if self._is_off:
|
|
|
|
return
|
|
|
|
|
2017-02-07 08:55:19 +00:00
|
|
|
from pyatv import exceptions
|
|
|
|
try:
|
|
|
|
playing = yield from self._atv.metadata.playing()
|
|
|
|
|
2017-02-12 21:34:02 +00:00
|
|
|
if self._has_playing_media_changed(playing):
|
|
|
|
base = str(playing.title) + str(playing.artist) + \
|
|
|
|
str(playing.album) + str(playing.total_time)
|
|
|
|
self._artwork_hash = hashlib.md5(
|
|
|
|
base.encode('utf-8')).hexdigest()
|
2017-02-07 08:55:19 +00:00
|
|
|
|
|
|
|
self._playing = playing
|
|
|
|
except exceptions.AuthenticationError as ex:
|
|
|
|
_LOGGER.warning('%s (bad login id?)', str(ex))
|
2017-02-09 21:25:06 +00:00
|
|
|
except aiohttp.errors.ClientOSError as ex:
|
|
|
|
_LOGGER.error('failed to connect to Apple TV (%s)', str(ex))
|
2017-02-07 08:55:19 +00:00
|
|
|
except asyncio.TimeoutError:
|
|
|
|
_LOGGER.warning('timed out while connecting to Apple TV')
|
|
|
|
|
2017-02-12 21:34:02 +00:00
|
|
|
def _has_playing_media_changed(self, new_playing):
|
2017-02-07 08:55:19 +00:00
|
|
|
if self._playing is None:
|
|
|
|
return True
|
|
|
|
old_playing = self._playing
|
|
|
|
return new_playing.media_type != old_playing.media_type or \
|
|
|
|
new_playing.title != old_playing.title
|
|
|
|
|
|
|
|
@property
|
|
|
|
def media_content_type(self):
|
|
|
|
"""Content type of current playing media."""
|
|
|
|
if self._playing is not None:
|
|
|
|
from pyatv import const
|
|
|
|
media_type = self._playing.media_type
|
|
|
|
if media_type == const.MEDIA_TYPE_VIDEO:
|
|
|
|
return MEDIA_TYPE_VIDEO
|
|
|
|
elif media_type == const.MEDIA_TYPE_MUSIC:
|
|
|
|
return MEDIA_TYPE_MUSIC
|
|
|
|
elif media_type == const.MEDIA_TYPE_TV:
|
|
|
|
return MEDIA_TYPE_TVSHOW
|
|
|
|
|
|
|
|
@property
|
|
|
|
def media_duration(self):
|
|
|
|
"""Duration of current playing media in seconds."""
|
|
|
|
if self._playing is not None:
|
|
|
|
return self._playing.total_time
|
|
|
|
|
|
|
|
@property
|
|
|
|
def media_position(self):
|
|
|
|
"""Position of current playing media in seconds."""
|
|
|
|
if self._playing is not None:
|
|
|
|
return self._playing.position
|
|
|
|
|
|
|
|
@property
|
|
|
|
def media_position_updated_at(self):
|
|
|
|
"""Last valid time of media position."""
|
|
|
|
state = self.state
|
|
|
|
if state == STATE_PLAYING or state == STATE_PAUSED:
|
|
|
|
return dt_util.utcnow()
|
|
|
|
|
|
|
|
@asyncio.coroutine
|
|
|
|
def async_play_media(self, media_type, media_id, **kwargs):
|
|
|
|
"""Send the play_media command to the media player."""
|
|
|
|
yield from self._atv.remote_control.play_url(media_id, 0)
|
|
|
|
|
|
|
|
@property
|
2017-02-12 21:34:02 +00:00
|
|
|
def media_image_hash(self):
|
|
|
|
"""Hash value for media image."""
|
|
|
|
return self._artwork_hash
|
|
|
|
|
|
|
|
@asyncio.coroutine
|
|
|
|
def async_get_media_image(self):
|
|
|
|
"""Fetch media image of current playing image."""
|
|
|
|
return (yield from self._atv.metadata.artwork()), 'image/png'
|
2017-02-07 08:55:19 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def media_title(self):
|
|
|
|
"""Title of current playing media."""
|
|
|
|
if self._playing is not None:
|
2017-02-15 17:10:48 +00:00
|
|
|
if self.state == STATE_IDLE:
|
|
|
|
return 'Nothing playing'
|
2017-02-07 08:55:19 +00:00
|
|
|
title = self._playing.title
|
|
|
|
return title if title else "No title"
|
|
|
|
|
|
|
|
@property
|
2017-02-08 04:42:45 +00:00
|
|
|
def supported_features(self):
|
|
|
|
"""Flag media player features that are supported."""
|
2017-02-15 17:10:48 +00:00
|
|
|
features = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA
|
|
|
|
if self._playing is None or self.state == STATE_IDLE:
|
|
|
|
return features
|
|
|
|
|
|
|
|
features |= SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \
|
|
|
|
SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK
|
|
|
|
|
|
|
|
return features
|
|
|
|
|
|
|
|
@asyncio.coroutine
|
|
|
|
def async_turn_on(self):
|
|
|
|
"""Turn the media player on."""
|
|
|
|
self._set_power_off(False)
|
|
|
|
|
|
|
|
@asyncio.coroutine
|
|
|
|
def async_turn_off(self):
|
|
|
|
"""Turn the media player off."""
|
|
|
|
self._set_power_off(True)
|
2017-02-07 08:55:19 +00:00
|
|
|
|
|
|
|
def async_media_play_pause(self):
|
|
|
|
"""Pause media on media player.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
if self._playing is not None:
|
|
|
|
state = self.state
|
|
|
|
if state == STATE_PAUSED:
|
|
|
|
return self._atv.remote_control.play()
|
|
|
|
elif state == STATE_PLAYING:
|
|
|
|
return self._atv.remote_control.pause()
|
|
|
|
|
|
|
|
def async_media_play(self):
|
|
|
|
"""Play media.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
if self._playing is not None:
|
|
|
|
return self._atv.remote_control.play()
|
|
|
|
|
|
|
|
def async_media_pause(self):
|
|
|
|
"""Pause the media player.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
if self._playing is not None:
|
|
|
|
return self._atv.remote_control.pause()
|
|
|
|
|
|
|
|
def async_media_next_track(self):
|
|
|
|
"""Send next track command.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
if self._playing is not None:
|
|
|
|
return self._atv.remote_control.next()
|
|
|
|
|
|
|
|
def async_media_previous_track(self):
|
|
|
|
"""Send previous track command.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
|
|
|
if self._playing is not None:
|
|
|
|
return self._atv.remote_control.previous()
|
|
|
|
|
|
|
|
def async_media_seek(self, position):
|
2017-02-15 17:10:48 +00:00
|
|
|
"""Send seek command.
|
|
|
|
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
|
|
"""
|
2017-02-07 08:55:19 +00:00
|
|
|
if self._playing is not None:
|
2017-02-15 17:10:48 +00:00
|
|
|
return self._atv.remote_control.set_position(position)
|