Update Apple TV integration to support tvOS 15 (#58665)

This commit is contained in:
Pierre Ståhl 2021-12-06 13:04:18 +01:00 committed by GitHub
parent 1dfadd72cf
commit 3a56cfed3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 919 additions and 359 deletions

View File

@ -4,11 +4,11 @@ import logging
from random import randrange
from pyatv import connect, exceptions, scan
from pyatv.const import Protocol
from pyatv.const import DeviceModel, Protocol
from pyatv.convert import model_str
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import (
ATTR_CONNECTIONS,
ATTR_IDENTIFIERS,
@ -19,7 +19,6 @@ from homeassistant.const import (
ATTR_SW_VERSION,
CONF_ADDRESS,
CONF_NAME,
CONF_PROTOCOL,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import callback
@ -31,7 +30,7 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import CONF_CREDENTIALS, CONF_IDENTIFIER, CONF_START_OFF, DOMAIN
from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -39,9 +38,6 @@ DEFAULT_NAME = "Apple TV"
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
NOTIFICATION_TITLE = "Apple TV Notification"
NOTIFICATION_ID = "apple_tv_notification"
SIGNAL_CONNECTED = "apple_tv_connected"
SIGNAL_DISCONNECTED = "apple_tv_disconnected"
@ -229,7 +225,12 @@ class AppleTVManager:
if conf:
await self._connect(conf)
except exceptions.AuthenticationError:
self._auth_problem()
self.config_entry.async_start_reauth(self.hass)
asyncio.create_task(self.disconnect())
_LOGGER.exception(
"Authentication failed for %s, try reconfiguring device",
self.config_entry.data[CONF_NAME],
)
break
except asyncio.CancelledError:
pass
@ -249,56 +250,37 @@ class AppleTVManager:
_LOGGER.debug("Connect loop ended")
self._task = None
def _auth_problem(self):
"""Problem to authenticate occurred that needs intervention."""
_LOGGER.debug("Authentication error, reconfigure integration")
name = self.config_entry.data[CONF_NAME]
identifier = self.config_entry.unique_id
self.hass.components.persistent_notification.create(
"An irrecoverable connection problem occurred when connecting to "
f"`{name}`. Please go to the Integrations page and reconfigure it",
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID,
)
# Add to event queue as this function is called from a task being
# cancelled from disconnect
asyncio.create_task(self.disconnect())
self.hass.async_create_task(
self.hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data={CONF_NAME: name, CONF_IDENTIFIER: identifier},
)
)
async def _scan(self):
"""Try to find device by scanning for it."""
identifier = self.config_entry.unique_id
identifiers = set(
self.config_entry.data.get(CONF_IDENTIFIERS, [self.config_entry.unique_id])
)
address = self.config_entry.data[CONF_ADDRESS]
protocol = Protocol(self.config_entry.data[CONF_PROTOCOL])
_LOGGER.debug("Discovering device %s", identifier)
# Only scan for and set up protocols that was successfully paired
protocols = {
Protocol(int(protocol))
for protocol in self.config_entry.data[CONF_CREDENTIALS]
}
_LOGGER.debug("Discovering device %s", self.config_entry.title)
atvs = await scan(
self.hass.loop, identifier=identifier, protocol=protocol, hosts=[address]
self.hass.loop, identifier=identifiers, protocol=protocols, hosts=[address]
)
if atvs:
return atvs[0]
_LOGGER.debug(
"Failed to find device %s with address %s, trying to scan",
identifier,
self.config_entry.title,
address,
)
atvs = await scan(self.hass.loop, identifier=identifier, protocol=protocol)
atvs = await scan(self.hass.loop, identifier=identifiers, protocol=protocols)
if atvs:
return atvs[0]
_LOGGER.debug("Failed to find device %s, trying later", identifier)
_LOGGER.debug("Failed to find device %s, trying later", self.config_entry.title)
return None
@ -307,8 +289,16 @@ class AppleTVManager:
credentials = self.config_entry.data[CONF_CREDENTIALS]
session = async_get_clientsession(self.hass)
for protocol, creds in credentials.items():
conf.set_credentials(Protocol(int(protocol)), creds)
for protocol_int, creds in credentials.items():
protocol = Protocol(int(protocol_int))
if conf.get_service(protocol) is not None:
conf.set_credentials(protocol, creds)
else:
_LOGGER.warning(
"Protocol %s not found for %s, functionality will be reduced",
protocol.name,
self.config_entry.data[CONF_NAME],
)
_LOGGER.debug("Connecting to device %s", self.config_entry.data[CONF_NAME])
self.atv = await connect(conf, self.hass.loop, session=session)
@ -322,7 +312,7 @@ class AppleTVManager:
self._connection_attempts = 0
if self._connection_was_lost:
_LOGGER.info(
'Connection was re-established to Apple TV "%s"',
'Connection was re-established to device "%s"',
self.config_entry.data[CONF_NAME],
)
self._connection_was_lost = False
@ -345,7 +335,9 @@ class AppleTVManager:
dev_info = self.atv.device_info
attrs[ATTR_MODEL] = (
DEFAULT_NAME + " " + dev_info.model.name.replace("Gen", "")
dev_info.raw_model
if dev_info.model == DeviceModel.Unknown and dev_info.raw_model
else model_str(dev_info.model)
)
attrs[ATTR_SW_VERSION] = dev_info.version

View File

@ -1,22 +1,23 @@
"""Config flow for Apple TV integration."""
from collections import deque
from ipaddress import ip_address
import logging
from random import randrange
from pyatv import exceptions, pair, scan
from pyatv.const import Protocol
from pyatv.convert import protocol_str
from pyatv.const import DeviceModel, PairingRequirement, Protocol
from pyatv.convert import model_str, protocol_str
from pyatv.helpers import get_unique_id
import voluptuous as vol
from homeassistant import config_entries
from homeassistant import config_entries, data_entry_flow
from homeassistant.components import zeroconf
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN, CONF_PROTOCOL
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_CREDENTIALS, CONF_IDENTIFIER, CONF_START_OFF, DOMAIN
from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN
_LOGGER = logging.getLogger(__name__)
@ -25,10 +26,9 @@ DEVICE_INPUT = "device_input"
INPUT_PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN, default=None): int})
DEFAULT_START_OFF = False
PROTOCOL_PRIORITY = [Protocol.MRP, Protocol.DMAP, Protocol.AirPlay]
async def device_scan(identifier, loop, cache=None):
async def device_scan(identifier, loop):
"""Scan for a specific device using identifier as filter."""
def _filter_device(dev):
@ -46,27 +46,14 @@ async def device_scan(identifier, loop, cache=None):
except ValueError:
return None
if cache:
matches = [atv for atv in cache if _filter_device(atv)]
if matches:
return cache, matches[0]
for hosts in (_host_filter(), None):
scan_result = await scan(loop, timeout=3, hosts=hosts)
matches = [atv for atv in scan_result if _filter_device(atv)]
if matches:
return scan_result, matches[0]
return matches[0], matches[0].all_identifiers
return scan_result, None
def is_valid_credentials(credentials):
"""Verify that credentials are valid for establishing a connection."""
return (
credentials.get(Protocol.MRP.value) is not None
or credentials.get(Protocol.DMAP.value) is not None
)
return None, None
class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@ -82,19 +69,43 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self):
"""Initialize a new AppleTVConfigFlow."""
self.target_device = None
self.scan_result = None
self.scan_filter = None
self.atv = None
self.atv_identifiers = None
self.protocol = None
self.pairing = None
self.credentials = {} # Protocol -> credentials
self.protocols_to_pair = deque()
async def async_step_reauth(self, info):
@property
def device_identifier(self):
"""Return a identifier for the config entry.
A device has multiple unique identifiers, but Home Assistant only supports one
per config entry. Normally, a "main identifier" is determined by pyatv by
first collecting all identifiers and then picking one in a pre-determine order.
Under normal circumstances, this works fine but if a service is missing or
removed due to deprecation (which happened with MRP), then another identifier
will be calculated instead. To fix this, all identifiers belonging to a device
is stored with the config entry and one of them (could be random) is used as
unique_id for said entry. When a new (zeroconf) service or device is
discovered, the identifier is first used to look up if it belongs to an
existing config entry. If that's the case, the unique_id from that entry is
re-used, otherwise the newly discovered identifier is used instead.
"""
for entry in self._async_current_entries():
for identifier in self.atv.all_identifiers:
if identifier in entry.data.get(CONF_IDENTIFIERS, [entry.unique_id]):
return entry.unique_id
return self.atv.identifier
async def async_step_reauth(self, user_input=None):
"""Handle initial step when updating invalid credentials."""
await self.async_set_unique_id(info[CONF_IDENTIFIER])
self.target_device = info[CONF_IDENTIFIER]
self.context["title_placeholders"] = {"name": info[CONF_NAME]}
self.context["title_placeholders"] = {
"name": user_input[CONF_NAME],
"type": "Apple TV",
}
self.scan_filter = self.unique_id
self.context["identifier"] = self.unique_id
return await self.async_step_reconfigure()
@ -102,70 +113,97 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""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
self.async_pair_next_protocol, allow_exist=True
)
return self.async_show_form(step_id="reconfigure")
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
# Be helpful to the user and look for devices
if self.scan_result is None:
self.scan_result, _ = await device_scan(None, self.hass.loop)
errors = {}
default_suggestion = self._prefill_identifier()
if user_input is not None:
self.target_device = user_input[DEVICE_INPUT]
self.scan_filter = user_input[DEVICE_INPUT]
try:
await self.async_find_device()
except DeviceNotFound:
errors["base"] = "no_devices_found"
except DeviceAlreadyConfigured:
errors["base"] = "already_configured"
except exceptions.NoServiceError:
errors["base"] = "no_usable_service"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(
self.atv.identifier, raise_on_progress=False
self.device_identifier, raise_on_progress=False
)
self.context["all_identifiers"] = self.atv.all_identifiers
return await self.async_step_confirm()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(DEVICE_INPUT, default=default_suggestion): str}
),
data_schema=vol.Schema({vol.Required(DEVICE_INPUT): str}),
errors=errors,
description_placeholders={"devices": self._devices_str()},
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> FlowResult:
) -> data_entry_flow.FlowResult:
"""Handle device found via zeroconf."""
service_type = discovery_info.type
service_type = discovery_info.type[:-1] # Remove leading .
name = discovery_info.name.replace(f".{service_type}.", "")
properties = discovery_info.properties
if service_type == "_mediaremotetv._tcp.local.":
identifier = properties["UniqueIdentifier"]
name = properties["Name"]
elif service_type == "_touch-able._tcp.local.":
identifier = discovery_info.name.split(".")[0]
name = properties["CtlN"]
else:
# Extract unique identifier from service
self.scan_filter = get_unique_id(service_type, name, properties)
if self.scan_filter is None:
return self.async_abort(reason="unknown")
await self.async_set_unique_id(identifier)
# Scan for the device in order to extract _all_ unique identifiers assigned to
# it. Not doing it like this will yield multiple config flows for the same
# device, one per protocol, which is undesired.
return await self.async_find_device_wrapper(self.async_found_zeroconf_device)
async def async_found_zeroconf_device(self, user_input=None):
"""Handle device found after Zeroconf discovery."""
# Suppose we have a device with three services: A, B and C. Let's assume
# service A is discovered by Zeroconf, triggering a device scan that also finds
# service B but *not* C. An identifier is picked from one of the services and
# used as unique_id. The select process is deterministic (let's say in order A,
# B and C) but in practice that doesn't matter. So, a flow is set up for the
# device with unique_id set to "A" for services A and B.
#
# Now, service C is found and the same thing happens again but only service B
# is found. In this case, unique_id will be set to "B" which is problematic
# since both flows really represent the same device. They will however end up
# as two separate flows.
#
# To solve this, all identifiers found during a device scan is stored as
# "all_identifiers" in the flow context. When a new service is discovered, the
# code below will check these identifiers for all active flows and abort if a
# match is found. Before aborting, the original flow is updated with any
# potentially new identifiers. In the example above, when service C is
# discovered, the identifier of service C will be inserted into
# "all_identifiers" of the original flow (making the device complete).
for flow in self._async_in_progress():
for identifier in self.atv.all_identifiers:
if identifier not in flow["context"].get("all_identifiers", []):
continue
# Add potentially new identifiers from this device to the existing flow
identifiers = set(flow["context"]["all_identifiers"])
identifiers.update(self.atv.all_identifiers)
flow["context"]["all_identifiers"] = list(identifiers)
raise data_entry_flow.AbortFlow("already_in_progress")
self.context["all_identifiers"] = self.atv.all_identifiers
# Also abort if an integration with this identifier already exists
await self.async_set_unique_id(self.device_identifier)
self._abort_if_unique_id_configured()
self.context["identifier"] = self.unique_id
self.context["title_placeholders"] = {"name": name}
self.target_device = identifier
return await self.async_find_device_wrapper(self.async_step_confirm)
return await 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.
@ -187,56 +225,101 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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.target_device, self.hass.loop, cache=self.scan_result
self.atv, self.atv_identifiers = await device_scan(
self.scan_filter, self.hass.loop
)
if not self.atv:
raise DeviceNotFound()
self.protocol = self.atv.main_service().protocol
# Protocols supported by the device are prospects for pairing
self.protocols_to_pair = deque(
service.protocol for service in self.atv.services if service.enabled
)
dev_info = self.atv.device_info
self.context["title_placeholders"] = {
"name": self.atv.name,
"type": (
dev_info.raw_model
if dev_info.model == DeviceModel.Unknown and dev_info.raw_model
else model_str(dev_info.model)
),
}
if not allow_exist:
for identifier in self.atv.all_identifiers:
if identifier in self._async_current_ids():
raise DeviceAlreadyConfigured()
# If credentials were found, save them
for service in self.atv.services:
if service.credentials:
self.credentials[service.protocol.value] = service.credentials
for entry in self._async_current_entries():
if identifier in entry.data.get(
CONF_IDENTIFIERS, [entry.unique_id]
):
raise DeviceAlreadyConfigured()
async def async_step_confirm(self, user_input=None):
"""Handle user-confirmation of discovered node."""
if user_input is not None:
return await self.async_begin_pairing()
expected_identifier_count = len(self.context["all_identifiers"])
# If number of services found during device scan mismatch number of
# identifiers collected during Zeroconf discovery, then trigger a new scan
# with hopes of finding all services.
if len(self.atv.all_identifiers) != expected_identifier_count:
try:
await self.async_find_device()
except DeviceNotFound:
return self.async_abort(reason="device_not_found")
# If all services still were not found, bail out with an error
if len(self.atv.all_identifiers) != expected_identifier_count:
return self.async_abort(reason="inconsistent_device")
return await self.async_pair_next_protocol()
return self.async_show_form(
step_id="confirm", description_placeholders={"name": self.atv.name}
step_id="confirm",
description_placeholders={
"name": self.atv.name,
"type": model_str(self.atv.device_info.model),
},
)
async def async_begin_pairing(self):
async def async_pair_next_protocol(self):
"""Start pairing process for the next available protocol."""
self.protocol = self._next_protocol_to_pair()
# Dispose previous pairing sessions
if self.pairing is not None:
await self.pairing.close()
self.pairing = None
await self._async_cleanup()
# Any more protocols to pair? Else bail out here
if not self.protocol:
await self.async_set_unique_id(self.atv.main_service().identifier)
return self._async_get_entry(
self.atv.main_service().protocol,
self.atv.name,
self.credentials,
self.atv.address,
)
if not self.protocols_to_pair:
return await self._async_get_entry()
self.protocol = self.protocols_to_pair.popleft()
service = self.atv.get_service(self.protocol)
# Service requires a password
if service.requires_password:
return await self.async_step_password()
# Figure out, depending on protocol, what kind of pairing is needed
if service.pairing == PairingRequirement.Unsupported:
_LOGGER.debug("%s does not support pairing", self.protocol)
return await self.async_pair_next_protocol()
if service.pairing == PairingRequirement.Disabled:
return await self.async_step_protocol_disabled()
if service.pairing == PairingRequirement.NotNeeded:
_LOGGER.debug("%s does not require pairing", self.protocol)
self.credentials[self.protocol.value] = None
return await self.async_pair_next_protocol()
_LOGGER.debug("%s requires pairing", self.protocol)
# Protocol specific arguments
pair_args = {}
if self.protocol == Protocol.DMAP:
pair_args["name"] = "Home Assistant"
pair_args["zeroconf"] = await zeroconf.async_get_instance(self.hass)
# Initiate the pairing process
abort_reason = None
session = async_get_clientsession(self.hass)
self.pairing = await pair(
self.atv, self.protocol, self.hass.loop, session=session
self.atv, self.protocol, self.hass.loop, session=session, **pair_args
)
try:
await self.pairing.begin()
@ -252,8 +335,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
abort_reason = "unknown"
if abort_reason:
if self.pairing:
await self.pairing.close()
await self._async_cleanup()
return self.async_abort(reason=abort_reason)
# Choose step depending on if PIN is required from user or not
@ -262,6 +344,15 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_pair_no_pin()
async def async_step_protocol_disabled(self, user_input=None):
"""Inform user that a protocol is disabled and cannot be paired."""
if user_input is not None:
return await self.async_pair_next_protocol()
return self.async_show_form(
step_id="protocol_disabled",
description_placeholders={"protocol": protocol_str(self.protocol)},
)
async def async_step_pair_with_pin(self, user_input=None):
"""Handle pairing step where a PIN is required from the user."""
errors = {}
@ -270,12 +361,10 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
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()
return await self.async_pair_next_protocol()
except exceptions.PairingError:
_LOGGER.exception("Authentication problem")
errors["base"] = "invalid_auth"
except AbortFlow:
raise
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@ -293,7 +382,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.pairing.finish()
if self.pairing.has_paired:
self.credentials[self.protocol.value] = self.pairing.service.credentials
return await self.async_begin_pairing()
return await self.async_pair_next_protocol()
await self.pairing.close()
return self.async_abort(reason="device_did_not_pair")
@ -311,55 +400,57 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_service_problem(self, user_input=None):
"""Inform user that a service will not be added."""
if user_input is not None:
self.credentials[self.protocol.value] = None
return await self.async_begin_pairing()
return await self.async_pair_next_protocol()
return self.async_show_form(
step_id="service_problem",
description_placeholders={"protocol": protocol_str(self.protocol)},
)
def _async_get_entry(self, protocol, name, credentials, address):
if not is_valid_credentials(credentials):
return self.async_abort(reason="invalid_config")
async def async_step_password(self, user_input=None):
"""Inform user that password is not supported."""
if user_input is not None:
return await self.async_pair_next_protocol()
data = {
CONF_PROTOCOL: protocol.value,
CONF_NAME: name,
CONF_CREDENTIALS: credentials,
CONF_ADDRESS: str(address),
}
self._abort_if_unique_id_configured(reload_on_update=False, updates=data)
return self.async_create_entry(title=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
for protocol in PROTOCOL_PRIORITY:
if _needs_pairing(protocol):
return protocol
return None
def _devices_str(self):
return ", ".join(
[
f"`{atv.name} ({atv.address})`"
for atv in self.scan_result
if atv.identifier not in self._async_current_ids()
]
return self.async_show_form(
step_id="password",
description_placeholders={"protocol": protocol_str(self.protocol)},
)
def _prefill_identifier(self):
# Return identifier (address) of one device that has not been paired with
for atv in self.scan_result:
if atv.identifier not in self._async_current_ids():
return str(atv.address)
return ""
async def _async_cleanup(self):
"""Clean up allocated resources."""
if self.pairing is not None:
await self.pairing.close()
self.pairing = None
async def _async_get_entry(self):
"""Return config entry or update existing config entry."""
# Abort if no protocols were paired
if not self.credentials:
return self.async_abort(reason="setup_failed")
data = {
CONF_NAME: self.atv.name,
CONF_CREDENTIALS: self.credentials,
CONF_ADDRESS: str(self.atv.address),
CONF_IDENTIFIERS: self.atv_identifiers,
}
existing_entry = await self.async_set_unique_id(
self.device_identifier, raise_on_progress=False
)
# If an existing config entry is updated, then this was a re-auth
if existing_entry:
self.hass.config_entries.async_update_entry(
existing_entry, data=data, unique_id=self.unique_id
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(existing_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry(title=self.atv.name, data=data)
class AppleTVOptionsFlow(config_entries.OptionsFlow):

View File

@ -2,10 +2,7 @@
DOMAIN = "apple_tv"
CONF_IDENTIFIER = "identifier"
CONF_CREDENTIALS = "credentials"
CONF_CREDENTIALS_MRP = "mrp"
CONF_CREDENTIALS_DMAP = "dmap"
CONF_CREDENTIALS_AIRPLAY = "airplay"
CONF_IDENTIFIERS = "identifiers"
CONF_START_OFF = "start_off"

View File

@ -3,8 +3,12 @@
"name": "Apple TV",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"requirements": ["pyatv==0.8.2"],
"zeroconf": ["_mediaremotetv._tcp.local.", "_touch-able._tcp.local."],
"requirements": ["pyatv==0.9.7"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_touch-able._tcp.local.",
{"type":"_airplay._tcp.local.","model":"appletv*"}
],
"codeowners": ["@postlund"],
"iot_class": "local_push"
}

View File

@ -10,6 +10,7 @@ from pyatv.const import (
RepeatState,
ShuffleState,
)
from pyatv.helpers import is_streamable
from homeassistant.components.media_player import MediaPlayerEntity
from homeassistant.components.media_player.const import (
@ -50,9 +51,14 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
# We always consider these to be supported
SUPPORT_BASE = SUPPORT_TURN_ON | SUPPORT_TURN_OFF
# This is the "optimistic" view of supported features and will be returned until the
# actual set of supported feature have been determined (will always be all or a subset
# of these).
SUPPORT_APPLE_TV = (
SUPPORT_TURN_ON
| SUPPORT_TURN_OFF
SUPPORT_BASE
| SUPPORT_PLAY_MEDIA
| SUPPORT_PAUSE
| SUPPORT_PLAY
@ -66,6 +72,23 @@ SUPPORT_APPLE_TV = (
)
# Map features in pyatv to Home Assistant
SUPPORT_FEATURE_MAPPING = {
FeatureName.PlayUrl: SUPPORT_PLAY_MEDIA,
FeatureName.StreamFile: SUPPORT_PLAY_MEDIA,
FeatureName.Pause: SUPPORT_PAUSE,
FeatureName.Play: SUPPORT_PLAY,
FeatureName.SetPosition: SUPPORT_SEEK,
FeatureName.Stop: SUPPORT_STOP,
FeatureName.Next: SUPPORT_NEXT_TRACK,
FeatureName.Previous: SUPPORT_PREVIOUS_TRACK,
FeatureName.VolumeUp: SUPPORT_VOLUME_STEP,
FeatureName.VolumeDown: SUPPORT_VOLUME_STEP,
FeatureName.SetRepeat: SUPPORT_REPEAT_SET,
FeatureName.SetShuffle: SUPPORT_SHUFFLE_SET,
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Load Apple TV media player based on a config entry."""
name = config_entry.data[CONF_NAME]
@ -86,8 +109,27 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
@callback
def async_device_connected(self, atv):
"""Handle when connection is made to device."""
self.atv.push_updater.listener = self
self.atv.push_updater.start()
# NB: Do not use _is_feature_available here as it only works when playing
if self.atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
self.atv.push_updater.listener = self
self.atv.push_updater.start()
self._attr_supported_features = SUPPORT_BASE
# Determine the actual set of supported features. All features not reported as
# "Unsupported" are considered here as the state of such a feature can never
# change after a connection has been established, i.e. an unsupported feature
# can never change to be supported.
all_features = self.atv.features.all_features()
for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items():
feature_info = all_features.get(feature_name)
if feature_info and feature_info.state != FeatureState.Unsupported:
self._attr_supported_features |= support_flag
# No need to schedule state update here as that will happen when the first
# metadata update arrives (sometime very soon after this callback returns)
# Listen to power updates
self.atv.power.listener = self
@callback
@ -96,6 +138,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
self.atv.push_updater.stop()
self.atv.push_updater.listener = None
self.atv.power.listener = None
self._attr_supported_features = SUPPORT_APPLE_TV
@property
def state(self):
@ -186,13 +229,28 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
async def async_play_media(self, media_type, media_id, **kwargs):
"""Send the play_media command to the media player."""
await self.atv.stream.play_url(media_id)
# If input (file) has a file format supported by pyatv, then stream it with
# RAOP. Otherwise try to play it with regular AirPlay.
if self._is_feature_available(FeatureName.StreamFile) and (
await is_streamable(media_id) or media_type == MEDIA_TYPE_MUSIC
):
_LOGGER.debug("Streaming %s via RAOP", media_id)
await self.atv.stream.stream_file(media_id)
elif self._is_feature_available(FeatureName.PlayUrl):
_LOGGER.debug("Playing %s via AirPlay", media_id)
await self.atv.stream.play_url(media_id)
else:
_LOGGER.error("Media streaming is not possible with current configuration")
@property
def media_image_hash(self):
"""Hash value for media image."""
state = self.state
if self._playing and state not in [None, STATE_OFF, STATE_IDLE]:
if (
self._playing
and self._is_feature_available(FeatureName.Artwork)
and state not in [None, STATE_OFF, STATE_IDLE]
):
return self.atv.metadata.artwork_id
return None
@ -267,7 +325,6 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
"""Pause media on media player."""
if self._playing:
await self.atv.remote_control.play_pause()
return None
async def async_media_play(self):
"""Play media."""
@ -302,12 +359,12 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
async def async_volume_up(self):
"""Turn volume up for media player."""
if self.atv:
await self.atv.remote_control.volume_up()
await self.atv.audio.volume_up()
async def async_volume_down(self):
"""Turn volume down for media player."""
if self.atv:
await self.atv.remote_control.volume_down()
await self.atv.audio.volume_down()
async def async_set_repeat(self, repeat):
"""Set repeat mode."""

View File

@ -1,17 +1,17 @@
{
"config": {
"flow_title": "{name}",
"flow_title": "{name} ({type})",
"step": {
"user": {
"title": "Setup a new Apple TV",
"description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add. If any devices were automatically found on your network, they are shown below.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.\n\n{devices}",
"description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.",
"data": {
"device_input": "Device"
}
},
"reconfigure": {
"title": "Device reconfiguration",
"description": "This Apple TV is experiencing some connection difficulties and must be reconfigured."
"description": "Reconfigure this device to restore its functionality."
},
"pair_with_pin": {
"title": "Pairing",
@ -22,32 +22,42 @@
},
"pair_no_pin": {
"title": "Pairing",
"description": "Pairing is required for the `{protocol}` service. Please enter PIN {pin} on your Apple TV to continue."
"description": "Pairing is required for the `{protocol}` service. Please enter PIN {pin} on your device to continue."
},
"protocol_disabled": {
"title": "Pairing not possible",
"description": "Pairing is required for `{protocol}` but it is disabled on the device. Please review potential access restrictions (e.g. allow all devices on the local network to connect) on the device.\n\nYou may continue without pairing this protocol, but some functionality will be limited."
},
"confirm": {
"title": "Confirm adding Apple TV",
"description": "You are about to add `{name}` of type `{type}` to Home Assistant.\n\n**To complete the process, you may have to enter multiple PIN codes.**\n\nPlease note that you will *not* be able to power off your Apple TV with this integration. Only the media player in Home Assistant will turn off!"
},
"service_problem": {
"title": "Failed to add service",
"description": "A problem occurred while pairing protocol `{protocol}`. It will be ignored."
},
"confirm": {
"title": "Confirm adding Apple TV",
"description": "You are about to add the Apple TV named `{name}` to Home Assistant.\n\n**To complete the process, you may have to enter multiple PIN codes.**\n\nPlease note that you will *not* be able to power off your Apple TV with this integration. Only the media player in Home Assistant will turn off!"
"password": {
"title": "Password required",
"description": "A password is required by `{protocol}`. This is not yet supported, please disable password to continue."
}
},
"error": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_usable_service": "A device was found but could not identify any way to establish a connection to it. If you keep seeing this message, try specifying its IP address or restarting your Apple TV.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"device_did_not_pair": "No attempt to finish pairing process was made from the device.",
"backoff": "Device does not accept pairing reqests at this time (you might have entered an invalid PIN code too many times), try again later.",
"invalid_config": "The configuration for this device is incomplete. Please try adding it again.",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"setup_failed": "Failed to set up device.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"device_not_found": "Device was not found during discovery, please try adding it again.",
"inconsistent_device": "Expected protocols were not found during discovery. This normally indicates a problem with multicast DNS (Zeroconf). Please try adding the device again."
}
},
"options": {

View File

@ -1,64 +1,74 @@
{
"config": {
"abort": {
"already_configured_device": "Device is already configured",
"already_in_progress": "Configuration flow is already in progress",
"backoff": "Device does not accept pairing reqests at this time (you might have entered an invalid PIN code too many times), try again later.",
"device_did_not_pair": "No attempt to finish pairing process was made from the device.",
"invalid_config": "The configuration for this device is incomplete. Please try adding it again.",
"no_devices_found": "No devices found on the network",
"unknown": "Unexpected error"
},
"error": {
"already_configured": "Device is already configured",
"invalid_auth": "Invalid authentication",
"no_devices_found": "No devices found on the network",
"no_usable_service": "A device was found but could not identify any way to establish a connection to it. If you keep seeing this message, try specifying its IP address or restarting your Apple TV.",
"unknown": "Unexpected error"
},
"flow_title": "{name}",
"step": {
"confirm": {
"description": "You are about to add the Apple TV named `{name}` to Home Assistant.\n\n**To complete the process, you may have to enter multiple PIN codes.**\n\nPlease note that you will *not* be able to power off your Apple TV with this integration. Only the media player in Home Assistant will turn off!",
"title": "Confirm adding Apple TV"
},
"pair_no_pin": {
"description": "Pairing is required for the `{protocol}` service. Please enter PIN {pin} on your Apple TV to continue.",
"title": "Pairing"
},
"pair_with_pin": {
"data": {
"pin": "PIN Code"
},
"description": "Pairing is required for the `{protocol}` protocol. Please enter the PIN code displayed on screen. Leading zeros shall be omitted, i.e. enter 123 if the displayed code is 0123.",
"title": "Pairing"
},
"reconfigure": {
"description": "This Apple TV is experiencing some connection difficulties and must be reconfigured.",
"title": "Device reconfiguration"
},
"service_problem": {
"description": "A problem occurred while pairing protocol `{protocol}`. It will be ignored.",
"title": "Failed to add service"
},
"user": {
"data": {
"device_input": "Device"
},
"description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add. If any devices were automatically found on your network, they are shown below.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.\n\n{devices}",
"title": "Setup a new Apple TV"
}
"config": {
"flow_title": "{name} ({type})",
"step": {
"user": {
"title": "Setup a new Apple TV",
"description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.",
"data": {
"device_input": "Device"
}
},
"options": {
"step": {
"init": {
"data": {
"start_off": "Do not turn device on when starting Home Assistant"
},
"description": "Configure general device settings"
}
},
"reconfigure": {
"title": "Device reconfiguration",
"description": "Reconfigure this device to restore its functionality."
},
"pair_with_pin": {
"title": "Pairing",
"description": "Pairing is required for the `{protocol}` protocol. Please enter the PIN code displayed on screen. Leading zeros shall be omitted, i.e. enter 123 if the displayed code is 0123.",
"data": {
"pin": "PIN Code"
}
},
"pair_no_pin": {
"title": "Pairing",
"description": "Pairing is required for the `{protocol}` service. Please enter PIN {pin} on your device to continue."
},
"protocol_disabled": {
"title": "Pairing not possible",
"description": "Pairing is required for `{protocol}` but it is disabled on the device. Please review potential access restrictions (e.g. allow all devices on the local network to connect) on the device.\n\nYou may continue without pairing this protocol, but some functionality will be limited."
},
"confirm": {
"title": "Confirm adding Apple TV",
"description": "You are about to add `{name}` of type `{type}` to Home Assistant.\n\n**To complete the process, you may have to enter multiple PIN codes.**"
},
"service_problem": {
"title": "Failed to add service",
"description": "A problem occurred while pairing protocol `{protocol}`. It will be ignored."
},
"password": {
"title": "Password required",
"description": "A password is required by `{protocol}`. This is not yet supported, please disable password to continue."
}
},
"title": "Apple TV"
}
"error": {
"no_devices_found": "No devices found on the network",
"already_configured_device": "Device is already configured",
"unknown": "Unexpected error",
"invalid_auth": "Invalid authentication"
},
"abort": {
"no_devices_found": "No devices found on the network",
"already_configured_device": "Device is already configured",
"device_did_not_pair": "No attempt to finish pairing process was made from the device.",
"backoff": "Device does not accept pairing reqests at this time (you might have entered an invalid PIN code too many times), try again later.",
"already_in_progress": "Configuration flow is already in progress",
"unknown": "Unexpected error",
"setup_failed": "Failed to set up device.",
"reauth_successful": "Re-authentication was successful",
"device_not_found": "Device was not found during discovery, please try adding it again.",
"inconsistent_device": "Expected protocols were not found during discovery. This normally indicates a problem with multicast DNS (Zeroconf). Please try adding the device again."
}
},
"options": {
"step": {
"init": {
"description": "Configure general device settings",
"data": {
"start_off": "Do not turn device on when starting Home Assistant",
"reconfigure": "Force reconfiguration of device"
}
}
}
}
}

View File

@ -12,6 +12,10 @@ ZEROCONF = {
}
],
"_airplay._tcp.local.": [
{
"domain": "apple_tv",
"model": "appletv*"
},
{
"domain": "samsungtv",
"manufacturer": "samsung*"

View File

@ -1372,7 +1372,7 @@ pyatmo==6.2.0
pyatome==0.1.1
# homeassistant.components.apple_tv
pyatv==0.8.2
pyatv==0.9.7
# homeassistant.components.balboa
pybalboa==0.13

View File

@ -838,7 +838,7 @@ pyatag==0.3.5.3
pyatmo==6.2.0
# homeassistant.components.apple_tv
pyatv==0.8.2
pyatv==0.9.7
# homeassistant.components.balboa
pybalboa==0.13

View File

@ -1,6 +1,6 @@
"""Test code shared between test files."""
from pyatv import conf, interface
from pyatv import conf, const, interface
from pyatv.const import Protocol
@ -47,3 +47,26 @@ def create_conf(name, address, *services):
for service in services:
atv.add_service(service)
return atv
def mrp_service(enabled=True):
"""Create example MRP service."""
return conf.ManualService(
"mrpid",
Protocol.MRP,
5555,
{},
pairing_requirement=const.PairingRequirement.Mandatory,
enabled=enabled,
)
def airplay_service():
"""Create example AirPlay service."""
return conf.ManualService(
"airplayid",
Protocol.AirPlay,
7777,
{},
pairing_requirement=const.PairingRequirement.Mandatory,
)

View File

@ -3,10 +3,11 @@
from unittest.mock import patch
from pyatv import conf
from pyatv.support.http import create_session
from pyatv.const import PairingRequirement, Protocol
from pyatv.support import http
import pytest
from .common import MockPairingHandler, create_conf
from .common import MockPairingHandler, airplay_service, create_conf, mrp_service
@pytest.fixture(autouse=True, name="mock_scan")
@ -40,7 +41,7 @@ def pairing():
async def _pair(config, protocol, loop, session=None, **kwargs):
handler = MockPairingHandler(
await create_session(session), config.get_service(protocol)
await http.create_session(session), config.get_service(protocol)
)
handler.always_fail = mock_pair.always_fail
return handler
@ -78,9 +79,15 @@ def full_device(mock_scan, dmap_pin):
create_conf(
"127.0.0.1",
"MRP Device",
conf.MrpService("mrpid", 5555),
conf.DmapService("dmapid", None, port=6666),
conf.AirPlayService("airplayid", port=7777),
mrp_service(),
conf.ManualService(
"dmapid",
Protocol.DMAP,
6666,
{},
pairing_requirement=PairingRequirement.Mandatory,
),
airplay_service(),
)
)
yield mock_scan
@ -90,7 +97,31 @@ def full_device(mock_scan, dmap_pin):
def mrp_device(mock_scan):
"""Mock pyatv.scan."""
mock_scan.result.append(
create_conf("127.0.0.1", "MRP Device", conf.MrpService("mrpid", 5555))
create_conf(
"127.0.0.1",
"MRP Device",
mrp_service(),
)
)
yield mock_scan
@pytest.fixture
def airplay_with_disabled_mrp(mock_scan):
"""Mock pyatv.scan."""
mock_scan.result.append(
create_conf(
"127.0.0.1",
"AirPlay Device",
mrp_service(enabled=False),
conf.ManualService(
"airplayid",
Protocol.AirPlay,
7777,
{},
pairing_requirement=PairingRequirement.Mandatory,
),
)
)
yield mock_scan
@ -102,7 +133,14 @@ def dmap_device(mock_scan):
create_conf(
"127.0.0.1",
"DMAP Device",
conf.DmapService("dmapid", None, port=6666),
conf.ManualService(
"dmapid",
Protocol.DMAP,
6666,
{},
credentials=None,
pairing_requirement=PairingRequirement.Mandatory,
),
)
)
yield mock_scan
@ -115,14 +153,48 @@ def dmap_device_with_credentials(mock_scan):
create_conf(
"127.0.0.1",
"DMAP Device",
conf.DmapService("dmapid", "dummy_creds", port=6666),
conf.ManualService(
"dmapid",
Protocol.DMAP,
6666,
{},
credentials="dummy_creds",
pairing_requirement=PairingRequirement.NotNeeded,
),
)
)
yield mock_scan
@pytest.fixture
def device_with_no_services(mock_scan):
def airplay_device_with_password(mock_scan):
"""Mock pyatv.scan."""
mock_scan.result.append(create_conf("127.0.0.1", "Invalid Device"))
mock_scan.result.append(
create_conf(
"127.0.0.1",
"AirPlay Device",
conf.ManualService(
"airplayid", Protocol.AirPlay, 7777, {}, requires_password=True
),
)
)
yield mock_scan
@pytest.fixture
def dmap_with_requirement(mock_scan, pairing_requirement):
"""Mock pyatv.scan."""
mock_scan.result.append(
create_conf(
"127.0.0.1",
"DMAP Device",
conf.ManualService(
"dmapid",
Protocol.DMAP,
6666,
{},
pairing_requirement=pairing_requirement,
),
)
)
yield mock_scan

View File

@ -3,25 +3,32 @@
from unittest.mock import patch
from pyatv import exceptions
from pyatv.const import Protocol
from pyatv.const import PairingRequirement, Protocol
import pytest
from homeassistant import config_entries, data_entry_flow
from homeassistant.components import zeroconf
from homeassistant.components.apple_tv.const import CONF_START_OFF, DOMAIN
from .common import airplay_service, create_conf, mrp_service
from tests.common import MockConfigEntry
DMAP_SERVICE = zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
name="dmapid.something",
port=None,
properties={"CtlN": "Apple TV"},
type="_touch-able._tcp.local.",
name="dmapid._touch-able._tcp.local.",
properties={"CtlN": "Apple TV"},
)
@pytest.fixture(autouse=True)
def use_mocked_zeroconf(mock_async_zeroconf):
"""Mock zeroconf in all tests."""
@pytest.fixture(autouse=True)
def mock_setup_entry():
"""Mock setting up a config entry."""
@ -39,9 +46,8 @@ async def test_user_input_device_not_found(hass, mrp_device):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["description_placeholders"] == {"devices": "`MRP Device (127.0.0.1)`"}
assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -81,7 +87,10 @@ async def test_user_adds_full_device(hass, full_device, pairing):
{"device_input": "MRP Device"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["description_placeholders"] == {"name": "MRP Device"}
assert result2["description_placeholders"] == {
"name": "MRP Device",
"type": "Unknown",
}
result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
@ -108,8 +117,8 @@ async def test_user_adds_full_device(hass, full_device, pairing):
Protocol.MRP.value: "mrp_creds",
Protocol.AirPlay.value: "airplay_creds",
},
"identifiers": ["mrpid", "dmapid", "airplayid"],
"name": "MRP Device",
"protocol": Protocol.MRP.value,
}
@ -124,7 +133,10 @@ async def test_user_adds_dmap_device(hass, dmap_device, dmap_pin, pairing):
{"device_input": "DMAP Device"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["description_placeholders"] == {"name": "DMAP Device"}
assert result2["description_placeholders"] == {
"name": "DMAP Device",
"type": "Unknown",
}
result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
@ -137,8 +149,8 @@ async def test_user_adds_dmap_device(hass, dmap_device, dmap_pin, pairing):
assert result6["data"] == {
"address": "127.0.0.1",
"credentials": {Protocol.DMAP.value: "dmap_creds"},
"identifiers": ["dmapid"],
"name": "DMAP Device",
"protocol": Protocol.DMAP.value,
}
@ -162,29 +174,6 @@ async def test_user_adds_dmap_device_failed(hass, dmap_device, dmap_pin, pairing
assert result2["reason"] == "device_did_not_pair"
async def test_user_adds_device_with_credentials(hass, dmap_device_with_credentials):
"""Test adding DMAP device with existing credentials (home sharing)."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"device_input": "DMAP Device"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["description_placeholders"] == {"name": "DMAP Device"}
result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result3["type"] == "create_entry"
assert result3["data"] == {
"address": "127.0.0.1",
"credentials": {Protocol.DMAP.value: "dummy_creds"},
"name": "DMAP Device",
"protocol": Protocol.DMAP.value,
}
async def test_user_adds_device_with_ip_filter(
hass, dmap_device_with_credentials, mock_scan
):
@ -198,15 +187,33 @@ async def test_user_adds_device_with_ip_filter(
{"device_input": "127.0.0.1"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["description_placeholders"] == {"name": "DMAP Device"}
result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result3["type"] == "create_entry"
assert result3["data"] == {
"address": "127.0.0.1",
"credentials": {Protocol.DMAP.value: "dummy_creds"},
assert result2["description_placeholders"] == {
"name": "DMAP Device",
"type": "Unknown",
}
@pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.NotNeeded)])
async def test_user_pair_no_interaction(hass, dmap_with_requirement, pairing_mock):
"""Test pairing service without user interaction."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"device_input": "DMAP Device"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["data"] == {
"address": "127.0.0.1",
"credentials": {Protocol.DMAP.value: None},
"identifiers": ["dmapid"],
"name": "DMAP Device",
"protocol": Protocol.DMAP.value,
}
@ -240,20 +247,6 @@ async def test_user_adds_existing_device(hass, mrp_device):
assert result2["errors"] == {"base": "already_configured"}
async def test_user_adds_unusable_device(hass, device_with_no_services):
"""Test that it is not possible to add device with no services."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"device_input": "Invalid Device"},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "no_usable_service"}
async def test_user_connection_failed(hass, mrp_device, pairing_mock):
"""Test error message when connection to device fails."""
pairing_mock.begin.side_effect = exceptions.ConnectionFailedError
@ -277,7 +270,7 @@ async def test_user_connection_failed(hass, mrp_device, pairing_mock):
{},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result2["reason"] == "invalid_config"
assert result2["reason"] == "setup_failed"
async def test_user_start_pair_error_failed(hass, mrp_device, pairing_mock):
@ -301,6 +294,81 @@ async def test_user_start_pair_error_failed(hass, mrp_device, pairing_mock):
assert result2["reason"] == "invalid_auth"
async def test_user_pair_service_with_password(
hass, airplay_device_with_password, pairing_mock
):
"""Test pairing with service requiring a password (not supported)."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"device_input": "AirPlay Device"},
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "password"
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result3["reason"] == "setup_failed"
@pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.Disabled)])
async def test_user_pair_disabled_service(hass, dmap_with_requirement, pairing_mock):
"""Test pairing with disabled service (is ignored with message)."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"device_input": "DMAP Device"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "protocol_disabled"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result2["reason"] == "setup_failed"
@pytest.mark.parametrize("pairing_requirement", [(PairingRequirement.Unsupported)])
async def test_user_pair_ignore_unsupported(hass, dmap_with_requirement, pairing_mock):
"""Test pairing with disabled service (is ignored silently)."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
await hass.config_entries.flow.async_configure(
result["flow_id"],
{"device_input": "DMAP Device"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "setup_failed"
async def test_user_pair_invalid_pin(hass, mrp_device, pairing_mock):
"""Test pairing with invalid pin."""
pairing_mock.finish.side_effect = exceptions.PairingError
@ -395,6 +463,41 @@ async def test_user_pair_begin_unexpected_error(hass, mrp_device, pairing_mock):
assert result2["reason"] == "unknown"
async def test_ignores_disabled_service(hass, airplay_with_disabled_mrp, pairing):
"""Test adding device with only DMAP service."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# Find based on mrpid (but do not pair that service since it's disabled)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"device_input": "mrpid"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["description_placeholders"] == {
"name": "AirPlay Device",
"type": "Unknown",
}
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["description_placeholders"] == {"protocol": "AirPlay"}
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"pin": 1111}
)
assert result3["type"] == "create_entry"
assert result3["data"] == {
"address": "127.0.0.1",
"credentials": {
Protocol.AirPlay.value: "airplay_creds",
},
"identifiers": ["mrpid", "airplayid"],
"name": "AirPlay Device",
}
# Zeroconf
@ -408,8 +511,8 @@ async def test_zeroconf_unsupported_service_aborts(hass):
hostname="mock_hostname",
name="mock_name",
port=None,
properties={},
type="_dummy._tcp.local.",
properties={},
),
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
@ -424,14 +527,17 @@ async def test_zeroconf_add_mrp_device(hass, mrp_device, pairing):
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
name="mock_name",
port=None,
name="Kitchen",
properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"},
type="_mediaremotetv._tcp.local.",
),
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["description_placeholders"] == {"name": "MRP Device"}
assert result["description_placeholders"] == {
"name": "MRP Device",
"type": "Unknown",
}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -447,8 +553,8 @@ async def test_zeroconf_add_mrp_device(hass, mrp_device, pairing):
assert result3["data"] == {
"address": "127.0.0.1",
"credentials": {Protocol.MRP.value: "mrp_creds"},
"identifiers": ["mrpid"],
"name": "MRP Device",
"protocol": Protocol.MRP.value,
}
@ -458,7 +564,10 @@ async def test_zeroconf_add_dmap_device(hass, dmap_device, dmap_pin, pairing):
DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DMAP_SERVICE
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["description_placeholders"] == {"name": "DMAP Device"}
assert result["description_placeholders"] == {
"name": "DMAP Device",
"type": "Unknown",
}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -472,8 +581,8 @@ async def test_zeroconf_add_dmap_device(hass, dmap_device, dmap_pin, pairing):
assert result3["data"] == {
"address": "127.0.0.1",
"credentials": {Protocol.DMAP.value: "dmap_creds"},
"identifiers": ["dmapid"],
"name": "DMAP Device",
"protocol": Protocol.DMAP.value,
}
@ -521,17 +630,226 @@ async def test_zeroconf_unexpected_error(hass, mock_scan):
assert result["reason"] == "unknown"
async def test_zeroconf_abort_if_other_in_progress(hass, mock_scan):
"""Test discovering unsupported zeroconf service."""
mock_scan.result = [create_conf("127.0.0.1", "Device", airplay_service())]
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
port=None,
type="_airplay._tcp.local.",
name="Kitchen",
properties={"deviceid": "airplayid"},
),
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "confirm"
mock_scan.result = [
create_conf("127.0.0.1", "Device", mrp_service(), airplay_service())
]
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
port=None,
type="_mediaremotetv._tcp.local.",
name="Kitchen",
properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"},
),
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result2["reason"] == "already_in_progress"
async def test_zeroconf_missing_device_during_protocol_resolve(
hass, mock_scan, pairing, mock_zeroconf
):
"""Test discovery after service been added to existing flow with missing device."""
mock_scan.result = [create_conf("127.0.0.1", "Device", airplay_service())]
# Find device with AirPlay service and set up flow for it
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
port=None,
type="_airplay._tcp.local.",
name="Kitchen",
properties={"deviceid": "airplayid"},
),
)
mock_scan.result = [
create_conf("127.0.0.1", "Device", mrp_service(), airplay_service())
]
# Find the same device again, but now also with MRP service. The first flow should
# be updated with the MRP service.
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
port=None,
type="_mediaremotetv._tcp.local.",
name="Kitchen",
properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"},
),
)
mock_scan.result = []
# Number of services found during initial scan (1) will not match the updated count
# (2), so it will trigger a re-scan to find all services. This will fail as no
# device is found.
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result2["reason"] == "device_not_found"
async def test_zeroconf_additional_protocol_resolve_failure(
hass, mock_scan, pairing, mock_zeroconf
):
"""Test discovery with missing service."""
mock_scan.result = [create_conf("127.0.0.1", "Device", airplay_service())]
# Find device with AirPlay service and set up flow for it
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
port=None,
type="_airplay._tcp.local.",
name="Kitchen",
properties={"deviceid": "airplayid"},
),
)
mock_scan.result = [
create_conf("127.0.0.1", "Device", mrp_service(), airplay_service())
]
# Find the same device again, but now also with MRP service. The first flow should
# be updated with the MRP service.
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
port=None,
type="_mediaremotetv._tcp.local.",
name="Kitchen",
properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"},
),
)
mock_scan.result = [create_conf("127.0.0.1", "Device", airplay_service())]
# Number of services found during initial scan (1) will not match the updated count
# (2), so it will trigger a re-scan to find all services. This will however fail
# due to only one of the services found, yielding an error message.
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result2["reason"] == "inconsistent_device"
async def test_zeroconf_pair_additionally_found_protocols(
hass, mock_scan, pairing, mock_zeroconf
):
"""Test discovered protocols are merged to original flow."""
mock_scan.result = [create_conf("127.0.0.1", "Device", airplay_service())]
# Find device with AirPlay service and set up flow for it
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
port=None,
type="_airplay._tcp.local.",
name="Kitchen",
properties={"deviceid": "airplayid"},
),
)
mock_scan.result = [
create_conf("127.0.0.1", "Device", mrp_service(), airplay_service())
]
# Find the same device again, but now also with MRP service. The first flow should
# be updated with the MRP service.
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
host="mock_host",
hostname="mock_hostname",
port=None,
type="_mediaremotetv._tcp.local.",
name="Kitchen",
properties={"UniqueIdentifier": "mrpid", "Name": "Kitchen"},
),
)
# Verify that _both_ protocols are paired
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "pair_with_pin"
assert result2["description_placeholders"] == {"protocol": "MRP"}
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"pin": 1234},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result3["step_id"] == "pair_with_pin"
assert result3["description_placeholders"] == {"protocol": "AirPlay"}
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"pin": 1234},
)
assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
# Re-configuration
async def test_reconfigure_update_credentials(hass, mrp_device, pairing):
"""Test that reconfigure flow updates config entry."""
config_entry = MockConfigEntry(domain="apple_tv", unique_id="mrpid")
config_entry = MockConfigEntry(
domain="apple_tv", unique_id="mrpid", data={"identifiers": ["mrpid"]}
)
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_REAUTH},
context={"source": "reauth"},
data={"identifier": "mrpid", "name": "apple tv"},
)
@ -546,34 +864,16 @@ async def test_reconfigure_update_credentials(hass, mrp_device, pairing):
result["flow_id"], {"pin": 1111}
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result3["reason"] == "already_configured"
assert result3["reason"] == "reauth_successful"
assert config_entry.data == {
"address": "127.0.0.1",
"protocol": Protocol.MRP.value,
"name": "MRP Device",
"credentials": {Protocol.MRP.value: "mrp_creds"},
"identifiers": ["mrpid"],
}
async def test_reconfigure_ongoing_aborts(hass, mrp_device):
"""Test start additional reconfigure flow aborts."""
data = {
"identifier": "mrpid",
"name": "Apple TV",
}
await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=data
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=data
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_in_progress"
# Options