From 5e1289b50a56b8c2274dc16b870215b52d880272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ak=C4=B1n=20=C3=96mero=C4=9Flu?= Date: Thu, 23 Jan 2020 13:49:18 +0300 Subject: [PATCH] add apple tv beta --- .../apple_tv/.translations/en.json | 67 +++ custom_components/apple_tv/__init__.py | 326 ++++++++++++++ custom_components/apple_tv/config_flow.py | 422 ++++++++++++++++++ custom_components/apple_tv/const.py | 15 + custom_components/apple_tv/manifest.json | 19 + custom_components/apple_tv/media_player.py | 287 ++++++++++++ custom_components/apple_tv/remote.py | 105 +++++ custom_components/apple_tv/strings.json | 67 +++ ui-lovelace.yaml | 4 +- 9 files changed, 1310 insertions(+), 2 deletions(-) create mode 100644 custom_components/apple_tv/.translations/en.json create mode 100644 custom_components/apple_tv/__init__.py create mode 100644 custom_components/apple_tv/config_flow.py create mode 100644 custom_components/apple_tv/const.py create mode 100644 custom_components/apple_tv/manifest.json create mode 100644 custom_components/apple_tv/media_player.py create mode 100644 custom_components/apple_tv/remote.py create mode 100644 custom_components/apple_tv/strings.json diff --git a/custom_components/apple_tv/.translations/en.json b/custom_components/apple_tv/.translations/en.json new file mode 100644 index 0000000..16a5a1f --- /dev/null +++ b/custom_components/apple_tv/.translations/en.json @@ -0,0 +1,67 @@ +{ + "config": { + "title": "Apple TV", + "flow_title": "Apple TV: {name}", + "step": { + "user": { + "title": "Add new device", + "description": "Enter a device name, IP-address or unique identifier. In case your device is not shown, try using its IP-address.\n\nIf any devices were automatically discovered on your network, they are shown below.\n\n{devices}", + "data": { + "device_id": "Device" + } + }, + "reconfigure": { + "title": "Device Reconfiguration", + "description": "This Apple TV is experiencing connection problems and must be reconfigured. Please continue to finish the process." + }, + "pair_with_pin": { + "title": "Pair with device", + "description": "Pairing is required for protocol `{protocol}`. Please input PIN code displayed on screen.", + "data": { + "pin": "PIN Code" + } + }, + "pair_no_pin": { + "title": "Pair with device", + "description": "Pairing is required for protocol `{protocol}`. Please enter PIN {pin} on device to continue." + }, + "service_problem": { + "title": "Failed to add protocol", + "description": "A problem occurred while pairing protocol `{protocol}`. It will be ignored." + }, + "confirm": { + "title": "Discovered Apple TV", + "description": "Do you want to setup the Apple TV named `{name}` in Home Assistant?" + }, + "zeroconf": { + "lookup_id_failed": "Failed to look up device id for device." + } + }, + "error": { + "device_not_found": "Device could not be found on network.", + "device_already_configured": "This device has already been configured. Please choose another device.", + "no_usable_service": "A device was found but could not identify any way to establish a connection.", + "unknown": "Unexpected error", + "auth": "Authentication error (invalid PIN?)" + }, + "abort": { + "already_configured": "Device is already configured!", + "updated_configuration": "Updated existing configuration!", + "no_credentials": "No credentials available for device", + "unrecoverable_error": "An unrecoverable occurred", + "device_did_not_pair": "No attempt to finish pairing process was made from the device.", + "backoff": "Device does not accept pairing reqests at this stage, try again later.", + "timeout": "Timed out while waiting for device" + } + }, + "options": { + "step": { + "device_options": { + "description": "Configure general device settings", + "data": { + "start_off": "Do not turn on device when starting Home Assistant" + } + } + } + } +} diff --git a/custom_components/apple_tv/__init__.py b/custom_components/apple_tv/__init__.py new file mode 100644 index 0000000..eed1770 --- /dev/null +++ b/custom_components/apple_tv/__init__.py @@ -0,0 +1,326 @@ +"""The Apple TV integration.""" +import asyncio +import logging +from random import randrange +from functools import partial +from typing import Sequence, TypeVar, Union + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_NAME, + CONF_HOST, + EVENT_HOMEASSISTANT_STOP, +) + +from .const import ( + DOMAIN, CONF_ADDRESS, CONF_IDENTIFIER, CONF_PROTOCOL, + CONF_CREDENTIALS, CONF_CREDENTIALS_MRP, CONF_CREDENTIALS_DMAP, + CONF_CREDENTIALS_AIRPLAY, CONF_START_OFF, SOURCE_INVALID_CREDENTIALS +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Apple TV" + +BACKOFF_TIME_UPPER_LIMIT = 600 # Ten minutes + +NOTIFICATION_TITLE = "Apple TV Notification" +NOTIFICATION_ID = "apple_tv_notification" + +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] + + +CREDENTIALS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CREDENTIALS_MRP): cv.string, + vol.Optional(CONF_CREDENTIALS_DMAP): cv.string, + vol.Optional(CONF_CREDENTIALS_AIRPLAY): cv.string, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_IDENTIFIER): cv.string, + vol.Required(CONF_PROTOCOL): vol.In(['DMAP', 'MRP']), + vol.Required(CONF_CREDENTIALS): CREDENTIALS_SCHEMA, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_START_OFF, default=False): cv.boolean, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Apple TV integration.""" + if DOMAIN not in config: + return True + + for conf in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=conf, + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry for Apple TV.""" + identifier = entry.data[CONF_IDENTIFIER] + manager = AppleTVManager(hass, entry) + hass.data.setdefault(DOMAIN, {})[identifier] = manager + + @callback + def on_hass_stop(event): + """Stop push updates when hass stops.""" + asyncio.ensure_future(manager.disconnect(), loop=hass.loop) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "media_player") + ) + + hass.async_create_task( + discovery.async_load_platform(hass, "remote", DOMAIN, entry.data, entry.data) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload Twente Milieu config entry.""" + # TODO: This is not finished yet + identifier = entry.data[CONF_IDENTIFIER] + manager = hass.data[DOMAIN].pop(identifier) + await manager.disconnect() + + await hass.config_entries.async_forward_entry_unload(entry, "media_player") + + # TODO: unload remote? + + return True + + +class AppleTVManager: + """Connection and power manager for an Apple TV. + + An instance is used per device to share the same power state between + several platforms. It also manages scanning and connection establishment + in case of problems. + """ + + def __init__(self, hass, config_entry): + """Initialize power manager.""" + self.config_entry = config_entry + self.hass = hass + self.listeners = [] + self.message = None + self.atv = None + self._is_on = config_entry.options.get(CONF_START_OFF, True) + self._connection_attempts = 0 + self._connection_was_lost = False + self._task = None + + async def init(self): + """Initialize power management.""" + if self._is_on: + await self.connect() + + def connection_lost(self, exception): + """Device was unexpectedly disconnected.""" + _LOGGER.warning('Connection lost to Apple TV "%s"', + self.atv.service.name) + + self.atv = None + self._connection_was_lost = True + self._start_connect_loop() + self._update_state(disconnected=True) + + def connection_closed(self): + """Device connection was (intentionally) closed.""" + self.atv = None + self._start_connect_loop() + self._update_state(disconnected=True) + + async def connect(self): + self._is_on = True + self._start_connect_loop() + + async def disconnect(self): + _LOGGER.debug("Disconnecting from device") + self._is_on = False + try: + if self.atv: + await self.atv.close() + self.atv = None + if self._task: + self._task.cancel() + self._task = None + finally: + self._update_state(disconnected=False) + + def _start_connect_loop(self): + if not self._task and self.atv is None and self._is_on: + self._task = asyncio.ensure_future( + self._connect_loop(), loop=self.hass.loop) + + async def _connect_loop(self): + from pyatv import exceptions + + _LOGGER.debug("Starting connect loop") + + # Try to find device and connect as long as the user has said that + # we are allowed to connect and we are not already connected. + while self._is_on and self.atv is None: + try: + conf = await self._scan() + if conf: + await self._connect(conf) + except exceptions.AuthenticationError: + self._auth_problem() + break + except asyncio.CancelledError: + pass + except Exception: + _LOGGER.exception("Failed to connect") + self.atv = None + + if self.atv is None: + self._connection_attempts += 1 + backoff = min(randrange(2**self._connection_attempts), + BACKOFF_TIME_UPPER_LIMIT) + + _LOGGER.debug("Reconnecting in %d seconds", backoff) + await asyncio.sleep(backoff) + + _LOGGER.debug("Connect loop ended") + self._task = None + + def _auth_problem(self): + _LOGGER.debug("Authentication error, reconfigure integration") + + name = self.config_entry.data.get(CONF_NAME) + identifier = self.config_entry.data.get(CONF_IDENTIFIER) + + self.hass.components.persistent_notification.create( + "An irrecoverable connection occurred when connecting to " + "`{0}`. Please go to the Integrations page and reconfigure it".format( + name), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) + + # Add to event queue as this function is called from a task being + # cancelled from disconnect + asyncio.ensure_future(self.disconnect()) + + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_INVALID_CREDENTIALS}, + data={CONF_NAME: name, CONF_IDENTIFIER: identifier}, + )) + + async def _scan(self): + from pyatv import const, scan + + identifier = self.config_entry.data[CONF_IDENTIFIER] + address = self.config_entry.data[CONF_ADDRESS] + protocol = const.Protocol(self.config_entry.data[CONF_PROTOCOL]) + + self._update_state(message="Discovering device...") + atvs = await scan(self.hass.loop, + identifier=identifier, + protocol=protocol, + hosts=[address]) + if atvs: + return atvs[0] + + _LOGGER.debug("Failed to find device %s with address %s, trying to scan", + identifier, address) + + atvs = await scan(self.hass.loop, + identifier=identifier, + protocol=protocol) + if atvs: + return atvs[0] + + self._update_state("Device not found, trying again later...") + _LOGGER.debug("Failed to find device %s, trying later", identifier) + + return None + + async def _connect(self, conf): + from pyatv import const, connect + + credentials = self.config_entry.data[CONF_CREDENTIALS] + session = async_get_clientsession(self.hass) + + for protocol, creds in credentials.items(): + conf.set_credentials(const.Protocol(int(protocol)), creds) + + self._update_state("Connecting to device...") + self.atv = await connect(conf, self.hass.loop, session=session) + self.atv.listener = self + + self._update_state("Connected, waiting for update...", connected=True) + self.atv.push_updater.start() + + self.address_updated(str(conf.address)) + + self._connection_attempts = 0 + if self._connection_was_lost: + _LOGGER.info('Connection was re-established to Apple TV "%s"', + self.atv.service.name) + self._connection_was_lost = False + + @property + def is_connecting(self): + """Return true if connection is in progress.""" + return self._task is not None + + def _update_state(self, message="", connected=False, disconnected=False): + for listener in self.listeners: + if connected: + listener.device_connected() + if disconnected: + listener.device_disconnected() + self.message = message + self.hass.async_create_task(listener.async_update_ha_state()) + + def address_updated(self, address): + """Update cached address in config entry.""" + _LOGGER.debug("Changing address to %s", address) + self.config_entry.data[CONF_ADDRESS] = address + update_entry = partial( + self.hass.config_entries.async_update_entry, data={**self.config_entry.data} + ) + self.hass.add_job(update_entry, self.config_entry) diff --git a/custom_components/apple_tv/config_flow.py b/custom_components/apple_tv/config_flow.py new file mode 100644 index 0000000..0d078b2 --- /dev/null +++ b/custom_components/apple_tv/config_flow.py @@ -0,0 +1,422 @@ +"""Config flow for Apple TV integration.""" +import asyncio +import logging +from random import randrange +from ipaddress import ip_address + +import voluptuous as vol + +from homeassistant import core, config_entries, exceptions +from homeassistant.core import callback +from homeassistant.const import CONF_PIN, CONF_NAME, CONF_HOST, CONF_PROTOCOL, CONF_TYPE +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import (DOMAIN, CONF_ADDRESS, CONF_IDENTIFIER, CONF_CREDENTIALS, CONF_START_OFF, + CONF_CREDENTIALS_MRP, CONF_CREDENTIALS_DMAP, CONF_CREDENTIALS_AIRPLAY) + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_IDENTIFIER): str}) +INPUT_PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN, default=None): int}) + +DEFAULT_START_OFF = False + + +async def device_scan(identifier, loop, cache=None): + + def _matches_device(dev): + # TODO: encoding should be done in pyatv + if identifier == dev.name.encode('ascii', 'ignore').decode(): + return True + if identifier == str(dev.address): + return True + + return any([x.identifier == identifier for x in dev.services]) + + def _host_filter(): + try: + return [ip_address(identifier)] + except ValueError: + return None + + from pyatv import scan + + if cache: + matches = [atv for atv in cache if _matches_device(atv)] + if matches: + return cache, matches[0] + + for hosts in [_host_filter(), None]: + scan_result = atvs = await scan(loop, timeout=3, hosts=hosts) + matches = [atv for atv in scan_result if _matches_device(atv)] + if matches: + return scan_result, matches[0] + + return scan_result, None + + +def _devices_str(atvs): + return ', '.join(["`{0} ({1})`".format(x.name, x.address) for x in atvs]) + + +class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Apple TV.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow for this handler.""" + return AppleTVOptionsFlow(config_entry) + + def __init__(self): + self.scan_result = None + self.atv = None + self.identifier = None + self.protocol = None + self.pairing = None + self.credentials = {} # Protocol -> credentials + + async def async_step_invalid_credentials(self, info): + """Handle initial step when updating invalid credentials.""" + self.identifier = info.get(CONF_IDENTIFIER) + self.context["title_placeholders"] = {"name": info.get(CONF_NAME)} + + for flow in self._async_in_progress(): + if flow["context"].get("identifier") == self.identifier: + return self.async_abort(reason="already_configured") + + self.context["identifier"] = self.identifier + return await self.async_step_reconfigure() + + async def async_step_reconfigure(self, user_input=None): + """Inform user that reconfiguration is about to start.""" + if user_input is not None: + return await self.async_find_device_wrapper( + self.async_begin_pairing, allow_exist=True) + + return self.async_show_form(step_id='reconfigure') + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + from pyatv import scan + from pyatv.exceptions import NoServiceError + + # Be helpful to the user and look for devices + if self.scan_result is None: + self.scan_result = atvs = await scan( + self.hass.loop, timeout=3) + + errors = {} + if user_input is not None: + self.identifier = user_input[CONF_IDENTIFIER] + try: + await self.async_find_device() + return await self.async_step_confirm() + except DeviceNotFound: + errors["base"] = "device_not_found" + except DeviceAlreadyConfigured: + errors["base"] = "device_already_configured" + except NoServiceError: + errors["base"] = "no_usable_service" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id='user', data_schema=DATA_SCHEMA, errors=errors, + description_placeholders={'devices': _devices_str(self.scan_result)}) + + async def async_step_zeroconf(self, discovery_info): + """Handle device found via zeroconf.""" + from pyatv import const + + service_type = discovery_info[CONF_TYPE] + properties = discovery_info['properties'] + + if service_type == "_mediaremotetv._tcp.local.": + self.identifier = properties['UniqueIdentifier'] + name = properties["Name"] + elif service_type == "_touch-able._tcp.local.": + self.identifier = discovery_info['name'].split('.')[0] + name = properties["CtlN"] + elif service_type == "_appletv-v2._tcp.local.": + self.identifier = discovery_info['name'].split('.')[0] + name = "{0} (Home Sharing)".format(properties["Name"]) + else: + return self.async_abort(reason="unrecoverable_error") + + if flow["context"].get("identifier") == self.identifier: + return self.async_abort(reason="already_configured") + + self.context["identifier"] = self.identifier + self.context["title_placeholders"] = {"name": name} + await self.async_find_device_wrapper(self.async_step_confirm) + + async def async_find_device_wrapper(self, next_func, allow_exist=False): + """Find a specific device and call another function when done. + + This function will do error handling and bail out when an error + occurs. + """ + try: + await self.async_find_device(allow_exist) + except DeviceNotFound: + return self.async_abort(reason="device_not_found") + except DeviceAlreadyConfigured: + return self.async_abort(reason="already_configured") + except Exception: + _LOGGER.exception("exception") + return self.async_abort(reason="unrecoverable_error") + + return await next_func() + + async def async_find_device(self, allow_exist=False): + """Scan for the selected device to discover services.""" + self.scan_result, self.atv = await device_scan( + self.identifier, self.hass.loop, cache=self.scan_result) + if not self.atv: + raise DeviceNotFound() + + self.protocol = self.atv.main_service().protocol + + if not allow_exist: + for identifier in self.atv.all_identifiers: + if self._is_already_configured(identifier): + raise DeviceAlreadyConfigured() + + # If credentials were found, save them + for service in self.atv.services: + if service.credentials: + self.credentials[service.protocol.value] = service.credentials + + async def async_step_confirm(self, user_input=None): + """Handle user-confirmation of discovered node.""" + if user_input is not None: + if self._is_already_configured(self.identifier): + return self.async_abort(reason="already_configured") + + return await self.async_begin_pairing() + return self.async_show_form( + step_id="confirm", description_placeholders={"name": self.atv.name}) + + async def async_begin_pairing(self): + """Start pairing process for the next available protocol.""" + from pyatv import pair, exceptions + + self.protocol = self._next_protocol_to_pair() + + # Dispose previous pairing sessions + if self.pairing is not None: + await self.pairing.close() + self.pairing = None + + # Any more protocols to pair? Else bail out here + if not self.protocol: + return await self._async_get_entry() + + # Initiate the pairing process + abort_reason = None + try: + session = async_get_clientsession(self.hass) + self.pairing = await pair( + self.atv, self.protocol, self.hass.loop, session=session) + await self.pairing.begin() + except OSError: # Could not connect to service (ignore it) + return await self.async_step_service_problem() + except asyncio.TimeoutError: + abort_reason = "timeout" + except exceptions.BackOffError: + abort_reason = "backoff" + except exceptions.PairingError: + abort_reason = "auth" + except Exception: + _LOGGER.exception("Unexpected exception") + abort_reason = "unrecoverable_error" + + if abort_reason: + if self.pairing: + await self.pairing.close() + return self.async_abort(reason=abort_reason) + + # Choose step depending on if PIN is required from user or not + if self.pairing.device_provides_pin: + return await self.async_step_pair_with_pin() + + return await self.async_step_pair_no_pin() + + async def async_step_pair_with_pin(self, user_input=None): + """Handle pairing step where a PIN is required from the user.""" + from pyatv import convert, exceptions + + errors = {} + if user_input is not None: + try: + self.pairing.pin(user_input[CONF_PIN]) + await self.pairing.finish() + self.credentials[self.protocol.value] = self.pairing.service.credentials + return await self.async_begin_pairing() + except exceptions.PairingError: + errors["base"] = "auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="pair_with_pin", data_schema=INPUT_PIN_SCHEMA, errors=errors, + description_placeholders={ + "protocol": convert.protocol_str(self.protocol)}) + + async def async_step_pair_no_pin(self, user_input=None): + """Handle step where user has to enter a PIN on the device.""" + from pyatv import convert + + if user_input is not None: + await self.pairing.close() + + if self.pairing.has_paired: + return await self.async_begin_pairing() + return self.async_abort(reason="device_did_not_pair") + + random_pin = randrange(1000, stop=10000) + self.pairing.pin(random_pin) + return self.async_show_form( + step_id="pair_no_pin", + description_placeholders={ + "protocol": convert.protocol_str(self.protocol), + "pin": random_pin + }) + + async def async_step_service_problem(self, user_input=None): + """Inform user that a service will not be added.""" + from pyatv.convert import protocol_str + if user_input is not None: + self.credentials[self.protocol.value] = None + return await self.async_begin_pairing() + + return self.async_show_form( + step_id="service_problem", + description_placeholders={"protocol": protocol_str(self.protocol)}) + + async def async_step_import(self, info): + """Import device from configuration file.""" + self.identifier = info.get(CONF_IDENTIFIER) + _LOGGER.debug("Starting import of %s", self.identifier) + try: + await self.async_find_device() + return self.import_device(info) + except Exception: + return self.async_abort(reason="unrecoverable_error") + + def import_device(self, conf): + """Add device that has been imported.""" + from pyatv.conf import Protocol + conf_creds = conf.get(CONF_CREDENTIALS).items() + + # Mapping between config entry format and pyatv + credential_map = { + CONF_CREDENTIALS_MRP: Protocol.MRP.value, + CONF_CREDENTIALS_DMAP: Protocol.DMAP.value, + CONF_CREDENTIALS_AIRPLAY: Protocol.AirPlay.value + } + + _LOGGER.debug('Importing device with identifier %s', self.identifier) + creds = dict([(credential_map[prot], creds) for prot, creds in conf_creds]) + + return self.async_create_entry( + title=conf.get(CONF_NAME) + ' (import from configuration.yaml)', + data={ + CONF_IDENTIFIER: conf.get(CONF_IDENTIFIER), + CONF_PROTOCOL: Protocol[conf.get(CONF_PROTOCOL)].value, + CONF_NAME: conf.get(CONF_NAME), + CONF_CREDENTIALS: creds, + CONF_ADDRESS: conf.get(CONF_HOST), + }, + ) + + async def _async_get_entry(self): + data = { + CONF_IDENTIFIER: self.atv.identifier, + CONF_PROTOCOL: self.atv.main_service().protocol.value, + CONF_NAME: self.atv.name, + CONF_CREDENTIALS: self.credentials, + CONF_ADDRESS: str(self.atv.address), + } + + config_entry = self._get_config_entry(self.atv.identifier) + if config_entry: + config_entry.data.update(data) + self.hass.config_entries.async_update_entry(config_entry) + return self.async_abort(reason="updated_configuration") + + return self.async_create_entry( + title=self.atv.name, + data=data, + ) + + def _next_protocol_to_pair(self): + def _needs_pairing(protocol): + if self.atv.get_service(protocol) is None: + return False + return protocol.value not in self.credentials + + from pyatv.const import Protocol + + protocols = [Protocol.MRP, Protocol.DMAP, Protocol.AirPlay] + for protocol in protocols: + if _needs_pairing(protocol): + return protocol + + return None + + def _is_already_configured(self, identifier): + return self._get_config_entry(identifier) is not None + + def _get_config_entry(self, identifier): + for ident in self.atv.all_identifiers: + for entry in self._async_current_entries(): + if entry.data[CONF_IDENTIFIER] == identifier: + return entry + return None + + +class AppleTVOptionsFlow(config_entries.OptionsFlow): + """Handle Apple TV options.""" + + def __init__(self, config_entry): + """Initialize Apple TV options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + """Manage the Apple TV options.""" + return await self.async_step_device_options() + + async def async_step_device_options(self, user_input=None): + """Manage the devices options.""" + if user_input is not None: + self.options[CONF_START_OFF] = user_input[CONF_START_OFF] + return self.async_create_entry(title="", data=self.options) + + return self.async_show_form( + step_id="device_options", + data_schema=vol.Schema( + { + vol.Optional( + CONF_START_OFF, + default=self.config_entry.options.get( + CONF_START_OFF, DEFAULT_START_OFF + ), + ): bool, + } + ), + ) + + +class DeviceNotFound(exceptions.HomeAssistantError): + """Error to indicate device could not be found.""" + + +class DeviceAlreadyConfigured(exceptions.HomeAssistantError): + """Error to indicate device is already configured.""" diff --git a/custom_components/apple_tv/const.py b/custom_components/apple_tv/const.py new file mode 100644 index 0000000..d860d64 --- /dev/null +++ b/custom_components/apple_tv/const.py @@ -0,0 +1,15 @@ +"""Constants for the Apple TV integration.""" + +DOMAIN = "apple_tv" + +CONF_ADDRESS = "address" +CONF_IDENTIFIER = "identifier" +CONF_PROTOCOL = "protocol" +CONF_CREDENTIALS = "credentials" +CONF_CREDENTIALS_MRP = 'mrp' +CONF_CREDENTIALS_DMAP = 'dmap' +CONF_CREDENTIALS_AIRPLAY = 'airplay' + +CONF_START_OFF = "start_off" + +SOURCE_INVALID_CREDENTIALS = "invalid_credentials" diff --git a/custom_components/apple_tv/manifest.json b/custom_components/apple_tv/manifest.json new file mode 100644 index 0000000..6010d33 --- /dev/null +++ b/custom_components/apple_tv/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "apple_tv", + "name": "Apple TV", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/apple_tv", + "requirements": [ + "pyatv==0.4.0a12" + ], + "zeroconf": [ + "_mediaremotetv._tcp.local.", + "_appletv-v2._tcp.local.", + "_touch-able._tcp.local." + ], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@postlund" + ] +} \ No newline at end of file diff --git a/custom_components/apple_tv/media_player.py b/custom_components/apple_tv/media_player.py new file mode 100644 index 0000000..a22c1e4 --- /dev/null +++ b/custom_components/apple_tv/media_player.py @@ -0,0 +1,287 @@ +"""Support for Apple TV media player.""" +import logging + +from pyatv.const import DeviceState, MediaType + +from homeassistant.core import callback +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_VIDEO, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + STATE_IDLE, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, + STATE_STANDBY, + STATE_UNKNOWN, +) +import homeassistant.util.dt as dt_util + +from .const import DOMAIN, CONF_IDENTIFIER + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +SUPPORT_APPLE_TV = ( + SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PLAY_MEDIA + | SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_SEEK + | SUPPORT_STOP + | SUPPORT_NEXT_TRACK + | SUPPORT_PREVIOUS_TRACK +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Load Apple TV media player based on a config entry.""" + identifier = config_entry.data[CONF_IDENTIFIER] + name = config_entry.data[CONF_NAME] + manager = hass.data[DOMAIN][identifier] + async_add_entities([AppleTvDevice(name, identifier, manager)]) + + +class AppleTvDevice(MediaPlayerDevice): + """Representation of an Apple TV device.""" + + def __init__(self, name, identifier, manager): + """Initialize the Apple TV device.""" + self.atv = None + self._name = name + self._identifier = identifier + self._playing = None + self._manager = manager + + async def async_added_to_hass(self): + """Handle when an entity is about to be added to Home Assistant.""" + self._manager.listeners.append(self) + await self._manager.init() + + @callback + def device_connected(self): + """Handle when connection is made to device.""" + self.atv = self._manager.atv + self.atv.push_updater.listener = self + + @callback + def device_disconnected(self): + """Handle when connection was lost to device.""" + self.atv = None + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._identifier)}, + "manufacturer": "Apple", + "model": "Media Player", + "name": self.name, + "sw_version": "0.0", + "via_device": (DOMAIN, self._identifier), + } + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return "mp_" + self._identifier + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def state(self): + """Return the state of the device.""" + if self._manager.is_connecting: + return STATE_UNKNOWN + if not self.atv: + return STATE_OFF + + if self._playing: + + state = self._playing.device_state + if state in (DeviceState.Idle, DeviceState.Loading): + return STATE_IDLE + if state == DeviceState.Playing: + return STATE_PLAYING + if state in ( + DeviceState.Paused, + DeviceState.Seeking, + DeviceState.Stopped + ): + # Catch fast forward/backward here so "play" is default action + return STATE_PAUSED + return STATE_STANDBY # Bad or unknown state? + + @callback + def playstatus_update(self, _, playing): + """Print what is currently playing when it changes.""" + self._playing = playing + self.async_schedule_update_ha_state() + + @callback + def playstatus_error(self, _, exception): + """Inform about an error and restart push updates.""" + _LOGGER.warning("A %s error occurred: %s", exception.__class__, exception) + self._playing = None + self.async_schedule_update_ha_state() + + @property + def media_content_type(self): + """Content type of current playing media.""" + if self._playing: + + media_type = self._playing.media_type + if media_type == MediaType.Video: + return MEDIA_TYPE_VIDEO + if media_type == MediaType.Music: + return MEDIA_TYPE_MUSIC + if media_type == MediaType.TV: + return MEDIA_TYPE_TVSHOW + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + if self._playing: + return self._playing.total_time + + @property + def media_position(self): + """Position of current playing media in seconds.""" + if self._playing: + return self._playing.position + + @property + def media_position_updated_at(self): + """Last valid time of media position.""" + if self.state in (STATE_PLAYING, STATE_PAUSED): + return dt_util.utcnow() + + async def async_play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the media player.""" + await self.atv.airplay.play_url(media_id) + + @property + def media_image_hash(self): + """Hash value for media image.""" + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + return self._playing.hash + + async def async_get_media_image(self): + """Fetch media image of current playing image.""" + state = self.state + if self._playing and state not in [STATE_OFF, STATE_IDLE]: + artwork = await self.atv.metadata.artwork() + if artwork: + return artwork.bytes, artwork.mimetype + + return None, None + + @property + def media_title(self): + """Title of current playing media.""" + if self._playing: + if self.state == STATE_IDLE: + return "Nothing playing" + title = self._playing.title + return title if title else "No title" + + return self._manager.message + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_APPLE_TV + + async def async_turn_on(self): + """Turn the media player on.""" + await self._manager.connect() + + async def async_turn_off(self): + """Turn the media player off.""" + self._playing = None + await self._manager.disconnect() + + 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: + state = self.state + if state == STATE_PAUSED: + return self.atv.remote_control.play() + if 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: + 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: + return self.atv.remote_control.stop() + + 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: + 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: + 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: + return self.atv.remote_control.previous() + + def async_media_seek(self, position): + """Send seek command. + + This method must be run in the event loop and returns a coroutine. + """ + if self._playing: + return self.atv.remote_control.set_position(position) diff --git a/custom_components/apple_tv/remote.py b/custom_components/apple_tv/remote.py new file mode 100644 index 0000000..5a09889 --- /dev/null +++ b/custom_components/apple_tv/remote.py @@ -0,0 +1,105 @@ +"""Remote control support for Apple TV.""" + +from homeassistant.core import callback +from homeassistant.const import CONF_NAME +from homeassistant.components import remote + +from .const import DOMAIN, CONF_IDENTIFIER + + +PARALLEL_UPDATES = 0 + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Apple TV remote platform.""" + if not discovery_info: + return + + identifier = discovery_info[CONF_IDENTIFIER] + name = discovery_info[CONF_NAME] + manager = hass.data[DOMAIN][identifier] + async_add_entities([AppleTVRemote(name, identifier, manager)]) + + +class AppleTVRemote(remote.RemoteDevice): + """Device that sends commands to an Apple TV.""" + + def __init__(self, name, identifier, manager): + """Initialize device.""" + self.atv = None + self._name = name + self._identifier = identifier + self._manager = manager + + async def async_added_to_hass(self): + """Handle when an entity is about to be added to Home Assistant.""" + self._manager.listeners.append(self) + + @callback + def device_connected(self): + self.atv = self._manager.atv + + @callback + def device_disconnected(self): + self.atv = None + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._identifier)}, + "manufacturer": "Apple", + "model": "Remote", + "name": self.name, + "sw_version": "0.0", + "via_device": (DOMAIN, self._identifier), + } + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return "remote_" + self._identifier + + @property + def is_on(self): + """Return true if device is on.""" + return self.atv is not None + + @property + def should_poll(self): + """No polling needed for Apple TV.""" + return False + + async def async_turn_on(self, **kwargs): + """Turn the device on. + + This method is a coroutine. + """ + await self._manager.connect() + + async def async_turn_off(self, **kwargs): + """Turn the device off. + + This method is a coroutine. + """ + await self._manager.disconnect() + + def async_send_command(self, command, **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 + async def _send_commands(): + for single_command in command: + if not hasattr(self.atv.remote_control, single_command): + continue + + await getattr(self.atv.remote_control, single_command)() + + return _send_commands() diff --git a/custom_components/apple_tv/strings.json b/custom_components/apple_tv/strings.json new file mode 100644 index 0000000..16a5a1f --- /dev/null +++ b/custom_components/apple_tv/strings.json @@ -0,0 +1,67 @@ +{ + "config": { + "title": "Apple TV", + "flow_title": "Apple TV: {name}", + "step": { + "user": { + "title": "Add new device", + "description": "Enter a device name, IP-address or unique identifier. In case your device is not shown, try using its IP-address.\n\nIf any devices were automatically discovered on your network, they are shown below.\n\n{devices}", + "data": { + "device_id": "Device" + } + }, + "reconfigure": { + "title": "Device Reconfiguration", + "description": "This Apple TV is experiencing connection problems and must be reconfigured. Please continue to finish the process." + }, + "pair_with_pin": { + "title": "Pair with device", + "description": "Pairing is required for protocol `{protocol}`. Please input PIN code displayed on screen.", + "data": { + "pin": "PIN Code" + } + }, + "pair_no_pin": { + "title": "Pair with device", + "description": "Pairing is required for protocol `{protocol}`. Please enter PIN {pin} on device to continue." + }, + "service_problem": { + "title": "Failed to add protocol", + "description": "A problem occurred while pairing protocol `{protocol}`. It will be ignored." + }, + "confirm": { + "title": "Discovered Apple TV", + "description": "Do you want to setup the Apple TV named `{name}` in Home Assistant?" + }, + "zeroconf": { + "lookup_id_failed": "Failed to look up device id for device." + } + }, + "error": { + "device_not_found": "Device could not be found on network.", + "device_already_configured": "This device has already been configured. Please choose another device.", + "no_usable_service": "A device was found but could not identify any way to establish a connection.", + "unknown": "Unexpected error", + "auth": "Authentication error (invalid PIN?)" + }, + "abort": { + "already_configured": "Device is already configured!", + "updated_configuration": "Updated existing configuration!", + "no_credentials": "No credentials available for device", + "unrecoverable_error": "An unrecoverable occurred", + "device_did_not_pair": "No attempt to finish pairing process was made from the device.", + "backoff": "Device does not accept pairing reqests at this stage, try again later.", + "timeout": "Timed out while waiting for device" + } + }, + "options": { + "step": { + "device_options": { + "description": "Configure general device settings", + "data": { + "start_off": "Do not turn on device when starting Home Assistant" + } + } + } + } +} diff --git a/ui-lovelace.yaml b/ui-lovelace.yaml index b53de12..0d95e63 100644 --- a/ui-lovelace.yaml +++ b/ui-lovelace.yaml @@ -7,9 +7,9 @@ resources: url: /local/battery-entity.js title: OmerogluHQ views: - - icon: 'mdi:home' + - title: Home + icon: 'mdi:home' path: default_view - title: Home panel: false badges: [] cards: