diff --git a/music_assistant/providers/wiim/__init__.py b/music_assistant/providers/wiim/__init__.py new file mode 100644 index 0000000000..3faa3ddfde --- /dev/null +++ b/music_assistant/providers/wiim/__init__.py @@ -0,0 +1,49 @@ +""" +Provider for WiiM speakers. + +This package provides a Music Assistant provider implementation for WiiM speakers. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.enums import ProviderFeature + +from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS + +from .provider import WiimProvider + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigEntry, 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 provider(instance) with given configuration.""" + return WiimProvider(mass, manifest, config, SUPPORTED_FEATURES) + + +async def get_config_entries( + mass: MusicAssistant, # noqa: ARG001 + instance_id: str | None = None, # noqa: ARG001 + action: str | None = None, # noqa: ARG001 + values: dict[str, ConfigValueType] | None = None, # noqa: ARG001 +) -> 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. + """ + return (CONF_ENTRY_MANUAL_DISCOVERY_IPS,) diff --git a/music_assistant/providers/wiim/icon.svg b/music_assistant/providers/wiim/icon.svg new file mode 100644 index 0000000000..56ab4e9629 --- /dev/null +++ b/music_assistant/providers/wiim/icon.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/music_assistant/providers/wiim/manifest.json b/music_assistant/providers/wiim/manifest.json new file mode 100644 index 0000000000..af0690aee4 --- /dev/null +++ b/music_assistant/providers/wiim/manifest.json @@ -0,0 +1,10 @@ +{ + "type": "player", + "domain": "wiim", + "name": "WiiM", + "stage": "beta", + "description": "Stream music to WiiM and LinkPlay devices.", + "codeowners": ["@davidanthoff"], + "requirements": ["pywiim==2.1.86", "async-upnp-client==0.46.2"], + "documentation": "https://music-assistant.io/player-support/wiim/" +} diff --git a/music_assistant/providers/wiim/player.py b/music_assistant/providers/wiim/player.py new file mode 100644 index 0000000000..55f392a12c --- /dev/null +++ b/music_assistant/providers/wiim/player.py @@ -0,0 +1,314 @@ +"""Wiim Player implementation.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, cast + +import pywiim +from music_assistant_models.enums import IdentifierType, PlaybackState, PlayerFeature, PlayerType +from music_assistant_models.player import DeviceInfo, PlayerSource +from pywiim.upnp.client import UpnpClient +from pywiim.upnp.eventer import UpnpEventer + +from music_assistant.models.player import Player, PlayerMedia + +if TYPE_CHECKING: + from .provider import WiimProvider + + +class WiimPlayer(Player): + """Wiim Player in Music Assistant.""" + + wiim_eventer: UpnpEventer + + def __init__( + self, + provider: WiimProvider, + player_id: str, + name: str, + client: pywiim.WiiMClient, + upnp_client: UpnpClient, + ) -> None: + """Initialize the Player.""" + super().__init__(provider, player_id) + + # init some static variables + self._attr_name = name + self._attr_type = PlayerType.PLAYER + self._attr_supported_features = { + PlayerFeature.PLAY_MEDIA, + PlayerFeature.VOLUME_SET, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.PAUSE, + PlayerFeature.SET_MEMBERS, + PlayerFeature.NEXT_PREVIOUS, + PlayerFeature.SEEK, + PlayerFeature.SELECT_SOURCE, + PlayerFeature.PLAY_ANNOUNCEMENT, + } + self._attr_can_group_with = {provider.instance_id} + self.wiim_client = client + self.wiim_upnp_client = upnp_client + self.wiim_player = pywiim.Player( + client, + upnp_client=upnp_client, + on_state_changed=self.update_ma_state, + ) + self.current_uri: str | None = None + + @staticmethod + async def setup(ip_address: str, provider: WiimProvider) -> None: + """Handle logic when the player is set up in the Player controller.""" + client = pywiim.WiiMClient(ip_address, session=provider.mass.http_session) + + try: + # Get device info for UUID + device_info = await client.get_device_info_model() + + if device_info.uuid is None or device_info.name is None: + raise RuntimeError(f"WiiM player at {ip_address} doesn't have a uuid or name") + + if not provider.mass.config.get_raw_player_config_value( + device_info.uuid, "enabled", True + ): + provider.logger.debug( + "Ignoring %s in discovery as it is disabled.", device_info.name + ) + await client.close() + return + + # Create UPnP client (required for events and queue management) + description_url = f"http://{ip_address}:49152/description.xml" + upnp_client = await UpnpClient.create( + ip_address, description_url, session=provider.mass.http_session + ) + + try: + player = WiimPlayer( + provider=provider, + player_id=device_info.uuid, + name=device_info.name, + client=client, + upnp_client=upnp_client, + ) + + # Create UpnpEventer with same UPnP client (for real-time events) + player.wiim_eventer = UpnpEventer( + upnp_client, # Share same UPnP client + player.wiim_player, # Player implements apply_diff() for state updates + player.player_id, + state_updated_callback=player.update_ma_state, + ) + + # Start UPnP event subscriptions + await player.wiim_eventer.start() + + try: + await player.wiim_player.refresh() + + player._attr_device_info = DeviceInfo( + model=player.wiim_player.model_name + if player.wiim_player.model_name + else "", + software_version=player.wiim_player.firmware + if player.wiim_player.firmware + else "", + manufacturer=player.wiim_player.client.capabilities.get( + "vendor", "Unknown" + ), + ) + + if player.wiim_player.device_info: + if player.wiim_player.device_info.ip is not None: + player._attr_device_info.add_identifier( + IdentifierType.IP_ADDRESS, player.wiim_player.device_info.ip + ) + + if player.wiim_player.device_info.mac is not None: + player._attr_device_info.add_identifier( + IdentifierType.MAC_ADDRESS, player.wiim_player.device_info.mac + ) + + if player.wiim_player.uuid: + player._attr_device_info.add_identifier( + IdentifierType.UUID, player.wiim_player.uuid + ) + + for source in player.wiim_player.source_catalog: + player._attr_source_list.append( + PlayerSource( + id=source.get("id", ""), + name=source.get("name", ""), + passive=not source.get("selectable", False), + can_play_pause=source.get("supports_pause", False), + can_seek=source.get("supports_seek", False), + can_next_previous=source.get("supports_next_track", False) + and source.get("supports_previous_track", False), + ) + ) + + await provider.mass.players.register_or_update(player) + + except Exception: + await player.wiim_eventer.async_unsubscribe() + raise + except Exception: + await upnp_client.close() + raise + except Exception: + await client.close() + raise + + @property + def needs_poll(self) -> bool: + """Return if the player needs to be polled for state updates.""" + return True + + @property + def poll_interval(self) -> int: + """Return the interval in seconds to poll the player for state updates.""" + return 5 + + async def poll(self) -> None: + """Poll player for state updates.""" + await self.wiim_player.refresh() + + async def select_source(self, source: str) -> None: + """Handle SELECT SOURCE command on the player.""" + await self.wiim_player.set_source(source) + + async def volume_set(self, volume_level: int) -> None: + """Handle VOLUME_SET command on the player.""" + await self.wiim_player.set_volume(volume_level / 100.0) + + async def volume_mute(self, muted: bool) -> None: + """Handle VOLUME MUTE command on the player.""" + await self.wiim_player.set_mute(muted) + + async def next_track(self) -> None: + """Next command.""" + await self.wiim_player.next_track() + + async def previous_track(self) -> None: + """Previous command.""" + await self.wiim_player.previous_track() + + async def seek(self, position: int) -> None: + """SEEK command on the player.""" + await self.wiim_player.seek(position) + + async def play(self) -> None: + """Play command.""" + await self.wiim_player.resume() + + async def stop(self) -> None: + """Stop command.""" + await self.wiim_player.stop() + + async def pause(self) -> None: + """Pause command.""" + await self.wiim_player.pause() + + async def play_media(self, media: PlayerMedia) -> None: + """Play media command.""" + url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media) + await self.wiim_player.play_url(url) + self.current_uri = url + + async def play_announcement( + self, announcement: PlayerMedia, volume_level: int | None = None + ) -> None: + """Handle (native) playback of an announcement on the player.""" + if volume_level is not None: + self.logger.warning( + "Announcement volume level is not supported for player %s", + self.display_name, + ) + + await self.wiim_player.play_notification(announcement.uri) + + async def on_unload(self) -> None: + """Handle logic when the player is unloaded from the Player controller.""" + await super().on_unload() + await self.wiim_eventer.async_unsubscribe() + await self.wiim_upnp_client.close() + await self.wiim_client.close() + + 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 the player.""" + if player_ids_to_add: + for i in player_ids_to_add: + child_player = self.mass.players.get_player(i) + if child_player is None: + self.logger.error( + "Player %s: Cannot add player with id %s to group - player not found", + self.display_name, + i, + ) + continue + child_player = cast("WiimPlayer", child_player) + await child_player.wiim_player.join_group(self.wiim_player) + + if player_ids_to_remove: + for i in player_ids_to_remove: + child_player = self.mass.players.get_player(i) + if child_player is None: + self.logger.error( + "Player %s: Cannot remove player with id %s from group - player not found", + self.display_name, + i, + ) + continue + child_player = cast("WiimPlayer", child_player) + await child_player.wiim_player.leave_group() + + def update_ma_state(self) -> None: + """Update MA state from SDK's cache/HTTP poll attributes.""" + self.logger.debug("Device %s: Updating MA state from SDK cache/HTTP poll", self._attr_name) + + self._attr_available = self.wiim_player.available + + self._attr_volume_level = ( + int(self.wiim_player.volume_level * 100) + if self.wiim_player.volume_level is not None + else None + ) + self._attr_volume_muted = self.wiim_player.is_muted + + self._attr_playback_state = PlaybackState(self.wiim_player.state) + + self._attr_elapsed_time = self.wiim_player.media_position + self._attr_elapsed_time_last_updated = time.time() + + if self.wiim_player.is_master: + self._attr_group_members = ( + [ + self.player_id, + *(i.uuid for i in self.wiim_player.group.slaves if i.uuid is not None), + ] + if self.wiim_player.group is not None + else [] + ) + else: + self._attr_group_members.clear() + + if not self.wiim_player.is_slave: + if self.current_uri and self.current_uri == self.wiim_player.media_content_id: + self._attr_active_source = self.player_id + else: + self._attr_active_source = self.wiim_player.source + self.set_current_media( + uri=self.wiim_player.media_content_id or "", + title=self.wiim_player.media_title, + artist=self.wiim_player.media_artist, + album=self.wiim_player.media_album, + image_url=self.wiim_player.media_image_url, + duration=self.wiim_player.media_duration, + ) + + self.update_state() diff --git a/music_assistant/providers/wiim/provider.py b/music_assistant/providers/wiim/provider.py new file mode 100644 index 0000000000..4c19c90ee6 --- /dev/null +++ b/music_assistant/providers/wiim/provider.py @@ -0,0 +1,76 @@ +"""WiiM Player Provider implementation.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import cast + +from pywiim import discover_devices + +from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL +from music_assistant.models.player_provider import PlayerProvider +from music_assistant.providers.wiim.player import WiimPlayer + + +class WiimProvider(PlayerProvider): + """ + WiiM player provider. + + This provides a WiiM player implementation for Music Assistant. + """ + + async def handle_async_init(self) -> None: + """Handle async initialization of the provider.""" + self.logger.debug("Initializing WiimProvider with config: %s", self.config) + + if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL): + logging.getLogger("pywiim").setLevel(logging.DEBUG) + logging.getLogger("async_upnp_client").setLevel(logging.DEBUG) + else: + logging.getLogger("pywiim").setLevel(self.logger.level + 10) + logging.getLogger("async_upnp_client").setLevel(self.logger.level + 10) + + async def discover_players(self) -> None: + """Discover players for the Wiim provider.""" + discovered_devices = await discover_devices() + + device_ip_addresses: list[str] = [ + ip_address.strip() + for ip_address in cast( + "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key) + ) + if len(ip_address.strip()) > 0 + ] + + # De-duplicate manual IPs while preserving order + device_ip_addresses = list(dict.fromkeys(device_ip_addresses)) + + # Remove duplicates (by IP) + for device in discovered_devices: + if device.ip not in device_ip_addresses: + device_ip_addresses.append(device.ip) + + # Run the rest of each player setup in parallel, so we don't have to wait for each player + # to be setup before starting the next one. + setup_coroutines = [ + WiimPlayer.setup(ip_address, self) for ip_address in device_ip_addresses + ] + results = await asyncio.gather(*setup_coroutines, return_exceptions=True) + for ip_address, result in zip(device_ip_addresses, results, strict=True): + if isinstance(result, Exception): + # Log per-device failures without aborting setup of other devices. + self.logger.error("Failed to set up WiiM player at %s: %s", ip_address, result) + + 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. + """ + for player in self.players: + # if you have any cleanup logic for the players, you can do that here. + # e.g. disconnecting from the player, closing connections, etc. + self.logger.debug("Unloading player %s", player.name) + await self.mass.players.unregister(player.player_id) diff --git a/requirements_all.txt b/requirements_all.txt index 4dee33193e..0f44533c00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -63,6 +63,7 @@ python-fullykiosk==0.0.14 python-slugify==8.0.4 pytz==2025.2 pywidevine==1.9.0 +pywiim==2.1.86 radios==0.3.2 rokuecp==0.19.5 shortuuid==1.0.13