Skip to content

Instantly share code, notes, and snippets.

@btoconnor
Created March 27, 2025 13:04
Show Gist options
  • Save btoconnor/e531762ce4033bb0b834ace35a069f09 to your computer and use it in GitHub Desktop.
Save btoconnor/e531762ce4033bb0b834ace35a069f09 to your computer and use it in GitHub Desktop.
diff --git a/music_assistant/providers/heos/__init__.py b/music_assistant/providers/heos/__init__.py
new file mode 100644
index 00000000..d6e3cb42
--- /dev/null
+++ b/music_assistant/providers/heos/__init__.py
@@ -0,0 +1,510 @@
+"""
+DEMO/TEMPLATE Player Provider for Music Assistant.
+
+This is an empty player provider with no actual implementation.
+Its meant to get started developing a new player provider for Music Assistant.
+
+Use it as a reference to discover what methods exists and what they should return.
+Also it is good to look at existing player providers to get a better understanding,
+due to the fact that providers may be flexible and support different features and/or
+ways to discover players on the network.
+
+In general, the actual device communication should reside in a separate library.
+You can then reference your library in the manifest in the requirements section,
+which is a list of (versioned!) python modules (pip syntax) that should be installed
+when the provider is selected by the user.
+
+To add a new player provider to Music Assistant, you need to create a new folder
+in the providers folder with the name of your provider (e.g. 'my_player_provider').
+In that folder you should create (at least) a __init__.py file and a manifest.json file.
+
+Optional is an icon.svg file that will be used as the icon for the provider in the UI,
+but we also support that you specify a material design icon in the manifest.json file.
+
+IMPORTANT NOTE:
+We strongly recommend developing on either MacOS or Linux and start your development
+environment by running the setup.sh scripts in the scripts folder of the repository.
+This will create a virtual environment and install all dependencies needed for development.
+See also our general DEVELOPMENT.md guide in the repository for more information.
+
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from typing import TYPE_CHECKING
+
+import pyheos
+
+from music_assistant_models.enums import PlayerFeature, PlayerType, ProviderFeature
+from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
+from zeroconf import ServiceStateChange
+
+from music_assistant.constants import (
+ CONF_ENTRY_CROSSFADE,
+ CONF_ENTRY_ENFORCE_MP3,
+ CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
+ MASS_LOGO_ONLINE,
+ VERBOSE_LOG_LEVEL,
+ create_sample_rates_config_entry,
+)
+
+from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf
+from music_assistant.models.player_provider import PlayerProvider
+
+if TYPE_CHECKING:
+ from music_assistant_models.config_entries import (
+ ConfigEntry,
+ ConfigValueType,
+ PlayerConfig,
+ ProviderConfig,
+ )
+ from music_assistant_models.provider import ProviderManifest
+ from zeroconf.asyncio import AsyncServiceInfo
+
+ from music_assistant.mass import MusicAssistant
+ from music_assistant.models import ProviderInstanceType
+
+
+async def setup(
+ mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+ """Initialize provider(instance) with given configuration."""
+ # setup is called when the user wants to setup a new provider instance.
+ # you are free to do any preflight checks here and but you must return
+ # an instance of the provider.
+ return HEOSPlayerProvider(mass, manifest, config)
+
+
+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
+ # Config Entries are used to configure the Player Provider if needed.
+ # See the models of ConfigEntry and ConfigValueType for more information what is supported.
+ # The ConfigEntry is a dataclass that represents a single configuration entry.
+ # The ConfigValueType is an Enum that represents the type of value that
+ # can be stored in a ConfigEntry.
+ # If your provider does not need any configuration, you can return an empty tuple.
+ return ()
+
+class HEOSPlayer:
+ def __init__(
+ self,
+ prov: HEOSPlayerProvider,
+ player_id: str,
+ heos_data: pyheos.HeosPlayer
+ ):
+ self.heos_prov = prov
+ self.mass = prov.mass
+ self.player_id = player_id
+ self.logger = prov.logger.getChild(str(player_id))
+ self.mass_player: Player | None = None
+ self.heos_data = heos_data
+
+ async def setup(self):
+ self.logger.info("Setting up HEOS player %s", self.player_id)
+
+ self.mass_player = mass_player = Player(
+ player_id=self.player_id,
+ provider=self.heos_prov.lookup_key,
+ type=PlayerType.PLAYER,
+ name=self.heos_data.name,
+ available=True,
+ device_info=DeviceInfo(
+ model=self.heos_data.model,
+ manufacturer="Denon", # TODO - not necessarily true?
+ ip_address=self.heos_data.ip_address,
+ software_version=self.heos_data.version,
+ ),
+ supported_features={
+ PlayerFeature.SET_MEMBERS,
+ PlayerFeature.PAUSE,
+ PlayerFeature.ENQUEUE,
+ PlayerFeature.NEXT_PREVIOUS,
+ PlayerFeature.SEEK,
+ PlayerFeature.SELECT_SOURCE,
+ }
+ )
+ await self.mass.players.register_or_update(mass_player)
+
+class HEOSPlayerProvider(PlayerProvider):
+ """
+ Example/demo Player provider.
+
+ Note that this is always subclassed from PlayerProvider,
+ which in turn is a subclass of the generic Provider model.
+
+ The base implementation already takes care of some convenience methods,
+ such as the mass object and the logger. Take a look at the base class
+ for more information on what is available.
+
+ Just like with any other subclass, make sure that if you override
+ any of the default methods (such as __init__), you call the super() method.
+ In most cases its not needed to override any of the builtin methods and you only
+ implement the abc methods with your actual implementation.
+ """
+
+ heos_players: dict[str, HEOSPlayer]
+ heos_conn: pyheos.Heos
+
+ @property
+ def supported_features(self) -> set[ProviderFeature]:
+ """Return the features supported by this Provider."""
+ # MANDATORY
+ # you should return a set of provider-level features
+ # here that your player provider supports or an empty set if none.
+ # for example 'ProviderFeature.SYNC_PLAYERS' if you can sync players.
+ return {ProviderFeature.SYNC_PLAYERS}
+
+ async def handle_async_init(self) -> None:
+ self.logger.info("HEOS Player Provider initialized")
+ self.heos_players: dict[str, HEOSPlayer] = {}
+
+ async def loaded_in_mass(self) -> None:
+ """Call after the provider has been loaded."""
+ # OPTIONAL
+ # this is an optional method that you can implement if
+ # relevant or leave out completely if not needed.
+ # it will be called after the provider has been fully loaded into Music Assistant.
+ # you can use this for instance to trigger custom (non-mdns) discovery of players
+ # or any other logic that needs to run after the provider is fully loaded.
+ await super().loaded_in_mass()
+ self.logger.info("HEOS Player Provider loaded in Music Assistant")
+
+ self.logger.info("Getting players from HEOS")
+ self.heos_conn = await pyheos.Heos.create_and_connect("192.168.30.136")
+
+ players = await self.heos_conn.get_players()
+
+ self.logger.info("Found %s players", len(players))
+
+ for (id, player) in players.items():
+ self.logger.info("Found player {}, {}".format(id, player.name))
+ self.heos_players[str(id)] = heos_player = HEOSPlayer(
+ self,
+ str(id),
+ player
+ )
+
+ await heos_player.setup()
+
+
+
+ async def unload(self, is_removed: bool = False) -> None:
+ """
+ Handle unload/close of the provider.
+
+ Called when provider is deregistered (e.g. MA exiting or config reloading).
+ is_removed will be set to True when the provider is removed from the configuration.
+ """
+ # OPTIONAL
+ # this is an optional method that you can implement if
+ # relevant or leave out completely if not needed.
+ # it will be called when the provider is unloaded from Music Assistant.
+ # this means also when the provider is getting reloaded
+ self.logger.info("HEOS Player Provider unloaded from Music Assistant")
+ self.heos_players = None
+
+ async def on_mdns_service_state_change(
+ self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
+ ) -> None:
+ """Handle MDNS service state callback."""
+ # MANDATORY IF YOU WANT TO USE MDNS DISCOVERY
+ # OPTIONAL if you dont use mdns for discovery of players
+ # If you specify a mdns service type in the manifest.json, this method will be called
+ # automatically on mdns changes for the specified service type.
+
+ # If no mdns service type is specified, this method is omitted and you
+ # can completely remove it from your provider implementation.
+
+ if not info:
+ return
+
+ # NOTE: If you do not use mdns for discovery of players on the network,
+ # you must implement your own discovery mechanism and logic to add new players
+ # and update them on state changes when needed.
+ # Below is a bit of example implementation but we advise to look at existing
+ # player providers for more inspiration.
+ name = name.split("@", 1)[1] if "@" in name else name
+ player_id = info.decoded_properties["uuid"] # this is just an example!
+
+ if not player_id:
+ return
+
+ # handle removed player
+ if state_change == ServiceStateChange.Removed:
+ # check if the player manager has an existing entry for this player
+ if mass_player := self.mass.players.get(player_id):
+ # the player has become unavailable
+ self.logger.debug("Player offline: %s", mass_player.display_name)
+ mass_player.available = False
+ self.mass.players.update(player_id)
+ return
+ # handle update for existing device
+ # (state change is either updated or added)
+ # check if we have an existing player in the player manager
+ # note that you can use this point to update the player connection info
+ # if that changed (e.g. ip address)
+ if mass_player := self.mass.players.get(player_id):
+ # existing player found in the player manager,
+ # this is an existing player that has been updated/reconnected
+ # or simply a re-announcement on mdns.
+ cur_address = get_primary_ip_address_from_zeroconf(info)
+ if cur_address and cur_address != mass_player.device_info.ip_address:
+ self.logger.debug(
+ "Address updated to %s for player %s", cur_address, mass_player.display_name
+ )
+ mass_player.device_info = DeviceInfo(
+ model=mass_player.device_info.model,
+ manufacturer=mass_player.device_info.manufacturer,
+ ip_address=str(cur_address),
+ )
+ if not mass_player.available:
+ # if the player was marked offline and you now receive an mdns update
+ # it means the player is back online and we should try to connect to it
+ self.logger.debug("Player back online: %s", mass_player.display_name)
+ # you can try to connect to the player here if needed
+ mass_player.available = True
+ # inform the player manager of any changes to the player object
+ # note that you would normally call this from some other callback from
+ # the player's native api/library which informs you of changes in the player state.
+ # as a last resort you can also choose to let the player manager
+ # poll the player for state changes
+ self.mass.players.update(player_id)
+ return
+ # handle new player
+ self.logger.debug("Discovered device %s on %s", name, cur_address)
+ # your own connection logic will probably be implemented here where
+ # you connect to the player etc. using your device/provider specific library.
+
+ # Instantiate the MA Player object and register it with the player manager
+ mass_player = Player(
+ player_id=player_id,
+ provider=self.lookup_key,
+ type=PlayerType.PLAYER,
+ name=name,
+ available=True,
+ powered=False,
+ device_info=DeviceInfo(
+ model="Model XYX",
+ manufacturer="Super Brand",
+ ip_address=cur_address,
+ ),
+ # set the supported features for this player only with
+ # the ones the player actually supports
+ supported_features={
+ PlayerFeature.POWER, # if the player can be turned on/off
+ PlayerFeature.VOLUME_SET,
+ PlayerFeature.VOLUME_MUTE,
+ PlayerFeature.PLAY_ANNOUNCEMENT, # see play_announcement method
+ },
+ )
+ # register the player with the player manager
+ await self.mass.players.register(mass_player)
+
+ # once the player is registered, you can either instruct the player manager to
+ # poll the player for state changes or you can implement your own logic to
+ # listen for state changes from the player and update the player object accordingly.
+ # in any case, you need to call the update method on the player manager:
+ self.mass.players.update(player_id)
+
+ async def get_player_config_entries(self, player_id: str) -> tuple[ConfigEntry, ...]:
+ """Return all (provider/player specific) Config Entries for the given player (if any)."""
+ # OPTIONAL
+ # this method is optional and should be implemented if you need player specific
+ # configuration entries. If you do not need player specific configuration entries,
+ # you can leave this method out completely to accept the default implementation.
+ # Please note that you need to call the super() method to get the default entries.
+ self.logger.info("Getting player config entries for player %s", player_id)
+ base_entries = (
+ *await super().get_player_config_entries(player_id),
+ CONF_ENTRY_CROSSFADE,
+ #CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
+ #CONF_ENTRY_ENFORCE_MP3,
+ create_sample_rates_config_entry(48000, 24, 48000, 24, True),
+ )
+ return base_entries
+
+
+ async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[str]) -> None:
+ """Call (by config manager) when the configuration of a player changes."""
+ # OPTIONAL
+ # this will be called whenever a player config changes
+ # you can use this to react to changes in player configuration
+ # but this is completely optional and you can leave it out if not needed.
+
+ async def cmd_stop(self, player_id: str) -> None:
+ """Send STOP command to given player."""
+ # MANDATORY
+ # this method is mandatory and should be implemented.
+ # this method should send a stop command to the given player.
+ self.logger.info("Stopping player %s", player_id)
+
+ async def cmd_play(self, player_id: str) -> None:
+ """Send PLAY command to given player."""
+ # MANDATORY
+ # this method is mandatory and should be implemented.
+ # this method should send a play command to the given player.
+ self.logger.info("Playing player %s", player_id)
+
+ async def cmd_pause(self, player_id: str) -> None:
+ """Send PAUSE command to given player."""
+ # OPTIONAL - required only if you specified PlayerFeature.PAUSE
+ # this method should send a pause command to the given player.
+ self.logger.info("Pausing player %s", player_id)
+
+ async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+ """Send VOLUME_SET command to given player."""
+ # OPTIONAL - required only if you specified PlayerFeature.VOLUME_SET
+ # this method should send a volume set command to the given player.
+ self.logger.info("Setting volume to %s for player %s", volume_level, player_id)
+
+ player = self.heos_players[player_id]
+ await player.heos_data.set_volume(volume_level)
+
+ async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+ """Send VOLUME MUTE command to given player."""
+ # OPTIONAL - required only if you specified PlayerFeature.VOLUME_MUTE
+ # this method should send a volume mute command to the given player.
+ self.logger.info("Muting player %s: %s", player_id, muted)
+
+ player = self.heos_players[player_id]
+
+ await player.heos_data.set_mute(muted)
+
+
+ async def cmd_seek(self, player_id: str, position: int) -> None:
+ """Handle SEEK command for given queue.
+
+ - player_id: player_id of the player to handle the command.
+ - position: position in seconds to seek to in the current playing item.
+ """
+ # OPTIONAL - required only if you specified PlayerFeature.SEEK
+ # this method should handle the seek command for the given player.
+ # the position is the position in seconds to seek to in the current playing item.
+ self.logger.info("Seeking to %s for player %s", position, player_id)
+
+ async def play_media(
+ self,
+ player_id: str,
+ media: PlayerMedia,
+ ) -> None:
+ """Handle PLAY MEDIA on given player.
+
+ This is called by the Players controller to start playing a mediaitem on the given player.
+ The provider's own implementation should work out how to handle this request.
+
+ - player_id: player_id of the player to handle the command.
+ - media: Details of the item that needs to be played on the player.
+ """
+ # MANDATORY
+ # this method is mandatory and should be implemented.
+ # this method should handle the play_media command for the given player.
+ # It will be called when media needs to be played on the player.
+ # The media object contains all the details needed to play the item.
+
+ # In 99% of the cases this will be called by the Queue controller to play
+ # a single item from the queue on the player and the uri within the media
+ # object will then contain the URL to play that single queue item.
+
+ # If your player provider does not support enqueuing of items,
+ # the queue controller will simply call this play_media method for
+ # each item in the queue to play them one by one.
+
+ # In order to support true gapless and/or crossfade, we offer the option of
+ # 'flow_mode' playback. In that case the queue controller will stitch together
+ # all songs in the playback queue into a single stream and send that to the player.
+ # In that case the URI (and metadata) received here is that of the 'flow mode' stream.
+
+ # Examples of player providers that use flow mode for playback by default are Airplay,
+ # SnapCast and Fully Kiosk.
+
+ # Examples of player providers that optionally use 'flow mode' are Google Cast and
+ # Home Assistant. They provide a config entry to enable flow mode playback.
+
+ # Examples of player providers that natively support enqueuing of items are Sonos,
+ # Slimproto and Google Cast.
+ self.logger.info("Playing media %s on player %s", media.uri, player_id)
+ #heos_player = self.heos_players[player_id]
+
+ players = await self.heos_conn.get_players()
+
+ await players[int(player_id)].play_url(media.uri)
+
+ async def cmd_next(self, player_id: str) -> None:
+ self.logger.info("Next track for player %s", player_id)
+
+ async def cmd_previous(self, player_id):
+ self.logger.info("Previous track for player %s", player_id)
+
+ async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
+ """
+ Handle enqueuing of the next (queue) item on the player.
+
+ Called when player reports it started buffering a queue item
+ and when the queue items updated.
+
+ A PlayerProvider implementation is in itself responsible for handling this
+ so that the queue items keep playing until its empty or the player stopped.
+
+ This will NOT be called if the end of the queue is reached (and repeat disabled).
+ This will NOT be called if the player is using flow mode to playback the queue.
+ """
+ # this method should handle the enqueuing of the next queue item on the player.
+
+ async def cmd_group(self, player_id: str, target_player: str) -> None:
+ """Handle GROUP command for given player.
+
+ Join/add the given player(id) to the given (master) player/sync group.
+
+ - player_id: player_id of the player to handle the command.
+ - target_player: player_id of the syncgroup master or group player.
+ """
+ # OPTIONAL - required only if you specified ProviderFeature.SYNC_PLAYERS
+ # this method should handle the sync command for the given player.
+ # you should join the given player to the target_player/syncgroup.
+
+ async def cmd_ungroup(self, player_id: str) -> None:
+ """Handle UNGROUP command for given player.
+
+ Remove the given player from any (sync)groups it currently is grouped to.
+
+ - player_id: player_id of the player to handle the command.
+ """
+ # OPTIONAL - required only if you specified ProviderFeature.SYNC_PLAYERS
+ # this method should handle the ungroup command for the given player.
+ # you should unjoin the given player from the target_player/syncgroup.
+
+ async def play_announcement(
+ self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None
+ ) -> None:
+ """Handle (provider native) playback of an announcement on given player."""
+ # OPTIONAL - required only if you specified PlayerFeature.PLAY_ANNOUNCEMENT
+ # This method should handle the playback of an announcement on the given player.
+ # The announcement object contains all the details needed to play the announcement.
+ # The volume_level is optional and can be used to set the volume level for the announcement.
+ # If you do not use the announcement playerfeature, the default behavior is to play the
+ # announcement as a regular media item using the play_media method and the MA player manager
+ # will take care of setting the volume level for the announcement and resuming etc.
+ self.logger.info("Playing announcement on player %s", player_id)
+
+ async def poll_player(self, player_id: str) -> None:
+ """Poll player for state updates."""
+ # OPTIONAL
+ # This method is optional and should be implemented if you specified 'needs_poll'
+ # on the Player object. This method should poll the player for state changes
+ # and update the player object in the player manager if needed.
+ # This method will be called at the interval specified in the poll_interval attribute.
+ self.logger.info("Polling player %s", player_id)
diff --git a/music_assistant/providers/heos/manifest.json b/music_assistant/providers/heos/manifest.json
new file mode 100644
index 00000000..ff860f3f
--- /dev/null
+++ b/music_assistant/providers/heos/manifest.json
@@ -0,0 +1,11 @@
+{
+ "type": "player",
+ "domain": "heos",
+ "name": "Denon HEOS",
+ "description": "Denon HEOS Player provider for Music Assistant, based on the PyHeos library. Select this provider if you have Denon HEOS devices.",
+ "codeowners": ["@btoconnor"],
+ "requirements": [
+ "pyheos==1.0.2"
+ ],
+ "documentation": "Link to the documentation on the music-assistant.io helppage (may be added later)."
+}
diff --git a/requirements_all.txt b/requirements_all.txt
index dfea4fae..ad4a6273 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -37,6 +37,7 @@ py-opensonic==5.2.1
pyblu==2.0.0
PyChromecast==14.0.5
pycryptodome==3.21.0
+pyheos==1.0.2
python-fullykiosk==0.0.14
python-slugify==8.0.4
pywidevine==1.8.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment