1 """Config flow for Apple TV integration."""
3 from __future__
import annotations
6 from collections
import deque
7 from collections.abc
import Awaitable, Callable, Mapping
8 from ipaddress
import ip_address
10 from random
import randrange
11 from typing
import Any, Self
13 from pyatv
import exceptions, pair, scan
14 from pyatv.const
import DeviceModel, PairingRequirement, Protocol
15 from pyatv.convert
import model_str, protocol_str
16 from pyatv.helpers
import get_unique_id
17 from pyatv.interface
import BaseConfig, PairingHandler
18 import voluptuous
as vol
35 SchemaOptionsFlowHandler,
38 from .const
import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN
40 _LOGGER = logging.getLogger(__name__)
42 DEVICE_INPUT =
"device_input"
44 INPUT_PIN_SCHEMA = vol.Schema({vol.Required(CONF_PIN, default=
None): int})
46 DEFAULT_START_OFF =
False
48 DISCOVERY_AGGREGATION_TIME = 15
50 OPTIONS_SCHEMA = vol.Schema(
52 vol.Optional(CONF_START_OFF, default=DEFAULT_START_OFF): bool,
61 hass: HomeAssistant, identifier: str |
None, loop: asyncio.AbstractEventLoop
62 ) -> tuple[BaseConfig |
None, list[str] |
None]:
63 """Scan for a specific device using identifier as filter."""
65 def _filter_device(dev: BaseConfig) -> bool:
66 if identifier
is None:
68 if identifier ==
str(dev.address):
70 if identifier == dev.name:
72 return any(service.identifier == identifier
for service
in dev.services)
74 def _host_filter() -> list[str] | None:
75 if identifier
is None:
78 ip_address(identifier)
85 aiozc = await zeroconf.async_get_async_instance(hass)
86 scan_result = await scan(loop, timeout=3, hosts=_host_filter(), aiozc=aiozc)
87 matches = [atv
for atv
in scan_result
if _filter_device(atv)]
90 return matches[0], matches[0].all_identifiers
96 """Handle a config flow for Apple TV."""
100 scan_filter: str |
None =
None
101 all_identifiers: set[str]
102 atv: BaseConfig |
None =
None
103 atv_identifiers: list[str] |
None =
None
105 host: str |
None =
None
106 protocol: Protocol |
None =
None
107 pairing: PairingHandler |
None =
None
108 protocols_to_pair: deque[Protocol] |
None =
None
113 config_entry: ConfigEntry,
114 ) -> SchemaOptionsFlowHandler:
115 """Get options flow for this handler."""
119 """Initialize a new AppleTVConfigFlow."""
120 self.credentials: dict[int, str |
None] = {}
124 """Return a identifier for the config entry.
126 A device has multiple unique identifiers, but Home Assistant only supports one
127 per config entry. Normally, a "main identifier" is determined by pyatv by
128 first collecting all identifiers and then picking one in a pre-determine order.
129 Under normal circumstances, this works fine but if a service is missing or
130 removed due to deprecation (which happened with MRP), then another identifier
131 will be calculated instead. To fix this, all identifiers belonging to a device
132 is stored with the config entry and one of them (could be random) is used as
133 unique_id for said entry. When a new (zeroconf) service or device is
134 discovered, the identifier is first used to look up if it belongs to an
135 existing config entry. If that's the case, the unique_id from that entry is
136 re-used, otherwise the newly discovered identifier is used instead.
139 all_identifiers = set(self.atv.all_identifiers)
142 return self.atv.identifier
146 """Search existing entries for an identifier and return the unique id."""
148 if not all_identifiers.isdisjoint(
149 entry.data.get(CONF_IDENTIFIERS, [entry.unique_id])
151 return entry.unique_id
155 self, entry_data: Mapping[str, Any]
156 ) -> ConfigFlowResult:
157 """Handle initial step when updating invalid credentials."""
158 self.context[
"title_placeholders"] = {
159 "name": entry_data[CONF_NAME],
166 self, user_input: dict[str, str] |
None =
None
167 ) -> ConfigFlowResult:
168 """Inform user that reconfiguration is about to start."""
169 if user_input
is not None:
177 self, user_input: dict[str, str] |
None =
None
178 ) -> ConfigFlowResult:
179 """Handle the initial step."""
181 if user_input
is not None:
182 self.
scan_filterscan_filter = user_input[DEVICE_INPUT]
185 except DeviceNotFound:
186 errors[
"base"] =
"no_devices_found"
187 except DeviceAlreadyConfigured:
188 errors[
"base"] =
"already_configured"
190 _LOGGER.exception(
"Unexpected exception")
191 errors[
"base"] =
"unknown"
202 data_schema=vol.Schema({vol.Required(DEVICE_INPUT): str}),
207 self, discovery_info: zeroconf.ZeroconfServiceInfo
208 ) -> ConfigFlowResult:
209 """Handle device found via zeroconf."""
210 if discovery_info.ip_address.version == 6:
212 self.
_host_host = host = discovery_info.host
213 service_type = discovery_info.type[:-1]
214 name = discovery_info.name.replace(f
".{service_type}.",
"")
215 properties = discovery_info.properties
219 if unique_id
is None:
245 """Wait for multiple zeroconf services to be discovered an aggregate them."""
276 await asyncio.sleep(DISCOVERY_AGGREGATION_TIME)
286 """Check for in-progress flows and update them with identifiers if needed."""
287 if self.hass.config_entries.flow.async_has_matching_flow(self):
291 """Return True if other_flow is matching this flow."""
293 other_flow.context.get(
"source") != SOURCE_ZEROCONF
294 or other_flow.host != self.
_host_host
299 other_flow.all_identifiers.add(self.
unique_idunique_id)
303 self, user_input: dict[str, str] |
None =
None
304 ) -> ConfigFlowResult:
305 """Handle device found after Zeroconf discovery."""
313 updates={CONF_ADDRESS:
str(self.atv.address)}
319 next_func: Callable[[], Awaitable[ConfigFlowResult]],
320 allow_exist: bool =
False,
321 ) -> ConfigFlowResult:
322 """Find a specific device and call another function when done.
324 This function will do error handling and bail out when an error
329 except DeviceNotFound:
331 except DeviceAlreadyConfigured:
334 _LOGGER.exception(
"Unexpected exception")
337 return await next_func()
340 """Scan for the selected device to discover services."""
342 self.hass, self.
scan_filterscan_filter, self.hass.loop
349 service.protocol
for service
in self.atv.services
if service.enabled
352 dev_info = self.atv.device_info
353 self.context[
"title_placeholders"] = {
354 "name": self.atv.name,
357 if dev_info.model == DeviceModel.Unknown
and dev_info.raw_model
358 else model_str(dev_info.model)
361 all_identifiers = set(self.atv.all_identifiers)
362 discovered_ip_address =
str(self.atv.address)
364 existing_identifiers = set(
365 entry.data.get(CONF_IDENTIFIERS, [entry.unique_id])
367 if all_identifiers.isdisjoint(existing_identifiers):
369 combined_identifiers = existing_identifiers | all_identifiers
372 ) != discovered_ip_address
or combined_identifiers != set(
373 entry.data.get(CONF_IDENTIFIERS, [])
375 self.hass.config_entries.async_update_entry(
379 CONF_ADDRESS: discovered_ip_address,
380 CONF_IDENTIFIERS:
list(combined_identifiers),
383 if entry.source != SOURCE_IGNORE:
384 self.hass.config_entries.async_schedule_reload(entry.entry_id)
386 raise DeviceAlreadyConfigured
389 self, user_input: dict[str, str] |
None =
None
390 ) -> ConfigFlowResult:
391 """Handle user-confirmation of discovered node."""
393 if user_input
is not None:
398 if len(self.atv.all_identifiers) != expected_identifier_count:
401 except DeviceNotFound:
405 if len(self.atv.all_identifiers) != expected_identifier_count:
412 description_placeholders={
413 "name": self.atv.name,
414 "type": model_str(self.atv.device_info.model),
419 """Start pairing process for the next available protocol."""
432 "%s does not support pairing (cannot find a corresponding service)",
438 if service.requires_password:
442 if service.pairing == PairingRequirement.Unsupported:
443 _LOGGER.debug(
"%s does not support pairing", self.
protocolprotocol)
445 if service.pairing == PairingRequirement.Disabled:
447 if service.pairing == PairingRequirement.NotNeeded:
448 _LOGGER.debug(
"%s does not require pairing", self.
protocolprotocol)
449 self.credentials[self.
protocolprotocol.value] =
None
452 _LOGGER.debug(
"%s requires pairing", self.
protocolprotocol)
455 pair_args: dict[str, Any] = {}
456 if self.
protocolprotocol
in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}:
457 pair_args[
"name"] =
"Home Assistant"
458 if self.
protocolprotocol == Protocol.DMAP:
459 pair_args[
"zeroconf"] = await zeroconf.async_get_instance(self.hass)
465 self.atv, self.
protocolprotocol, self.hass.loop, session=session, **pair_args
468 await self.
pairingpairing.begin()
469 except exceptions.ConnectionFailedError:
471 except exceptions.BackOffError:
472 abort_reason =
"backoff"
473 except exceptions.PairingError:
474 _LOGGER.exception(
"Authentication problem")
475 abort_reason =
"invalid_auth"
477 _LOGGER.exception(
"Unexpected exception")
478 abort_reason =
"unknown"
485 if self.
pairingpairing.device_provides_pin:
491 self, user_input: dict[str, str] |
None =
None
492 ) -> ConfigFlowResult:
493 """Inform user that a protocol is disabled and cannot be paired."""
495 if user_input
is not None:
498 step_id=
"protocol_disabled",
499 description_placeholders={
"protocol": protocol_str(self.
protocolprotocol)},
503 self, user_input: dict[str, str] |
None =
None
504 ) -> ConfigFlowResult:
505 """Handle pairing step where a PIN is required from the user."""
509 if user_input
is not None:
511 self.
pairingpairing.pin(user_input[CONF_PIN])
512 await self.
pairingpairing.finish()
513 self.credentials[self.
protocolprotocol.value] = self.
pairingpairing.service.credentials
515 except exceptions.PairingError:
516 _LOGGER.exception(
"Authentication problem")
517 errors[
"base"] =
"invalid_auth"
519 _LOGGER.exception(
"Unexpected exception")
520 errors[
"base"] =
"unknown"
523 step_id=
"pair_with_pin",
524 data_schema=INPUT_PIN_SCHEMA,
526 description_placeholders={
"protocol": protocol_str(self.
protocolprotocol)},
530 self, user_input: dict[str, str] |
None =
None
531 ) -> ConfigFlowResult:
532 """Handle step where user has to enter a PIN on the device."""
535 if user_input
is not None:
536 await self.
pairingpairing.finish()
537 if self.
pairingpairing.has_paired:
538 self.credentials[self.
protocolprotocol.value] = self.
pairingpairing.service.credentials
541 await self.
pairingpairing.close()
544 pin = randrange(1000, stop=10000)
547 step_id=
"pair_no_pin",
548 description_placeholders={
549 "protocol": protocol_str(self.
protocolprotocol),
555 self, user_input: dict[str, str] |
None =
None
556 ) -> ConfigFlowResult:
557 """Inform user that a service will not be added."""
559 if user_input
is not None:
563 step_id=
"service_problem",
564 description_placeholders={
"protocol": protocol_str(self.
protocolprotocol)},
568 self, user_input: dict[str, str] |
None =
None
569 ) -> ConfigFlowResult:
570 """Inform user that password is not supported."""
572 if user_input
is not None:
577 description_placeholders={
"protocol": protocol_str(self.
protocolprotocol)},
581 """Clean up allocated resources."""
582 if self.
pairingpairing
is not None:
583 await self.
pairingpairing.close()
587 """Return config entry or update existing config entry."""
589 if not self.credentials:
595 CONF_NAME: self.atv.name,
596 CONF_CREDENTIALS: self.credentials,
597 CONF_ADDRESS:
str(self.atv.address),
608 existing_entry, data=data, unique_id=self.
unique_idunique_id
615 """Error to indicate device could not be found."""
618 class DeviceAlreadyConfigured(HomeAssistantError):
619 """Error to indicate device is already configured."""
ConfigFlowResult async_step_pair_with_pin(self, dict[str, str]|None user_input=None)
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
ConfigFlowResult async_step_confirm(self, dict[str, str]|None user_input=None)
str|None _entry_unique_id_from_identifers(self, set[str] all_identifiers)
SchemaOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
ConfigFlowResult _async_get_entry(self)
str|None device_identifier(self)
ConfigFlowResult async_pair_next_protocol(self)
ConfigFlowResult async_step_service_problem(self, dict[str, str]|None user_input=None)
ConfigFlowResult async_step_protocol_disabled(self, dict[str, str]|None user_input=None)
bool is_matching(self, Self other_flow)
ConfigFlowResult async_step_password(self, dict[str, str]|None user_input=None)
ConfigFlowResult async_step_pair_no_pin(self, dict[str, str]|None user_input=None)
None _async_aggregate_discoveries(self, str host, str unique_id)
None async_find_device(self, bool allow_exist=False)
ConfigFlowResult async_found_zeroconf_device(self, dict[str, str]|None user_input=None)
None _async_check_and_update_in_progress(self, str host, str unique_id)
ConfigFlowResult async_step_restore_device(self, dict[str, str]|None user_input=None)
None _async_cleanup(self)
ConfigFlowResult async_find_device_wrapper(self, Callable[[], Awaitable[ConfigFlowResult]] next_func, bool allow_exist=False)
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
ConfigFlowResult async_step_user(self, dict[str, str]|None user_input=None)
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=None)
ConfigFlowResult async_update_reload_and_abort(self, ConfigEntry entry, *str|None|UndefinedType unique_id=UNDEFINED, str|UndefinedType title=UNDEFINED, Mapping[str, Any]|UndefinedType data=UNDEFINED, Mapping[str, Any]|UndefinedType data_updates=UNDEFINED, Mapping[str, Any]|UndefinedType options=UNDEFINED, str|UndefinedType reason=UNDEFINED, bool reload_even_if_entry_is_unchanged=True)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
None _async_abort_entries_match(self, dict[str, Any]|None match_dict=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
tuple[BaseConfig|None, list[str]|None] device_scan(HomeAssistant hass, str|None identifier, asyncio.AbstractEventLoop loop)
AppriseNotificationService|None get_service(HomeAssistant hass, ConfigType config, DiscoveryInfoType|None discovery_info=None)
str get_unique_id(dict[str, Any] data)
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)