1 """The Apple TV integration."""
3 from __future__
import annotations
7 from random
import randrange
8 from typing
import Any, cast
10 from pyatv
import connect, exceptions, scan
11 from pyatv.conf
import AppleTV
12 from pyatv.const
import DeviceModel, Protocol
13 from pyatv.convert
import model_str
14 from pyatv.interface
import AppleTV
as AppleTVInterface, DeviceListener
28 EVENT_HOMEASSISTANT_STOP,
46 _LOGGER = logging.getLogger(__name__)
48 DEFAULT_NAME_TV =
"Apple TV"
49 DEFAULT_NAME_HP =
"HomePod"
51 BACKOFF_TIME_LOWER_LIMIT = 15
52 BACKOFF_TIME_UPPER_LIMIT = 300
54 PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
57 exceptions.AuthenticationError,
58 exceptions.InvalidCredentialsError,
59 exceptions.NoCredentialsError,
61 CONNECTION_TIMEOUT_EXCEPTIONS = (
63 asyncio.CancelledError,
65 exceptions.ConnectionLostError,
66 exceptions.ConnectionFailedError,
69 exceptions.ProtocolError,
70 exceptions.NoServiceError,
71 exceptions.PairingError,
72 exceptions.BackOffError,
73 exceptions.DeviceIdMissingError,
76 type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
80 """Set up a config entry for Apple TV."""
84 address = entry.data[CONF_ADDRESS]
87 await manager.async_first_connect()
88 except AUTH_EXCEPTIONS
as ex:
90 f
"{address}: Authentication failed, try reconfiguring device: {ex}"
92 except CONNECTION_TIMEOUT_EXCEPTIONS
as ex:
94 except DEVICE_EXCEPTIONS
as ex:
96 "Error setting up apple_tv at %s: %s", address, ex, exc_info=ex
100 entry.runtime_data = manager
102 async
def on_hass_stop(event: Event) ->
None:
103 """Stop push updates when hass stops."""
104 await manager.disconnect()
106 entry.async_on_unload(
107 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
109 entry.async_on_unload(manager.disconnect)
111 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
118 """Unload an Apple TV config entry."""
119 return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
123 """Connection and power manager for an Apple TV.
125 An instance is used per device to share the same power state between
126 several platforms. It also manages scanning and connection establishment
130 atv: AppleTVInterface |
None =
None
131 _connection_attempts = 0
132 _connection_was_lost =
False
133 _task: asyncio.Task[
None] |
None =
None
135 def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) ->
None:
136 """Initialize power manager."""
139 self.
is_onis_on =
not config_entry.options.get(CONF_START_OFF,
False)
142 """Initialize power management."""
147 """Device was unexpectedly disconnected.
149 This is a callback function from pyatv.interface.DeviceListener.
152 'Connection lost to Apple TV "%s"', self.
config_entryconfig_entry.data[CONF_NAME]
158 """Device connection was (intentionally) closed.
160 This is a callback function from pyatv.interface.DeviceListener.
165 """Handle that the device disconnected and restart connect loop."""
173 """Connect to device."""
174 self.
is_onis_on =
True
178 """Disconnect from device."""
179 _LOGGER.debug(
"Disconnecting from device")
180 self.
is_onis_on =
False
186 self.
_task_task.cancel()
189 _LOGGER.exception(
"An error occurred while disconnecting")
192 """Start background connect loop to device."""
193 if not self.
_task_task
and self.
atvatv
is None and self.
is_onis_on:
197 name=f
"apple_tv connect loop {self.config_entry.title}",
202 "Not starting connect loop (%s, %s)", self.
atvatv
is None, self.
is_onis_on
206 """Connect to device once."""
207 if conf := await self.
_scan_scan():
208 await self.
_connect_connect(conf, raise_missing_credentials)
211 """Connect to device for the first time."""
214 await self.
_connect_once_connect_once(raise_missing_credentials=
True)
221 """Try to connect once."""
223 await self.
_connect_once_connect_once(raise_missing_credentials)
224 except exceptions.AuthenticationError:
228 "Authentication failed for %s, try reconfiguring device",
232 except asyncio.CancelledError:
235 _LOGGER.exception(
"Failed to connect")
239 """Connect loop background task function."""
240 _LOGGER.debug(
"Starting connect loop")
244 while self.
is_onis_on
and self.
atvatv
is None:
245 await self.
connect_onceconnect_once(raise_missing_credentials=
False)
246 if self.
atvatv
is not None:
252 BACKOFF_TIME_LOWER_LIMIT,
255 BACKOFF_TIME_UPPER_LIMIT,
258 _LOGGER.debug(
"Reconnecting in %d seconds", backoff)
259 await asyncio.sleep(backoff)
261 _LOGGER.debug(
"Connect loop ended")
262 self.
_task_task =
None
264 async
def _scan(self) -> AppleTV | None:
265 """Try to find device by scanning for it."""
267 identifiers: set[str] = set(
268 config_entry.data.get(CONF_IDENTIFIERS, [config_entry.unique_id])
270 address: str = config_entry.data[CONF_ADDRESS]
275 Protocol(
int(protocol))
for protocol
in config_entry.data[CONF_CREDENTIALS]
278 _LOGGER.debug(
"Discovering device %s", config_entry.title)
279 aiozc = await zeroconf.async_get_async_instance(hass)
282 identifier=identifiers,
288 return cast(AppleTV, atvs[0])
291 "Failed to find device %s with address %s",
299 async
def _connect(self, conf: AppleTV, raise_missing_credentials: bool) ->
None:
300 """Connect to device."""
302 credentials: dict[int, str |
None] = config_entry.data[CONF_CREDENTIALS]
303 name: str = config_entry.data[CONF_NAME]
304 missing_protocols = []
305 for protocol_int, creds
in credentials.items():
306 protocol = Protocol(
int(protocol_int))
307 if conf.get_service(protocol)
is not None:
308 conf.set_credentials(protocol, creds)
310 missing_protocols.append(protocol.name)
312 if missing_protocols:
313 missing_protocols_str =
", ".join(missing_protocols)
314 if raise_missing_credentials:
316 f
"Protocol(s) {missing_protocols_str} not yet found for {name},"
317 " waiting for discovery."
320 "Protocol(s) %s not yet found for %s, trying later",
321 missing_protocols_str,
326 _LOGGER.debug(
"Connecting to device %s", self.
config_entryconfig_entry.data[CONF_NAME])
328 self.
atvatv = await
connect(conf, self.
hasshass.loop, session=session)
329 self.
atvatv.listener = self
339 'Connection was re-established to device "%s"',
347 ATTR_IDENTIFIERS: {(DOMAIN, self.
config_entryconfig_entry.unique_id)},
348 ATTR_MANUFACTURER:
"Apple",
349 ATTR_NAME: self.
config_entryconfig_entry.data[CONF_NAME],
351 attrs[ATTR_SUGGESTED_AREA] = (
353 .removesuffix(f
" {DEFAULT_NAME_TV}")
354 .removesuffix(f
" {DEFAULT_NAME_HP}")
358 dev_info = self.
atvatv.device_info
360 attrs[ATTR_MODEL] = (
362 if dev_info.model == DeviceModel.Unknown
and dev_info.raw_model
363 else model_str(dev_info.model)
365 attrs[ATTR_SW_VERSION] = dev_info.version
368 attrs[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, dev_info.mac)}
370 device_registry = dr.async_get(self.
hasshass)
371 device_registry.async_get_or_create(
372 config_entry_id=self.
config_entryconfig_entry.entry_id, **attrs
377 """Return true if connection is in progress."""
378 return self.
_task_task
is not None
381 """Update cached address in config entry."""
382 _LOGGER.debug(
"Changing address to %s", address)
383 self.
hasshass.config_entries.async_update_entry(
388 """Dispatch a signal to all entities managed by this manager."""
390 self.
hasshass, f
"{signal}_{self.config_entry.unique_id}", *args
None _dispatch_send(self, str signal, *Any args)
None _address_updated(self, str address)
None _start_connect_loop(self)
bool _connection_was_lost
None connect_once(self, bool raise_missing_credentials)
None _connect_once(self, bool raise_missing_credentials)
None __init__(self, HomeAssistant hass, ConfigEntry config_entry)
None connection_closed(self)
None connection_lost(self, Exception exception)
None _handle_disconnect(self)
None async_first_connect(self)
None _async_setup_device_registry(self)
None _connect(self, AppleTV conf, bool raise_missing_credentials)
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
bool async_setup_entry(HomeAssistant hass, AppleTvConfigEntry entry)
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)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)