diff --git a/.coveragerc b/.coveragerc index be2f12aa434..251d9a7b6dc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -211,6 +211,7 @@ omit = homeassistant/components/lirc.py homeassistant/components/lock/nuki.py homeassistant/components/media_player/anthemav.py + homeassistant/components/media_player/apple_tv.py homeassistant/components/media_player/aquostv.py homeassistant/components/media_player/braviatv.py homeassistant/components/media_player/cast.py diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py new file mode 100644 index 00000000000..0eec30a3f70 --- /dev/null +++ b/homeassistant/components/media_player/apple_tv.py @@ -0,0 +1,246 @@ +""" +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 + +import voluptuous as vol + +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_STOP, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, MediaPlayerDevice, + PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, MEDIA_TYPE_VIDEO, MEDIA_TYPE_TVSHOW) +from homeassistant.const import ( + STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, CONF_HOST, + CONF_NAME, EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + + +REQUIREMENTS = ['pyatv==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_LOGIN_ID = 'login_id' + +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, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Setup the Apple TV platform.""" + import pyatv + + if DATA_APPLE_TV not in hass.data: + hass.data[DATA_APPLE_TV] = [] + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + login_id = config.get(CONF_LOGIN_ID) + + key = '{}:{}'.format(host, name) + if key in hass.data[DATA_APPLE_TV]: + return False + hass.data[DATA_APPLE_TV].append(key) + + details = pyatv.AppleTVDevice(name, host, login_id) + atv = pyatv.connect_to_apple_tv(details, hass.loop) + entity = AppleTvDevice(atv, name) + + @asyncio.coroutine + def async_stop_subscription(event): + """Logout device to close its session.""" + _LOGGER.info("Closing Apple TV session") + yield from atv.logout() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + async_stop_subscription) + + yield from async_add_entities([entity], update_before_add=True) + + +class AppleTvDevice(MediaPlayerDevice): + """Representation of an Apple TV device.""" + + def __init__(self, atv, name): + """Initialize the Apple TV device.""" + self._name = name + self._atv = atv + self._playing = None + self._artwork = None + self._artwork_hash = None + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + 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.""" + from pyatv import exceptions + try: + playing = yield from self._atv.metadata.playing() + + if self._should_download_artwork(playing): + self._artwork = None + self._artwork_hash = None + self._artwork = yield from self._atv.metadata.artwork() + if self._artwork: + self._artwork_hash = hashlib.md5(self._artwork).hexdigest() + + self._playing = playing + except exceptions.AuthenticationError as ex: + _LOGGER.warning('%s (bad login id?)', str(ex)) + except asyncio.TimeoutError: + _LOGGER.warning('timed out while connecting to Apple TV') + + def _should_download_artwork(self, new_playing): + 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 + def media_image(self): + """Artwork for what is currently playing.""" + return self._artwork, 'image/png', self._artwork_hash + + @property + def media_title(self): + """Title of current playing media.""" + if self._playing is not None: + title = self._playing.title + return title if title else "No title" + + @property + def supported_media_commands(self): + """Flag of media commands that are supported.""" + if self._playing is not None: + if self.state != STATE_IDLE: + return SUPPORT_PAUSE | SUPPORT_PLAY | \ + SUPPORT_SEEK | SUPPORT_STOP | \ + SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_PLAY_MEDIA + else: + return SUPPORT_PLAY_MEDIA + + 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() + + @asyncio.coroutine + def async_media_seek(self, position): + """Send seek command.""" + if self._playing is not None: + yield from self._atv.remote_control.set_position(position) diff --git a/requirements_all.txt b/requirements_all.txt index bcf935e7e69..e3e06dcebe8 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -414,6 +414,9 @@ pyasn1-modules==0.0.8 # homeassistant.components.notify.xmpp pyasn1==0.2.1 +# homeassistant.components.media_player.apple_tv +pyatv==0.0.1 + # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox pybbox==0.0.5-alpha