diff --git a/.coveragerc b/.coveragerc index c74de0026b1..9f538911a6e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py new file mode 100644 index 00000000000..17cc46f3318 --- /dev/null +++ b/homeassistant/components/apple_tv.py @@ -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!

Add the following ' + 'to credentials: in your apple_tv configuration:

' + '{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?

' + '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}
Host: {1}
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:

' + + '

'.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()) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index fc239bf70c5..3dfe4b9731c 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -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'), diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 97114a6bc84..a7017f73fc4 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -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) diff --git a/homeassistant/components/remote/apple_tv.py b/homeassistant/components/remote/apple_tv.py new file mode 100644 index 00000000000..a7ea113c2db --- /dev/null +++ b/homeassistant/components/remote/apple_tv.py @@ -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() diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index d81d14fc991..db71b2322fa 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -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. + diff --git a/requirements_all.txt b/requirements_all.txt index fc71a3ab3ff..d3f24dad542 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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