From 750865d8a5e0992dec13ac02888c19f60d7a108c Mon Sep 17 00:00:00 2001
From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com>
Date: Tue, 29 Apr 2025 23:07:17 +0200
Subject: [PATCH] Add Yamaha MusicCast provider (#2142)
---
music_assistant/constants.py | 9 +
.../helpers/{didl_lite.py => upnp.py} | 93 ++
music_assistant/providers/dlna/__init__.py | 2 +-
.../providers/musiccast/__init__.py | 826 ++++++++++++++++++
.../providers/musiccast/avt_helpers.py | 148 ++++
.../providers/musiccast/constants.py | 80 ++
music_assistant/providers/musiccast/icon.svg | 11 +
.../providers/musiccast/manifest.json | 18 +
.../providers/musiccast/musiccast.py | 602 +++++++++++++
music_assistant/providers/sonos/provider.py | 2 +-
.../providers/sonos_s1/__init__.py | 2 +-
requirements_all.txt | 2 +
12 files changed, 1792 insertions(+), 3 deletions(-)
rename music_assistant/helpers/{didl_lite.py => upnp.py} (53%)
create mode 100644 music_assistant/providers/musiccast/__init__.py
create mode 100644 music_assistant/providers/musiccast/avt_helpers.py
create mode 100644 music_assistant/providers/musiccast/constants.py
create mode 100644 music_assistant/providers/musiccast/icon.svg
create mode 100644 music_assistant/providers/musiccast/manifest.json
create mode 100644 music_assistant/providers/musiccast/musiccast.py
diff --git a/music_assistant/constants.py b/music_assistant/constants.py
index 64d0076a..ac5bc287 100644
--- a/music_assistant/constants.py
+++ b/music_assistant/constants.py
@@ -587,6 +587,15 @@ CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN = ConfigEntry.from_dict(
{**CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(), "hidden": True}
)
+CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED = ConfigEntry.from_dict(
+ {
+ **CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(),
+ "default_value": False,
+ "value": False,
+ "hidden": True,
+ }
+)
+
CONF_ENTRY_WARN_PREVIEW = ConfigEntry(
key="preview_note",
type=ConfigEntryType.ALERT,
diff --git a/music_assistant/helpers/didl_lite.py b/music_assistant/helpers/upnp.py
similarity index 53%
rename from music_assistant/helpers/didl_lite.py
rename to music_assistant/helpers/upnp.py
index 2e0738e2..b560f3e5 100644
--- a/music_assistant/helpers/didl_lite.py
+++ b/music_assistant/helpers/upnp.py
@@ -13,9 +13,102 @@ from music_assistant.constants import MASS_LOGO_ONLINE
if TYPE_CHECKING:
from music_assistant_models.player import PlayerMedia
+
# ruff: noqa: E501
+# XML
+def _get_soap_action(command: str) -> str:
+ return f"urn:schemas-upnp-org:service:AVTransport:1#{command}"
+
+
+def _get_body(command: str, arguments: str = "") -> str:
+ return (
+ f''
+ r"0"
+ f"{arguments}"
+ f""
+ )
+
+
+def _get_xml(body: str) -> str:
+ return (
+ r''
+ r''
+ r""
+ f"{body}"
+ r""
+ r""
+ )
+
+
+def get_xml_soap_play() -> tuple[str, str]:
+ """Get UPnP xml and soap for Play."""
+ command = "Play"
+ arguments = r"1"
+ return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+def get_xml_soap_stop() -> tuple[str, str]:
+ """Get UPnP xml and soap for Stop."""
+ command = "Stop"
+ return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_pause() -> tuple[str, str]:
+ """Get UPnP xml and soap for Pause."""
+ command = "Pause"
+ return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_next() -> tuple[str, str]:
+ """Get UPnP xml and soap for Next."""
+ command = "Next"
+ return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_previous() -> tuple[str, str]:
+ """Get UPnP xml and soap for Previous."""
+ command = "Previous"
+ return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_transport_info() -> tuple[str, str]:
+ """Get UPnP xml and soap for GetTransportInfo."""
+ command = "GetTransportInfo"
+ return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_media_info() -> tuple[str, str]:
+ """Get UPnP xml and soap for GetMediaInfo."""
+ command = "GetMediaInfo"
+ return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_set_url(player_media: PlayerMedia) -> tuple[str, str]:
+ """Get UPnP xml and soap for SetAVTransportURI."""
+ metadata = create_didl_metadata_str(player_media)
+ command = "SetAVTransportURI"
+ arguments = (
+ f"{player_media.uri}"
+ ""
+ f"{metadata}"
+ ""
+ )
+ return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+def get_xml_soap_set_next_url(player_media: PlayerMedia) -> tuple[str, str]:
+ """Get UPnP xml and soap for SetNextAVTransportURI."""
+ metadata = create_didl_metadata_str(player_media)
+ command = "SetNextAVTransportURI"
+ arguments = (
+ f"{player_media.uri}{metadata}"
+ )
+ return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+# DIDL-LITE
def create_didl_metadata(media: PlayerMedia) -> str:
"""Create DIDL metadata string from url and PlayerMedia."""
ext = media.uri.split(".")[-1].split("?")[0]
diff --git a/music_assistant/providers/dlna/__init__.py b/music_assistant/providers/dlna/__init__.py
index c8ed06d1..ca9d5526 100644
--- a/music_assistant/providers/dlna/__init__.py
+++ b/music_assistant/providers/dlna/__init__.py
@@ -36,7 +36,7 @@ from music_assistant.constants import (
VERBOSE_LOG_LEVEL,
create_sample_rates_config_entry,
)
-from music_assistant.helpers.didl_lite import create_didl_metadata
+from music_assistant.helpers.upnp import create_didl_metadata
from music_assistant.helpers.util import TaskManager
from music_assistant.models.player_provider import PlayerProvider
diff --git a/music_assistant/providers/musiccast/__init__.py b/music_assistant/providers/musiccast/__init__.py
new file mode 100644
index 00000000..47acd9ea
--- /dev/null
+++ b/music_assistant/providers/musiccast/__init__.py
@@ -0,0 +1,826 @@
+"""MusicCast for MusicAssistant."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from aiohttp.client_exceptions import ServerDisconnectedError
+from aiomusiccast.exceptions import MusicCastGroupException
+from aiomusiccast.musiccast_device import MusicCastDevice
+from aiomusiccast.pyamaha import MusicCastConnectionException
+from music_assistant_models.config_entries import ConfigEntry
+from music_assistant_models.enums import (
+ ConfigEntryType,
+ PlayerFeature,
+ PlayerState,
+ PlayerType,
+ ProviderFeature,
+)
+from music_assistant_models.player import DeviceInfo, Player, PlayerMedia, PlayerSource
+from zeroconf import ServiceStateChange
+
+from music_assistant.constants import VERBOSE_LOG_LEVEL
+from music_assistant.models.player_provider import PlayerProvider
+from music_assistant.providers.musiccast.avt_helpers import (
+ avt_get_media_info,
+ avt_next,
+ avt_pause,
+ avt_play,
+ avt_previous,
+ avt_set_url,
+ avt_stop,
+ search_xml,
+)
+from music_assistant.providers.sonos.helpers import get_primary_ip_address
+
+from .constants import (
+ CONF_PLAYER_SWITCH_SOURCE_NON_NET,
+ CONF_PLAYER_TURN_OFF_ON_LEAVE,
+ MC_CONTROL_SOURCE_IDS,
+ MC_DEVICE_INFO_ENDPOINT,
+ MC_DEVICE_UPNP_ENDPOINT,
+ MC_DEVICE_UPNP_PORT,
+ MC_NETUSB_SOURCE_IDS,
+ MC_PASSIVE_SOURCE_IDS,
+ MC_POLL_INTERVAL,
+ MC_SOURCE_MAIN_SYNC,
+ MC_SOURCE_MC_LINK,
+ PLAYER_CONFIG_ENTRIES,
+ PLAYER_MAP_ZONE_SWITCH,
+ PLAYER_ZONE_SPLITTER,
+)
+from .musiccast import (
+ MusicCastController,
+ MusicCastPhysicalDevice,
+ MusicCastPlayerState,
+ MusicCastZoneDevice,
+)
+
+if TYPE_CHECKING:
+ from music_assistant_models.config_entries import (
+ ConfigValueType,
+ 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."""
+ return MusicCast(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
+ return ()
+
+
+@dataclass(kw_only=True)
+class MusicCastPlayer:
+ """MusicCastPlayer.
+
+ Helper class to store MA player alongside physical device.
+ """
+
+ device_id: str # device_id without ZONE_SPLITTER zone
+ player_main: Player | None = None # mass player
+ player_zone2: Player | None = None # mass player
+ # I can only test up to zone 2
+ player_zone3: Player | None = None # mass player
+ player_zone4: Player | None = None # mass player
+
+ # log allowed sources for a device with multiple sources once. see "_handle_zone_grouping"
+ _log_allowed_sources: bool = True
+
+ physical_device: MusicCastPhysicalDevice
+
+ def get_player(self, zone: str) -> Player | None:
+ """Get Player by zone name."""
+ match zone:
+ case "main":
+ return self.player_main
+ case "zone2":
+ return self.player_zone2
+ case "zone3":
+ return self.player_zone3
+ case "zone4":
+ return self.player_zone4
+ raise RuntimeError(f"Zone {zone} is unknown.")
+
+ def get_all_players(self) -> list[Player]:
+ """Get all players."""
+ assert self.player_main is not None # we always have main
+ players = [self.player_main]
+ if self.player_zone2 is not None:
+ players.append(self.player_zone2)
+ if self.player_zone3 is not None:
+ players.append(self.player_zone3)
+ if self.player_zone4 is not None:
+ players.append(self.player_zone4)
+ return players
+
+
+@dataclass(kw_only=True)
+class UpnpUpdateHelper:
+ """UpnpUpdateHelper.
+
+ See _update_player_attributes.
+ """
+
+ last_poll: float # time.time
+ controlled_by_mass: bool
+ current_uri: str | None
+
+
+class MusicCast(PlayerProvider):
+ """MusicCast."""
+
+ musiccast_players: dict[str, MusicCastPlayer] = {}
+
+ # poll upnp playback information, but not too often. see "_update_player_attributes"
+ # player_id: UpnpUpdateHelper
+ upnp_update_helper: dict[str, UpnpUpdateHelper] = {}
+
+ # str here is the device id, NOT the player_id
+ update_player_locks: dict[str, asyncio.Lock] = {}
+
+ @property
+ def supported_features(self) -> set[ProviderFeature]:
+ """Return the features supported by this Provider."""
+ return {ProviderFeature.SYNC_PLAYERS}
+
+ async def handle_async_init(self) -> None:
+ """Async init."""
+ self.mc_controller = MusicCastController(logger=self.logger)
+ # aiomusiccast logs all fetch requests after udp message as debug.
+ # same approach as in upnp
+ if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
+ logging.getLogger("aiomusiccast").setLevel(logging.DEBUG)
+ else:
+ logging.getLogger("aiomusiccast").setLevel(self.logger.level + 10)
+
+ async def unload(self, is_removed: bool = False) -> None:
+ """Call on unload."""
+ for mc_player in self.musiccast_players.values():
+ mc_player.physical_device.remove()
+
+ 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)."""
+ base_entries = await super().get_player_config_entries(player_id)
+ zone_entries: tuple[ConfigEntry, ...] = ()
+ if zone_player := self._get_zone_player(player_id):
+ if len(zone_player.physical_device.zone_devices) > 1:
+ zone_entries = (
+ ConfigEntry(
+ key=CONF_PLAYER_SWITCH_SOURCE_NON_NET,
+ type=ConfigEntryType.STRING,
+ label="Switch to this non-net source on group leave.",
+ default_value=PLAYER_MAP_ZONE_SWITCH[zone_player.zone_name],
+ description="Switch to this non-net source on group leave. "
+ " This must be the source_id.",
+ ),
+ ConfigEntry(
+ key=CONF_PLAYER_TURN_OFF_ON_LEAVE,
+ type=ConfigEntryType.BOOLEAN,
+ label="Turn off zone after group is left.",
+ default_value=False,
+ description="Turn off zone after group is left.",
+ ),
+ )
+
+ return base_entries + zone_entries + PLAYER_CONFIG_ENTRIES
+
+ def _get_zone_player(self, player_id: str) -> MusicCastZoneDevice | None:
+ """Get music cast zone entity based on player id."""
+ device_id, zone = player_id.split(PLAYER_ZONE_SPLITTER)
+ mc_player = self.musiccast_players.get(device_id)
+ if mc_player is None:
+ return None
+ return mc_player.physical_device.zone_devices.get(zone)
+
+ async def _set_player_unavailable(self, player_id: str) -> None:
+ """Set a player unavailable, and remove it from the MC group.
+
+ Update all clients.
+ """
+ device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER)
+ mc_player = self.musiccast_players.get(device_id)
+ if mc_player is None:
+ return
+ mc_player.physical_device.remove()
+ for player in mc_player.get_all_players():
+ # disable zones as well.
+ player.available = False
+ await self.mass.players.register_or_update(player)
+
+ async def _cmd_run(
+ self, player_id: str, fun: Callable[..., Coroutine[Any, Any, None]], *args: Any
+ ) -> None:
+ """Help function for all player cmds."""
+ try:
+ await fun(*args)
+ except MusicCastConnectionException:
+ await self._set_player_unavailable(player_id)
+ self.logger.debug("Player became unavailable.")
+ except MusicCastGroupException:
+ # can happen, user shall try again.
+ ...
+
+ def _get_player_id_from_mc_zone_player(self, zone_player: MusicCastZoneDevice) -> str:
+ device_id = zone_player.physical_device.device.data.device_id
+ assert device_id is not None
+ return f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_player.zone_name}"
+
+ async def cmd_stop(self, player_id: str) -> None:
+ """Send STOP command to given player."""
+ if zone_player := self._get_zone_player(player_id):
+ upnp_update_helper = self.upnp_update_helper.get(player_id)
+ if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+ await avt_stop(self.mass.http_session, zone_player.physical_device)
+ else:
+ await self._cmd_run(player_id, zone_player.stop)
+
+ async def cmd_play(self, player_id: str) -> None:
+ """Send PLAY command to given player."""
+ if zone_player := self._get_zone_player(player_id):
+ upnp_update_helper = self.upnp_update_helper.get(player_id)
+ if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+ await avt_play(self.mass.http_session, zone_player.physical_device)
+ else:
+ await self._cmd_run(player_id, zone_player.play)
+
+ async def cmd_pause(self, player_id: str) -> None:
+ """Send PAUSE command to given player."""
+ if zone_player := self._get_zone_player(player_id):
+ upnp_update_helper = self.upnp_update_helper.get(player_id)
+ if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+ await avt_pause(self.mass.http_session, zone_player.physical_device)
+ else:
+ await self._cmd_run(player_id, zone_player.pause)
+
+ async def cmd_next(self, player_id: str) -> None:
+ """Send NEXT."""
+ if zone_player := self._get_zone_player(player_id):
+ upnp_update_helper = self.upnp_update_helper.get(player_id)
+ if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+ await avt_next(self.mass.http_session, zone_player.physical_device)
+ else:
+ await self._cmd_run(player_id, zone_player.next_track)
+
+ async def cmd_previous(self, player_id: str) -> None:
+ """Send PREVIOUS."""
+ if zone_player := self._get_zone_player(player_id):
+ upnp_update_helper = self.upnp_update_helper.get(player_id)
+ if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+ await avt_previous(self.mass.http_session, zone_player.physical_device)
+ else:
+ await self._cmd_run(player_id, zone_player.previous_track)
+
+ async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+ """Send VOLUME_SET command to given player."""
+ if zone_player := self._get_zone_player(player_id):
+ await self._cmd_run(player_id, zone_player.volume_set, volume_level)
+
+ async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+ """Send VOLUME MUTE command to given player."""
+ if zone_player := self._get_zone_player(player_id):
+ await self._cmd_run(player_id, zone_player.volume_mute, muted)
+
+ async def cmd_power(self, player_id: str, powered: bool) -> None:
+ """Send POWER command to given player."""
+ if zone_player := self._get_zone_player(player_id):
+ if powered:
+ await self._cmd_run(player_id, zone_player.turn_on)
+ else:
+ await self._cmd_run(player_id, zone_player.turn_off)
+
+ async def cmd_group(self, player_id: str, target_player: str) -> None:
+ """Handle GROUP command for given player."""
+ await self.cmd_group_many(target_player=target_player, child_player_ids=[player_id])
+
+ async def cmd_ungroup(self, player_id: str) -> None:
+ """Handle UNGROUP command for given player."""
+ if zone_player := self._get_zone_player(player_id):
+ if zone_player.zone_name.startswith("zone"):
+ # We are are zone.
+ # We do not leave an MC group, but just change our source.
+ await self._handle_zone_grouping(zone_player)
+ return
+ await self._cmd_run(player_id, zone_player.unjoin_player)
+
+ def _get_allowed_sources_zone_switch(self, zone_player: MusicCastZoneDevice) -> set[str]:
+ """Return non net sources for a zone player."""
+ assert zone_player.zone_data is not None, "zone data missing"
+ _input_sources: set[str] = set(zone_player.zone_data.input_list)
+ _net_sources = set(MC_NETUSB_SOURCE_IDS)
+ _net_sources.add(MC_SOURCE_MC_LINK) # mc grouping source
+ return _input_sources.difference(_net_sources)
+
+ async def _handle_zone_grouping(self, zone_player: MusicCastZoneDevice) -> None:
+ """Handle zone grouping.
+
+ If a device has multiple zones, only a single zone can be net controlled.
+ If another zone wants to join the group, the current net zone has to switch
+ its input to a non-net one and optionally turn off.
+ """
+ player_id = self._get_player_id_from_mc_zone_player(zone_player)
+ assert player_id is not None # for TYPE_CHECKING
+ _source = str(
+ await self.mass.config.get_player_config_value(
+ player_id, CONF_PLAYER_SWITCH_SOURCE_NON_NET
+ )
+ )
+ # verify that this source actually exists and is non net
+ _allowed_sources = self._get_allowed_sources_zone_switch(zone_player)
+ if _source not in _allowed_sources:
+ mass_player = self.mass.players.get(player_id)
+ assert mass_player is not None
+ msg = (
+ f"The switch source you specified for {mass_player.name} is not allowed. "
+ f"The source must be any of: {', '.join(sorted(_allowed_sources))} "
+ "Will use the first available source."
+ )
+ self.logger.error(msg)
+ _source = _allowed_sources.pop()
+
+ await self._cmd_run(player_id, zone_player.select_source, _source)
+ _turn_off = bool(
+ await self.mass.config.get_player_config_value(player_id, CONF_PLAYER_TURN_OFF_ON_LEAVE)
+ )
+ if _turn_off:
+ await asyncio.sleep(2)
+ await self._cmd_run(player_id, zone_player.turn_off)
+
+ async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
+ """Create temporary sync group by joining given players to target player."""
+ device_id, zone_server = target_player.split(PLAYER_ZONE_SPLITTER)
+ server = self._get_zone_player(target_player)
+ if server is None:
+ return
+ children: set[MusicCastZoneDevice] = set()
+ children_zones: list[MusicCastZoneDevice] = []
+ for child_id in child_player_ids:
+ if child := self._get_zone_player(child_id):
+ _other_zone_mc: MusicCastZoneDevice | None = None
+ for x in child.other_zones:
+ if x.is_netusb:
+ _other_zone_mc = x
+ if _other_zone_mc and _other_zone_mc != child:
+ # of the same device, we use main_sync as input
+ if _other_zone_mc.zone_name == "main":
+ children_zones.append(child)
+ else:
+ self.logger.warning(
+ "It is impossible to join as a normal zone to another zone of the same "
+ "device. Only joining to main is possible. Please refer to the docs."
+ )
+ else:
+ children.add(child)
+
+ for child in children_zones:
+ child_player_id = self._get_player_id_from_mc_zone_player(child)
+ if child.state == MusicCastPlayerState.OFF:
+ await self._cmd_run(child_player_id, child.turn_on)
+ await self.select_source(child_player_id, MC_SOURCE_MAIN_SYNC)
+ if not children:
+ return
+
+ await self._cmd_run(target_player, server.join_players, list(children))
+
+ async def cmd_ungroup_member(self, player_id: str, target_player: str) -> None:
+ """Handle UNGROUP command for given player."""
+ await self.cmd_ungroup(player_id)
+
+ async def select_source(self, player_id: str, source: str) -> None:
+ """Handle SELECT SOURCE command on given player."""
+ if zone_player := self._get_zone_player(player_id):
+ await self._cmd_run(player_id, zone_player.select_source, source)
+
+ async def play_media(
+ self,
+ player_id: str,
+ media: PlayerMedia,
+ ) -> None:
+ """Handle PLAY MEDIA on given player."""
+ if zone_player := self._get_zone_player(player_id):
+ if len(zone_player.physical_device.zone_devices) > 1:
+ # zone handling
+ # only a single zone may have netusb capability
+ for zone_name, dev in zone_player.physical_device.zone_devices.items():
+ if zone_name == zone_player.zone_name:
+ continue
+ if dev.is_netusb:
+ await self._handle_zone_grouping(dev)
+ device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER)
+ lock = self.update_player_locks.get(device_id)
+ assert lock is not None # for type checking
+ async with lock:
+ # just in case
+ if zone_player.source_id != "server":
+ await self.select_source(player_id, "server")
+ await avt_set_url(
+ self.mass.http_session, zone_player.physical_device, player_media=media
+ )
+ await avt_play(self.mass.http_session, zone_player.physical_device)
+
+ self.upnp_update_helper[player_id] = UpnpUpdateHelper(
+ last_poll=time.time(),
+ controlled_by_mass=True,
+ current_uri=media.uri,
+ )
+
+ if ma_player := self.mass.players.get(player_id):
+ ma_player.current_media = media
+ await self.mass.players.register_or_update(ma_player)
+
+ async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
+ """Enqueue next."""
+ if zone_player := self._get_zone_player(player_id):
+ await avt_set_url(
+ self.mass.http_session,
+ zone_player.physical_device,
+ player_media=media,
+ enqueue=True,
+ )
+
+ async def poll_player(self, player_id: str, fetch: bool = True) -> None:
+ """Poll player for state updates, only main zone is polled."""
+ # we only poll for main, as we get zones alongside
+ device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER)
+ mc_player = self.musiccast_players.get(device_id)
+ if mc_player is None:
+ return
+
+ lock = self.update_player_locks.get(device_id)
+ assert lock is not None # for type checking
+ if lock.locked():
+ # we are called roughly every 1s when playing on udp callback, so just discard.
+ return
+ async with lock:
+ if fetch: # non udp "explicit polling case"
+ try:
+ await mc_player.physical_device.fetch()
+ except (MusicCastConnectionException, MusicCastGroupException):
+ await self._set_player_unavailable(player_id)
+ return
+ except ServerDisconnectedError:
+ return
+
+ for player in mc_player.get_all_players():
+ _, zone = player.player_id.split(PLAYER_ZONE_SPLITTER)
+ zone_device = mc_player.physical_device.zone_devices.get(zone)
+ if zone_device is None:
+ continue
+ await self._update_player_attributes(player, zone_device)
+ player.available = True
+ await self.mass.players.register_or_update(player)
+
+ async def on_mdns_service_state_change(
+ self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
+ ) -> None:
+ """Discovery via mdns."""
+ if state_change == ServiceStateChange.Removed:
+ # Wait for connection to fail, same as sonos.
+ return
+ if info is None:
+ return
+ device_ip = get_primary_ip_address(info)
+ if device_ip is None:
+ return
+ device_info = await self.mass.http_session.get(
+ f"http://{device_ip}/{MC_DEVICE_INFO_ENDPOINT}"
+ )
+ if device_info.status == 404:
+ return
+ device_info_json = await device_info.json()
+ device_id = device_info_json.get("device_id")
+ if device_id is None:
+ return
+ description_url = f"http://{device_ip}:{MC_DEVICE_UPNP_PORT}/{MC_DEVICE_UPNP_ENDPOINT}"
+
+ _check = await self.mass.http_session.get(description_url)
+ if _check.status == 404:
+ self.logger.debug("Missing description url for Yamaha device at %s", device_ip)
+ return
+ await self._device_discovered(
+ device_id=device_id, device_ip=device_ip, description_url=description_url
+ )
+
+ async def _device_discovered(
+ self, device_id: str, device_ip: str, description_url: str
+ ) -> None:
+ """Handle discovered MusicCast player."""
+ # verify that this is a MusicCast player
+ check: bool = await MusicCastDevice.check_yamaha_ssdp(
+ description_url, self.mass.http_session
+ )
+ if not check:
+ return
+
+ mc_player_known = self.musiccast_players.get(device_id)
+ if (
+ mc_player_known is not None
+ and mc_player_known.player_main is not None
+ and (
+ mc_player_known.physical_device.device.device.upnp_description == description_url
+ and mc_player_known.player_main.available
+ )
+ ):
+ # nothing to do, device is already connected
+ return
+ else:
+ # new or updated player detected
+ physical_device = MusicCastPhysicalDevice(
+ device=MusicCastDevice(
+ client=self.mass.http_session,
+ ip=device_ip,
+ upnp_description=description_url,
+ ),
+ controller=self.mc_controller,
+ )
+ self.update_player_locks[device_id] = asyncio.Lock()
+ success = await physical_device.async_init() # fetch + polling
+ if not success:
+ self.logger.debug(
+ "Had trouble setting up device at %s. Will be retried on next discovery.",
+ device_ip,
+ )
+ return
+ physical_device.register_callback(self._non_async_udp_callback)
+ await self._register_player(physical_device, device_id)
+
+ async def _register_player(
+ self, physical_device: MusicCastPhysicalDevice, device_id: str
+ ) -> None:
+ """Register player including zones."""
+ device_info = DeviceInfo(
+ manufacturer="Yamaha Corporation",
+ model=physical_device.device.data.model_name or "unknown model",
+ software_version=physical_device.device.data.system_version or "unknown version",
+ )
+
+ def get_player(zone_name: str, player_name: str) -> Player:
+ # player features
+ # TODO: There is seek in the upnp desc
+ # http://{ip}:49154/AVTransport/desc.xml
+ supported_features: set[PlayerFeature] = {
+ PlayerFeature.VOLUME_SET,
+ PlayerFeature.VOLUME_MUTE,
+ PlayerFeature.PAUSE,
+ PlayerFeature.POWER,
+ PlayerFeature.SELECT_SOURCE,
+ PlayerFeature.SET_MEMBERS,
+ PlayerFeature.NEXT_PREVIOUS,
+ PlayerFeature.ENQUEUE,
+ PlayerFeature.GAPLESS_PLAYBACK,
+ }
+
+ return Player(
+ player_id=f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_name}",
+ provider=self.instance_id,
+ type=PlayerType.PLAYER,
+ name=player_name,
+ available=True,
+ device_info=device_info,
+ needs_poll=zone_name == "main",
+ poll_interval=MC_POLL_INTERVAL,
+ supported_features=supported_features,
+ )
+
+ main_device = physical_device.zone_devices.get("main")
+ if (
+ main_device is None
+ or main_device.zone_data is None
+ or main_device.zone_data.name is None
+ ):
+ return
+ musiccast_player = MusicCastPlayer(
+ device_id=device_id,
+ physical_device=physical_device,
+ )
+
+ for zone_name, zone_device in physical_device.zone_devices.items():
+ if zone_device.zone_data is None or zone_device.zone_data.name is None:
+ continue
+ player = get_player(zone_name, zone_device.zone_data.name)
+ setattr(musiccast_player, f"player_{zone_device.zone_name}", player)
+ await self._update_player_attributes(player, zone_device)
+ await self.mass.players.register_or_update(player)
+
+ if musiccast_player.player_zone2 is not None and musiccast_player._log_allowed_sources:
+ musiccast_player._log_allowed_sources = False
+ player_main = musiccast_player.player_main
+ assert player_main is not None
+ main_zone_device = musiccast_player.physical_device.zone_devices.get("main")
+ assert main_zone_device is not None
+ _allowed_sources = self._get_allowed_sources_zone_switch(main_zone_device)
+ self.logger.info(
+ f"The player {player_main.name} has multiple zones. "
+ "Please use the player config to configure a non-net source for grouping. "
+ f"Allowed values are: {', '.join(_allowed_sources)}. See docs."
+ )
+
+ self.musiccast_players[device_id] = musiccast_player
+
+ async def _update_player_attributes(self, player: Player, device: MusicCastZoneDevice) -> None:
+ # ruff: noqa: PLR0915
+ zone_data = device.zone_data
+ if zone_data is None:
+ return
+
+ player.name = zone_data.name or "UNKNOWN NAME"
+ player.powered = zone_data.power == "on"
+
+ player.volume_level = int(
+ zone_data.current_volume / (zone_data.max_volume - zone_data.min_volume) * 100
+ )
+ player.volume_muted = zone_data.mute
+
+ # STATE
+
+ match device.state:
+ case MusicCastPlayerState.PAUSED:
+ player.state = PlayerState.PAUSED
+ case MusicCastPlayerState.PLAYING:
+ player.state = PlayerState.PLAYING
+ case MusicCastPlayerState.IDLE | MusicCastPlayerState.OFF:
+ player.state = PlayerState.IDLE
+ player.elapsed_time = device.media_position
+ player.elapsed_time_last_updated = device.media_position_updated_at
+
+ # SOURCES
+ source_list: list[PlayerSource] = []
+ for source_id, source_name in device.source_mapping.items():
+ control = source_id in MC_CONTROL_SOURCE_IDS
+ passive = source_id in MC_PASSIVE_SOURCE_IDS
+ source_list.append(
+ PlayerSource(
+ id=source_id,
+ name=source_name,
+ passive=passive,
+ can_play_pause=control,
+ can_seek=False,
+ can_next_previous=control,
+ )
+ )
+ player.source_list.set(source_list)
+
+ # UPDATE UPNP HELPER
+ update_helper = self.upnp_update_helper.get(player.player_id)
+ now = time.time()
+ if update_helper is None or now - update_helper.last_poll > 5:
+ # Let's not do this too often
+ # Note: The devices always return the last UPnP xmls, even if
+ # currently another source/ playback method is used
+ try:
+ _xml_media_info = await avt_get_media_info(
+ self.mass.http_session, device.physical_device
+ )
+ except ServerDisconnectedError:
+ return
+ _player_current_url = search_xml(_xml_media_info, "CurrentURI")
+
+ # controlled by mass is only True, if we are directly controlled
+ # i.e. we are not a group member.
+ # the device's source id is server, if controlled by upnp, but also, if the internal
+ # dlna function of the device are used. As a fallback, we then
+ # use the item's title. This can only fail, if our current and next item
+ # has the same name as the external.
+ controlled_by_mass = False
+ if _player_current_url is not None:
+ controlled_by_mass = (
+ player.player_id in _player_current_url
+ and self.mass.streams.base_url in _player_current_url
+ and device.source_id == "server"
+ )
+
+ update_helper = UpnpUpdateHelper(
+ last_poll=now,
+ controlled_by_mass=controlled_by_mass,
+ current_uri=_player_current_url,
+ )
+
+ self.upnp_update_helper[player.player_id] = update_helper
+
+ # UPDATE PLAYBACK INFORMATION
+ # Note to self:
+ # player.current_media tells queue controller what is playing
+ # and player.set_current_media is the helper function
+ # do not access the queue controller to gain playback information here
+ if update_helper.current_uri is not None and update_helper.controlled_by_mass:
+ player.set_current_media(uri=update_helper.current_uri)
+ elif device.is_client:
+ _server = device.group_server
+ _server_id = self._get_player_id_from_mc_zone_player(_server)
+ _server_update_helper = self.upnp_update_helper.get(_server_id)
+ if (
+ _server_update_helper is not None
+ and _server_update_helper.current_uri is not None
+ and _server_update_helper.controlled_by_mass
+ ):
+ player.set_current_media(
+ uri=_server_update_helper.current_uri,
+ )
+ else:
+ player.set_current_media(
+ uri=f"{_server_id}_{_server.source_id}",
+ title=_server.media_title,
+ artist=_server.media_artist,
+ album=_server.media_album_name,
+ image_url=_server.media_image_url,
+ )
+ else:
+ player.set_current_media(
+ uri=f"{player.player_id}_{device.source_id}",
+ title=device.media_title,
+ artist=device.media_artist,
+ album=device.media_album_name,
+ image_url=device.media_image_url,
+ )
+
+ # SOURCE
+ player.active_source = None # means the player controller will figure it out
+ if not device.is_client and not update_helper.controlled_by_mass:
+ player.active_source = device.source_id
+ elif device.is_client:
+ _server = device.group_server
+ _server_id = self._get_player_id_from_mc_zone_player(_server)
+ if _server_update_helper := self.upnp_update_helper.get(_server_id):
+ player.active_source = (
+ device.source_id if not _server_update_helper.controlled_by_mass else None
+ )
+
+ # GROUPING
+ # A zone cannot be synced to another zone or main of the same device.
+ # Additionally, a zone can only be synced, if main is currently not using any netusb
+ # function.
+ # For a Zone which will be synced to main, grouping emits a "main_sync" instead
+ # of a mc link. The other way round, we log a warning.
+ player.can_group_with = {self.instance_id}
+
+ if len(device.musiccast_group) == 1:
+ if device.musiccast_group[0] == device:
+ # we are in a group with ourselves.
+ player.group_childs.clear()
+ player.synced_to = None
+ player.active_group = None
+
+ elif not device.is_client and not device.is_server:
+ player.group_childs.clear()
+ player.synced_to = None
+ player.active_group = None
+
+ elif device.is_client:
+ _synced_to_id = self._get_player_id_from_mc_zone_player(device.group_server)
+ player.group_childs.clear()
+ player.synced_to = _synced_to_id
+ player.active_group = _synced_to_id
+
+ elif device.is_server:
+ player.group_childs.set(
+ [self._get_player_id_from_mc_zone_player(x) for x in device.musiccast_group]
+ )
+ player.synced_to = None
+ player.active_group = None
+
+ def _non_async_udp_callback(self, mc_physical_device: MusicCastPhysicalDevice) -> None:
+ """Update callback.
+
+ This is called if there are new UDP updates. Unfortunately, aiomusiccast
+ only allows a sync callback, so we schedule an async task.
+ """
+ mc_player: MusicCastPlayer | None = None
+ for mc_player in self.musiccast_players.values():
+ if mc_player.physical_device == mc_physical_device:
+ break
+ assert mc_player is not None # for type checking
+ if mc_player.player_main is None:
+ return
+ main_player_id = mc_player.player_main.player_id
+ # disable another fetch, these attributes were set via UDP
+ self.mass.loop.create_task(self.poll_player(main_player_id, False))
diff --git a/music_assistant/providers/musiccast/avt_helpers.py b/music_assistant/providers/musiccast/avt_helpers.py
new file mode 100644
index 00000000..c71602da
--- /dev/null
+++ b/music_assistant/providers/musiccast/avt_helpers.py
@@ -0,0 +1,148 @@
+"""Helpers to make an UPnP request."""
+
+import aiohttp
+from music_assistant_models.player import PlayerMedia
+
+from music_assistant.helpers.upnp import (
+ get_xml_soap_media_info,
+ get_xml_soap_next,
+ get_xml_soap_pause,
+ get_xml_soap_play,
+ get_xml_soap_previous,
+ get_xml_soap_set_next_url,
+ get_xml_soap_set_url,
+ get_xml_soap_stop,
+ get_xml_soap_transport_info,
+)
+from music_assistant.providers.musiccast.constants import (
+ MC_DEVICE_UPNP_CTRL_ENDPOINT,
+ MC_DEVICE_UPNP_PORT,
+)
+from music_assistant.providers.musiccast.musiccast import MusicCastPhysicalDevice
+
+
+def get_headers(xml: str, soap_action: str) -> dict[str, str]:
+ """Get headers for MusicCast."""
+ return {
+ "Content-Type": 'text/xml; charset="utf-8"',
+ "SOAPACTION": f'"{soap_action}"',
+ "Accept": "*/*",
+ "User-Agent": "MusicCast/6.00 (Android)",
+ "Content-Length": str(len(xml)),
+ }
+
+
+def get_upnp_ctrl_url(physical_device: MusicCastPhysicalDevice) -> str:
+ """Get UPNP control URL."""
+ return f"http://{physical_device.device.device.ip}:{MC_DEVICE_UPNP_PORT}/{MC_DEVICE_UPNP_CTRL_ENDPOINT}"
+
+
+async def avt_play(
+ client: aiohttp.ClientSession,
+ physical_device: MusicCastPhysicalDevice,
+) -> None:
+ """Play."""
+ ctrl_url = get_upnp_ctrl_url(physical_device)
+ xml, soap_action = get_xml_soap_play()
+ headers = get_headers(xml, soap_action)
+ await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_stop(
+ client: aiohttp.ClientSession,
+ physical_device: MusicCastPhysicalDevice,
+) -> None:
+ """Play."""
+ ctrl_url = get_upnp_ctrl_url(physical_device)
+ xml, soap_action = get_xml_soap_stop()
+ headers = get_headers(xml, soap_action)
+ await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_pause(
+ client: aiohttp.ClientSession,
+ physical_device: MusicCastPhysicalDevice,
+) -> None:
+ """Play."""
+ ctrl_url = get_upnp_ctrl_url(physical_device)
+ xml, soap_action = get_xml_soap_pause()
+ headers = get_headers(xml, soap_action)
+ await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_next(
+ client: aiohttp.ClientSession,
+ physical_device: MusicCastPhysicalDevice,
+) -> None:
+ """Play."""
+ ctrl_url = get_upnp_ctrl_url(physical_device)
+ xml, soap_action = get_xml_soap_next()
+ headers = get_headers(xml, soap_action)
+ await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_previous(
+ client: aiohttp.ClientSession,
+ physical_device: MusicCastPhysicalDevice,
+) -> None:
+ """Play."""
+ ctrl_url = get_upnp_ctrl_url(physical_device)
+ xml, soap_action = get_xml_soap_previous()
+ headers = get_headers(xml, soap_action)
+ await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_get_media_info(
+ client: aiohttp.ClientSession,
+ physical_device: MusicCastPhysicalDevice,
+) -> str:
+ """Get Media Info."""
+ ctrl_url = get_upnp_ctrl_url(physical_device)
+ xml, soap_action = get_xml_soap_media_info()
+ headers = get_headers(xml, soap_action)
+ response = await client.post(ctrl_url, headers=headers, data=xml)
+ response_text = await response.read()
+ return response_text.decode()
+
+
+async def avt_get_transport_info(
+ client: aiohttp.ClientSession,
+ physical_device: MusicCastPhysicalDevice,
+) -> str:
+ """Get Media Info."""
+ ctrl_url = get_upnp_ctrl_url(physical_device)
+ xml, soap_action = get_xml_soap_transport_info()
+ headers = get_headers(xml, soap_action)
+ response = await client.post(ctrl_url, headers=headers, data=xml)
+ response_text = await response.read()
+ return response_text.decode()
+
+
+async def avt_set_url(
+ client: aiohttp.ClientSession,
+ physical_device: MusicCastPhysicalDevice,
+ player_media: PlayerMedia,
+ enqueue: bool = False,
+) -> None:
+ """Set Url.
+
+ If device is playing, this will just continue with new media.
+ """
+ ctrl_url = get_upnp_ctrl_url(physical_device)
+ if enqueue:
+ xml, soap_action = get_xml_soap_set_next_url(player_media)
+ else:
+ xml, soap_action = get_xml_soap_set_url(player_media)
+ headers = get_headers(xml, soap_action)
+ await client.post(ctrl_url, headers=headers, data=xml)
+
+
+def search_xml(xml: str, tag: str) -> str | None:
+ """Search single line xml for these tags."""
+ start_str = f"<{tag}>"
+ end_str = f"{tag}>"
+ start_int = xml.find(start_str)
+ end_int = xml.find(end_str)
+ if start_int == -1 or end_int == -1:
+ return None
+ return xml[start_int + len(start_str) : end_int]
diff --git a/music_assistant/providers/musiccast/constants.py b/music_assistant/providers/musiccast/constants.py
new file mode 100644
index 00000000..911ae5cc
--- /dev/null
+++ b/music_assistant/providers/musiccast/constants.py
@@ -0,0 +1,80 @@
+"""Constants for the MusicCast provider."""
+
+from music_assistant.constants import (
+ CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
+ CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
+ CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED,
+ CONF_ENTRY_OUTPUT_CODEC,
+ create_sample_rates_config_entry,
+)
+
+# Constants for players
+# both the http profile and icy didn't matter for me testing it.
+PLAYER_CONFIG_ENTRIES = (
+ CONF_ENTRY_OUTPUT_CODEC,
+ CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
+ CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED,
+ CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
+ create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24),
+)
+# player id is {device_id}{ZONE_SPLITTER}{zone_name}
+PLAYER_ZONE_SPLITTER = "___" # must be url ok
+
+# Switch to these non netusb sources when leaving a group as a dev
+# with multiple zones. Optionally turn device off.
+CONF_PLAYER_SWITCH_SOURCE_NON_NET = "main_switch_source"
+CONF_PLAYER_TURN_OFF_ON_LEAVE = "turn_off_on_leave"
+MAIN_SWITCH_SOURCE_NON_NET = "audio1"
+PLAYER_ZONE2_SWITCH_SOURCE_NON_NET = "audio2"
+PLAYER_ZONE3_SWITCH_SOURCE_NON_NET = "audio3"
+PLAYER_ZONE4_SWITCH_SOURCE_NON_NET = "audio4"
+PLAYER_MAP_ZONE_SWITCH = {
+ "main": MAIN_SWITCH_SOURCE_NON_NET,
+ "zone2": PLAYER_ZONE2_SWITCH_SOURCE_NON_NET,
+ "zone3": PLAYER_ZONE3_SWITCH_SOURCE_NON_NET,
+ "zone4": PLAYER_ZONE4_SWITCH_SOURCE_NON_NET,
+}
+
+
+# MusicCast constants
+MC_POLL_INTERVAL = 10
+MC_PLAY_TITLE = "Music Assistant"
+
+MC_DEVICE_INFO_ENDPOINT = "YamahaExtendedControl/v1/system/getDeviceInfo"
+MC_DEVICE_UPNP_ENDPOINT = "MediaRenderer/desc.xml"
+# if this is not a constant, we'll have to do some xml parsing
+MC_DEVICE_UPNP_CTRL_ENDPOINT = "AVTransport/ctrl"
+MC_DEVICE_UPNP_PORT = 49154
+MC_NULL_GROUP = "00000000000000000000000000000000"
+MC_DEFAULT_ZONE = "main"
+
+MC_SOURCE_MC_LINK = "mc_link"
+MC_SOURCE_MAIN_SYNC = "main_sync"
+MC_LINK_SOURCES = [MC_SOURCE_MC_LINK, MC_SOURCE_MAIN_SYNC]
+
+MC_PASSIVE_SOURCE_IDS = [MC_SOURCE_MC_LINK]
+MC_NETUSB_SOURCE_IDS = [
+ "napster",
+ "spotify",
+ "qobuz",
+ "tidal",
+ "deezer",
+ "amazon_music",
+ "alexa",
+ "airplay",
+ "usb",
+ "server",
+ "net_radio",
+ "bluetooth",
+ # these were in aiomusiccast/musiccast_media_content.py:
+ "pandora",
+ "rhapsody",
+ "siriusxm",
+ "juke",
+ "radiko",
+]
+MC_CONTROL_SOURCE_IDS = MC_NETUSB_SOURCE_IDS
+MC_CONTROL_SOURCE_IDS.append(
+ # tuner can be controlled, will change the station
+ "tuner",
+)
diff --git a/music_assistant/providers/musiccast/icon.svg b/music_assistant/providers/musiccast/icon.svg
new file mode 100644
index 00000000..845920ca
--- /dev/null
+++ b/music_assistant/providers/musiccast/icon.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/music_assistant/providers/musiccast/manifest.json b/music_assistant/providers/musiccast/manifest.json
new file mode 100644
index 00000000..ae24d76b
--- /dev/null
+++ b/music_assistant/providers/musiccast/manifest.json
@@ -0,0 +1,18 @@
+{
+ "type": "player",
+ "domain": "musiccast",
+ "name": "MusicCast",
+ "description": "MusicCast for Music Assistant",
+ "requirements": [
+ "aiomusiccast==0.14.8",
+ "setuptools>=1.0.0"
+ ],
+ "codeowners": [
+ "@fmunkes"
+ ],
+ "mdns_discovery": [
+ "_http._tcp.local."
+ ],
+ "documentation": "https://music-assistant.io/player-support/musiccast",
+ "multi_instance": false
+}
diff --git a/music_assistant/providers/musiccast/musiccast.py b/music_assistant/providers/musiccast/musiccast.py
new file mode 100644
index 00000000..ac0a9134
--- /dev/null
+++ b/music_assistant/providers/musiccast/musiccast.py
@@ -0,0 +1,602 @@
+"""MusicCast Handling for Music Assistant.
+
+This is largely taken from the MusicCast integration in HomeAssistant,
+https://github.com/home-assistant/core/tree/dev/homeassistant/components/yamaha_musiccast
+and then adapted for MA.
+
+We have
+
+MusicCastController - only once, holds state information of MC network
+ MusicCastPhysicalDevice - AV Receiver, Boxes
+ MusicCastZoneDevice - Player entity, which can be controlled.
+"""
+
+import logging
+from collections.abc import Awaitable, Callable
+from contextlib import suppress
+from datetime import datetime
+from enum import Enum, auto
+from random import getrandbits
+from typing import cast
+
+from aiomusiccast.exceptions import MusicCastConnectionException, MusicCastGroupException
+from aiomusiccast.musiccast_device import MusicCastDevice
+
+from .constants import (
+ MC_DEFAULT_ZONE,
+ MC_NULL_GROUP,
+ MC_PLAY_TITLE,
+ MC_SOURCE_MAIN_SYNC,
+ MC_SOURCE_MC_LINK,
+)
+
+
+def random_uuid_hex() -> str:
+ """Generate a random UUID hex.
+
+ This uuid should not be used for cryptographically secure
+ operations.
+
+ Taken from HA.
+ """
+ return f"{getrandbits(32 * 4):032x}"
+
+
+class MusicCastPlayerState(Enum):
+ """MusicCastPlayerState."""
+
+ PLAYING = auto()
+ PAUSED = auto()
+ IDLE = auto()
+ OFF = auto()
+
+
+class MusicCastZoneDevice:
+ """Zone device.
+
+ A physical device may have different zones, though only a single zone
+ can be used for net playback (but the other ones can be synced internally).
+ """
+
+ def __init__(self, zone_name: str, physical_device: "MusicCastPhysicalDevice") -> None:
+ """Init."""
+ self.zone_name = zone_name # this is not the friendly name
+ self.controller = physical_device.controller
+ self.device = physical_device.device
+ self.zone_data = self.device.data.zones.get(self.zone_name)
+ self.physical_device = physical_device
+
+ self.physical_device.register_group_update_callback(self._group_update)
+
+ async def _group_update(self) -> None:
+ for entity in self.controller.all_server_devices:
+ if self.device.group_reduce_by_source:
+ await entity._check_client_list()
+
+ @property
+ def source_id(self) -> str:
+ """ID of the current input source.
+
+ Internal source name.
+ """
+ zone = self.device.data.zones.get(self.zone_name)
+ assert zone is not None
+ assert isinstance(zone.input, str)
+ return zone.input
+
+ @property
+ def reverse_source_mapping(self) -> dict[str, str]:
+ """Return a mapping from the source label to the source name."""
+ return {v: k for k, v in self.source_mapping.items()}
+
+ @property
+ def source(self) -> str:
+ """Name of the current input source."""
+ return self.source_mapping.get(self.source_id, "UNKNOWN SOURCE")
+
+ @property
+ def source_mapping(self) -> dict[str, str]:
+ """Return a mapping of the actual source names to their labels configured in the App."""
+ assert self.zone_data is not None # for type checking
+ result = {}
+ for input_ in self.zone_data.input_list:
+ label = self.device.data.input_names.get(input_, "")
+ if input_ != label and (
+ label in self.zone_data.input_list
+ or list(self.device.data.input_names.values()).count(label) > 1
+ ):
+ label += f" ({input_})"
+ if label == "":
+ label = input_
+ result[input_] = label
+ return result
+
+ @property
+ def is_netusb(self) -> bool:
+ """Controlled by network if true."""
+ return cast("bool", self.device.data.netusb_input == self.source_id)
+
+ @property
+ def is_tuner(self) -> bool:
+ """Tuner if true."""
+ return self.source_id == "tuner"
+
+ @property
+ def is_controlled_by_mass(self) -> bool:
+ """Controlled by mass if true."""
+ return self.source_id == "server" and self.media_title == MC_PLAY_TITLE
+
+ @property
+ def media_position(self) -> int | None:
+ """Position of current playing media in seconds."""
+ if self.is_netusb:
+ return cast("int", self.device.data.netusb_play_time)
+ return None
+
+ @property
+ def media_position_updated_at(self) -> float | None:
+ """When was the position of the current playing media valid."""
+ if self.is_netusb:
+ stamp: datetime = cast("datetime", self.device.data.netusb_play_time_updated)
+ return stamp.timestamp()
+
+ return None
+
+ @property
+ def is_network_server(self) -> bool:
+ """Return only true if the current entity is a network server.
+
+ I.e. not a main zone with an attached zone2.
+ """
+ return cast(
+ "bool",
+ self.device.data.group_role == "server"
+ and self.device.data.group_id != MC_NULL_GROUP
+ and self.zone_name == self.device.data.group_server_zone,
+ )
+
+ @property
+ def other_zones(self) -> list["MusicCastZoneDevice"]:
+ """Return media player entities of the other zones of this device."""
+ return [
+ entity
+ for entity in self.physical_device.zone_devices.values()
+ if entity != self and isinstance(entity, MusicCastZoneDevice)
+ ]
+
+ @property
+ def state(self) -> MusicCastPlayerState:
+ """Return the state of the player."""
+ assert self.zone_data is not None
+ if self.zone_data.power == "on":
+ if self.is_netusb and self.device.data.netusb_playback == "pause":
+ return MusicCastPlayerState.PAUSED
+ if self.is_netusb and self.device.data.netusb_playback == "stop":
+ return MusicCastPlayerState.IDLE
+ return MusicCastPlayerState.PLAYING
+ return MusicCastPlayerState.OFF
+
+ @property
+ def is_server(self) -> bool:
+ """Return whether the media player is the server/host of the group.
+
+ If the media player is not part of a group, False is returned.
+ """
+ return self.is_network_server or (
+ self.zone_name == MC_DEFAULT_ZONE
+ and len(
+ [entity for entity in self.other_zones if entity.source_id == MC_SOURCE_MAIN_SYNC]
+ )
+ > 0
+ )
+
+ @property
+ def is_network_client(self) -> bool:
+ """Return True if the current entity is a network client and not just a main sync entity."""
+ return (
+ self.device.data.group_role == "client"
+ and self.device.data.group_id != MC_NULL_GROUP
+ and self.source_id == MC_SOURCE_MC_LINK
+ )
+
+ @property
+ def is_client(self) -> bool:
+ """Return whether the media player is the client of a group.
+
+ If the media player is not part of a group, False is returned.
+ """
+ return self.is_network_client or self.source_id == MC_SOURCE_MAIN_SYNC
+
+ @property
+ def musiccast_zone_entity(self) -> "MusicCastZoneDevice":
+ """Return the musiccast entity of the physical device.
+
+ It is possible that multiple zones use MusicCast as client at the same time.
+ In this case the first one is returned.
+ """
+ for entity in self.other_zones:
+ if entity.is_network_server or entity.is_network_client:
+ return entity
+
+ return self
+
+ @property
+ def musiccast_group(self) -> list["MusicCastZoneDevice"]:
+ """Return all media players of the current group, if the media player is server."""
+ if self.is_client:
+ # If we are a client we can still share group information, but we will take them from
+ # the server.
+ if (server := self.group_server) != self:
+ return server.musiccast_group
+
+ return [self]
+ if not self.is_server:
+ return [self]
+ entities = self.controller.all_zone_devices
+ clients = [entity for entity in entities if entity.is_part_of_group(self)]
+ return [self, *clients]
+
+ @property
+ def group_server(self) -> "MusicCastZoneDevice":
+ """Return the server of the own group if present, self else."""
+ for entity in self.controller.all_server_devices:
+ if self.is_part_of_group(entity):
+ return entity
+ return self
+
+ @property
+ def media_title(self) -> str | None:
+ """Return the title of current playing media."""
+ if self.is_netusb:
+ return cast("str", self.device.data.netusb_track)
+ if self.is_tuner:
+ return cast("str", self.device.tuner_media_title)
+
+ return None
+
+ @property
+ def media_image_url(self) -> str | None:
+ """Return the image url of current playing media."""
+ if self.is_client and self.group_server != self:
+ return cast("str", self.group_server.device.media_image_url)
+ return cast("str", self.device.media_image_url) if self.is_netusb else None
+
+ @property
+ def media_artist(self) -> str | None:
+ """Return the artist of current playing media (Music track only)."""
+ if self.is_netusb:
+ return cast("str", self.device.data.netusb_artist)
+ if self.is_tuner:
+ return cast("str", self.device.tuner_media_artist)
+
+ return None
+
+ @property
+ def media_album_name(self) -> str | None:
+ """Return the album of current playing media (Music track only)."""
+ return cast("str", self.device.data.netusb_album) if self.is_netusb else None
+
+ async def turn_on(self) -> None:
+ """Turn on."""
+ await self.device.turn_on(self.zone_name)
+
+ async def turn_off(self) -> None:
+ """Turn off."""
+ await self.device.turn_off(self.zone_name)
+
+ async def volume_mute(self, mute: bool) -> None:
+ """Volume mute."""
+ await self.device.mute_volume(self.zone_name, mute)
+
+ async def volume_set(self, volume_level: int) -> None:
+ """Volume set."""
+ await self.device.set_volume_level(self.zone_name, volume_level / 100)
+
+ async def play(self) -> None:
+ """Play."""
+ if self.is_netusb:
+ await self.device.netusb_play()
+
+ async def pause(self) -> None:
+ """Pause."""
+ if self.is_netusb:
+ await self.device.netusb_pause()
+
+ async def stop(self) -> None:
+ """Stop."""
+ if self.is_netusb:
+ await self.device.netusb_stop()
+
+ async def previous_track(self) -> None:
+ """Send previous track command."""
+ if self.is_netusb:
+ await self.device.netusb_previous_track()
+ elif self.is_tuner:
+ await self.device.tuner_previous_station()
+
+ async def next_track(self) -> None:
+ """Send next track command."""
+ if self.is_netusb:
+ await self.device.netusb_next_track()
+ elif self.is_tuner:
+ await self.device.tuner_next_station()
+
+ async def play_url(self, url: str) -> None:
+ """Play http url."""
+ await self.device.play_url_media(self.zone_name, media_url=url, title=MC_PLAY_TITLE)
+
+ async def select_source(self, source_id: str) -> None:
+ """Select input source. Internal source name."""
+ await self.device.select_source(self.zone_name, source_id)
+
+ def is_part_of_group(self, group_server: "MusicCastZoneDevice") -> bool:
+ """Return True if the given server is the server of self's group."""
+ return group_server != self and (
+ (
+ self.device.ip in group_server.device.data.group_client_list
+ and self.device.data.group_id == group_server.device.data.group_id
+ and self.device.ip != group_server.device.ip
+ and self.source_id == MC_SOURCE_MC_LINK
+ )
+ or (self.device.ip == group_server.device.ip and self.source_id == MC_SOURCE_MAIN_SYNC)
+ )
+
+ async def join_players(self, group_members: list["MusicCastZoneDevice"]) -> None:
+ """Add all clients given in entities to the group of the server.
+
+ Creates a new group if necessary. Used for join service.
+ """
+ assert self.zone_data is not None
+ if self.state == MusicCastPlayerState.OFF:
+ await self.turn_on()
+
+ if not self.is_server and self.musiccast_zone_entity.is_server:
+ # The MusicCast Distribution Module of this device is already in use. To use it as a
+ # server, we first have to unjoin and wait until the servers are updated.
+ await self.musiccast_zone_entity._server_close_group()
+ elif self.musiccast_zone_entity.is_client:
+ await self._client_leave_group(True)
+ # Use existing group id if we are server, generate a new one else.
+ group_id = self.device.data.group_id if self.is_server else random_uuid_hex().upper()
+ assert group_id is not None # for type checking
+
+ ip_addresses = set()
+ # First let the clients join
+ for client in group_members:
+ if client != self:
+ try:
+ network_join = await client._client_join(group_id, self)
+ except MusicCastGroupException:
+ network_join = await client._client_join(group_id, self)
+
+ if network_join:
+ ip_addresses.add(client.device.ip)
+
+ if ip_addresses:
+ await self.device.mc_server_group_extend(
+ self.zone_name,
+ list(ip_addresses),
+ group_id,
+ self.controller.distribution_num,
+ )
+
+ await self._group_update()
+
+ async def unjoin_player(self) -> None:
+ """Leave the group.
+
+ Stops the distribution if device is server. Used for unjoin service.
+ """
+ if self.is_server:
+ await self._server_close_group()
+ else:
+ # this is not as in HA
+ await self._client_leave_group(True)
+
+ # Internal client functions
+
+ async def _client_join(self, group_id: str, server: "MusicCastZoneDevice") -> bool:
+ """Let the client join a group.
+
+ If this client is a server, the server will stop distributing.
+ If the client is part of a different group,
+ it will leave that group first. Returns True, if the server has to
+ add the client on his side.
+ """
+ # If we should join the group, which is served by the main zone,
+ # we can simply select main_sync as input.
+ if self.state == MusicCastPlayerState.OFF:
+ await self.turn_on()
+ if self.device.ip == server.device.ip:
+ if server.zone_name == MC_DEFAULT_ZONE:
+ await self.select_source(MC_SOURCE_MAIN_SYNC)
+ return False
+
+ # It is not possible to join a group hosted by zone2 from main zone.
+ # raise?
+ return False
+
+ if self.musiccast_zone_entity.is_server:
+ # If one of the zones of the device is a server, we need to unjoin first.
+ await self.musiccast_zone_entity._server_close_group()
+
+ elif self.is_client:
+ if self.is_part_of_group(server):
+ return False
+
+ await self._client_leave_group()
+
+ elif (
+ self.device.ip in server.device.data.group_client_list
+ and self.device.data.group_id == server.device.data.group_id
+ and self.device.data.group_role == "client"
+ ):
+ # The device is already part of this group (e.g. main zone is also a client of this
+ # group).
+ # Just select mc_link as source
+ await self.device.zone_join(self.zone_name)
+ return False
+
+ await self.device.mc_client_join(server.device.ip, group_id, self.zone_name)
+ return True
+
+ async def _client_leave_group(self, force: bool = False) -> None:
+ """Make self leave the group.
+
+ Should only be called for clients.
+ """
+ if not force and (
+ self.source_id == MC_SOURCE_MAIN_SYNC
+ or [entity for entity in self.other_zones if entity.source_id == MC_SOURCE_MC_LINK]
+ ):
+ await self.device.zone_unjoin(self.zone_name)
+ else:
+ servers = [
+ server
+ for server in self.controller.all_server_devices
+ if server.device.data.group_id == self.device.data.group_id
+ ]
+ await self.device.mc_client_unjoin()
+ if servers:
+ await servers[0].device.mc_server_group_reduce(
+ servers[0].zone_name,
+ [self.device.ip],
+ self.controller.distribution_num,
+ )
+
+ # Internal server functions
+
+ async def _server_close_group(self) -> None:
+ """Close group of self.
+
+ Should only be called for servers.
+ """
+ for client in self.musiccast_group:
+ if client != self:
+ await client._client_leave_group()
+ await self.device.mc_server_group_close()
+
+ async def _check_client_list(self) -> None:
+ """Let the server check if all its clients are still part of his group."""
+ if not self.is_server or self.device.data.group_update_lock.locked():
+ return
+
+ client_ips_for_removal = [
+ expected_client_ip
+ for expected_client_ip in self.device.data.group_client_list
+ # The client is no longer part of the group. Prepare removal.
+ if expected_client_ip not in [entity.device.ip for entity in self.musiccast_group]
+ ]
+
+ if client_ips_for_removal:
+ await self.device.mc_server_group_reduce(
+ self.zone_name, client_ips_for_removal, self.controller.distribution_num
+ )
+ if len(self.musiccast_group) < 2:
+ # The group is empty, stop distribution.
+ await self._server_close_group()
+
+
+class MusicCastPhysicalDevice:
+ """Physical MusicCast device.
+
+ May contain multiple zone devices, but at least one, main.
+ """
+
+ def __init__(
+ self,
+ device: MusicCastDevice,
+ controller: "MusicCastController",
+ ):
+ """Init."""
+ self.device = device
+ self.zone_devices: dict[str, MusicCastZoneDevice] = {} # zone_name: device
+ self.controller = controller
+ self.controller.physical_devices.append(self)
+
+ async def async_init(self) -> bool:
+ """Async init.
+
+ Returns true if initial fetch was successful.
+ """
+ try:
+ await self.fetch()
+ except (MusicCastConnectionException, MusicCastGroupException):
+ return False
+
+ self.device.build_capabilities()
+
+ # enable udp polling
+ await self.enable_polling()
+
+ for zone_name in self.device.data.zones:
+ self.zone_devices[zone_name] = MusicCastZoneDevice(zone_name, self)
+
+ return True
+
+ async def enable_polling(self) -> None:
+ """Enable udp polling."""
+ await self.device.device.enable_polling()
+
+ def disable_polling(self) -> None:
+ """Disable udp polling."""
+ self.device.device.disable_polling()
+
+ async def fetch(self) -> None:
+ """Fetch device information.
+
+ Should be called regularly, e.g. every 60s, in case some udp info
+ goes missing.
+ """
+ await self.device.fetch()
+
+ def register_callback(self, fun: Callable[["MusicCastPhysicalDevice"], None]) -> None:
+ """Register a non-async callback."""
+
+ def _cb() -> None:
+ fun(self)
+
+ self.device.register_callback(_cb)
+
+ def register_group_update_callback(self, fun: Callable[[], Awaitable[None]]) -> None:
+ """Register an async group update callback."""
+ self.device.register_group_update_callback(fun)
+
+ def remove(self) -> None:
+ """Remove physical device."""
+ with suppress(AttributeError):
+ # might already be closed
+ self.device.device.disable_polling()
+ with suppress(ValueError):
+ # might already be closed
+ self.controller.physical_devices.remove(self)
+
+
+class MusicCastController:
+ """MusicCastController.
+
+ Holds information of full known MC network.
+ """
+
+ def __init__(self, logger: logging.Logger) -> None:
+ """Init."""
+ self.physical_devices: list[MusicCastPhysicalDevice] = []
+ self.logger = logger
+
+ @property
+ def distribution_num(self) -> int:
+ """Return the distribution_num (number of clients in the whole musiccast system)."""
+ return sum(len(x.zone_devices) for x in self.physical_devices)
+
+ @property
+ def all_zone_devices(self) -> list[MusicCastZoneDevice]:
+ """Return all zone devices."""
+ result = []
+ for physical_device in self.physical_devices:
+ result.extend(list(physical_device.zone_devices.values()))
+ return result
+
+ @property
+ def all_server_devices(self) -> list[MusicCastZoneDevice]:
+ """Return server devices."""
+ return [x for x in self.all_zone_devices if x.is_server]
diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py
index d25c3b9d..8bbb233c 100644
--- a/music_assistant/providers/sonos/provider.py
+++ b/music_assistant/providers/sonos/provider.py
@@ -30,8 +30,8 @@ from music_assistant.constants import (
VERBOSE_LOG_LEVEL,
create_sample_rates_config_entry,
)
-from music_assistant.helpers.didl_lite import create_didl_metadata_str
from music_assistant.helpers.tags import async_parse_tags
+from music_assistant.helpers.upnp import create_didl_metadata_str
from music_assistant.models.player_provider import PlayerProvider
from .const import CONF_AIRPLAY_MODE
diff --git a/music_assistant/providers/sonos_s1/__init__.py b/music_assistant/providers/sonos_s1/__init__.py
index 72c4090d..2df049c4 100644
--- a/music_assistant/providers/sonos_s1/__init__.py
+++ b/music_assistant/providers/sonos_s1/__init__.py
@@ -38,7 +38,7 @@ from music_assistant.constants import (
VERBOSE_LOG_LEVEL,
create_sample_rates_config_entry,
)
-from music_assistant.helpers.didl_lite import create_didl_metadata
+from music_assistant.helpers.upnp import create_didl_metadata
from music_assistant.models.player_provider import PlayerProvider
from .player import SonosPlayer
diff --git a/requirements_all.txt b/requirements_all.txt
index d39c7eeb..5d34d5a6 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -7,6 +7,7 @@ aiofiles==24.1.0
aiohttp==3.11.18
aiohttp-fast-zlib==0.2.3
aiojellyfin==0.14.1
+aiomusiccast==0.14.8
aiorun==2025.1.1
aioslimproto==3.1.0
aiosonos==0.1.9
@@ -44,6 +45,7 @@ python-fullykiosk==0.0.14
python-slugify==8.0.4
pywidevine==1.8.0
radios==0.3.2
+setuptools>=1.0.0
shortuuid==1.0.13
snapcast==2.3.6
soco==0.30.9
--
2.34.1