Skip to content
Draft
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
cde7474
Add Wiim provider
davidanthoff Jan 31, 2026
6ccc16b
Small fixes for Wiim provider
davidanthoff Jan 31, 2026
786c9a2
Cleanup Wiim codebase
davidanthoff Feb 1, 2026
098e0be
Simplify Wiim code
davidanthoff Feb 1, 2026
3d6ceeb
Implement device discovery for Wiim provider
davidanthoff Feb 12, 2026
78ea62c
Reduce logging for Wiim provider
davidanthoff Feb 12, 2026
2463617
Add more features to Wiim provider
davidanthoff Feb 12, 2026
b7987b9
Merge branch 'dev' into da/pywiim
davidanthoff Feb 12, 2026
49b4ac8
Handle sources better for the Wiim provider
davidanthoff Feb 13, 2026
1dae323
Add beta stage to Wiim provider
davidanthoff Feb 13, 2026
1bc3342
Unload things properly for Wiim provider
davidanthoff Feb 13, 2026
b9728b8
Refactor a small part of the Wiim provider
davidanthoff Feb 13, 2026
8f8b922
Update pywiim version
davidanthoff Feb 14, 2026
50b2cd3
Fix source handling in Wiim provider
davidanthoff Feb 14, 2026
a3d885b
Update Wiim logo
davidanthoff Feb 14, 2026
4c46cab
Merge branch 'dev' into da/pywiim
davidanthoff Feb 14, 2026
e36f63b
Merge branch 'dev' into da/pywiim
MarvinSchenkel Feb 16, 2026
a9d65ef
Fix white space stripping for Wiim provider
davidanthoff Feb 17, 2026
32a4d2d
Merge branch 'da/pywiim' of https://github.com/davidanthoff/server in…
davidanthoff Feb 17, 2026
697ed6a
Address feedback on Wiim provider
davidanthoff Feb 17, 2026
a48a581
Remove old file from Wiim provider
davidanthoff Feb 18, 2026
5c5c52e
Pin async-upnp-client for Wiim provider
davidanthoff Feb 18, 2026
2f97f0c
Various progress on Wiim provider
davidanthoff Feb 18, 2026
a1e579e
Change a log level in the Wiim provider
davidanthoff Feb 18, 2026
d97dded
Update Wiim icon
davidanthoff Feb 18, 2026
7ad4036
Update pywiim and set manufacturer field
davidanthoff Feb 18, 2026
bd79980
Merge branch 'dev' into da/pywiim
davidanthoff Feb 18, 2026
86fef77
Move player disocvery to new function for Wiim provider
davidanthoff Feb 18, 2026
0e559eb
Handle discovery errors better in Wiim provider
davidanthoff Feb 18, 2026
4d9eba3
Merge branch 'dev' into da/pywiim
davidanthoff Feb 18, 2026
6f8a014
Deduplicate manual ip addresses for Wiim provider
davidanthoff Feb 18, 2026
87cd16b
Skip disabled player in discovery in Wiim provider
davidanthoff Feb 18, 2026
7dbcb2b
Add master to beginning of group for Wiim provider
davidanthoff Feb 19, 2026
d985d4e
Mention Linkplay in Wiim provider description
davidanthoff Feb 19, 2026
0ce3529
Make resource cleanup more robust in Wiim provider
davidanthoff Feb 19, 2026
0900ed8
Merge branch 'dev' into da/pywiim
davidanthoff Feb 19, 2026
6c90989
Fix string interpolation in Wiim provider
davidanthoff Feb 19, 2026
eee7b7c
Merge branch 'da/pywiim' of https://github.com/davidanthoff/server in…
davidanthoff Feb 19, 2026
889c1d0
Fix a None situation in Wiim provider
davidanthoff Feb 19, 2026
a7e8ade
Simplify code in Wiim player
davidanthoff Feb 20, 2026
2eb40b6
Merge branch 'dev' into da/pywiim
MarvinSchenkel Feb 23, 2026
66d1a56
Update pywiim dep for Wiim provider
davidanthoff Feb 24, 2026
ebc8392
Use better model name for Wiim provider
davidanthoff Feb 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions music_assistant/providers/wiim/__init__.py
Original file line number Diff line number Diff line change
@@ -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,)
61 changes: 61 additions & 0 deletions music_assistant/providers/wiim/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Constants for the WiiM Provider."""

from music_assistant_models.player import PlayerSource

SOURCE_LINE_IN = "line_in"
SOURCE_AIRPLAY = "airplay"
SOURCE_SPOTIFY = "spotify"
SOURCE_UNKNOWN = "unknown"
SOURCE_TV = "tv"
SOURCE_RADIO = "radio"

PLAYER_SOURCE_MAP = {
SOURCE_LINE_IN: PlayerSource(
id=SOURCE_LINE_IN,
name="Line-in",
passive=False,
can_play_pause=False,
can_next_previous=False,
can_seek=False,
),
SOURCE_TV: PlayerSource(
id=SOURCE_TV,
name="TV",
passive=False,
can_play_pause=False,
can_next_previous=False,
can_seek=False,
),
SOURCE_AIRPLAY: PlayerSource(
id=SOURCE_AIRPLAY,
name="AirPlay",
passive=True,
can_play_pause=True,
can_next_previous=True,
can_seek=True,
),
SOURCE_SPOTIFY: PlayerSource(
id=SOURCE_SPOTIFY,
name="Spotify",
passive=True,
can_play_pause=True,
can_next_previous=True,
can_seek=True,
),
SOURCE_RADIO: PlayerSource(
id=SOURCE_RADIO,
name="Radio",
passive=True,
can_play_pause=True,
can_next_previous=True,
can_seek=True,
),
SOURCE_UNKNOWN: PlayerSource(
id=SOURCE_UNKNOWN,
name="Unknown",
passive=True,
can_play_pause=True,
can_next_previous=True,
can_seek=True,
),
}
36 changes: 36 additions & 0 deletions music_assistant/providers/wiim/icon.svg
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems the lint is failing on this file. Could you add a blank line?

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions music_assistant/providers/wiim/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"type": "player",
"domain": "wiim",
"name": "WiiM",
"stage": "beta",
"description": "Stream music to WiiM devices.",
"codeowners": ["@davidanthoff"],
"requirements": ["pywiim==2.1.83", "async-upnp-client"],
"documentation": "https://music-assistant.io/player-support/wiim/"
}
217 changes: 217 additions & 0 deletions music_assistant/providers/wiim/player.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""Wiim Player implementation."""

