Skip to content

Comments

[linkplay] Initial Contribution#19554

Draft
digitaldan wants to merge 14 commits intoopenhab:mainfrom
digitaldan:linkplay
Draft

[linkplay] Initial Contribution#19554
digitaldan wants to merge 14 commits intoopenhab:mainfrom
digitaldan:linkplay

Conversation

@digitaldan
Copy link
Contributor

This integrates audio devices based on the LinkPlay platform. LinkPlay is a popular Wi-Fi audio module used by many manufacturers. This binding uses both UPnP communication for realtime events, meta data, etc.. and the LinkPlay HTTP API for control, group management, etc...

This is dependent on openhab/openhab-core#5095 which was just merged.

@digitaldan digitaldan requested a review from a team as a code owner October 25, 2025 19:47
@digitaldan
Copy link
Contributor Author

Yes, there is another Linkplay binding on the marketplace...... And no, i am not trying to make a habit of creating competing bindings out there ...... I promise 🤞

In this case i did try reaching out to Michael, but did not get a response. The existing binding has been untouched for a long time, and the author's github accounts looks like they have moved to another home automation system, so i don't think its a problem.

This version is fundamentally different in that is was built around UPnP for realtime updates, then i layered on the HTTP api when needed, so there's no polling and and it behaves very similar to the Linkplay/WiiM apps themselves. Also it has strong group support tailored for easy use in our UIs.

I have 4 WiiM streamers that have replaced my 4 aging Sonos Connect boxes i am using this with.

@lsiepel lsiepel added the new binding If someone has started to work on a binding. For a new binding PR. label Oct 25, 2025
@wborn wborn requested a review from Copilot October 26, 2025 09:19
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR introduces a new binding for LinkPlay-based Wi-Fi audio devices. LinkPlay is a popular audio module platform used by various manufacturers. The binding enables control of playback, volume, input sources, equalizer settings, and multi-room audio grouping through a combination of UPnP and HTTP API communications.

Key changes:

  • Adds complete LinkPlay binding with UPnP discovery support
  • Implements multi-room audio group management functionality
  • Provides AudioSink integration for audio streaming

Reviewed Changes

Copilot reviewed 52 out of 53 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
bundles/pom.xml Adds linkplay module to build
bundles/org.openhab.binding.linkplay/pom.xml Maven configuration for new binding
bundles/org.openhab.binding.linkplay/src/main/resources/OH-INF/thing/thing-types.xml Channel and thing type definitions
bundles/org.openhab.binding.linkplay/src/main/resources/OH-INF/i18n/linkplay.properties Internationalization strings
bundles/org.openhab.binding.linkplay/src/main/java/.../LinkPlayHandlerFactory.java Factory for creating handlers and managing UPnP device registry
bundles/org.openhab.binding.linkplay/src/main/java/.../group/*.java Multi-room group management service and interfaces
bundles/org.openhab.binding.linkplay/src/main/java/.../client/http/*.java HTTP API client and DTOs for LinkPlay devices
bundles/org.openhab.binding.linkplay/src/main/java/.../client/upnp/*.java UPnP communication, XML parsing, and command execution
bundles/org.openhab.binding.linkplay/src/main/java/.../discovery/*.java UPnP discovery participant implementation
bundles/org.openhab.binding.linkplay/README.md Complete binding documentation with examples
bom/openhab-addons/pom.xml Adds binding to BOM dependencies
CODEOWNERS Registers code owner for the binding

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@digitaldan digitaldan requested a review from Copilot October 26, 2025 15:53
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

Copilot reviewed 52 out of 53 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@digitaldan digitaldan requested a review from Copilot November 3, 2025 20:24
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

Copilot reviewed 54 out of 55 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

@lsiepel lsiepel left a comment

Choose a reason for hiding this comment

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

Thanks for adding this binding. Looked at 42/55 files, mainly dto's and metadata files. Handlers will be next.

@digitaldan
Copy link
Contributor Author

Thanks @lsiepel , hopefully those comments are addressed.

@lsiepel
Copy link
Contributor

lsiepel commented Nov 30, 2025

Thanks @lsiepel , hopefully those comments are addressed.

Currently reviewing the unifi binding, will come back to this PR when that first round is done.

Copy link
Contributor

@lsiepel lsiepel left a comment

Choose a reason for hiding this comment

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

Looked at 49 out of 55 files. Vital parts are left, didn't want to hold back on these.

Comment on lines +117 to +118
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
int hi = Character.digit(s.charAt(i), 16);
int lo = Character.digit(s.charAt(i + 1), 16);
// If any nibble is invalid, treat whole input as non-hex and return original
if (hi < 0 || lo < 0) {
return hex;
}
data[j] = (byte) ((hi << 4) + lo);

apiClient.getStatusEx().get(5000, TimeUnit.MILLISECONDS);
return port;
} catch (TimeoutException | ExecutionException | InterruptedException ignored) {
// Continue to next port
Copy link
Contributor

Choose a reason for hiding this comment

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

When interrupted, the method should restate the flag and exit immediate and gracefully

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, not sure i'm following, what flag?

Copy link
Contributor

@Nadahar Nadahar Dec 23, 2025

Choose a reason for hiding this comment

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

The interrupted flag - each thread has one. Just call Thread.currentThread().interrupt() after catching InterrutedExceptions (more or less always), because the flag is reset when you catch it - which means that if the code enters other blocking calls before execution is terminated, it will wait there instead of throwing a new InterruptedException. It's one of those Java peculiarities: https://www.baeldung.com/java-interrupted-exception

Many people get this wrong, which leads to problems with threads - as interruption is the control mechanism that is used to terminate threads in Java.

Once a thread is interrupted, it's supposed to just do the most necessary, non-blocking, cleanup, and exit gracefully.

Comment on lines 72 to 74
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
saxParser.getXMLReader().setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
Copy link
Contributor

Choose a reason for hiding this comment

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

I thikn the order is wrong. First apply the factory settings, and afterwards get the saxParser form the factory. Otherwise it won't have effect.

Copy link
Contributor Author

@digitaldan digitaldan Dec 23, 2025

Choose a reason for hiding this comment

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

So i'm not sure what to do here, as stated in the authors section, i "borrowed" this class from the Sonos binding actually the upnp binding, which borrowed from Sonos, and left it pretty much untouched since its been in production a very long time.

Copy link
Contributor

Choose a reason for hiding this comment

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

Remember that if you get this wrong, there will be no "immediate bug". It just means that the protection against XXE attacks won't work, which very well could be the case in the locations where the code has been copied from.

It's all about disabling various unsafe options in XML parsing, first and foremost prevent the parser from contacting and arbitrary URL that is specified in the XML document. XXE is used for that, and then other vulnerabilities are used for the actual attack. When you don't need validation against a "live" schema, like with UPnP, all this can and should just be disabled.

Copy link
Contributor

Choose a reason for hiding this comment

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

Fun fact: I once contributed a bugfix to an XXE scanner, it can be used to test for some of the XXE vulnerabilities, but it's primarily focused on Windows XXE exploits. I'm sure there are more practical scan tools available today, this is a bit involved setting up as far as I can remember.

Using such a scanner, one could relatively easily test the mentioned bindings for such vulnerabilities.

Copy link
Contributor

Choose a reason for hiding this comment

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

It turns out that this is quite a mess. I remember that XXE was "the big scare" some years ago, where a lot of people no doubt made a lot of money from "protection applications" against XXE with various tools and scanners. Then people mostly forgot about it, as there were no more money to be extracted.

In reality, I doubt it was ever anything near as "dangerous" as the hype claimed, at the same time, it's "dangerous enough" that measures should be taken to prevent it. Unfortunately, the Java XML parsers haven't made it easy to protect against, because of stupid implementations. It should be enough to have a flag that could tell every parser to never look outside the document itself, never follow links to other documents. But, that would be too easy I guess.

Instead, there are various confusing sources dealing with this, like this one: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html#java

The example code for the SAXParser links to this gist: https://gist.github.com/asudhakar02/45e2e6fd8bcdfb4bc3b2
...which, as far as I can tell, does it wrong. Which would explain why the current OH code is as it is. It does configure the factory after creating the parser... 😖

But, it gets worse. Because Java's XML parser is "pluggable", you don't actually know which parser you will get (unless you "know" how the application is configured and that this will never change). Not all the parsers support the same "flags", which causes a lot of the chaos.

Xerces 2 supports http://apache.org/xml/features/disallow-doctype-decl, which disables most of the vulnerabilities (although some claim that you need to setXIncludeAware(false) as well - even though it's "the default", the default varies between parsers. Xerces 1 on the other hand, and several others, don't support this, and you must instead use http://xml.org/sax/features/external-general-entities in combination with several other options to disable most of the "holes". Most people don't seem to get the details here... so they just throw in a couple flags, and hopes for the best.

But, some parsers throw exceptions if you try to set "features" that they don't support. So, to properly "secure" a parser factory, for an "unknown implementation", you need to set all the different flags encapsulated by separate catch blocks, so that the code will continue even if you set an unsupported "feature". You have to cover all the bases for all the different implementations, only you can't, because some implementations aren't properly secure no matter what, from what I understand.

So, all in all, it's a mess. My guess is that it's possible to figure out which parser OH actually uses and narrow down the mess quite a lot.

But, when looking in the UPnPControl binding, I see that the factory is acquired and configured for every message that it so be parsed. This is just crazy, you're supposed to create and configure the factory once, and then use it to produce, properly configured, parsers. So, there's that as well....

Maybe making Core configure "secure" XML parsers for bindings to use would be a better option? So, that everybody won't have to know all the details of this?

It should be mentioned that there are also situations where you can't enable these options, because it will make parsing fail for "valid" documents. Stupidly, these options don't just make the parser ignore the URIs in the document, they make them refuse to parse documents with URIs in them. Which can be a problem in some situations, so such a "secure" parser might not always be suitable. But, for many cases, I'm sure it would...

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I know that the licenses themselves allow it - I was thinking OH specific, SAT plugin, policies etc. But, discussing this in a comment on a PR review is the wrong place anyway 😉

Copy link
Contributor

Choose a reason for hiding this comment

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

The zwavejs binding and some others make use of the 3rdparty folder structure. Maybe that is what you are looking for?

Copy link
Contributor

Choose a reason for hiding this comment

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

As I said, I found another workaround in this specific case now, but I'm sure similar situations will arise in the future. I think I'll have to revisit it when it does, I have too many balls in the air at the moment 😉

Copy link
Contributor

Choose a reason for hiding this comment

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

I've been distracted with other things (as usual); so I've not yet completed my alternative suggestion for how to do the GENA subscriptions - but, there's no reason that should hold back this binding anyway, since the approach is shared with two other bindings. I suggest that we handle them as one in respect to the subscription handling and XML parsing.

Copy link
Contributor

Choose a reason for hiding this comment

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

My alternative suggestion just met a dead-end. UpnpIOService basically breaks how jUPnP is supposed to work, as it "destroys information" when receiving events before passing them on to listeners in eventReceived():

        protected void eventReceived(GENASubscription sub) {
            Map<String, StateVariableValue> values = sub.getCurrentValues();
            Device device = sub.getService().getDevice();
            String serviceId = sub.getService().getServiceId().getId();

            logger.trace("Receiving a GENA subscription '{}' response for device '{}'", serviceId,
                    device.getIdentity().getUdn());

            for (UpnpIOParticipant participant : participants) {
                Device participantDevice = getDevice(participant);
                if (Objects.equals(participantDevice, device) || Objects.equals(participantDevice, device.getRoot())) {
                    for (Entry<String, StateVariableValue> entry : values.entrySet()) {
                        Object value = entry.getValue().getValue();
                        if (value != null) {
                            try {
                                participant.onValueReceived(entry.getKey(), value.toString(), serviceId);
                            } catch (Exception e) {
                                logger.error("Participant threw an exception onValueReceived", e);
                            }
                        }
                    }
                    break;
                }
            }
        }

It takes the StateVariableValue and "flattens it" using toString() so that most of the information it carries is lost. This deems further processing to just work with strings manually, like the code already does. So, until this is changed, manual parsing is probably the way to go.

I've been trying to do something about some of the many problems around UPnP in OH, but it seems that there's simply no interest: openhab/openhab-core#5137

Nobody bothers to engage or do basic testing, so I guess I will just have to forget about doing anything about it.

It should still be possible to do UPnP properly, but that would mean to skip UpnpIOService entirely, and do much more of the "heavy lifting" in the binding. But, there's probably no interest for that either.

Comment on lines 131 to 134
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
saxParser.getXMLReader().setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
saxParser.parse(new InputSource(new StringReader(xml)), handler);
Copy link
Contributor

Choose a reason for hiding this comment

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

See other comment about order

LOGGER.error("Could not parse PlayQueue from string '{}'", xml, e);
return null;
} catch (SAXException | ParserConfigurationException s) {
LOGGER.debug("Could not parse PlayQueue from string '{}'", xml, s);
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure about the size, but we generally only list contents in trace. Maybe also guard with isDebugEnabled()

LOGGER.error("Could not parse BrowseQueueResponse from string '{}'", xml, e);
return null;
} catch (SAXException | ParserConfigurationException s) {
LOGGER.debug("Could not parse BrowseQueueResponse from string '{}'", xml, s);
Copy link
Contributor

Choose a reason for hiding this comment

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

See other comment about logging xml content.

<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.1.0-SNAPSHOT</version>
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
<version>5.1.0-SNAPSHOT</version>
<version>5.2.0-SNAPSHOT</version>

Copy link
Contributor

@Nadahar Nadahar Dec 23, 2025

Choose a reason for hiding this comment

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

Won't making that change just become a conflict with the main branch? Isn't it better to rebase it on the updated main where the correct version is set?

Forgot it - I'm just being slow 😒

Copy link
Contributor

@lsiepel lsiepel left a comment

Choose a reason for hiding this comment

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

Reviewed the remaining files of this binding. After all comments have been fixed, i'll probably need one last pass.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could you reduce the image size ?

| track-title | String | R | Current track title |
| track-artist | String | R | Current track artist |
| track-album | String | R | Current track album |
| track-uri | String | R | Current track uri |
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
| track-uri | String | R | Current track uri |
| track-uri | String | R | Current track uri |


The leader device provides special channels to control volume for all grouped devices simultaneously:

| Channel | Type | Description |
Copy link
Contributor

Choose a reason for hiding this comment

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

Please algin the table.

Comment on lines 365 to 366

3. **Independent Channels**: Each device maintains its own settings for:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
3. **Independent Channels**: Each device maintains its own settings for:
1. **Independent Channels**: Each device maintains its own settings for:

When devices are in a group:

1. **Playback Control**: Only the leader's playback controls are active. Member devices mirror the leader's state.
2. **Synchronized Channels**: The following channels are automatically synchronized from leader to all members:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
2. **Synchronized Channels**: The following channels are automatically synchronized from leader to all members:
1. **Synchronized Channels**: The following channels are automatically synchronized from leader to all members:

*/
@NonNullByDefault
public interface LinkPlayGroupParticipant {
void addedToOrUpdatedGroup(LinkPlayGroupParticipant leader, List<Slave> slaves);
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you add javadoc to all interface methods.

try {
member.getApiClient().multiroomJoinGroupMaster(leader.getIpAddress()).get();
} catch (InterruptedException | ExecutionException e) {
logger.error("Error joining group: {}", e.getMessage(), e);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
logger.error("Error joining group: {}", e.getMessage(), e);
logger.warn("Error joining group: {}", e.getMessage(), e);

try {
member.getApiClient().multiroomUngroup().get();
} catch (InterruptedException | ExecutionException e) {
logger.error("Error ungrouping: {}", e.getMessage(), e);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
logger.error("Error ungrouping: {}", e.getMessage(), e);
logger.warn("Error ungrouping: {}", e.getMessage(), e);


member.addedToOrUpdatedGroup(member, slaves);
} catch (InterruptedException | ExecutionException e) {
logger.error("Error getting slave list: {}", e.getMessage(), e);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
logger.error("Error getting slave list: {}", e.getMessage(), e);
logger.warn("Error getting slave list: {}", e.getMessage(), e);

}
member.getApiClient().multiroomJoinGroupMaster(leaderIp).get();
} catch (InterruptedException | ExecutionException e) {
logger.error("Error adding member: {}", e.getMessage(), e);
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
logger.error("Error adding member: {}", e.getMessage(), e);
logger.warn("Error adding member: {}", e.getMessage(), e);

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
@digitaldan
Copy link
Contributor Author

@lsiepel just fyi , I'm currently refactoring a few things out of the thing handler , so taking a little more time , but I'll have that done soon for review again

@jlaur jlaur added the rebuild Triggers Jenkins PR build label Jan 3, 2026
@github-actions github-actions bot removed the rebuild Triggers Jenkins PR build label Jan 3, 2026
Signed-off-by: Dan Cunningham <dan@digitaldan.com>
@digitaldan digitaldan marked this pull request as draft February 20, 2026 00:43
@digitaldan
Copy link
Contributor Author

Moving to a draft while i make some larger changes. I'll let you know when its ready.

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new binding If someone has started to work on a binding. For a new binding PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants