From: Tom Matheussen <13683094+Tommatheussen@users.noreply.github.com> Date: Mon, 26 Jan 2026 06:51:09 +0000 (+0100) Subject: Add HEOS Player provider (#2986) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=b06b694c8b7ee9f6746f598dcedaf8949d29cb70;p=music-assistant-server.git Add HEOS Player provider (#2986) * 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 --- diff --git a/music_assistant/providers/heos/__init__.py b/music_assistant/providers/heos/__init__.py new file mode 100644 index 00000000..e01a72ee --- /dev/null +++ b/music_assistant/providers/heos/__init__.py @@ -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 index 00000000..09d8605e --- /dev/null +++ b/music_assistant/providers/heos/constants.py @@ -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 index 00000000..07d4a463 --- /dev/null +++ b/music_assistant/providers/heos/helpers.py @@ -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 index 00000000..0a072165 --- /dev/null +++ b/music_assistant/providers/heos/icon.svg @@ -0,0 +1 @@ + diff --git a/music_assistant/providers/heos/manifest.json b/music_assistant/providers/heos/manifest.json new file mode 100644 index 00000000..a4f306ca --- /dev/null +++ b/music_assistant/providers/heos/manifest.json @@ -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 index 00000000..8005ad51 --- /dev/null +++ b/music_assistant/providers/heos/player.py @@ -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 index 00000000..c6dc950a --- /dev/null +++ b/music_assistant/providers/heos/provider.py @@ -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()) diff --git a/requirements_all.txt b/requirements_all.txt index 33c0cbd2..f837d46e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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