Update Apple TV integration to support tvOS 15 (#58665)
parent
1dfadd72cf
commit
3a56cfed3a
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,10 @@ ZEROCONF = {
|
|||
}
|
||||
],
|
||||
"_airplay._tcp.local.": [
|
||||
{
|
||||
"domain": "apple_tv",
|
||||
"model": "appletv*"
|
||||
},
|
||||
{
|
||||
"domain": "samsungtv",
|
||||
"manufacturer": "samsung*"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue