Add Wiim provider based on pywiim#3067
Add Wiim provider based on pywiim#3067davidanthoff wants to merge 20 commits intomusic-assistant:devfrom
Conversation
🔒 Dependency Security ReportThe Action required: Run 📦 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.
| discovered_devices = await discover_devices() | ||
|
|
||
| device_ip_addresses: list[str] = cast( | ||
| "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key) | ||
| ) | ||
|
|
||
| # Remove duplicates (by IP) | ||
| for device in discovered_devices: | ||
| if device.ip not in device_ip_addresses: | ||
| device_ip_addresses.append(device.ip) | ||
|
|
||
| for ip_address in device_ip_addresses: | ||
| stripped_ip_address = ip_address.strip() | ||
|
|
||
| client = WiiMClient(stripped_ip_address, session=self.mass.http_session) | ||
|
|
||
| # 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: | ||
| continue | ||
|
|
||
| # Create UPnP client (required for events and queue management) | ||
| description_url = f"http://{stripped_ip_address}:49152/description.xml" | ||
| upnp_client = await UpnpClient.create(stripped_ip_address, description_url) | ||
|
|
||
| player = WiimPlayer( | ||
| provider=self, | ||
| player_id=device_info.uuid, | ||
| name=device_info.name, | ||
| client=client, | ||
| upnp_client=upnp_client, | ||
| ) | ||
|
|
||
| await player.setup() | ||
|
|
||
| await self.mass.players.register(player) |
There was a problem hiding this comment.
loaded_in_mass performs network discovery and per-device setup without any error handling. If discover_devices(), get_device_info_model(), or UpnpClient.create() raises (e.g. device offline/bad manual IP), the entire provider load can fail. Also, device_ip_addresses is deduped before .strip(), so values like '1.2.3.4' and ' 1.2.3.4 ' can slip through as duplicates. Please (1) strip/validate IPs up front and skip empty values, (2) wrap discovery and each per-IP setup in try/except with a log + continue (similar to Sonos manual IP handling), (3) ensure any partially created WiiMClient/UPnP client is closed on failures, and (4) use register_or_update or guard against AlreadyRegisteredError so a duplicate UUID doesn’t crash startup.
| async def play_announcement( | ||
| self, announcement: PlayerMedia, volume_level: int | None = None | ||
| ) -> None: | ||
| """Handle (native) playback of an announcement on the player.""" |
There was a problem hiding this comment.
play_announcement ignores the volume_level parameter entirely. The Player API contract expects the player to apply the requested volume if provided (or at least warn that it’s unsupported). Please either honor volume_level (e.g., set/restore volume around the announcement) or log a warning like the HA player implementation does when it can’t apply it.
| """Handle (native) playback of an announcement on the player.""" | |
| """Handle (native) playback of an announcement on the player.""" | |
| if volume_level is not None: | |
| await self.wiim_player.set_volume(volume_level / 100.0) |
| 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)) |
There was a problem hiding this comment.
set_members assumes self.mass.players.get(i) always returns a WiimPlayer, but it can return None (unknown player id) or a different player type. This will raise at runtime when accessing child_player.wiim_player. Please add guards (and/or raise a clear error) when the player lookup fails or the type is unexpected.
| 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)) | |
| child_player = self.mass.players.get(i) | |
| if child_player is None: | |
| self.logger.warning( | |
| "Unable to add player %s to group for %s: player not found", | |
| i, | |
| self.player_id, | |
| ) | |
| continue | |
| if not isinstance(child_player, WiimPlayer): | |
| self.logger.warning( | |
| "Unable to add player %s to Wiim group for %s: " | |
| "player is not a WiimPlayer (got %s)", | |
| i, | |
| self.player_id, | |
| type(child_player).__name__, | |
| ) | |
| continue | |
| 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(i) | |
| if child_player is None: | |
| self.logger.warning( | |
| "Unable to remove player %s from group for %s: player not found", | |
| i, | |
| self.player_id, | |
| ) | |
| continue | |
| if not isinstance(child_player, WiimPlayer): | |
| self.logger.warning( | |
| "Unable to remove player %s from Wiim group for %s: " | |
| "player is not a WiimPlayer (got %s)", | |
| i, | |
| self.player_id, | |
| type(child_player).__name__, | |
| ) | |
| continue |
| """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, | ||
| ), | ||
| } |
There was a problem hiding this comment.
constants.py defines PLAYER_SOURCE_MAP and source IDs, but nothing in the provider currently imports or uses it (the source list is built from self.wiim_player.source_catalog). If this mapping is no longer needed, consider removing the file; if it’s intended to normalize sources, integrate it so it doesn’t become dead code.
| """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, | |
| ), | |
| } | |
| """Constants for the WiiM Provider. | |
| This module is currently kept as a stub for backward compatibility. | |
| Player source handling for the WiiM provider is now driven by | |
| ``wiim_player.source_catalog`` instead of static mappings. | |
| """ |
| "stage": "beta", | ||
| "description": "Stream music to WiiM devices.", | ||
| "codeowners": ["@davidanthoff"], | ||
| "requirements": ["pywiim==2.1.83", "async-upnp-client"], |
There was a problem hiding this comment.
In the provider manifest, async-upnp-client is unpinned. Other providers pin this dependency (e.g. music_assistant/providers/dlna/manifest.json and music_assistant/providers/roku_media_assistant/manifest.json both use async-upnp-client==0.46.2), which avoids runtime drift and potential incompatibilities. Please pin async-upnp-client here to the version used in the repo (or explicitly justify a different version).
| "requirements": ["pywiim==2.1.83", "async-upnp-client"], | |
| "requirements": ["pywiim==2.1.83", "async-upnp-client==0.46.2"], |
|
|
||
| async def handle_async_init(self) -> None: | ||
| """Handle async initialization of the provider.""" | ||
| self.logger.info("Initializing WiimProvider with config: %s", self.config) |
There was a problem hiding this comment.
handle_async_init logs the full provider config at INFO level. Provider configs can contain sensitive values in other integrations, and logging them at INFO increases the risk of leaking secrets into logs. Consider removing this log line, redacting sensitive fields, or downgrading it to DEBUG.
| self.logger.info("Initializing WiimProvider with config: %s", self.config) | |
| self.logger.debug("Initializing WiimProvider with config: %s", self.config) |
|
Few generic comments:
|
MarvinSchenkel
left a comment
There was a problem hiding this comment.
We are getting close 👏 . I agree we should move ahead with this PR and potentially swap packages once WiiM has released a stable pypi package. Please have a look at the comments and mark this PR as 'Ready for review' again when you want us to have another look 🙏
I just converted a bitmap I pulled from Wiim's website into an SVG. It looks fine on my end. Do you have a screenshot that shows how it looks weird. Also, if someone can help with that, it would be appreciated, I don't really know how to create an SVG for this, beyond the simple conversion I tried :) |
|
|
||
| await player.setup() | ||
|
|
||
| await self.mass.players.register(player) |
There was a problem hiding this comment.
Getting "Player FF98F359098B83662F7A94C5 is already registered!". We should change this call to register_or_update
| """Call after the provider has been loaded.""" | ||
| self.logger.info("WiimProvider loaded") | ||
|
|
||
| discovered_devices = await discover_devices() |
There was a problem hiding this comment.
I noticed that my Sonos devices also get discovered by the package which then cause a warning in the logs such as
2026-02-17 14:37:22.241 WARNING (MainThread) [pywiim.client] Failed to detect capabilities for 192.168.1.10: Request failed after 3 attempts: Device unreachable at 192.168.1.10. Connection failed on all attempted protocols. Check that the device is powered on, connected to your network, and that your host can reach 192.168.1.10 (e.g. WSL2/firewall may block access). (endpoint=/httpapi.asp?command=getStatusEx, attempts=5) (endpoint=/httpapi.asp?command=getStatusEx, attempts=3). Using defaults.
Can we work around this?
There was a problem hiding this comment.
@mjcumming do you know why that might occur and whether we could somehow filter the Sonos speakers out earlier? Sorry to drag you into this PR, hope that is ok :)
|
Just had a look at the
You can have a look at the Sonos provider to see how you could support both enqueue and flow mode in 1 provider. I would suggest to leave this for now and do it later in a follow up PR as an enhancement. |
I feel your arguments goes against the Open Home Foundation fundamental principle of choice for smart homes if you purposely only target WiiM branded dvices when the Linkplay protocol is also licenced to third-parties similar to Google’s Cast and Apple’s AirPlay protocols. Giving it the brand name is not transparent nor vender-neutral: As such I believe the arguments against renaming in your reply above falls flat as that would be also like renaming the existing Cast provider to Goole Nest or the AirPlay provier to Apple Homepod if you name it after the brand product instead of after the protocol it supports. I think it is should be made clear that the long-term goal would be to support all Linkplay protocol compatible devices. |
|
@Hedda we had a chat about this with LinkPlay itself and they stated as following; Next to that they have their own brand targeted to consumers directly, which is WiiM (which is also the most known one in our community) - which is based on their standard LinkPlay chip but also has a lot of features on top of it. While you can ofcourse control all base functionality with a generic LinkPlay library (or even the WiiM library), you will not be able to support all features of all devices that use the LinkPlay chip, you would need to implement a library for Vendor A, B and C. Their own WiiM app is a good example. You can fully control all WiiM devices in there. Other LinkPlay devices do show up (and you can also group them just fine) but you won't be able to control all their features, simply because that is manufacturer specific. Next to that there is also consent. While LinkPlay gave approval (and their support) to implement a HA integration + library and provider in MA for their WiiM devices, they can not give that same consent for other vendors. The contributor here is focused on WiiM devices and wants to make a provider that works well for the WiiM devices specifically, also most likely because he owns WiiM device(s) himself and not other LinkPlay generic ones. Which I think is perfectly fine. You can then challenge if this WiiM provider should also support at least the base features of all other LinkPlay based devices (just like the WiiM app does) or you ALSO get a LinkPlay generic provider, a Vendor A provider, a Vendor B provider etc. For the Home Assistant integration it will be a generic LinkPlay integration (which already exists today) and a WiiM-only integration which only supports the WiiM devices in the full featureset. If somebody steps up and wants to create a LinkPlay generic provider, that would ofcourse be very welcome but now the PR author focuses on WiiM devices, which is very understandable and I'm sure it will make lots of people very happy as the WiiM devices are very beloved in the community. |
|
The My own motivation for this PR is the Wiim stuff and that is what I can test, but at the moment it seems to me we can essentially get the other brands for "free" by using I do think the "Wiim" name should show up in the provider name somehow, it seems by far the most widely used brand, I think. Beyond that, I don't really care about naming/branding and am happy to go with whatever the maintainers of MA want me to use :) I would appreciate if we could keep the discussion on this PR mostly focused on getting the tech going, maybe this branding/naming discussion could happen in some other forum/issue? It is kind of distracting from doing the actual work of getting the provider into a working form :) |




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.