2019-03-29 02:03:02 +00:00
|
|
|
"""Denon HEOS Media Player."""
|
2021-03-18 08:25:40 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2019-03-29 02:03:02 +00:00
|
|
|
import asyncio
|
2019-04-01 16:58:52 +00:00
|
|
|
from datetime import timedelta
|
2019-03-29 02:03:02 +00:00
|
|
|
import logging
|
|
|
|
|
2019-08-25 18:57:43 +00:00
|
|
|
from pyheos import Heos, HeosError, const as heos_const
|
2019-03-29 02:03:02 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN
|
2021-04-25 09:27:40 +00:00
|
|
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
2019-03-29 02:03:02 +00:00
|
|
|
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
2021-04-23 07:49:02 +00:00
|
|
|
from homeassistant.core import HomeAssistant
|
2019-04-01 16:58:52 +00:00
|
|
|
from homeassistant.exceptions import ConfigEntryNotReady
|
2019-03-29 02:03:02 +00:00
|
|
|
import homeassistant.helpers.config_validation as cv
|
2021-04-23 07:49:02 +00:00
|
|
|
from homeassistant.helpers.typing import ConfigType
|
2019-04-01 16:58:52 +00:00
|
|
|
from homeassistant.util import Throttle
|
2019-03-29 02:03:02 +00:00
|
|
|
|
2019-05-07 16:39:42 +00:00
|
|
|
from . import services
|
2019-03-30 04:10:00 +00:00
|
|
|
from .config_flow import format_title
|
2019-04-01 16:58:52 +00:00
|
|
|
from .const import (
|
2019-07-31 19:25:30 +00:00
|
|
|
COMMAND_RETRY_ATTEMPTS,
|
|
|
|
COMMAND_RETRY_DELAY,
|
|
|
|
DATA_CONTROLLER_MANAGER,
|
|
|
|
DATA_SOURCE_MANAGER,
|
|
|
|
DOMAIN,
|
|
|
|
SIGNAL_HEOS_UPDATED,
|
|
|
|
)
|
|
|
|
|
2021-04-27 14:09:59 +00:00
|
|
|
PLATFORMS = [MEDIA_PLAYER_DOMAIN]
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
CONFIG_SCHEMA = vol.Schema(
|
2021-05-05 07:52:11 +00:00
|
|
|
vol.All(
|
|
|
|
cv.deprecated(DOMAIN),
|
|
|
|
{DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})},
|
|
|
|
),
|
|
|
|
extra=vol.ALLOW_EXTRA,
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2019-03-29 02:03:02 +00:00
|
|
|
|
2019-04-01 16:58:52 +00:00
|
|
|
MIN_UPDATE_SOURCES = timedelta(seconds=1)
|
|
|
|
|
2019-03-29 02:03:02 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2021-08-18 11:22:05 +00:00
|
|
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
2019-03-29 02:03:02 +00:00
|
|
|
"""Set up the HEOS component."""
|
2019-03-30 13:52:17 +00:00
|
|
|
if DOMAIN not in config:
|
|
|
|
return True
|
2019-03-29 02:03:02 +00:00
|
|
|
host = config[DOMAIN][CONF_HOST]
|
2019-03-30 04:10:00 +00:00
|
|
|
entries = hass.config_entries.async_entries(DOMAIN)
|
|
|
|
if not entries:
|
|
|
|
# Create new entry based on config
|
|
|
|
hass.async_create_task(
|
|
|
|
hass.config_entries.flow.async_init(
|
2021-04-25 09:27:40 +00:00
|
|
|
DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: host}
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
|
|
|
)
|
2019-03-30 04:10:00 +00:00
|
|
|
else:
|
|
|
|
# Check if host needs to be updated
|
|
|
|
entry = entries[0]
|
|
|
|
if entry.data[CONF_HOST] != host:
|
2020-03-09 21:07:50 +00:00
|
|
|
hass.config_entries.async_update_entry(
|
|
|
|
entry, title=format_title(host), data={**entry.data, CONF_HOST: host}
|
|
|
|
)
|
2019-03-30 04:10:00 +00:00
|
|
|
|
|
|
|
return True
|
2019-03-29 02:03:02 +00:00
|
|
|
|
2019-03-30 04:10:00 +00:00
|
|
|
|
2021-05-27 15:39:06 +00:00
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
2019-03-30 04:10:00 +00:00
|
|
|
"""Initialize config entry which represents the HEOS controller."""
|
2020-05-26 15:51:50 +00:00
|
|
|
# For backwards compat
|
|
|
|
if entry.unique_id is None:
|
|
|
|
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
|
|
|
|
|
2019-03-30 04:10:00 +00:00
|
|
|
host = entry.data[CONF_HOST]
|
|
|
|
# Setting all_progress_events=False ensures that we only receive a
|
|
|
|
# media position update upon start of playback or when media changes
|
|
|
|
controller = Heos(host, all_progress_events=False)
|
2019-03-29 02:03:02 +00:00
|
|
|
try:
|
2019-03-30 04:10:00 +00:00
|
|
|
await controller.connect(auto_reconnect=True)
|
|
|
|
# Auto reconnect only operates if initial connection was successful.
|
2019-08-25 18:57:43 +00:00
|
|
|
except HeosError as error:
|
2019-03-30 04:10:00 +00:00
|
|
|
await controller.disconnect()
|
2019-04-01 16:58:52 +00:00
|
|
|
_LOGGER.debug("Unable to connect to controller %s: %s", host, error)
|
2020-08-28 11:50:32 +00:00
|
|
|
raise ConfigEntryNotReady from error
|
2019-03-29 02:03:02 +00:00
|
|
|
|
2019-04-01 16:58:52 +00:00
|
|
|
# Disconnect when shutting down
|
2019-03-30 04:10:00 +00:00
|
|
|
async def disconnect_controller(event):
|
|
|
|
await controller.disconnect()
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2021-04-20 16:12:42 +00:00
|
|
|
entry.async_on_unload(
|
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect_controller)
|
|
|
|
)
|
2019-03-29 02:03:02 +00:00
|
|
|
|
2019-04-09 16:21:00 +00:00
|
|
|
# Get players and sources
|
2019-03-30 04:10:00 +00:00
|
|
|
try:
|
2019-04-09 16:21:00 +00:00
|
|
|
players = await controller.get_players()
|
|
|
|
favorites = {}
|
|
|
|
if controller.is_signed_in:
|
|
|
|
favorites = await controller.get_favorites()
|
|
|
|
else:
|
2019-05-07 16:39:42 +00:00
|
|
|
_LOGGER.warning(
|
2019-08-29 19:23:42 +00:00
|
|
|
"%s is not logged in to a HEOS account and will be unable to retrieve "
|
|
|
|
"HEOS favorites: Use the 'heos.sign_in' service to sign-in to a HEOS account",
|
2019-07-31 19:25:30 +00:00
|
|
|
host,
|
|
|
|
)
|
2019-04-09 16:21:00 +00:00
|
|
|
inputs = await controller.get_input_sources()
|
2019-08-25 18:57:43 +00:00
|
|
|
except HeosError as error:
|
2019-03-30 04:10:00 +00:00
|
|
|
await controller.disconnect()
|
2019-08-25 18:57:43 +00:00
|
|
|
_LOGGER.debug("Unable to retrieve players and sources: %s", error)
|
2020-08-28 11:50:32 +00:00
|
|
|
raise ConfigEntryNotReady from error
|
2019-04-01 16:58:52 +00:00
|
|
|
|
2019-05-06 15:53:11 +00:00
|
|
|
controller_manager = ControllerManager(hass, controller)
|
|
|
|
await controller_manager.connect_listeners()
|
|
|
|
|
2019-04-01 16:58:52 +00:00
|
|
|
source_manager = SourceManager(favorites, inputs)
|
|
|
|
source_manager.connect_update(hass, controller)
|
2019-03-29 02:03:02 +00:00
|
|
|
|
2019-03-30 04:10:00 +00:00
|
|
|
hass.data[DOMAIN] = {
|
2019-05-06 15:53:11 +00:00
|
|
|
DATA_CONTROLLER_MANAGER: controller_manager,
|
2019-04-01 16:58:52 +00:00
|
|
|
DATA_SOURCE_MANAGER: source_manager,
|
2019-07-31 19:25:30 +00:00
|
|
|
MEDIA_PLAYER_DOMAIN: players,
|
2019-03-30 04:10:00 +00:00
|
|
|
}
|
2019-05-07 16:39:42 +00:00
|
|
|
|
|
|
|
services.register(hass, controller)
|
|
|
|
|
2021-04-27 14:09:59 +00:00
|
|
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
|
|
|
|
2019-03-30 04:10:00 +00:00
|
|
|
return True
|
2019-03-29 02:03:02 +00:00
|
|
|
|
|
|
|
|
2021-10-06 08:48:11 +00:00
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
2019-03-30 04:10:00 +00:00
|
|
|
"""Unload a config entry."""
|
2019-05-06 15:53:11 +00:00
|
|
|
controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER]
|
|
|
|
await controller_manager.disconnect()
|
2019-03-30 04:10:00 +00:00
|
|
|
hass.data.pop(DOMAIN)
|
2019-05-07 16:39:42 +00:00
|
|
|
|
|
|
|
services.remove(hass)
|
|
|
|
|
2021-04-27 14:09:59 +00:00
|
|
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
2019-04-01 16:58:52 +00:00
|
|
|
|
|
|
|
|
2019-05-06 15:53:11 +00:00
|
|
|
class ControllerManager:
|
|
|
|
"""Class that manages events of the controller."""
|
|
|
|
|
|
|
|
def __init__(self, hass, controller):
|
|
|
|
"""Init the controller manager."""
|
|
|
|
self._hass = hass
|
|
|
|
self._device_registry = None
|
|
|
|
self._entity_registry = None
|
|
|
|
self.controller = controller
|
|
|
|
self._signals = []
|
|
|
|
|
|
|
|
async def connect_listeners(self):
|
|
|
|
"""Subscribe to events of interest."""
|
|
|
|
self._device_registry, self._entity_registry = await asyncio.gather(
|
|
|
|
self._hass.helpers.device_registry.async_get_registry(),
|
2019-07-31 19:25:30 +00:00
|
|
|
self._hass.helpers.entity_registry.async_get_registry(),
|
|
|
|
)
|
2019-05-06 15:53:11 +00:00
|
|
|
# Handle controller events
|
2019-07-31 19:25:30 +00:00
|
|
|
self._signals.append(
|
|
|
|
self.controller.dispatcher.connect(
|
|
|
|
heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event
|
|
|
|
)
|
|
|
|
)
|
2019-05-06 15:53:11 +00:00
|
|
|
# Handle connection-related events
|
2019-07-31 19:25:30 +00:00
|
|
|
self._signals.append(
|
|
|
|
self.controller.dispatcher.connect(
|
|
|
|
heos_const.SIGNAL_HEOS_EVENT, self._heos_event
|
|
|
|
)
|
|
|
|
)
|
2019-05-06 15:53:11 +00:00
|
|
|
|
|
|
|
async def disconnect(self):
|
|
|
|
"""Disconnect subscriptions."""
|
|
|
|
for signal_remove in self._signals:
|
|
|
|
signal_remove()
|
|
|
|
self._signals.clear()
|
|
|
|
self.controller.dispatcher.disconnect_all()
|
|
|
|
await self.controller.disconnect()
|
|
|
|
|
|
|
|
async def _controller_event(self, event, data):
|
|
|
|
"""Handle controller event."""
|
2019-05-26 11:47:11 +00:00
|
|
|
if event == heos_const.EVENT_PLAYERS_CHANGED:
|
|
|
|
self.update_ids(data[heos_const.DATA_MAPPED_IDS])
|
2019-05-06 15:53:11 +00:00
|
|
|
# Update players
|
2019-07-31 19:25:30 +00:00
|
|
|
self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED)
|
2019-05-06 15:53:11 +00:00
|
|
|
|
|
|
|
async def _heos_event(self, event):
|
|
|
|
"""Handle connection event."""
|
2019-05-26 11:47:11 +00:00
|
|
|
if event == heos_const.EVENT_CONNECTED:
|
2019-05-06 15:53:11 +00:00
|
|
|
try:
|
|
|
|
# Retrieve latest players and refresh status
|
|
|
|
data = await self.controller.load_players()
|
2019-05-26 11:47:11 +00:00
|
|
|
self.update_ids(data[heos_const.DATA_MAPPED_IDS])
|
2019-08-25 18:57:43 +00:00
|
|
|
except HeosError as ex:
|
2019-05-06 15:53:11 +00:00
|
|
|
_LOGGER.error("Unable to refresh players: %s", ex)
|
|
|
|
# Update players
|
2019-07-31 19:25:30 +00:00
|
|
|
self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED)
|
2019-05-06 15:53:11 +00:00
|
|
|
|
2021-03-18 08:25:40 +00:00
|
|
|
def update_ids(self, mapped_ids: dict[int, int]):
|
2019-05-06 15:53:11 +00:00
|
|
|
"""Update the IDs in the device and entity registry."""
|
|
|
|
# mapped_ids contains the mapped IDs (new:old)
|
|
|
|
for new_id, old_id in mapped_ids.items():
|
|
|
|
# update device registry
|
2021-01-07 12:49:45 +00:00
|
|
|
entry = self._device_registry.async_get_device({(DOMAIN, old_id)})
|
2019-05-06 15:53:11 +00:00
|
|
|
new_identifiers = {(DOMAIN, new_id)}
|
|
|
|
if entry:
|
|
|
|
self._device_registry.async_update_device(
|
2019-07-31 19:25:30 +00:00
|
|
|
entry.id, new_identifiers=new_identifiers
|
|
|
|
)
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Updated device %s identifiers to %s", entry.id, new_identifiers
|
|
|
|
)
|
2019-05-06 15:53:11 +00:00
|
|
|
# update entity registry
|
|
|
|
entity_id = self._entity_registry.async_get_entity_id(
|
2019-07-31 19:25:30 +00:00
|
|
|
MEDIA_PLAYER_DOMAIN, DOMAIN, str(old_id)
|
|
|
|
)
|
2019-05-06 15:53:11 +00:00
|
|
|
if entity_id:
|
|
|
|
self._entity_registry.async_update_entity(
|
2019-07-31 19:25:30 +00:00
|
|
|
entity_id, new_unique_id=str(new_id)
|
|
|
|
)
|
|
|
|
_LOGGER.debug("Updated entity %s unique id to %s", entity_id, new_id)
|
2019-05-06 15:53:11 +00:00
|
|
|
|
|
|
|
|
2019-04-01 16:58:52 +00:00
|
|
|
class SourceManager:
|
|
|
|
"""Class that manages sources for players."""
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
favorites,
|
|
|
|
inputs,
|
|
|
|
*,
|
|
|
|
retry_delay: int = COMMAND_RETRY_DELAY,
|
|
|
|
max_retry_attempts: int = COMMAND_RETRY_ATTEMPTS,
|
|
|
|
):
|
2019-04-01 16:58:52 +00:00
|
|
|
"""Init input manager."""
|
|
|
|
self.retry_delay = retry_delay
|
|
|
|
self.max_retry_attempts = max_retry_attempts
|
|
|
|
self.favorites = favorites
|
|
|
|
self.inputs = inputs
|
|
|
|
self.source_list = self._build_source_list()
|
|
|
|
|
|
|
|
def _build_source_list(self):
|
|
|
|
"""Build a single list of inputs from various types."""
|
|
|
|
source_list = []
|
2019-07-31 19:25:30 +00:00
|
|
|
source_list.extend([favorite.name for favorite in self.favorites.values()])
|
2019-04-01 16:58:52 +00:00
|
|
|
source_list.extend([source.name for source in self.inputs])
|
|
|
|
return source_list
|
|
|
|
|
|
|
|
async def play_source(self, source: str, player):
|
|
|
|
"""Determine type of source and play it."""
|
2019-07-31 19:25:30 +00:00
|
|
|
index = next(
|
|
|
|
(
|
|
|
|
index
|
|
|
|
for index, favorite in self.favorites.items()
|
|
|
|
if favorite.name == source
|
|
|
|
),
|
|
|
|
None,
|
|
|
|
)
|
2019-04-01 16:58:52 +00:00
|
|
|
if index is not None:
|
|
|
|
await player.play_favorite(index)
|
|
|
|
return
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
input_source = next(
|
|
|
|
(
|
|
|
|
input_source
|
|
|
|
for input_source in self.inputs
|
|
|
|
if input_source.name == source
|
|
|
|
),
|
|
|
|
None,
|
|
|
|
)
|
2019-04-01 16:58:52 +00:00
|
|
|
if input_source is not None:
|
|
|
|
await player.play_input_source(input_source)
|
|
|
|
return
|
|
|
|
|
|
|
|
_LOGGER.error("Unknown source: %s", source)
|
|
|
|
|
|
|
|
def get_current_source(self, now_playing_media):
|
|
|
|
"""Determine current source from now playing media."""
|
|
|
|
# Match input by input_name:media_id
|
2019-05-26 11:47:11 +00:00
|
|
|
if now_playing_media.source_id == heos_const.MUSIC_SOURCE_AUX_INPUT:
|
2019-07-31 19:25:30 +00:00
|
|
|
return next(
|
|
|
|
(
|
|
|
|
input_source.name
|
|
|
|
for input_source in self.inputs
|
|
|
|
if input_source.input_name == now_playing_media.media_id
|
|
|
|
),
|
|
|
|
None,
|
|
|
|
)
|
2019-04-01 16:58:52 +00:00
|
|
|
# Try matching favorite by name:station or media_id:album_id
|
2019-07-31 19:25:30 +00:00
|
|
|
return next(
|
|
|
|
(
|
|
|
|
source.name
|
|
|
|
for source in self.favorites.values()
|
|
|
|
if source.name == now_playing_media.station
|
|
|
|
or source.media_id == now_playing_media.album_id
|
|
|
|
),
|
|
|
|
None,
|
|
|
|
)
|
2019-04-01 16:58:52 +00:00
|
|
|
|
|
|
|
def connect_update(self, hass, controller):
|
|
|
|
"""
|
|
|
|
Connect listener for when sources change and signal player update.
|
|
|
|
|
|
|
|
EVENT_SOURCES_CHANGED is often raised multiple times in response to a
|
|
|
|
physical event therefore throttle it. Retrieving sources immediately
|
|
|
|
after the event may fail so retry.
|
|
|
|
"""
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2019-04-01 16:58:52 +00:00
|
|
|
@Throttle(MIN_UPDATE_SOURCES)
|
|
|
|
async def get_sources():
|
|
|
|
retry_attempts = 0
|
|
|
|
while True:
|
|
|
|
try:
|
2019-04-09 16:21:00 +00:00
|
|
|
favorites = {}
|
|
|
|
if controller.is_signed_in:
|
|
|
|
favorites = await controller.get_favorites()
|
|
|
|
inputs = await controller.get_input_sources()
|
|
|
|
return favorites, inputs
|
2019-08-25 18:57:43 +00:00
|
|
|
except HeosError as error:
|
2019-04-01 16:58:52 +00:00
|
|
|
if retry_attempts < self.max_retry_attempts:
|
|
|
|
retry_attempts += 1
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.debug(
|
2019-08-29 19:23:42 +00:00
|
|
|
"Error retrieving sources and will retry: %s", error
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2019-04-01 16:58:52 +00:00
|
|
|
await asyncio.sleep(self.retry_delay)
|
|
|
|
else:
|
2019-08-25 18:57:43 +00:00
|
|
|
_LOGGER.error("Unable to update sources: %s", error)
|
2019-04-01 16:58:52 +00:00
|
|
|
return
|
|
|
|
|
2019-05-06 15:53:11 +00:00
|
|
|
async def update_sources(event, data=None):
|
2019-07-31 19:25:30 +00:00
|
|
|
if event in (
|
|
|
|
heos_const.EVENT_SOURCES_CHANGED,
|
|
|
|
heos_const.EVENT_USER_CHANGED,
|
|
|
|
heos_const.EVENT_CONNECTED,
|
|
|
|
):
|
2019-04-01 16:58:52 +00:00
|
|
|
sources = await get_sources()
|
|
|
|
# If throttled, it will return None
|
|
|
|
if sources:
|
|
|
|
self.favorites, self.inputs = sources
|
|
|
|
self.source_list = self._build_source_list()
|
|
|
|
_LOGGER.debug("Sources updated due to changed event")
|
|
|
|
# Let players know to update
|
2019-07-31 19:25:30 +00:00
|
|
|
hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED)
|
2019-04-01 16:58:52 +00:00
|
|
|
|
|
|
|
controller.dispatcher.connect(
|
2019-07-31 19:25:30 +00:00
|
|
|
heos_const.SIGNAL_CONTROLLER_EVENT, update_sources
|
|
|
|
)
|
|
|
|
controller.dispatcher.connect(heos_const.SIGNAL_HEOS_EVENT, update_sources)
|