Add Wiim provider based on pywiim#3067
Add Wiim provider based on pywiim#3067davidanthoff wants to merge 43 commits intomusic-assistant:devfrom
Conversation
🔒 Dependency Security Report📦 Modified Dependencies
|
While it might be seen as bikesheeding the naming of these providers at a quick glanc I still stongky think that you should consider renaming this to “Linkplay Player Provider” (instead of ”WiiM Player Provider”) because it will not only be compatible with official WiiM-only devices but also with third-party Linkplay-compatible devices too. The arguments and rationele reasoning for that rename was put forward in the previous PR here -> #2947 (comment) In summery; Understand that all WiiM devices are Linkplay devices but not all Linkplay devices are WiiM branded devices as Linkplay license their technology for the streaming protocol used. WiiM is just Linkplay’s first-party brand products that make use of the Linkplay streaming protocol, but Linkplay also license the same Linkplay streaming protocol to third-parties which makes their products alao compatible with this even though they are not WiiM branded products. To compare, naming this after the WiiM product-line brand instead of the Linkplay protocol it uses would be link renaming the Google Cast provider to “Google Nest” because that is the first-party product brand supports it even though there are loads of third-party audio streamers/recievers that also support the same protocol. So naming it “ Linkplay Player Provider” instead should make it more clear that this should be compatible will all Linkplay devices, including third-party brands using Linkplay technology, and not only limited to WiiM branded devices. Linkplay Technology is actually also the company who own the WiiM brand. That is, WiiM is a first-party brand but there are also many third-party brands out there that are Linkplay compatible audio streamers. WiiM is just a consumer brand of the Linkplay Technology, but yes they also license their "Linkplay" tech to other companies too. If look at the wiimhome.com website you will see that it is says © 2025 Linkplay Technology Inc. And if look at the "About us" company page on the linkplay.com website it mentions that WiiM is their own brand of electronics. Linkplay Technology Inc. has full owership + control of both of Linkplay as a protocol as well as WiiM as a brand and their code. As such there is nothing preventing that company from making a single integration compatible with Linkplay and WiiM. You can kind of see WiiM as a hardware reference platform which show off what the Linkplay ecosystem can do. That is, WiiM products might have more features but the integration can techically be made cross-compatible with both. I understand it is a confusing since "Linkplay Technology" is both name of the company and their technology is named "Linkplay". Update: Forn reference, here are few blog posts that put better words on the connection between Linkplay and its WiiM brand: "The WiiM trademark belongs to Linkplay Technology, founded in 2014. It included experienced engineers and programmers from the world’s leading companies Google, Broadcom, Harman and InterVideo, who hardly need a separate introduction. Since its inception, Linkplay has specialized in the development and improvement of voice interactive control systems, home automation and IT technologies. Her clients included Yamaha, Marshall, Edifier, Audio Pro and other well-known companies. And then LinkPlay decided to produce “smart” electronics under its own brand." "‘WiiM’ is the rather awkward brand name adopted by Linkplay for a (quickly expanding) range of home audio streaming products." "WiiM is part of LinkPlay Technology Inc., a collaborative team from the likes of Google and Harmon, developing bespoke audio streaming software and hardware for OEM partners, with bases in the US, China and South Korea." "Linkplay Technology has launched an upgraded version of its affordable audiophile music streaming box called the WiiM Pro Plus" Again, i question if this shoukd really be named "WiiM Player Provider" and not "Linkplay Player Provider"? Is or will this provider be hardcoded to only be specifically just for WiiM branded products or will it technically be a generic Linkplay implementation? Might we also need a seperate "Linkplay Player Provider" as well is this is added as-is? The main difference is that WiiM is only a brand while Linkplay is a (propriatory) protocol that other manufacturers can also license to use? If I understand correctly, Linkplay Technology has at least in the past licensed its Linkplay technology to others, even if it is today more focused on WiiM which is Linkplay Technology etelectronics brand (though initially it sounds like it was more meant as a reference design that others could copy to make their own Linkplay-based products). So if this provider is not WiiM-specific but instead a general Linkplay provider implementation that coould technically also be used by other manufacturers that support the Linkplay protocol then suggest name it "Linkplay Player Provider" in Music Assistant . However I see that you have also an open PR for a pure WiiM media player integration which is very relevant to this as well: PS: Public info on how Linkplay technology is used in other brands and others manufacturer's products is not perfectly clear: Quotes from https://www.linkplay.com/ "We provide a turnkey solution that includes software, Voice, Wi-Fi, Bluetooth Modules, and global streaming content integrated into one central mobile app for smart, voice-enabled, and IoT products. " "Our smart solution currently powers over hundreds of brands and smart products in multiple regions around the world, in the United States, Europe, Asia, and South America." "Brands We’ve Worked With"
Ping @Linkplay2020 and @WiimHome for further clearification between the Linkplay streaming protocol and the WiiM brand. |
There was a problem hiding this comment.
Pull request overview
Adds a new WiiM/Linkplay player provider to Music Assistant, using the pywiim library as an alternative implementation path to the earlier WIP approach.
Changes:
- Introduces a new
wiimplayer provider (manifest, provider bootstrap, player implementation, icon). - Adds
pywiim==2.1.83to the project’s full requirements set and provider manifest requirements. - Implements basic discovery (SSDP via
pywiim) plus manual IP discovery support, and core playback/grouping controls.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| requirements_all.txt | Adds pywiim dependency pin. |
| music_assistant/providers/wiim/init.py | Registers the provider and exposes manual IP config entry. |
| music_assistant/providers/wiim/provider.py | Implements discovery + player instantiation/registration and logging setup. |
| music_assistant/providers/wiim/player.py | Implements the WiiM player control surface and state syncing into MA. |
| music_assistant/providers/wiim/constants.py | Adds WiiM source constants/mapping (currently unused). |
| music_assistant/providers/wiim/manifest.json | Declares provider metadata and runtime requirements. |
| music_assistant/providers/wiim/icon.svg | Adds provider icon asset. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 |
There was a problem hiding this comment.
The current_uri tracking mechanism could become stale if playback is controlled outside of Music Assistant (e.g., through the WiiM app or voice commands). In update_ma_state(), the comparison at line 317 may incorrectly identify external playback as Music Assistant playback if the URIs happen to match. This could cause incorrect active_source assignment and prevent proper media metadata updates.
Consider adding a timestamp or session identifier to more reliably track whether Music Assistant initiated the current playback session, or reset current_uri when detecting source changes.
There was a problem hiding this comment.
I don't think this is true, as we are always using flow mode. Presumably no external playback will end up using the flow URI that MA generates.
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Argh, this whole change was directly implementing what the bot in a previous round asked me to do, now it changed its mind?
| 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() | ||
|
|
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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 "" | ||
| ) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
That also just seems wrong? If self.current_uri is None, then the entire condition is False and we are done?
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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() | ||
|
|
There was a problem hiding this comment.
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.
| # Start UPnP event subscriptions | ||
| await player.wiim_eventer.start() | ||
|
|
||
| try: | ||
| await player.wiim_player.refresh() | ||
|
|
||
| player._attr_device_info = DeviceInfo( |
There was a problem hiding this comment.
This provider introduces non-trivial behavior (player setup with event subscriptions and state mapping in update_ma_state) but there are no unit tests. The repo has provider-focused pytest coverage (see tests/providers/*); adding tests with mocked pywiim objects would help prevent regressions (e.g., current_media/active_source handling across source changes and grouping).
| 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. |
There was a problem hiding this comment.
Leftover from the demo provider?
| "stage": "beta", | ||
| "description": "Stream music to WiiM and LinkPlay devices.", | ||
| "codeowners": ["@davidanthoff"], | ||
| "requirements": ["pywiim==2.1.84", "async-upnp-client==0.46.2"], |
There was a problem hiding this comment.
If you bump pywiim to the latest version, it should also fix those Sonos discovery warnings in the logs
| self.current_uri: str | None = None | ||
|
|
||
| @staticmethod | ||
| async def setup(ip_address: str, provider: WiimProvider) -> None: |
There was a problem hiding this comment.
I feel this belongs in provider.py it self rather than as a static method on the player?
|
|
||
| # 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 = [ |
There was a problem hiding this comment.
We should check somewhere if a device has already been registered. The logic in the setup now seems to create duplicate subscriptions when a device gets rediscovered?
There was a problem hiding this comment.
Seems the lint is failing on this file. Could you add a blank line?
|
|
||
| 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) |
There was a problem hiding this comment.
This could be verbose to avoid clogging the logs too much
|
Did this get abandoned? For an alternative, or just stale? |

UPDATED: This uses the https://github.com/mjcumming/pywiim package to provide a player integration for Wiim and Linkplay devices. It is an alternative to #2947.
This PR here seems to work quite well now, and I am at a point where I'm very much leaning towards using the
pywiimpackage for the integration. Would be great if folks could try this PR here and review it, it probably is ready for that!The main thing I'm still working on for a first release is some tweaking of source selection.Done.