Add HEOS Player provider (#2986)
authorTom Matheussen <13683094+Tommatheussen@users.noreply.github.com>
Mon, 26 Jan 2026 06:51:09 +0000 (07:51 +0100)
committerGitHub <noreply@github.com>
Mon, 26 Jan 2026 06:51:09 +0000 (07:51 +0100)
* Initial commit, ability to connect to a HEOS system and play URL

* Added more function handling

* Some code improvements, fix disconnection done too late

* Map music sources to inputs

* Make sure players are registered, even when they are marked as unavailable

* Build proper source list

* Keep internal heos reference in player

* Set dynamic attributes after player setup

* Implement grouping

* Grab group information when players are set up

* Handle group change events

* Build source list in provider

* Minor rearrangement

* Make sure player update event is cleaned up

* Some cleanup

* Remove some leftover logging

* Handle controller event in provider

* Split player updating into dedicated events

* Populate current_media when we're not playing

* Set sample rate configs

* Handle connection errors

* Temp troubleshooting commit

* Clean up some code and comments, adjusted some minor things

* Use fallbacks on mappings

* Disable player re-enabling for now

* Handle AUX inputs

* Handle player change event from HEOS

* address PR review comments

* Reduce safe max sample rate and bit depth for older devices

* Don't need to set credentials, unused right now

music_assistant/providers/heos/__init__.py [new file with mode: 0644]
music_assistant/providers/heos/constants.py [new file with mode: 0644]
music_assistant/providers/heos/helpers.py [new file with mode: 0644]
music_assistant/providers/heos/icon.svg [new file with mode: 0644]
music_assistant/providers/heos/manifest.json [new file with mode: 0644]
music_assistant/providers/heos/player.py [new file with mode: 0644]
music_assistant/providers/heos/provider.py [new file with mode: 0644]
requirements_all.txt

diff --git a/music_assistant/providers/heos/__init__.py b/music_assistant/providers/heos/__init__.py
new file mode 100644 (file)
index 0000000..e01a72e
--- /dev/null
@@ -0,0 +1,57 @@
+"""HEOS Player Provider for HEOS system to work with Music Assistant."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from music_assistant_models.config_entries import ConfigEntry
+from music_assistant_models.enums import ConfigEntryType, ProviderFeature
+
+from music_assistant.constants import CONF_IP_ADDRESS
+
+from .provider import HeosPlayerProvider
+
+if TYPE_CHECKING:
+    from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
+    from music_assistant_models.provider import ProviderManifest
+
+    from music_assistant.mass import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+SUPPORTED_FEATURES = {
+    ProviderFeature.SYNC_PLAYERS,
+}
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize HEOS instance with given configuration."""
+    return HeosPlayerProvider(mass, manifest, config, SUPPORTED_FEATURES)
+
+
+async def get_config_entries(
+    mass: MusicAssistant,
+    instance_id: str | None = None,
+    action: str | None = None,
+    values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    instance_id: id of an existing provider instance (None if new instance setup).
+    action: [optional] action key called from config entries UI.
+    values: the (intermediate) raw values for config entries sent with the action.
+    """
+    # ruff: noqa: ARG001
+    return (
+        ConfigEntry(
+            key=CONF_IP_ADDRESS,
+            type=ConfigEntryType.STRING,
+            label="Main controller hostname or IP address.",
+            required=True,
+            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.",
+        ),
+    )
diff --git a/music_assistant/providers/heos/constants.py b/music_assistant/providers/heos/constants.py
new file mode 100644 (file)
index 0000000..09d8605
--- /dev/null
@@ -0,0 +1,28 @@
+"""Constants for HEOS Player Provider."""
+
+from music_assistant_models.enums import MediaType, PlaybackState
+from pyheos import MediaType as HeosMediaType
+from pyheos import PlayState as HeosPlayState
+from pyheos import const
+
+HEOS_MEDIA_TYPE_TO_MEDIA_TYPE: dict[HeosMediaType | None, MediaType] = {
+    HeosMediaType.ALBUM: MediaType.ALBUM,
+    HeosMediaType.ARTIST: MediaType.ARTIST,
+    HeosMediaType.CONTAINER: MediaType.FOLDER,
+    HeosMediaType.GENRE: MediaType.GENRE,
+    HeosMediaType.HEOS_SERVER: MediaType.FOLDER,
+    HeosMediaType.HEOS_SERVICE: MediaType.FOLDER,
+    HeosMediaType.MUSIC_SERVICE: MediaType.FOLDER,
+    HeosMediaType.PLAYLIST: MediaType.PLAYLIST,
+    HeosMediaType.SONG: MediaType.TRACK,
+    HeosMediaType.STATION: MediaType.TRACK,
+}
+
+HEOS_PLAY_STATE_TO_PLAYBACK_STATE: dict[HeosPlayState | None, PlaybackState] = {
+    HeosPlayState.PLAY: PlaybackState.PLAYING,
+    HeosPlayState.PAUSE: PlaybackState.PAUSED,
+    HeosPlayState.STOP: PlaybackState.IDLE,
+    HeosPlayState.UNKNOWN: PlaybackState.UNKNOWN,
+}
+
+HEOS_PASSIVE_SOURCES = [const.MUSIC_SOURCE_AUX_INPUT]
diff --git a/music_assistant/providers/heos/helpers.py b/music_assistant/providers/heos/helpers.py
new file mode 100644 (file)
index 0000000..07d4a46
--- /dev/null
@@ -0,0 +1,32 @@
+"""Helpers for HEOS Player Provider."""
+
+from urllib.parse import urlencode
+
+from pyheos import HeosNowPlayingMedia
+from pyheos.util.mediauri import BASE_URI
+
+
+def media_uri_from_now_playing_media(now_playing_media: HeosNowPlayingMedia) -> str:
+    """Generate a media URI based on available data in now playing media."""
+    base_uri = f"{BASE_URI}/{now_playing_media.source_id}/{now_playing_media.type}"
+
+    params: dict[str, str] = {}
+
+    if now_playing_media.song:
+        params["song"] = now_playing_media.song
+    if now_playing_media.station:
+        params["station"] = now_playing_media.station
+    if now_playing_media.album:
+        params["album"] = now_playing_media.album
+    if now_playing_media.artist:
+        params["artist"] = now_playing_media.artist
+    if now_playing_media.image_url:
+        params["image_url"] = now_playing_media.image_url
+    if now_playing_media.album_id:
+        params["album_id"] = now_playing_media.album_id
+    if now_playing_media.media_id:
+        params["media_id"] = now_playing_media.media_id
+    if now_playing_media.queue_id:
+        params["queue_id"] = str(now_playing_media.queue_id)
+
+    return f"{base_uri}?{urlencode(params)}"
diff --git a/music_assistant/providers/heos/icon.svg b/music_assistant/providers/heos/icon.svg
new file mode 100644 (file)
index 0000000..0a07216
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" version="1.1"><path d="M 27.500 183.965 C 21.734 185.468, 18.982 186.975, 14.560 191.051 C 6.348 198.621, 6.500 197.395, 6.500 256 C 6.500 297.064, 6.778 308.286, 7.871 311.381 C 10.747 319.523, 21.062 327.504, 29.593 328.187 L 33.500 328.500 33.500 256.014 C 33.500 176.380, 33.991 182.273, 27.500 183.965 M 478 256.066 L 478 329.223 481.750 328.574 C 491.950 326.811, 501.658 318.699, 504.007 309.975 C 504.709 307.366, 504.972 288.366, 504.784 253.767 C 504.503 202.053, 504.477 201.456, 502.294 197.376 C 498.563 190.402, 490.753 185.153, 481.750 183.569 L 478 182.909 478 256.066 M 75.852 224.391 C 67.859 227.246, 60.813 235.766, 58.926 244.858 C 57.452 251.956, 58.216 267.291, 60.287 272.190 C 62.453 277.311, 69.713 284.942, 74.500 287.129 C 76.700 288.134, 80.188 288.966, 82.250 288.978 L 86 289 86 256 L 86 223 82.750 223.044 C 80.963 223.068, 77.858 223.674, 75.852 224.391 M 426 256 L 426 289 429.250 288.990 C 434.014 288.976, 442.023 284.876, 445.889 280.474 C 451.905 273.621, 452.953 269.995, 452.978 255.946 C 452.998 244.428, 452.787 242.965, 450.416 238.207 C 446.014 229.375, 436.884 223, 428.634 223 L 426 223 426 256" stroke="none" fill="#040404" fill-rule="evenodd"/><path d="M 244.500 1.039 C 225.111 5.014, 209.028 20.820, 204.498 40.353 C 203.161 46.116, 202.993 58.330, 203.228 132.250 L 203.500 217.500 206.490 222.899 C 209.917 229.087, 216.792 234.222, 224.050 236.013 C 227.064 236.757, 238.707 237.029, 258.550 236.818 L 288.500 236.500 293.590 233.814 C 299.267 230.819, 304.120 225.803, 306.752 220.213 C 308.373 216.769, 308.500 210.306, 308.500 131 C 308.500 50.876, 308.383 45.043, 306.638 38.231 C 302.325 21.400, 287.148 6.288, 270.081 1.833 C 263.158 0.026, 251.246 -0.344, 244.500 1.039 M 150.760 106.558 C 135.803 110.183, 121.974 121.961, 115.405 136.670 C 111.098 146.313, 110.886 152.644, 111.220 261.500 C 111.567 374.839, 111.183 367.804, 117.683 379.629 C 121.778 387.077, 133.571 398.223, 141.110 401.772 C 146.910 404.501, 157.292 407, 162.832 407 L 165 407 165 256 L 165 105 160.750 105.083 C 158.412 105.129, 153.917 105.793, 150.760 106.558 M 347 256 L 347 407 350.750 406.985 C 356.541 406.962, 366.789 404.236, 372.404 401.223 C 384.761 394.594, 396.596 378.968, 398.995 366.114 C 399.629 362.718, 399.995 321.884, 399.985 255.614 C 399.969 140.883, 400.094 143.551, 394.223 132.587 C 390.721 126.046, 378.954 114.279, 372.413 110.777 C 366.786 107.763, 356.537 105.038, 350.750 105.015 L 347 105 347 256 M 221.882 276.354 C 216.294 278.392, 211.210 281.858, 208.394 285.550 C 202.929 292.715, 202.999 291.476, 203.017 381.409 C 203.036 471.013, 203.107 472.372, 208.319 482.631 C 214.304 494.411, 227.733 506.061, 239.276 509.489 C 258.840 515.299, 279.029 510.254, 293 496.065 C 300.561 488.385, 304.347 481.873, 306.459 472.913 C 308.753 463.180, 308.752 298.383, 306.457 291.879 C 304.633 286.708, 300.217 281.847, 294 278.165 L 289.500 275.500 257.500 275.267 C 233.019 275.090, 224.650 275.345, 221.882 276.354" stroke="none" fill="#ca2d1a" fill-rule="evenodd"/></svg>
diff --git a/music_assistant/providers/heos/manifest.json b/music_assistant/providers/heos/manifest.json
new file mode 100644 (file)
index 0000000..a4f306c
--- /dev/null
@@ -0,0 +1,17 @@
+{
+  "type": "player",
+  "domain": "heos",
+  "stage": "beta",
+  "name": "HEOS",
+  "description": "Play to Denon & Marantz devices via HEOS.",
+  "codeowners": [
+    "@Tommatheussen"
+  ],
+  "requirements": [
+    "pyheos==1.0.6"
+  ],
+  "documentation": "https://music-assistant.io/player-support/heos/",
+  "mdns_discovery": [
+    "_heos-audio._tcp.local."
+  ]
+}
diff --git a/music_assistant/providers/heos/player.py b/music_assistant/providers/heos/player.py
new file mode 100644 (file)
index 0000000..8005ad5
--- /dev/null
@@ -0,0 +1,307 @@
+"""HEOS Player implementation."""
+
+from __future__ import annotations
+
+from copy import copy
+from typing import TYPE_CHECKING, cast
+
+from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
+from music_assistant_models.errors import SetupFailedError
+from music_assistant_models.player import DeviceInfo, PlayerSource
+from pyheos import Heos, const
+
+from music_assistant.constants import (
+    CONF_ENTRY_FLOW_MODE_ENFORCED,
+    create_sample_rates_config_entry,
+)
+from music_assistant.models.player import Player, PlayerMedia
+from music_assistant.providers.heos.helpers import media_uri_from_now_playing_media
+
+from .constants import (
+    HEOS_MEDIA_TYPE_TO_MEDIA_TYPE,
+    HEOS_PLAY_STATE_TO_PLAYBACK_STATE,
+)
+
+if TYPE_CHECKING:
+    from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
+    from pyheos import HeosPlayer as PyHeosPlayer
+
+    from .provider import HeosPlayerProvider
+
+
+PLAYER_FEATURES = {
+    PlayerFeature.VOLUME_SET,
+    PlayerFeature.VOLUME_MUTE,
+    PlayerFeature.PAUSE,
+    PlayerFeature.NEXT_PREVIOUS,
+    PlayerFeature.SELECT_SOURCE,
+    PlayerFeature.SET_MEMBERS,
+}
+
+
+class HeosPlayer(Player):
+    """HeosPlayer in Music Assistant."""
+
+    _heos: Heos
+    _device: PyHeosPlayer
+
+    def __init__(self, provider: HeosPlayerProvider, device: PyHeosPlayer) -> None:
+        """Initialize the Player."""
+        super().__init__(provider, str(device.player_id))
+
+        self._device: PyHeosPlayer = device
+
+        if self._device.heos is None:
+            raise SetupFailedError("HEOS device has no controller assigned")
+
+        # Keep internal reference so we don't need to check None on each call
+        self._heos = self._device.heos
+
+    async def setup(self) -> None:
+        """Set up the player."""
+        self.set_static_attributes()
+        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)
+            )
+
+            await self.build_group_list()
+            await self.build_source_list()
+
+    def set_static_attributes(self) -> None:
+        """Set all player static 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
+        self._attr_device_info = DeviceInfo(
+            model=model,
+            software_version=self._device.version,
+            ip_address=self._device.ip_address,
+            manufacturer=manufacturer,
+        )
+        self._attr_can_group_with = {self.provider.instance_id}
+        self._attr_available = self._device.available
+        self._attr_name = self._device.name
+
+    async def build_group_list(self) -> None:
+        """Build group list based on group info from controller."""
+        # Group IDs are the player ID of the leader
+        if self._device.group_id is not None and str(self._device.group_id) == self.player_id:
+            group_info = await self._heos.get_group_info(self._device.group_id)
+            self._attr_group_members = [
+                str(group_info.lead_player_id),
+                *(str(member) for member in group_info.member_player_ids),
+            ]
+        else:
+            self._attr_group_members.clear()
+
+        self.update_state()
+
+    async def build_source_list(self) -> None:
+        """Build source list based on music source list, combined with player specific inputs."""
+        prov = cast("HeosPlayerProvider", self.provider)
+        self._attr_source_list = prov.music_source_list[:]  # copy so we can modify
+
+        for input_source in prov.input_source_list:
+            # Only add input sources that belong to this player
+            if str(input_source.source_id) != self.player_id or input_source.media_id is None:
+                continue
+
+            self._attr_source_list.append(
+                PlayerSource(
+                    id=input_source.media_id,
+                    name=input_source.name,
+                    can_play_pause=True,
+                )
+            )
+
+        self.update_state()
+
+    async def _player_event_received(self, event: str) -> None:
+        """Handle player device events."""
+        self.logger.debug("[%s] Event received: %s", self._device.name, event)
+
+        match event:
+            case const.EVENT_PLAYER_STATE_CHANGED:
+                self._update_player_state()
+
+            case const.EVENT_PLAYER_NOW_PLAYING_CHANGED:
+                self._update_player_current_media()
+                self._update_player_playing_progress()
+
+            case const.EVENT_PLAYER_NOW_PLAYING_PROGRESS:
+                self._update_player_playing_progress()
+
+            case const.EVENT_PLAYER_VOLUME_CHANGED:
+                self._update_player_volume()
+
+            case _:
+                # Update everything on other events
+                self.set_dynamic_attributes()
+
+        self.update_state()
+
+    def _update_player_volume(self) -> None:
+        """Update volume properties."""
+        self._attr_volume_level = self._device.volume
+        self._attr_volume_muted = self._device.is_muted
+
+    def _update_player_state(self) -> None:
+        """Update playback state."""
+        self._attr_playback_state = HEOS_PLAY_STATE_TO_PLAYBACK_STATE.get(
+            self._device.state, PlaybackState.UNKNOWN
+        )
+
+    def _update_player_current_media(self) -> None:
+        """Update current media properties."""
+        now_playing = self._device.now_playing_media
+
+        # Only update if we're not playing from our queue
+        # HEOS does not make a distinction on source ID when playing from a DLNA server, USB stick,
+        # generic URL (like MA), or other local source.
+        # We can only know we're playing from MA if we started this session.
+        if (now_playing.source_id != const.MUSIC_SOURCE_LOCAL_MUSIC) or (
+            self._attr_active_source != self.player_id
+        ):
+            self.logger.debug(
+                "[%s] Now playing changed externally: %s", self._device.name, now_playing
+            )
+
+            if now_playing.source_id == const.MUSIC_SOURCE_AUX_INPUT:
+                self._attr_active_source = str(now_playing.media_id)
+            else:
+                self._attr_active_source = str(now_playing.source_id)
+
+            self._attr_current_media = PlayerMedia(
+                uri=now_playing.media_id or media_uri_from_now_playing_media(now_playing),
+                media_type=HEOS_MEDIA_TYPE_TO_MEDIA_TYPE.get(
+                    now_playing.type,
+                    MediaType.UNKNOWN,
+                ),
+                title=now_playing.song,
+                artist=now_playing.artist,
+                album=now_playing.album,
+                image_url=now_playing.image_url,
+                duration=now_playing.duration,
+                source_id=str(now_playing.source_id),
+                elapsed_time=now_playing.current_position,
+                elapsed_time_last_updated=(
+                    now_playing.current_position_updated.timestamp()
+                    if now_playing.current_position_updated
+                    else None
+                ),
+                # TODO: We can use custom_data to set the IDs
+            )
+
+    def _update_player_playing_progress(self) -> None:
+        """Update current media progress properties."""
+        now_playing = self._device.now_playing_media
+
+        self._attr_elapsed_time = (
+            now_playing.current_position / 1000 if now_playing.current_position else None
+        )
+        self._attr_elapsed_time_last_updated = (
+            now_playing.current_position_updated.timestamp()
+            if now_playing.current_position_updated
+            else None
+        )
+
+    def set_dynamic_attributes(self) -> None:
+        """Update all player dynamic attributes."""
+        self._update_player_volume()
+        self._update_player_state()
+        self._update_player_current_media()
+        self._update_player_playing_progress()
+
+    async def volume_set(self, volume_level: int) -> None:
+        """Handle VOLUME_SET command on the player."""
+        await self._device.set_volume(volume_level)
+
+    async def volume_mute(self, muted: bool) -> None:
+        """Handle VOLUME MUTE command on the player."""
+        if muted:
+            await self._device.mute()
+        else:
+            await self._device.unmute()
+
+    async def play(self) -> None:
+        """Handle PLAY command on the player."""
+        await self._device.play()
+
+    async def stop(self) -> None:
+        """Handle STOP command on the player."""
+        await self._device.stop()
+
+    async def pause(self) -> None:
+        """Handle PAUSE command on the player."""
+        await self._device.pause()
+
+    async def next_track(self) -> None:
+        """Handle NEXT_TRACK command on the player."""
+        await self._device.play_next()
+
+    async def previous_track(self) -> None:
+        """Handle PREVIOUS_TRACK command on the player."""
+        await self._device.play_previous()
+
+    async def play_media(self, media: PlayerMedia) -> None:
+        """Handle PLAY MEDIA command on given player."""
+        await self._device.play_url(media.uri)
+
+        self._attr_current_media = media
+        self._attr_active_source = self.player_id
+
+        self.update_state()
+
+    async def set_members(
+        self,
+        player_ids_to_add: list[str] | None = None,
+        player_ids_to_remove: list[str] | None = None,
+    ) -> None:
+        """Handle SET MEMBERS command on player."""
+        if player_ids_to_add is None and player_ids_to_remove is None:
+            return
+
+        members: list[str] = copy(self._attr_group_members)
+
+        #  Make sure we are always in the group
+        if self.player_id not in members:
+            members = [self.player_id, *members]
+
+        for added_player_id in player_ids_to_add or []:
+            members.append(added_player_id)
+
+        for removed_player_id in player_ids_to_remove or []:
+            members.remove(removed_player_id)
+
+        if len(members) <= 1:
+            await self._heos.remove_group(self._device.player_id)
+        else:
+            await self._heos.set_group([int(player) for player in members])
+        # group_members will be updated when group_changed event is handled
+
+    async def get_config_entries(
+        self,
+        action: str | None = None,
+        values: dict[str, ConfigValueType] | None = None,
+    ) -> list[ConfigEntry]:
+        """Return all (provider/player specific) Config Entries for the player."""
+        return [
+            *await super().get_config_entries(action=action, values=values),
+            # Gen 1 devices, like HEOS Link, only support up to 48kHz/16bit
+            create_sample_rates_config_entry(
+                max_sample_rate=192000,
+                safe_max_sample_rate=48000,
+                max_bit_depth=24,
+                safe_max_bit_depth=16,
+            ),
+            CONF_ENTRY_FLOW_MODE_ENFORCED,
+        ]
diff --git a/music_assistant/providers/heos/provider.py b/music_assistant/providers/heos/provider.py
new file mode 100644 (file)
index 0000000..c6dc950
--- /dev/null
@@ -0,0 +1,142 @@
+"""HEOS Player Provider implementation."""
+
+from __future__ import annotations
+
+import logging
+
+from music_assistant_models.errors import SetupFailedError
+from music_assistant_models.player import PlayerSource
+from pyheos import Heos, HeosError, HeosOptions, MediaItem, PlayerUpdateResult, const
+
+from music_assistant.constants import (
+    CONF_IP_ADDRESS,
+    VERBOSE_LOG_LEVEL,
+)
+from music_assistant.models.player_provider import PlayerProvider
+from music_assistant.providers.heos.constants import HEOS_PASSIVE_SOURCES
+
+from .player import HeosPlayer
+
+
+class HeosPlayerProvider(PlayerProvider):
+    """Player provided for Denon HEOS."""
+
+    _heos: Heos
+    _music_source_list: list[PlayerSource] = []
+    _input_source_list: list[MediaItem] = []
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
+            logging.getLogger("pyheos").setLevel(logging.DEBUG)
+        else:
+            logging.getLogger("pyheos").setLevel(self.logger.level + 10)
+
+        self._heos = Heos(
+            HeosOptions(
+                str(self.config.get_value(CONF_IP_ADDRESS)),
+                auto_reconnect=True,
+            )
+        )
+
+        try:
+            await self._heos.connect()
+
+            self._heos.add_on_controller_event(self._handle_controller_event)
+        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
+            await self._populate_sources()
+
+            # Build player configs
+            devices = await self._heos.get_players()
+            for device in devices.values():
+                heos_player = HeosPlayer(self, device)
+
+                await heos_player.setup()
+        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
+
+    async def _handle_controller_event(
+        self, event: str, result: PlayerUpdateResult | None = None
+    ) -> None:
+        self.logger.debug("Controller event received: %s", event)
+
+        if event == const.EVENT_GROUPS_CHANGED:
+            for player in self.mass.players.all(provider_filter=self.instance_id):
+                assert isinstance(player, HeosPlayer)  # for type checking
+                await player.build_group_list()
+
+        if event == const.EVENT_PLAYERS_CHANGED:
+            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
+
+    async def _populate_sources(self) -> None:
+        """Build source list based on data from controller."""
+        self._input_source_list = list(await self._heos.get_input_sources())
+
+        music_sources = await self._heos.get_music_sources()
+        for source_id, source in music_sources.items():
+            self._music_source_list.append(
+                PlayerSource(
+                    id=str(source_id),
+                    name=source.name,
+                    passive=source_id in HEOS_PASSIVE_SOURCES or not source.available,
+                    can_play_pause=True,  # All sources support play/pause
+                    can_next_previous=source_id == 1024,  # TODO: properly check
+                )
+            )
+
+    @property
+    def music_source_list(self) -> list[PlayerSource]:
+        """Get mapped music source list from controller info."""
+        return self._music_source_list
+
+    @property
+    def input_source_list(self) -> list[MediaItem]:
+        """Get input list from controller info. This represents all inputs across all players."""
+        return self._input_source_list
+
+    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()
+
+        for player in self.players:
+            self.logger.debug("Unloading player %s", player.name)
+            await self.mass.players.unregister(player.player_id)
+
+    def on_player_disabled(self, player_id: str) -> None:
+        """Unregister player when it is disabled, cleans up connections."""
+        # Clean up event handling connection
+        self.mass.create_task(self.mass.players.unregister(player_id))
+
+    # TODO: Re-enable when MA lifecycles get updated.
+    # Currently a race-condition prevents `register_or_update` to finish because Enabled is still false  # noqa: E501
+    # def on_player_enabled(self, player_id: str) -> None:
+    #     """Reregister player when it is enabled."""
+    #     self.logger.debug("Attempting player re-enabling")
+    #     if device := self._device_map.get(player_id):
+    #         # Reinstantiate the player
+    #         heos_player = HeosPlayer(self, device)
+    #         self.mass.create_task(heos_player.setup())
index 33c0cbd28c5dff573be40bcac5bd6ed80d9ed3a1..f837d46e7d27c282b5d9e606e4e4bc84d6ca1553 100644 (file)
@@ -56,6 +56,7 @@ pyblu==2.0.5
 pycares==4.11.0
 PyChromecast==14.0.9
 pycryptodome==3.23.0
+pyheos==1.0.6
 pylast==6.0.0
 python-fullykiosk==0.0.14
 python-slugify==8.0.4