--- /dev/null
+"""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.",
+ ),
+ )
--- /dev/null
+"""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]
--- /dev/null
+"""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)}"
--- /dev/null
+<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>
--- /dev/null
+{
+ "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."
+ ]
+}
--- /dev/null
+"""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,
+ ]
--- /dev/null
+"""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())
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