Add new feature to Apple TV platform (#8122)

* Lift Apple TV to pyatv 0.3.2

Update code to use basic new features.

* Support button presses in Apple TV

* Support device authentication

* Convert Apple TV to a component

A media_player platform and a remote platform will be loaded for each
manually configured or discovered device.

* Move device auth to apple_tv component

* Update requirements and coverage config

* Add scan support to apple_tv
This commit is contained in:
Pierre Ståhl 2017-07-05 06:37:18 +02:00 committed by Paulus Schoutsen
parent 8185587100
commit ea5bec3ef4
7 changed files with 422 additions and 102 deletions

View file

@ -14,6 +14,9 @@ omit =
homeassistant/components/apcupsd.py
homeassistant/components/*/apcupsd.py
homeassistant/components/apple_tv.py
homeassistant/components/*/apple_tv.py
homeassistant/components/arduino.py
homeassistant/components/*/arduino.py
@ -302,7 +305,6 @@ omit =
homeassistant/components/lock/lockitron.py
homeassistant/components/lock/sesame.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

View file

@ -0,0 +1,259 @@
"""
Support for Apple TV.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/apple_tv/
"""
import os
import asyncio
import logging
import voluptuous as vol
from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_ENTITY_ID)
from homeassistant.config import load_yaml_config_file
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers import discovery
from homeassistant.components.discovery import SERVICE_APPLE_TV
from homeassistant.loader import get_component
import homeassistant.helpers.config_validation as cv
REQUIREMENTS = ['pyatv==0.3.2']
_LOGGER = logging.getLogger(__name__)
DOMAIN = 'apple_tv'
SERVICE_SCAN = 'apple_tv_scan'
SERVICE_AUTHENTICATE = 'apple_tv_authenticate'
ATTR_ATV = 'atv'
ATTR_POWER = 'power'
CONF_LOGIN_ID = 'login_id'
CONF_START_OFF = 'start_off'
CONF_CREDENTIALS = 'credentials'
DEFAULT_NAME = 'Apple TV'
DATA_APPLE_TV = 'data_apple_tv'
DATA_ENTITIES = 'data_apple_tv_entities'
KEY_CONFIG = 'apple_tv_configuring'
NOTIFICATION_AUTH_ID = 'apple_tv_auth_notification'
NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication'
NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification'
NOTIFICATION_SCAN_TITLE = 'Apple TV Scan'
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_LOGIN_ID): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_CREDENTIALS, default=None): cv.string,
vol.Optional(CONF_START_OFF, default=False): cv.boolean
})])
}, extra=vol.ALLOW_EXTRA)
# Currently no attributes but it might change later
APPLE_TV_SCAN_SCHEMA = vol.Schema({})
APPLE_TV_AUTHENTICATE_SCHEMA = vol.Schema({
ATTR_ENTITY_ID: cv.entity_ids,
})
def request_configuration(hass, config, atv, credentials):
"""Request configuration steps from the user."""
configurator = get_component('configurator')
@asyncio.coroutine
def configuration_callback(callback_data):
"""Handle the submitted configuration."""
from pyatv import exceptions
pin = callback_data.get('pin')
notification = get_component('persistent_notification')
try:
yield from atv.airplay.finish_authentication(pin)
notification.async_create(
hass,
'Authentication succeeded!<br /><br />Add the following '
'to credentials: in your apple_tv configuration:<br /><br />'
'{0}'.format(credentials),
title=NOTIFICATION_AUTH_TITLE,
notification_id=NOTIFICATION_AUTH_ID)
except exceptions.DeviceAuthenticationError as ex:
notification.async_create(
hass,
'Authentication failed! Did you enter correct PIN?<br /><br />'
'Details: {0}'.format(ex),
title=NOTIFICATION_AUTH_TITLE,
notification_id=NOTIFICATION_AUTH_ID)
hass.async_add_job(configurator.request_done, instance)
instance = configurator.request_config(
hass, 'Apple TV Authentication', configuration_callback,
description='Please enter PIN code shown on screen.',
submit_caption='Confirm',
fields=[{'id': 'pin', 'name': 'PIN Code', 'type': 'password'}]
)
@asyncio.coroutine
def scan_for_apple_tvs(hass):
"""Scan for devices and present a notification of the ones found."""
import pyatv
atvs = yield from pyatv.scan_for_apple_tvs(hass.loop, timeout=3)
devices = []
for atv in atvs:
login_id = atv.login_id
if login_id is None:
login_id = 'Home Sharing disabled'
devices.append('Name: {0}<br />Host: {1}<br />Login ID: {2}'.format(
atv.name, atv.address, login_id))
if not devices:
devices = ['No device(s) found']
notification = get_component('persistent_notification')
notification.async_create(
hass,
'The following devices were found:<br /><br />' +
'<br /><br />'.join(devices),
title=NOTIFICATION_SCAN_TITLE,
notification_id=NOTIFICATION_SCAN_ID)
@asyncio.coroutine
def async_setup(hass, config):
"""Set up the Apple TV component."""
if DATA_APPLE_TV not in hass.data:
hass.data[DATA_APPLE_TV] = {}
@asyncio.coroutine
def async_service_handler(service):
"""Handler for service calls."""
entity_ids = service.data.get(ATTR_ENTITY_ID)
if entity_ids:
devices = [device for device in hass.data[DATA_ENTITIES]
if device.entity_id in entity_ids]
else:
devices = hass.data[DATA_ENTITIES]
for device in devices:
atv = device.atv
if service.service == SERVICE_AUTHENTICATE:
credentials = yield from atv.airplay.generate_credentials()
yield from atv.airplay.load_credentials(credentials)
_LOGGER.debug('Generated new credentials: %s', credentials)
yield from atv.airplay.start_authentication()
hass.async_add_job(request_configuration,
hass, config, atv, credentials)
elif service.service == SERVICE_SCAN:
hass.async_add_job(scan_for_apple_tvs, hass)
@asyncio.coroutine
def atv_discovered(service, info):
"""Setup an Apple TV that was auto discovered."""
yield from _setup_atv(hass, {
CONF_NAME: info['name'],
CONF_HOST: info['host'],
CONF_LOGIN_ID: info['properties']['hG'],
CONF_START_OFF: False
})
discovery.async_listen(hass, SERVICE_APPLE_TV, atv_discovered)
tasks = [_setup_atv(hass, conf) for conf in config.get(DOMAIN, [])]
if tasks:
yield from asyncio.wait(tasks, loop=hass.loop)
descriptions = yield from hass.async_add_job(
load_yaml_config_file, os.path.join(
os.path.dirname(__file__), 'services.yaml'))
hass.services.async_register(
DOMAIN, SERVICE_SCAN, async_service_handler,
descriptions.get(SERVICE_SCAN),
schema=APPLE_TV_SCAN_SCHEMA)
hass.services.async_register(
DOMAIN, SERVICE_AUTHENTICATE, async_service_handler,
descriptions.get(SERVICE_AUTHENTICATE),
schema=APPLE_TV_AUTHENTICATE_SCHEMA)
return True
@asyncio.coroutine
def _setup_atv(hass, atv_config):
"""Setup an Apple TV."""
import pyatv
name = atv_config.get(CONF_NAME)
host = atv_config.get(CONF_HOST)
login_id = atv_config.get(CONF_LOGIN_ID)
start_off = atv_config.get(CONF_START_OFF)
credentials = atv_config.get(CONF_CREDENTIALS)
if host in hass.data[DATA_APPLE_TV]:
return
details = pyatv.AppleTVDevice(name, host, login_id)
session = async_get_clientsession(hass)
atv = pyatv.connect_to_apple_tv(details, hass.loop, session=session)
if credentials:
yield from atv.airplay.load_credentials(credentials)
power = AppleTVPowerManager(hass, atv, start_off)
hass.data[DATA_APPLE_TV][host] = {
ATTR_ATV: atv,
ATTR_POWER: power
}
hass.async_add_job(discovery.async_load_platform(
hass, 'media_player', DOMAIN, atv_config))
hass.async_add_job(discovery.async_load_platform(
hass, 'remote', DOMAIN, atv_config))
class AppleTVPowerManager:
"""Manager for global power management of an Apple TV.
An instance is used per device to share the same power state between
several platforms.
"""
def __init__(self, hass, atv, is_off):
"""Initialize power manager."""
self.hass = hass
self.atv = atv
self.listeners = []
self._is_on = not is_off
def init(self):
"""Initialize power management."""
if self._is_on:
self.atv.push_updater.start()
@property
def turned_on(self):
"""If device is on or off."""
return self._is_on
def set_power_on(self, value):
"""Change if a device is on or off."""
if value != self._is_on:
self._is_on = value
if not self._is_on:
self.atv.push_updater.stop()
else:
self.atv.push_updater.start()
for listener in self.listeners:
self.hass.async_add_job(listener.async_update_ha_state())

View file

@ -32,6 +32,7 @@ SERVICE_HASS_IOS_APP = 'hass_ios'
SERVICE_IKEA_TRADFRI = 'ikea_tradfri'
SERVICE_HASSIO = 'hassio'
SERVICE_AXIS = 'axis'
SERVICE_APPLE_TV = 'apple_tv'
SERVICE_HANDLERS = {
SERVICE_HASS_IOS_APP: ('ios', None),
@ -40,6 +41,7 @@ SERVICE_HANDLERS = {
SERVICE_IKEA_TRADFRI: ('tradfri', None),
SERVICE_HASSIO: ('hassio', None),
SERVICE_AXIS: ('axis', None),
SERVICE_APPLE_TV: ('apple_tv', None),
'philips_hue': ('light', 'hue'),
'google_cast': ('media_player', 'cast'),
'panasonic_viera': ('media_player', 'panasonic_viera'),
@ -52,7 +54,6 @@ SERVICE_HANDLERS = {
'denonavr': ('media_player', 'denonavr'),
'samsung_tv': ('media_player', 'samsungtv'),
'yeelight': ('light', 'yeelight'),
'apple_tv': ('media_player', 'apple_tv'),
'frontier_silicon': ('media_player', 'frontier_silicon'),
'openhome': ('media_player', 'openhome'),
'harmony': ('remote', 'harmony'),

View file

@ -6,70 +6,41 @@ https://home-assistant.io/components/media_player.apple_tv/
"""
import asyncio
import logging
import hashlib
import voluptuous as vol
from homeassistant.core import callback
from homeassistant.components.apple_tv import (
ATTR_ATV, ATTR_POWER, DATA_APPLE_TV, DATA_ENTITIES)
from homeassistant.components.media_player import (
SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
SUPPORT_STOP, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_TURN_ON,
SUPPORT_TURN_OFF, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC,
SUPPORT_TURN_OFF, MediaPlayerDevice, MEDIA_TYPE_MUSIC,
MEDIA_TYPE_VIDEO, MEDIA_TYPE_TVSHOW)
from homeassistant.const import (
STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, CONF_HOST,
STATE_OFF, CONF_NAME, EVENT_HOMEASSISTANT_STOP)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
REQUIREMENTS = ['pyatv==0.2.1']
DEPENDENCIES = ['apple_tv']
_LOGGER = logging.getLogger(__name__)
CONF_LOGIN_ID = 'login_id'
CONF_START_OFF = 'start_off'
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,
vol.Optional(CONF_START_OFF, default=False): cv.boolean
})
@asyncio.coroutine
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Set up the Apple TV platform."""
import pyatv
if not discovery_info:
return
if discovery_info is not None:
name = discovery_info['name']
host = discovery_info['host']
login_id = discovery_info['properties']['hG']
start_off = False
else:
name = config.get(CONF_NAME)
host = config.get(CONF_HOST)
login_id = config.get(CONF_LOGIN_ID)
start_off = config.get(CONF_START_OFF)
# Manage entity cache for service handler
if DATA_ENTITIES not in hass.data:
hass.data[DATA_ENTITIES] = []
if DATA_APPLE_TV not in hass.data:
hass.data[DATA_APPLE_TV] = []
if host in hass.data[DATA_APPLE_TV]:
return False
hass.data[DATA_APPLE_TV].append(host)
details = pyatv.AppleTVDevice(name, host, login_id)
session = async_get_clientsession(hass)
atv = pyatv.connect_to_apple_tv(details, hass.loop, session=session)
entity = AppleTvDevice(atv, name, start_off)
name = discovery_info[CONF_NAME]
host = discovery_info[CONF_HOST]
atv = hass.data[DATA_APPLE_TV][host][ATTR_ATV]
power = hass.data[DATA_APPLE_TV][host][ATTR_POWER]
entity = AppleTvDevice(atv, name, power)
@callback
def on_hass_stop(event):
@ -78,44 +49,39 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
if entity not in hass.data[DATA_ENTITIES]:
hass.data[DATA_ENTITIES].append(entity)
async_add_devices([entity])
class AppleTvDevice(MediaPlayerDevice):
"""Representation of an Apple TV device."""
def __init__(self, atv, name, is_off):
def __init__(self, atv, name, power):
"""Initialize the Apple TV device."""
self._atv = atv
self.atv = atv
self._name = name
self._is_off = is_off
self._playing = None
self._artwork_hash = None
self._atv.push_updater.listener = self
self._power = power
self._power.listeners.append(self)
self.atv.push_updater.listener = self
@asyncio.coroutine
def async_added_to_hass(self):
"""Handle when an entity is about to be added to Home Assistant."""
if not self._is_off:
self._atv.push_updater.start()
@callback
def _set_power_off(self, is_off):
"""Set the power to off."""
self._playing = None
self._artwork_hash = None
self._is_off = is_off
if is_off:
self._atv.push_updater.stop()
else:
self._atv.push_updater.start()
self.hass.async_add_job(self.async_update_ha_state())
self._power.init()
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def unique_id(self):
"""Return an unique ID."""
return self.atv.metadata.device_id
@property
def should_poll(self):
"""No polling needed."""
@ -124,16 +90,16 @@ class AppleTvDevice(MediaPlayerDevice):
@property
def state(self):
"""Return the state of the device."""
if self._is_off:
if not self._power.turned_on:
return STATE_OFF
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 \
if state == const.PLAY_STATE_NO_MEDIA or \
state == const.PLAY_STATE_LOADING:
return STATE_IDLE
elif state == const.PLAY_STATE_PLAYING:
return STATE_PLAYING
elif state == const.PLAY_STATE_PAUSED or \
state == const.PLAY_STATE_FAST_FORWARD or \
@ -147,24 +113,8 @@ class AppleTvDevice(MediaPlayerDevice):
def playstatus_update(self, updater, playing):
"""Print what is currently playing when it changes."""
self._playing = playing
if self.state == STATE_IDLE:
self._artwork_hash = None
elif 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()
self.hass.async_add_job(self.async_update_ha_state())
def _has_playing_media_changed(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
@callback
def playstatus_error(self, updater, exception):
"""Inform about an error and restart push updates."""
@ -177,7 +127,6 @@ class AppleTvDevice(MediaPlayerDevice):
# implemented here later.
updater.start(initial_delay=10)
self._playing = None
self._artwork_hash = None
self.hass.async_add_job(self.async_update_ha_state())
@property
@ -215,18 +164,18 @@ class AppleTvDevice(MediaPlayerDevice):
@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)
yield from self.atv.airplay.play_url(media_id)
@property
def media_image_hash(self):
"""Hash value for media image."""
if self.state != STATE_IDLE:
return self._artwork_hash
if self._playing is not None and self.state != STATE_IDLE:
return self._playing.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'
return (yield from self.atv.metadata.artwork()), 'image/png'
@property
def media_title(self):
@ -235,9 +184,9 @@ class AppleTvDevice(MediaPlayerDevice):
if self.state == STATE_IDLE:
return 'Nothing playing'
title = self._playing.title
return title if title else "No title"
return title if title else 'No title'
return 'Not connected to Apple TV'
return 'Establishing a connection to {0}...'.format(self._name)
@property
def supported_features(self):
@ -254,12 +203,13 @@ class AppleTvDevice(MediaPlayerDevice):
@asyncio.coroutine
def async_turn_on(self):
"""Turn the media player on."""
self._set_power_off(False)
self._power.set_power_on(True)
@asyncio.coroutine
def async_turn_off(self):
"""Turn the media player off."""
self._set_power_off(True)
self._playing = None
self._power.set_power_on(False)
def async_media_play_pause(self):
"""Pause media on media player.
@ -269,9 +219,9 @@ class AppleTvDevice(MediaPlayerDevice):
if self._playing is not None:
state = self.state
if state == STATE_PAUSED:
return self._atv.remote_control.play()
return self.atv.remote_control.play()
elif state == STATE_PLAYING:
return self._atv.remote_control.pause()
return self.atv.remote_control.pause()
def async_media_play(self):
"""Play media.
@ -279,7 +229,15 @@ class AppleTvDevice(MediaPlayerDevice):
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()
return self.atv.remote_control.play()
def async_media_stop(self):
"""Stop 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.stop()
def async_media_pause(self):
"""Pause the media player.
@ -287,7 +245,7 @@ class AppleTvDevice(MediaPlayerDevice):
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()
return self.atv.remote_control.pause()
def async_media_next_track(self):
"""Send next track command.
@ -295,7 +253,7 @@ class AppleTvDevice(MediaPlayerDevice):
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()
return self.atv.remote_control.next()
def async_media_previous_track(self):
"""Send previous track command.
@ -303,7 +261,7 @@ class AppleTvDevice(MediaPlayerDevice):
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()
return self.atv.remote_control.previous()
def async_media_seek(self, position):
"""Send seek command.
@ -311,4 +269,4 @@ class AppleTvDevice(MediaPlayerDevice):
This method must be run in the event loop and returns a coroutine.
"""
if self._playing is not None:
return self._atv.remote_control.set_position(position)
return self.atv.remote_control.set_position(position)

View file

@ -0,0 +1,87 @@
"""
Remote control support for Apple TV.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/remote.apple_tv/
"""
import asyncio
from homeassistant.components.apple_tv import (
ATTR_ATV, ATTR_POWER, DATA_APPLE_TV)
from homeassistant.components.remote import ATTR_COMMAND
from homeassistant.components import remote
from homeassistant.const import (CONF_NAME, CONF_HOST)
DEPENDENCIES = ['apple_tv']
@asyncio.coroutine
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Set up the Apple TV remote platform."""
if not discovery_info:
return
name = discovery_info[CONF_NAME]
host = discovery_info[CONF_HOST]
atv = hass.data[DATA_APPLE_TV][host][ATTR_ATV]
power = hass.data[DATA_APPLE_TV][host][ATTR_POWER]
async_add_devices([AppleTVRemote(atv, power, name)])
class AppleTVRemote(remote.RemoteDevice):
"""Device that sends commands to an Apple TV."""
def __init__(self, atv, power, name):
"""Initialize device."""
self._atv = atv
self._name = name
self._power = power
self._power.listeners.append(self)
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def is_on(self):
"""Return true if device is on."""
return self._power.turned_on
@property
def should_poll(self):
"""No polling needed for Apple TV."""
return False
@asyncio.coroutine
def async_turn_on(self, **kwargs):
"""Turn the device on.
This method is a coroutine.
"""
self._power.set_power_on(True)
@asyncio.coroutine
def async_turn_off(self):
"""Turn the device off.
This method is a coroutine.
"""
self._power.set_power_on(False)
def async_send_command(self, **kwargs):
"""Send a command to one device.
This method must be run in the event loop and returns a coroutine.
"""
# Send commands in specified order but schedule only one coroutine
@asyncio.coroutine
def _send_commads():
for command in kwargs[ATTR_COMMAND]:
if not hasattr(self._atv.remote_control, command):
continue
yield from getattr(self._atv.remote_control, command)()
return _send_commads()

View file

@ -470,3 +470,16 @@ axis:
param:
description: What parameter to operate on. [Required]
example: 'package=VideoMotionDetection'
apple_tv:
apple_tv_authenticate:
description: Start AirPlay device authentication.
fields:
entity_id:
description: Name(s) of entities to authenticate with.
example: 'media_player.apple_tv'
apple_tv_scan:
description: Scan for Apple TV devices.

View file

@ -514,8 +514,8 @@ pyasn1-modules==0.0.9
# homeassistant.components.notify.xmpp
pyasn1==0.2.3
# homeassistant.components.media_player.apple_tv
pyatv==0.2.1
# homeassistant.components.apple_tv
pyatv==0.3.2
# homeassistant.components.device_tracker.bbox
# homeassistant.components.sensor.bbox