Skip to content

Commit

Permalink
Add new feature to Apple TV platform (home-assistant#8122)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
postlund authored and dethpickle committed Aug 18, 2017
1 parent 73b39a2 commit f963bd2
Show file tree
Hide file tree
Showing 7 changed files with 423 additions and 103 deletions.
4 changes: 3 additions & 1 deletion .coveragerc
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
259 changes: 259 additions & 0 deletions 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!<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())
3 changes: 2 additions & 1 deletion homeassistant/components/discovery.py
Expand Up @@ -32,6 +32,7 @@
SERVICE_IKEA_TRADFRI = 'ikea_tradfri'
SERVICE_HASSIO = 'hassio'
SERVICE_AXIS = 'axis'
SERVICE_APPLE_TV = 'apple_tv'

SERVICE_HANDLERS = {
SERVICE_HASS_IOS_APP: ('ios', None),
Expand All @@ -40,6 +41,7 @@
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'),
Expand All @@ -52,7 +54,6 @@
'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'),
Expand Down

0 comments on commit f963bd2

Please sign in to comment.