256 lines
8.2 KiB
Python
256 lines
8.2 KiB
Python
"""
|
|
Support for Apple TV.
|
|
|
|
For more details about this platform, please refer to the documentation at
|
|
https://home-assistant.io/components/apple_tv/
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from typing import Sequence, TypeVar, Union
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.discovery import SERVICE_APPLE_TV
|
|
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME
|
|
from homeassistant.helpers import discovery
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
import homeassistant.helpers.config_validation as cv
|
|
|
|
REQUIREMENTS = ['pyatv==0.3.10']
|
|
|
|
_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'
|
|
|
|
T = TypeVar('T') # pylint: disable=invalid-name
|
|
|
|
|
|
# This version of ensure_list interprets an empty dict as no value
|
|
def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]:
|
|
"""Wrap value in list if it is not one."""
|
|
if value is None or (isinstance(value, dict) and not value):
|
|
return []
|
|
return value if isinstance(value, list) else [value]
|
|
|
|
|
|
CONFIG_SCHEMA = vol.Schema({
|
|
DOMAIN: vol.All(ensure_list, [vol.Schema({
|
|
vol.Required(CONF_HOST): cv.string,
|
|
vol.Required(CONF_LOGIN_ID): cv.string,
|
|
vol.Optional(CONF_CREDENTIALS): cv.string,
|
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): 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 = hass.components.configurator
|
|
|
|
async def configuration_callback(callback_data):
|
|
"""Handle the submitted configuration."""
|
|
from pyatv import exceptions
|
|
pin = callback_data.get('pin')
|
|
|
|
try:
|
|
await atv.airplay.finish_authentication(pin)
|
|
hass.components.persistent_notification.async_create(
|
|
'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:
|
|
hass.components.persistent_notification.async_create(
|
|
'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(
|
|
'Apple TV Authentication', configuration_callback,
|
|
description='Please enter PIN code shown on screen.',
|
|
submit_caption='Confirm',
|
|
fields=[{'id': 'pin', 'name': 'PIN Code', 'type': 'password'}]
|
|
)
|
|
|
|
|
|
async def scan_for_apple_tvs(hass):
|
|
"""Scan for devices and present a notification of the ones found."""
|
|
import pyatv
|
|
atvs = await 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']
|
|
|
|
hass.components.persistent_notification.async_create(
|
|
'The following devices were found:<br /><br />' +
|
|
'<br /><br />'.join(devices),
|
|
title=NOTIFICATION_SCAN_TITLE,
|
|
notification_id=NOTIFICATION_SCAN_ID)
|
|
|
|
|
|
async def async_setup(hass, config):
|
|
"""Set up the Apple TV component."""
|
|
if DATA_APPLE_TV not in hass.data:
|
|
hass.data[DATA_APPLE_TV] = {}
|
|
|
|
async def async_service_handler(service):
|
|
"""Handle service calls."""
|
|
entity_ids = service.data.get(ATTR_ENTITY_ID)
|
|
|
|
if service.service == SERVICE_SCAN:
|
|
hass.async_add_job(scan_for_apple_tvs, hass)
|
|
return
|
|
|
|
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:
|
|
if service.service != SERVICE_AUTHENTICATE:
|
|
continue
|
|
|
|
atv = device.atv
|
|
credentials = await atv.airplay.generate_credentials()
|
|
await atv.airplay.load_credentials(credentials)
|
|
_LOGGER.debug('Generated new credentials: %s', credentials)
|
|
await atv.airplay.start_authentication()
|
|
hass.async_add_job(request_configuration,
|
|
hass, config, atv, credentials)
|
|
|
|
async def atv_discovered(service, info):
|
|
"""Set up an Apple TV that was auto discovered."""
|
|
await _setup_atv(hass, config, {
|
|
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, config, conf) for conf in config.get(DOMAIN, [])]
|
|
if tasks:
|
|
await asyncio.wait(tasks, loop=hass.loop)
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_SCAN, async_service_handler,
|
|
schema=APPLE_TV_SCAN_SCHEMA)
|
|
|
|
hass.services.async_register(
|
|
DOMAIN, SERVICE_AUTHENTICATE, async_service_handler,
|
|
schema=APPLE_TV_AUTHENTICATE_SCHEMA)
|
|
|
|
return True
|
|
|
|
|
|
async def _setup_atv(hass, hass_config, atv_config):
|
|
"""Set up 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:
|
|
await 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_create_task(discovery.async_load_platform(
|
|
hass, 'media_player', DOMAIN, atv_config, hass_config))
|
|
|
|
hass.async_create_task(discovery.async_load_platform(
|
|
hass, 'remote', DOMAIN, atv_config, hass_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):
|
|
"""Return true 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_create_task(listener.async_update_ha_state())
|