Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Denon HEOS Media Player."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from datetime import timedelta
7 import logging
8 
9 from pyheos import Heos, HeosError, const as heos_const
10 import voluptuous as vol
11 
12 from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
13 from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
14 from homeassistant.core import HomeAssistant, callback
15 from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
16 from homeassistant.helpers import device_registry as dr, entity_registry as er
19  async_dispatcher_connect,
20  async_dispatcher_send,
21 )
22 from homeassistant.helpers.typing import ConfigType
23 from homeassistant.util import Throttle
24 
25 from . import services
26 from .config_flow import format_title
27 from .const import (
28  COMMAND_RETRY_ATTEMPTS,
29  COMMAND_RETRY_DELAY,
30  DATA_CONTROLLER_MANAGER,
31  DATA_ENTITY_ID_MAP,
32  DATA_GROUP_MANAGER,
33  DATA_SOURCE_MANAGER,
34  DOMAIN,
35  SIGNAL_HEOS_PLAYER_ADDED,
36  SIGNAL_HEOS_UPDATED,
37 )
38 
39 PLATFORMS = [Platform.MEDIA_PLAYER]
40 
41 CONFIG_SCHEMA = vol.Schema(
42  vol.All(
43  cv.deprecated(DOMAIN),
44  {DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})},
45  ),
46  extra=vol.ALLOW_EXTRA,
47 )
48 
49 MIN_UPDATE_SOURCES = timedelta(seconds=1)
50 
51 _LOGGER = logging.getLogger(__name__)
52 
53 
54 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
55  """Set up the HEOS component."""
56  if DOMAIN not in config:
57  return True
58  host = config[DOMAIN][CONF_HOST]
59  entries = hass.config_entries.async_entries(DOMAIN)
60  if not entries:
61  # Create new entry based on config
62  hass.async_create_task(
63  hass.config_entries.flow.async_init(
64  DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: host}
65  )
66  )
67  else:
68  # Check if host needs to be updated
69  entry = entries[0]
70  if entry.data[CONF_HOST] != host:
71  hass.config_entries.async_update_entry(
72  entry, title=format_title(host), data={**entry.data, CONF_HOST: host}
73  )
74 
75  return True
76 
77 
78 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
79  """Initialize config entry which represents the HEOS controller."""
80  # For backwards compat
81  if entry.unique_id is None:
82  hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
83 
84  host = entry.data[CONF_HOST]
85  # Setting all_progress_events=False ensures that we only receive a
86  # media position update upon start of playback or when media changes
87  controller = Heos(host, all_progress_events=False)
88  try:
89  await controller.connect(auto_reconnect=True)
90  # Auto reconnect only operates if initial connection was successful.
91  except HeosError as error:
92  await controller.disconnect()
93  _LOGGER.debug("Unable to connect to controller %s: %s", host, error)
94  raise ConfigEntryNotReady from error
95 
96  # Disconnect when shutting down
97  async def disconnect_controller(event):
98  await controller.disconnect()
99 
100  entry.async_on_unload(
101  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect_controller)
102  )
103 
104  # Get players and sources
105  try:
106  players = await controller.get_players()
107  favorites = {}
108  if controller.is_signed_in:
109  favorites = await controller.get_favorites()
110  else:
111  _LOGGER.warning(
112  (
113  "%s is not logged in to a HEOS account and will be unable to"
114  " retrieve HEOS favorites: Use the 'heos.sign_in' service to"
115  " sign-in to a HEOS account"
116  ),
117  host,
118  )
119  inputs = await controller.get_input_sources()
120  except HeosError as error:
121  await controller.disconnect()
122  _LOGGER.debug("Unable to retrieve players and sources: %s", error)
123  raise ConfigEntryNotReady from error
124 
125  controller_manager = ControllerManager(hass, controller)
126  await controller_manager.connect_listeners()
127 
128  source_manager = SourceManager(favorites, inputs)
129  source_manager.connect_update(hass, controller)
130 
131  group_manager = GroupManager(hass, controller)
132 
133  hass.data[DOMAIN] = {
134  DATA_CONTROLLER_MANAGER: controller_manager,
135  DATA_GROUP_MANAGER: group_manager,
136  DATA_SOURCE_MANAGER: source_manager,
137  Platform.MEDIA_PLAYER: players,
138  # Maps player_id to entity_id. Populated by the individual
139  # HeosMediaPlayer entities.
140  DATA_ENTITY_ID_MAP: {},
141  }
142 
143  services.register(hass, controller)
144  group_manager.connect_update()
145  entry.async_on_unload(group_manager.disconnect_update)
146 
147  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
148 
149  return True
150 
151 
152 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
153  """Unload a config entry."""
154  controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER]
155  await controller_manager.disconnect()
156  hass.data.pop(DOMAIN)
157 
158  services.remove(hass)
159 
160  return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
161 
162 
164  """Class that manages events of the controller."""
165 
166  def __init__(self, hass, controller):
167  """Init the controller manager."""
168  self._hass_hass = hass
169  self._device_registry_device_registry = None
170  self._entity_registry_entity_registry = None
171  self.controllercontroller = controller
172  self._signals_signals = []
173 
174  async def connect_listeners(self):
175  """Subscribe to events of interest."""
176  self._device_registry_device_registry = dr.async_get(self._hass_hass)
177  self._entity_registry_entity_registry = er.async_get(self._hass_hass)
178 
179  # Handle controller events
180  self._signals_signals.append(
181  self.controllercontroller.dispatcher.connect(
182  heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event_controller_event
183  )
184  )
185  # Handle connection-related events
186  self._signals_signals.append(
187  self.controllercontroller.dispatcher.connect(
188  heos_const.SIGNAL_HEOS_EVENT, self._heos_event_heos_event
189  )
190  )
191 
192  async def disconnect(self):
193  """Disconnect subscriptions."""
194  for signal_remove in self._signals_signals:
195  signal_remove()
196  self._signals_signals.clear()
197  self.controllercontroller.dispatcher.disconnect_all()
198  await self.controllercontroller.disconnect()
199 
200  async def _controller_event(self, event, data):
201  """Handle controller event."""
202  if event == heos_const.EVENT_PLAYERS_CHANGED:
203  self.update_idsupdate_ids(data[heos_const.DATA_MAPPED_IDS])
204  # Update players
205  async_dispatcher_send(self._hass_hass, SIGNAL_HEOS_UPDATED)
206 
207  async def _heos_event(self, event):
208  """Handle connection event."""
209  if event == heos_const.EVENT_CONNECTED:
210  try:
211  # Retrieve latest players and refresh status
212  data = await self.controllercontroller.load_players()
213  self.update_idsupdate_ids(data[heos_const.DATA_MAPPED_IDS])
214  except HeosError as ex:
215  _LOGGER.error("Unable to refresh players: %s", ex)
216  # Update players
217  async_dispatcher_send(self._hass_hass, SIGNAL_HEOS_UPDATED)
218 
219  def update_ids(self, mapped_ids: dict[int, int]):
220  """Update the IDs in the device and entity registry."""
221  # mapped_ids contains the mapped IDs (new:old)
222  for new_id, old_id in mapped_ids.items():
223  # update device registry
224  entry = self._device_registry_device_registry.async_get_device(
225  identifiers={(DOMAIN, old_id)}
226  )
227  new_identifiers = {(DOMAIN, new_id)}
228  if entry:
229  self._device_registry_device_registry.async_update_device(
230  entry.id, new_identifiers=new_identifiers
231  )
232  _LOGGER.debug(
233  "Updated device %s identifiers to %s", entry.id, new_identifiers
234  )
235  # update entity registry
236  entity_id = self._entity_registry_entity_registry.async_get_entity_id(
237  Platform.MEDIA_PLAYER, DOMAIN, str(old_id)
238  )
239  if entity_id:
240  self._entity_registry_entity_registry.async_update_entity(
241  entity_id, new_unique_id=str(new_id)
242  )
243  _LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id)
244 
245 
247  """Class that manages HEOS groups."""
248 
249  def __init__(self, hass, controller):
250  """Init group manager."""
251  self._hass_hass = hass
252  self._group_membership_group_membership = {}
253  self._disconnect_player_added_disconnect_player_added = None
254  self._initialized_initialized = False
255  self.controllercontroller = controller
256 
258  """Return mapping of all HeosMediaPlayer entity_ids to player_ids."""
259  return {v: k for k, v in self._hass_hass.data[DOMAIN][DATA_ENTITY_ID_MAP].items()}
260 
261  async def async_get_group_membership(self):
262  """Return all group members for each player as entity_ids."""
263  group_info_by_entity_id = {
264  player_entity_id: []
265  for player_entity_id in self._get_entity_id_to_player_id_map_get_entity_id_to_player_id_map()
266  }
267 
268  try:
269  groups = await self.controllercontroller.get_groups(refresh=True)
270  except HeosError as err:
271  _LOGGER.error("Unable to get HEOS group info: %s", err)
272  return group_info_by_entity_id
273 
274  player_id_to_entity_id_map = self._hass_hass.data[DOMAIN][DATA_ENTITY_ID_MAP]
275  for group in groups.values():
276  leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id)
277  member_entity_ids = [
278  player_id_to_entity_id_map[member.player_id]
279  for member in group.members
280  if member.player_id in player_id_to_entity_id_map
281  ]
282  # Make sure the group leader is always the first element
283  group_info = [leader_entity_id, *member_entity_ids]
284  if leader_entity_id:
285  group_info_by_entity_id[leader_entity_id] = group_info
286  for member_entity_id in member_entity_ids:
287  group_info_by_entity_id[member_entity_id] = group_info
288 
289  return group_info_by_entity_id
290 
292  self, leader_entity_id: str, member_entity_ids: list[str]
293  ) -> None:
294  """Create a group a group leader and member players."""
295  entity_id_to_player_id_map = self._get_entity_id_to_player_id_map_get_entity_id_to_player_id_map()
296  leader_id = entity_id_to_player_id_map.get(leader_entity_id)
297  if not leader_id:
298  raise HomeAssistantError(
299  f"The group leader {leader_entity_id} could not be resolved to a HEOS"
300  " player."
301  )
302  member_ids = [
303  entity_id_to_player_id_map[member]
304  for member in member_entity_ids
305  if member in entity_id_to_player_id_map
306  ]
307 
308  try:
309  await self.controllercontroller.create_group(leader_id, member_ids)
310  except HeosError as err:
311  _LOGGER.error(
312  "Failed to group %s with %s: %s",
313  leader_entity_id,
314  member_entity_ids,
315  err,
316  )
317 
318  async def async_unjoin_player(self, player_entity_id: str):
319  """Remove `player_entity_id` from any group."""
320  player_id = self._get_entity_id_to_player_id_map_get_entity_id_to_player_id_map().get(player_entity_id)
321  if not player_id:
322  raise HomeAssistantError(
323  f"The player {player_entity_id} could not be resolved to a HEOS player."
324  )
325 
326  try:
327  await self.controllercontroller.create_group(player_id, [])
328  except HeosError as err:
329  _LOGGER.error(
330  "Failed to ungroup %s: %s",
331  player_entity_id,
332  err,
333  )
334 
335  async def async_update_groups(self, event, data=None):
336  """Update the group membership from the controller."""
337  if event in (
338  heos_const.EVENT_GROUPS_CHANGED,
339  heos_const.EVENT_CONNECTED,
340  SIGNAL_HEOS_PLAYER_ADDED,
341  ):
342  if groups := await self.async_get_group_membershipasync_get_group_membership():
343  self._group_membership_group_membership = groups
344  _LOGGER.debug("Groups updated due to change event")
345  # Let players know to update
346  async_dispatcher_send(self._hass_hass, SIGNAL_HEOS_UPDATED)
347  else:
348  _LOGGER.debug("Groups empty")
349 
350  def connect_update(self):
351  """Connect listener for when groups change and signal player update."""
352  self.controllercontroller.dispatcher.connect(
353  heos_const.SIGNAL_CONTROLLER_EVENT, self.async_update_groupsasync_update_groups
354  )
355  self.controllercontroller.dispatcher.connect(
356  heos_const.SIGNAL_HEOS_EVENT, self.async_update_groupsasync_update_groups
357  )
358 
359  # When adding a new HEOS player we need to update the groups.
360  async def _async_handle_player_added():
361  # Avoid calling async_update_groups when `DATA_ENTITY_ID_MAP` has not been
362  # fully populated yet. This may only happen during early startup.
363  if (
364  len(self._hass_hass.data[DOMAIN][Platform.MEDIA_PLAYER])
365  <= len(self._hass_hass.data[DOMAIN][DATA_ENTITY_ID_MAP])
366  and not self._initialized_initialized
367  ):
368  self._initialized_initialized = True
369  await self.async_update_groupsasync_update_groups(SIGNAL_HEOS_PLAYER_ADDED)
370 
371  self._disconnect_player_added_disconnect_player_added = async_dispatcher_connect(
372  self._hass_hass, SIGNAL_HEOS_PLAYER_ADDED, _async_handle_player_added
373  )
374 
375  @callback
376  def disconnect_update(self):
377  """Disconnect the listeners."""
378  if self._disconnect_player_added_disconnect_player_added:
379  self._disconnect_player_added_disconnect_player_added()
380  self._disconnect_player_added_disconnect_player_added = None
381 
382  @property
383  def group_membership(self):
384  """Provide access to group members for player entities."""
385  return self._group_membership_group_membership
386 
387 
389  """Class that manages sources for players."""
390 
391  def __init__(
392  self,
393  favorites,
394  inputs,
395  *,
396  retry_delay: int = COMMAND_RETRY_DELAY,
397  max_retry_attempts: int = COMMAND_RETRY_ATTEMPTS,
398  ) -> None:
399  """Init input manager."""
400  self.retry_delayretry_delay = retry_delay
401  self.max_retry_attemptsmax_retry_attempts = max_retry_attempts
402  self.favoritesfavorites = favorites
403  self.inputsinputs = inputs
404  self.source_listsource_list = self._build_source_list_build_source_list()
405 
407  """Build a single list of inputs from various types."""
408  source_list = []
409  source_list.extend([favorite.name for favorite in self.favoritesfavorites.values()])
410  source_list.extend([source.name for source in self.inputsinputs])
411  return source_list
412 
413  async def play_source(self, source: str, player):
414  """Determine type of source and play it."""
415  index = next(
416  (
417  index
418  for index, favorite in self.favoritesfavorites.items()
419  if favorite.name == source
420  ),
421  None,
422  )
423  if index is not None:
424  await player.play_favorite(index)
425  return
426 
427  input_source = next(
428  (
429  input_source
430  for input_source in self.inputsinputs
431  if input_source.name == source
432  ),
433  None,
434  )
435  if input_source is not None:
436  await player.play_input_source(input_source)
437  return
438 
439  _LOGGER.error("Unknown source: %s", source)
440 
441  def get_current_source(self, now_playing_media):
442  """Determine current source from now playing media."""
443  # Match input by input_name:media_id
444  if now_playing_media.source_id == heos_const.MUSIC_SOURCE_AUX_INPUT:
445  return next(
446  (
447  input_source.name
448  for input_source in self.inputsinputs
449  if input_source.input_name == now_playing_media.media_id
450  ),
451  None,
452  )
453  # Try matching favorite by name:station or media_id:album_id
454  return next(
455  (
456  source.name
457  for source in self.favoritesfavorites.values()
458  if source.name == now_playing_media.station
459  or source.media_id == now_playing_media.album_id
460  ),
461  None,
462  )
463 
464  def connect_update(self, hass, controller):
465  """Connect listener for when sources change and signal player update.
466 
467  EVENT_SOURCES_CHANGED is often raised multiple times in response to a
468  physical event therefore throttle it. Retrieving sources immediately
469  after the event may fail so retry.
470  """
471 
472  @Throttle(MIN_UPDATE_SOURCES)
473  async def get_sources():
474  retry_attempts = 0
475  while True:
476  try:
477  favorites = {}
478  if controller.is_signed_in:
479  favorites = await controller.get_favorites()
480  inputs = await controller.get_input_sources()
481  except HeosError as error:
482  if retry_attempts < self.max_retry_attemptsmax_retry_attempts:
483  retry_attempts += 1
484  _LOGGER.debug(
485  "Error retrieving sources and will retry: %s", error
486  )
487  await asyncio.sleep(self.retry_delayretry_delay)
488  else:
489  _LOGGER.error("Unable to update sources: %s", error)
490  return None
491  else:
492  return favorites, inputs
493 
494  async def update_sources(event, data=None):
495  if event in (
496  heos_const.EVENT_SOURCES_CHANGED,
497  heos_const.EVENT_USER_CHANGED,
498  heos_const.EVENT_CONNECTED,
499  ):
500  # If throttled, it will return None
501  if sources := await get_sources():
502  self.favoritesfavorites, self.inputsinputs = sources
503  self.source_listsource_list = self._build_source_list_build_source_list()
504  _LOGGER.debug("Sources updated due to changed event")
505  # Let players know to update
506  async_dispatcher_send(hass, SIGNAL_HEOS_UPDATED)
507 
508  controller.dispatcher.connect(
509  heos_const.SIGNAL_CONTROLLER_EVENT, update_sources
510  )
511  controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, update_sources)
def update_ids(self, dict[int, int] mapped_ids)
Definition: __init__.py:219
def __init__(self, hass, controller)
Definition: __init__.py:166
def __init__(self, hass, controller)
Definition: __init__.py:249
def async_update_groups(self, event, data=None)
Definition: __init__.py:335
None async_join_players(self, str leader_entity_id, list[str] member_entity_ids)
Definition: __init__.py:293
def async_unjoin_player(self, str player_entity_id)
Definition: __init__.py:318
def play_source(self, str source, player)
Definition: __init__.py:413
def connect_update(self, hass, controller)
Definition: __init__.py:464
None __init__(self, favorites, inputs, *int retry_delay=COMMAND_RETRY_DELAY, int max_retry_attempts=COMMAND_RETRY_ATTEMPTS)
Definition: __init__.py:398
def get_current_source(self, now_playing_media)
Definition: __init__.py:441
None async_update_device(HomeAssistant hass, ConfigEntry entry, str adapter, AdapterDetails details)
Definition: __init__.py:294
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:54
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:78
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:152
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
None async_update_entity(HomeAssistant hass, str entity_id)