Created
March 27, 2025 13:04
-
-
Save btoconnor/e531762ce4033bb0b834ace35a069f09 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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