from __future__ import annotations

import time
from typing import TYPE_CHECKING, cast

import pywiim
from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType
from music_assistant_models.player import DeviceInfo, PlayerSource
from pywiim.upnp.eventer import UpnpEventer

from music_assistant.models.player import Player, PlayerMedia

if TYPE_CHECKING:
from pywiim.upnp.client import UpnpClient

from .provider import WiimProvider


class WiimPlayer(Player):
"""Wiim Player in Music Assistant."""

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.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

async def setup(self) -> None:
"""Handle logic when the player is set up in the Player controller."""
# Create UpnpEventer with same UPnP client (for real-time events)
self.wiim_eventer = UpnpEventer(
self.wiim_upnp_client, # Share same UPnP client
self.wiim_player, # Player implements apply_diff() for state updates
self.player_id,
state_updated_callback=self.update_ma_state,
)

# Start UPnP event subscriptions
await self.wiim_eventer.start()

await self.wiim_player.refresh()

self._attr_device_info = DeviceInfo(
model=self.wiim_player.model if self.wiim_player.model else "",
software_version=self.wiim_player.firmware if self.wiim_player.firmware else "",
)

for source in self.wiim_player.source_catalog:
self._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),
)
)

@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."""
await self.wiim_player.play_url(media.uri)
self.current_uri = media.uri

async def play_announcement(
self, announcement: PlayerMedia, volume_level: int | None = None
) -> None:
"""Handle (native) playback of an announcement on the player."""
await self.wiim_player.play_notification(announcement.uri)
Comment on lines +219 to +229
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent feature support declaration. The player declares support for PLAY_ANNOUNCEMENT feature (line 48), but the implementation logs a warning and ignores the volume_level parameter (lines 239-242) rather than implementing it. If volume control for announcements is not supported by the underlying pywiim library, the feature should either be omitted from supported_features or the warning should clarify that the volume will default to current player volume.

Consider checking if pywiim provides any way to set announcement volume, or remove PLAY_ANNOUNCEMENT from supported_features if the functionality is too limited.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Argh, this whole change was directly implementing what the bot in a previous round asked me to do, now it changed its mind?


async def on_unload(self) -> None:
"""Handle logic when the player is unloaded from the Player controller."""
await self.wiim_upnp_client.close()
await self.wiim_client.close()

Comment on lines +231 to +237
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on_unload awaits unsubscribe/close calls sequentially without guarding cleanup in a try/finally. If async_unsubscribe() raises, the UPnP client / WiiM client may never be closed. Consider using try/finally (or asyncio.gather(..., return_exceptions=True)) so all resources get a best-effort close during unload.

Copilot uses AI. Check for mistakes.
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 = cast("WiimPlayer", self.mass.players.get(i))
await child_player.wiim_player.join_group(self.wiim_player)

if player_ids_to_remove:
for i in player_ids_to_remove:
child_player = cast("WiimPlayer", self.mass.players.get(i))
await child_player.wiim_player.leave_group()

def update_ma_state(self) -> None:
"""Update MA state from SDK's cache/HTTP poll attributes."""
logger = self.logger
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 = (
[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()

Comment on lines +288 to +299
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The group_members list is cleared when the player is not a master (line 314), but this logic appears incomplete. If a player transitions from master to slave (e.g., joins another group), the old group_members list is cleared but not properly updated with the new master's ID. The slave should either report an empty group_members list or the full group including the master.

Verify this matches the expected behavior in Music Assistant's player model. Consider checking how other providers like HEOS or Sonos handle slave players' group_members attribute.

Suggested change
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()
group = self.wiim_player.group
if group is None:
# Not part of a group: expose an empty group_members list.
self._attr_group_members = []
else:
# Part of a group (either master or slave): expose the full group,
# including the master and all slaves.
master = getattr(group, "master", None)
master_uuid = getattr(master, "uuid", None) if master is not None else None
# Fallback: if no master UUID is available but this player is the master,
# use this player's id as the master identifier.
if master_uuid is None and self.wiim_player.is_master:
master_uuid = self.player_id
group_member_ids: list[str] = []
if master_uuid is not None:
group_member_ids.append(master_uuid)
for slave in getattr(group, "slaves", []):
slave_uuid = getattr(slave, "uuid", None)
if slave_uuid is not None and slave_uuid not in group_member_ids:
group_member_ids.append(slave_uuid)
self._attr_group_members = group_member_ids

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, the semantics here are not clear to me. My understanding was that the master player should list all the slaves in its _attr_group_members, but that any slave player should have an empty _attr_group_members. That is what this code currently implements. Is that not correct?

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 if self.wiim_player.source else ""
)
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The active_source logic may not properly handle all edge cases. When not a slave (line 316), the code checks if current_uri matches media_content_id to set active_source to player_id (line 318), otherwise it falls back to wiim_player.source (line 321). However, if current_uri is None and media_content_id is also None, the comparison will succeed, incorrectly setting active_source to player_id.

Add an explicit check that both current_uri and media_content_id are not None before comparing them, or ensure current_uri is always initialized to a non-None value (e.g., empty string).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That also just seems wrong? If self.current_uri is None, then the entire condition is False and we are done?

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()
Loading
Loading