From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:28:08 +0000 (+0100) Subject: Add auto discovery to HEOS (#3056) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=1a065223069ca21592cddc12ec7e193aead87145;p=music-assistant-server.git Add auto discovery to HEOS (#3056) * Adjust player registration and availability state * Discover controller via mDNS * Add some logging, fix possible None references * Review adjustments * Minor docstring update * Move manual config to advanced section --- diff --git a/music_assistant/providers/heos/__init__.py b/music_assistant/providers/heos/__init__.py index e01a72ee..94554b38 100644 --- a/music_assistant/providers/heos/__init__.py +++ b/music_assistant/providers/heos/__init__.py @@ -49,9 +49,10 @@ async def get_config_entries( key=CONF_IP_ADDRESS, type=ConfigEntryType.STRING, label="Main controller hostname or IP address.", - required=True, + required=False, description="Hostname or IP address of the HEOS device " "to be used as the main controller. It is recommended to use a " "wired device as the main controller.", + category="advanced", ), ) diff --git a/music_assistant/providers/heos/player.py b/music_assistant/providers/heos/player.py index 783d792e..27b08dda 100644 --- a/music_assistant/providers/heos/player.py +++ b/music_assistant/providers/heos/player.py @@ -56,30 +56,31 @@ class HeosPlayer(Player): # Keep internal reference so we don't need to check None on each call self._heos = self._device.heos + self._attr_type = PlayerType.PLAYER + self._attr_supported_features = PLAYER_FEATURES + self._attr_can_group_with = {self.provider.instance_id} + async def setup(self) -> None: """Set up the player.""" - self.set_static_attributes() + self.set_device_info() self.set_dynamic_attributes() await self.mass.players.register_or_update(self) - if self.enabled: - self._on_unload_callbacks.append( - self._device.add_on_player_event(self._player_event_received) - ) + self._on_unload_callbacks.append( + self._device.add_on_player_event(self._player_event_received) + ) - await self.build_group_list() - await self.build_source_list() + await self.build_group_list() + await self.build_source_list() - def set_static_attributes(self) -> None: - """Set all player static attributes.""" + def set_device_info(self) -> None: + """Set all device info attributes.""" # Extract manufacturer and model from device model string, if available model_parts = self._device.model.split(maxsplit=1) manufacturer = model_parts[0] if len(model_parts) == 2 else "HEOS" model = model_parts[1] if len(model_parts) == 2 else self._device.model - self._attr_type = PlayerType.PLAYER - self._attr_supported_features = PLAYER_FEATURES _device_info = DeviceInfo( model=model, software_version=self._device.version, @@ -87,7 +88,6 @@ class HeosPlayer(Player): ) _device_info.ip_address = self._device.ip_address self._attr_device_info = _device_info - self._attr_can_group_with = {self.provider.instance_id} self._attr_available = self._device.available self._attr_name = self._device.name diff --git a/music_assistant/providers/heos/provider.py b/music_assistant/providers/heos/provider.py index 516169e5..afe8c9ba 100644 --- a/music_assistant/providers/heos/provider.py +++ b/music_assistant/providers/heos/provider.py @@ -3,25 +3,32 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING, cast from music_assistant_models.errors import SetupFailedError from music_assistant_models.player import PlayerSource from pyheos import Heos, HeosError, HeosOptions, MediaItem, PlayerUpdateResult, const +from zeroconf import ServiceStateChange from music_assistant.constants import CONF_ENABLED, CONF_IP_ADDRESS, VERBOSE_LOG_LEVEL +from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf from music_assistant.models.player_provider import PlayerProvider from music_assistant.providers.heos.constants import HEOS_PASSIVE_SOURCES from .player import HeosPlayer +if TYPE_CHECKING: + from zeroconf.asyncio import AsyncServiceInfo + class HeosPlayerProvider(PlayerProvider): """Player provided for Denon HEOS.""" - _heos: Heos + _heos: Heos | None = None _music_source_list: list[PlayerSource] = [] _input_source_list: list[MediaItem] = [] - _discovery_running: bool = False + _player_discovery_running: bool = False + _controller_discovery_running: bool = False async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" @@ -30,26 +37,44 @@ class HeosPlayerProvider(PlayerProvider): else: logging.getLogger("pyheos").setLevel(self.logger.level + 10) - self._heos = Heos( - HeosOptions( - str(self.config.get_value(CONF_IP_ADDRESS)), - auto_reconnect=True, - ) - ) + if ip_address := self.config.get_value(CONF_IP_ADDRESS): + # Manual IP path + ip_address = cast("str", ip_address) + await self._setup_controller(ip_address) + + async def _setup_controller(self, controller_ip: str, connect_preferred: bool = False) -> None: + """Set up the HEOS controller.""" + self.logger.debug("Attempting HEOS controller setup on IP %s", controller_ip) + self._heos = Heos(HeosOptions(controller_ip, auto_reconnect=True, auto_failover=True)) try: await self._heos.connect() - self._heos.add_on_controller_event(self._handle_controller_event) + self.logger.debug("HEOS controller connected, checking preferred setup") + system_info = await self._heos.get_system_info() + preferred_ips: list[str] | None = [ + host.ip_address for host in system_info.preferred_hosts if host.ip_address + ] + + if preferred_ips and controller_ip not in preferred_ips: + if connect_preferred: + await self._heos.disconnect() + # Set up controller with preferred host instead + return await self._setup_controller(preferred_ips[0], connect_preferred=False) + + # Just log a warning, it still works but might be less reliable + self.logger.warning(f"Configured IP {controller_ip} is not a preferred HEOS host") except HeosError as e: self.logger.error(f"Failed to connect to HEOS controller: {e}") raise SetupFailedError("Failed to connect to HEOS controller") from e # Initialize library values try: - # Populate source lists + self._heos.add_on_controller_event(self._handle_controller_event) await self._populate_sources() - # NOTE: players are discovered via discovery method (called automatically by core) + + # Explicitly discover players now, in case we are set up from discovery + await self.discover_players() except HeosError as e: self.logger.error(f"Unexpected error setting up HEOS controller: {e}") raise SetupFailedError("Unexpected error setting up HEOS controller") from e @@ -68,23 +93,12 @@ class HeosPlayerProvider(PlayerProvider): if result is None: return - for removed_player_id in result.removed_player_ids: - await self.mass.players.unregister(str(removed_player_id)) - - for new_player_id in result.added_player_ids: - try: - device = await self._heos.get_player_info(new_player_id) - heos_player = HeosPlayer(self, device) - - await heos_player.setup() - except HeosError as e: - self.logger.error( - "Error adding new HEOS player with id %s: %s", new_player_id, e - ) - continue + await self.discover_players() async def _populate_sources(self) -> None: """Build source list based on data from controller.""" + if not self._heos: + return self._input_source_list = list(await self._heos.get_input_sources()) music_sources = await self._heos.get_music_sources() @@ -111,8 +125,9 @@ class HeosPlayerProvider(PlayerProvider): async def unload(self, is_removed: bool = False) -> None: """Handle unload/close of the provider.""" - self._heos.dispatcher.disconnect_all() # Remove all event connections - await self._heos.disconnect() + if self._heos: + self._heos.dispatcher.disconnect_all() # Remove all event connections + await self._heos.disconnect() for player in self.players: self.logger.debug("Unloading player %s", player.name) @@ -120,29 +135,63 @@ class HeosPlayerProvider(PlayerProvider): async def discover_players(self) -> None: """Discover players for this provider.""" - if self._discovery_running: - return # discovery already running + if self._player_discovery_running or not self._heos: + return # discovery already running or not set up + try: - self._discovery_running = True + self._player_discovery_running = True self.logger.debug("Discovering HEOS players") devices = await self._heos.get_players() - already_registered = {p.player_id for p in self.players} for device in devices.values(): player_id = str(device.player_id) - if player_id in already_registered: - continue # already registered - # ignore disabled players in discovery + if player := cast("HeosPlayer", self.mass.players.get(player_id)): + self.logger.debug( + "Updating existing HEOS player: %s (%s)", device.name, player_id + ) + # Update properties such as name or availability + player.set_device_info() + player.update_state() + continue + player_enabled = self.mass.config.get_raw_player_config_value( player_id, CONF_ENABLED, default=True ) if not player_enabled: + self.logger.debug("Skipping disabled player: %s (%s)", device.name, player_id) continue self.logger.info("Discovered new HEOS player: %s (%s)", device.name, player_id) heos_player = HeosPlayer(self, device) await heos_player.setup() finally: - self._discovery_running = False - # reschedule discovery - task_id = f"discover_players_{self.instance_id}" - self.mass.call_later(600, self.discover_players, task_id=task_id) + self._player_discovery_running = False + + async def on_mdns_service_state_change( + self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None + ) -> None: + """Discovery via mdns.""" + if state_change == ServiceStateChange.Removed: + return + + if not info: + return + + if self._heos or self._controller_discovery_running: + self.logger.debug("Ignoring mDNS configuration because we're already set up") + # We're already set up or in the process of setting up + return + + device_ip = get_primary_ip_address_from_zeroconf(info) + if not device_ip: + self.logger.debug("Ignoring incomplete mdns discovery for HEOS player: %s", name) + return + + self.logger.debug("Discovered HEOS device %s on %s", name, device_ip) + + self._controller_discovery_running = True + try: + await self._setup_controller(device_ip, True) + except SetupFailedError: + self.logger.error("Failed to set up HEOS controller at %s discovered via mDNS") + finally: + self._controller_discovery_running = False