Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """The Apple TV integration."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import logging
7 from random import randrange
8 from typing import Any, cast
9 
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
15 
16 from homeassistant.components import zeroconf
17 from homeassistant.config_entries import ConfigEntry
18 from homeassistant.const import (
19  ATTR_CONNECTIONS,
20  ATTR_IDENTIFIERS,
21  ATTR_MANUFACTURER,
22  ATTR_MODEL,
23  ATTR_NAME,
24  ATTR_SUGGESTED_AREA,
25  ATTR_SW_VERSION,
26  CONF_ADDRESS,
27  CONF_NAME,
28  EVENT_HOMEASSISTANT_STOP,
29  Platform,
30 )
31 from homeassistant.core import Event, HomeAssistant, callback
32 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
33 from homeassistant.helpers import device_registry as dr
34 from homeassistant.helpers.aiohttp_client import async_get_clientsession
35 from homeassistant.helpers.dispatcher import async_dispatcher_send
36 
37 from .const import (
38  CONF_CREDENTIALS,
39  CONF_IDENTIFIERS,
40  CONF_START_OFF,
41  DOMAIN,
42  SIGNAL_CONNECTED,
43  SIGNAL_DISCONNECTED,
44 )
45 
46 _LOGGER = logging.getLogger(__name__)
47 
48 DEFAULT_NAME_TV = "Apple TV"
49 DEFAULT_NAME_HP = "HomePod"
50 
51 BACKOFF_TIME_LOWER_LIMIT = 15 # seconds
52 BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
53 
54 PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
55 
56 AUTH_EXCEPTIONS = (
57  exceptions.AuthenticationError,
58  exceptions.InvalidCredentialsError,
59  exceptions.NoCredentialsError,
60 )
61 CONNECTION_TIMEOUT_EXCEPTIONS = (
62  OSError,
63  asyncio.CancelledError,
64  TimeoutError,
65  exceptions.ConnectionLostError,
66  exceptions.ConnectionFailedError,
67 )
68 DEVICE_EXCEPTIONS = (
69  exceptions.ProtocolError,
70  exceptions.NoServiceError,
71  exceptions.PairingError,
72  exceptions.BackOffError,
73  exceptions.DeviceIdMissingError,
74 )
75 
76 type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
77 
78 
79 async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
80  """Set up a config entry for Apple TV."""
81  manager = AppleTVManager(hass, entry)
82 
83  if manager.is_on:
84  address = entry.data[CONF_ADDRESS]
85 
86  try:
87  await manager.async_first_connect()
88  except AUTH_EXCEPTIONS as ex:
90  f"{address}: Authentication failed, try reconfiguring device: {ex}"
91  ) from ex
92  except CONNECTION_TIMEOUT_EXCEPTIONS as ex:
93  raise ConfigEntryNotReady(f"{address}: {ex}") from ex
94  except DEVICE_EXCEPTIONS as ex:
95  _LOGGER.debug(
96  "Error setting up apple_tv at %s: %s", address, ex, exc_info=ex
97  )
98  raise ConfigEntryNotReady(f"{address}: {ex}") from ex
99 
100  entry.runtime_data = manager
101 
102  async def on_hass_stop(event: Event) -> None:
103  """Stop push updates when hass stops."""
104  await manager.disconnect()
105 
106  entry.async_on_unload(
107  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
108  )
109  entry.async_on_unload(manager.disconnect)
110 
111  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
112  await manager.init()
113 
114  return True
115 
116 
117 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
118  """Unload an Apple TV config entry."""
119  return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
120 
121 
122 class AppleTVManager(DeviceListener):
123  """Connection and power manager for an Apple TV.
124 
125  An instance is used per device to share the same power state between
126  several platforms. It also manages scanning and connection establishment
127  in case of problems.
128  """
129 
130  atv: AppleTVInterface | None = None
131  _connection_attempts = 0
132  _connection_was_lost = False
133  _task: asyncio.Task[None] | None = None
134 
135  def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
136  """Initialize power manager."""
137  self.config_entryconfig_entry = config_entry
138  self.hasshass = hass
139  self.is_onis_on = not config_entry.options.get(CONF_START_OFF, False)
140 
141  async def init(self) -> None:
142  """Initialize power management."""
143  if self.is_onis_on:
144  await self.connectconnect()
145 
146  def connection_lost(self, exception: Exception) -> None:
147  """Device was unexpectedly disconnected.
148 
149  This is a callback function from pyatv.interface.DeviceListener.
150  """
151  _LOGGER.warning(
152  'Connection lost to Apple TV "%s"', self.config_entryconfig_entry.data[CONF_NAME]
153  )
154  self._connection_was_lost_connection_was_lost_connection_was_lost = True
155  self._handle_disconnect_handle_disconnect()
156 
157  def connection_closed(self) -> None:
158  """Device connection was (intentionally) closed.
159 
160  This is a callback function from pyatv.interface.DeviceListener.
161  """
162  self._handle_disconnect_handle_disconnect()
163 
164  def _handle_disconnect(self) -> None:
165  """Handle that the device disconnected and restart connect loop."""
166  if self.atvatv:
167  self.atvatv.close()
168  self.atvatv = None
169  self._dispatch_send_dispatch_send(SIGNAL_DISCONNECTED)
170  self._start_connect_loop_start_connect_loop()
171 
172  async def connect(self) -> None:
173  """Connect to device."""
174  self.is_onis_on = True
175  self._start_connect_loop_start_connect_loop()
176 
177  async def disconnect(self) -> None:
178  """Disconnect from device."""
179  _LOGGER.debug("Disconnecting from device")
180  self.is_onis_on = False
181  try:
182  if self.atvatv:
183  self.atvatv.close()
184  self.atvatv = None
185  if self._task_task:
186  self._task_task.cancel()
187  self._task_task = None
188  except Exception:
189  _LOGGER.exception("An error occurred while disconnecting")
190 
191  def _start_connect_loop(self) -> None:
192  """Start background connect loop to device."""
193  if not self._task_task and self.atvatv is None and self.is_onis_on:
194  self._task_task = self.config_entryconfig_entry.async_create_background_task(
195  self.hasshass,
196  self._connect_loop_connect_loop(),
197  name=f"apple_tv connect loop {self.config_entry.title}",
198  eager_start=True,
199  )
200  else:
201  _LOGGER.debug(
202  "Not starting connect loop (%s, %s)", self.atvatv is None, self.is_onis_on
203  )
204 
205  async def _connect_once(self, raise_missing_credentials: bool) -> None:
206  """Connect to device once."""
207  if conf := await self._scan_scan():
208  await self._connect_connect(conf, raise_missing_credentials)
209 
210  async def async_first_connect(self) -> None:
211  """Connect to device for the first time."""
212  connect_ok = False
213  try:
214  await self._connect_once_connect_once(raise_missing_credentials=True)
215  connect_ok = True
216  finally:
217  if not connect_ok:
218  await self.disconnectdisconnect()
219 
220  async def connect_once(self, raise_missing_credentials: bool) -> None:
221  """Try to connect once."""
222  try:
223  await self._connect_once_connect_once(raise_missing_credentials)
224  except exceptions.AuthenticationError:
225  self.config_entryconfig_entry.async_start_reauth(self.hasshass)
226  await self.disconnectdisconnect()
227  _LOGGER.exception(
228  "Authentication failed for %s, try reconfiguring device",
229  self.config_entryconfig_entry.data[CONF_NAME],
230  )
231  return
232  except asyncio.CancelledError:
233  pass
234  except Exception:
235  _LOGGER.exception("Failed to connect")
236  await self.disconnectdisconnect()
237 
238  async def _connect_loop(self) -> None:
239  """Connect loop background task function."""
240  _LOGGER.debug("Starting connect loop")
241 
242  # Try to find device and connect as long as the user has said that
243  # we are allowed to connect and we are not already connected.
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:
247  # Calling self.connect_once may have set self.atv
248  break # type: ignore[unreachable]
249  self._connection_attempts_connection_attempts_connection_attempts += 1
250  backoff = min(
251  max(
252  BACKOFF_TIME_LOWER_LIMIT,
253  randrange(2**self._connection_attempts_connection_attempts_connection_attempts),
254  ),
255  BACKOFF_TIME_UPPER_LIMIT,
256  )
257 
258  _LOGGER.debug("Reconnecting in %d seconds", backoff)
259  await asyncio.sleep(backoff)
260 
261  _LOGGER.debug("Connect loop ended")
262  self._task_task = None
263 
264  async def _scan(self) -> AppleTV | None:
265  """Try to find device by scanning for it."""
266  config_entry = self.config_entryconfig_entry
267  identifiers: set[str] = set(
268  config_entry.data.get(CONF_IDENTIFIERS, [config_entry.unique_id])
269  )
270  address: str = config_entry.data[CONF_ADDRESS]
271  hass = self.hasshass
272 
273  # Only scan for and set up protocols that was successfully paired
274  protocols = {
275  Protocol(int(protocol)) for protocol in config_entry.data[CONF_CREDENTIALS]
276  }
277 
278  _LOGGER.debug("Discovering device %s", config_entry.title)
279  aiozc = await zeroconf.async_get_async_instance(hass)
280  atvs = await scan(
281  hass.loop,
282  identifier=identifiers,
283  protocol=protocols,
284  hosts=[address],
285  aiozc=aiozc,
286  )
287  if atvs:
288  return cast(AppleTV, atvs[0])
289 
290  _LOGGER.debug(
291  "Failed to find device %s with address %s",
292  config_entry.title,
293  address,
294  )
295  # We no longer multicast scan for the device since as soon as async_step_zeroconf runs,
296  # it will update the address and reload the config entry when the device is found.
297  return None
298 
299  async def _connect(self, conf: AppleTV, raise_missing_credentials: bool) -> None:
300  """Connect to device."""
301  config_entry = self.config_entryconfig_entry
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) # type: ignore[arg-type]
309  else:
310  missing_protocols.append(protocol.name)
311 
312  if missing_protocols:
313  missing_protocols_str = ", ".join(missing_protocols)
314  if raise_missing_credentials:
315  raise ConfigEntryNotReady(
316  f"Protocol(s) {missing_protocols_str} not yet found for {name},"
317  " waiting for discovery."
318  )
319  _LOGGER.debug(
320  "Protocol(s) %s not yet found for %s, trying later",
321  missing_protocols_str,
322  name,
323  )
324  return
325 
326  _LOGGER.debug("Connecting to device %s", self.config_entryconfig_entry.data[CONF_NAME])
327  session = async_get_clientsession(self.hasshass)
328  self.atvatv = await connect(conf, self.hasshass.loop, session=session)
329  self.atvatv.listener = self
330 
331  self._dispatch_send_dispatch_send(SIGNAL_CONNECTED, self.atvatv)
332  self._address_updated_address_updated(str(conf.address))
333 
334  self._async_setup_device_registry_async_setup_device_registry()
335 
336  self._connection_attempts_connection_attempts_connection_attempts = 0
337  if self._connection_was_lost_connection_was_lost_connection_was_lost:
338  _LOGGER.warning(
339  'Connection was re-established to device "%s"',
340  self.config_entryconfig_entry.data[CONF_NAME],
341  )
342  self._connection_was_lost_connection_was_lost_connection_was_lost = False
343 
344  @callback
345  def _async_setup_device_registry(self) -> None:
346  attrs = {
347  ATTR_IDENTIFIERS: {(DOMAIN, self.config_entryconfig_entry.unique_id)},
348  ATTR_MANUFACTURER: "Apple",
349  ATTR_NAME: self.config_entryconfig_entry.data[CONF_NAME],
350  }
351  attrs[ATTR_SUGGESTED_AREA] = (
352  attrs[ATTR_NAME]
353  .removesuffix(f" {DEFAULT_NAME_TV}")
354  .removesuffix(f" {DEFAULT_NAME_HP}")
355  )
356 
357  if self.atvatv:
358  dev_info = self.atvatv.device_info
359 
360  attrs[ATTR_MODEL] = (
361  dev_info.raw_model
362  if dev_info.model == DeviceModel.Unknown and dev_info.raw_model
363  else model_str(dev_info.model)
364  )
365  attrs[ATTR_SW_VERSION] = dev_info.version
366 
367  if dev_info.mac:
368  attrs[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, dev_info.mac)}
369 
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
373  )
374 
375  @property
376  def is_connecting(self) -> bool:
377  """Return true if connection is in progress."""
378  return self._task_task is not None
379 
380  def _address_updated(self, address: str) -> None:
381  """Update cached address in config entry."""
382  _LOGGER.debug("Changing address to %s", address)
383  self.hasshass.config_entries.async_update_entry(
384  self.config_entryconfig_entry, data={**self.config_entryconfig_entry.data, CONF_ADDRESS: address}
385  )
386 
387  def _dispatch_send(self, signal: str, *args: Any) -> None:
388  """Dispatch a signal to all entities managed by this manager."""
390  self.hasshass, f"{signal}_{self.config_entry.unique_id}", *args
391  )
None _dispatch_send(self, str signal, *Any args)
Definition: __init__.py:387
None connect_once(self, bool raise_missing_credentials)
Definition: __init__.py:220
None _connect_once(self, bool raise_missing_credentials)
Definition: __init__.py:205
None __init__(self, HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:135
None connection_lost(self, Exception exception)
Definition: __init__.py:146
None _connect(self, AppleTV conf, bool raise_missing_credentials)
Definition: __init__.py:299
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:117
bool async_setup_entry(HomeAssistant hass, AppleTvConfigEntry entry)
Definition: __init__.py:79
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)
Definition: dispatcher.py:193