Add auto discovery to HEOS (#3056)
authorTom Matheussen <13683094+Tommatheussen@users.noreply.github.com>
Mon, 2 Feb 2026 10:28:08 +0000 (11:28 +0100)
committerGitHub <noreply@github.com>
Mon, 2 Feb 2026 10:28:08 +0000 (11:28 +0100)
* 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

music_assistant/providers/heos/__init__.py
music_assistant/providers/heos/player.py
music_assistant/providers/heos/provider.py

index e01a72ee85cb5163bc3108bab986fc5b72b9ee23..94554b389280bb7d693a6c921d4baff07d183593 100644 (file)
@@ -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",
         ),
     )
index 783d792e6682abda07101ee3cf54d13cb2a415a0..27b08dda3d024121dd4c5fce0d642fddd930a241 100644 (file)
@@ -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
 
index 516169e5941589b9b8e47887c4f69ab3afcc5ab6..afe8c9babc682ff9c8cb7860331b708a12a43dd3 100644 (file)
@@ -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