added migrated 2.x add-ons

Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Kai Kreuzer
2020-09-21 01:58:32 +02:00
parent bbf1a7fd29
commit 6df6783b60
11662 changed files with 1302875 additions and 11 deletions

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.binding.upnpcontrol</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
</projectDescription>

View File

@@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons

View File

@@ -0,0 +1,165 @@
# UpnpControl Binding
This binding acts as a UPnP control point to control UPnP AV media servers and media renderers as defined by the [UPnP Forum](https://openconnectivity.org/developer/specifications/upnp-resources/upnp/).
It discovers UPnP media servers and renderers in the local network.
UPnP AV media servers generally allow selecting content from a content directory.
UPnP AV media renderers take care of playback of the content.
You can select a renderer to play the media served from a server.
The full content hierarchy of the media on the server can be browsed hierarchically.
Searching the media library is also supported using UPnP search syntax.
Controls are available to control the playback of the media on the renderer.
Each discovered renderer will also be registered as an openHAB audio sink.
## Supported Things
Two thing types are supported, a server thing, `upnpserver`, and a renderer thing, `upnprenderer`.
The binding has been tested with the AV Media Server and AV Media Renderer from Intel Developer Tools for UPnP Technology, available [here](https://www.meshcommander.com/upnptools).
A second test set included a [TVersity Media Server](http://tversity.com/).
It complies with part of the UPnP AV Media standard, but has not been verified to comply with the full specification.
Tests have focused on the playback of audio, but if the server and renderer support it, other media types should play as well.
## Discovery
UPnP media servers and media renderers in the network will be discovered automatically.
## Thing Configuration
Both the `upnprenderer` and `upnpserver` thing require a configuration parameter, `udn` (Universal Device Name).
This `udn` uniquely defines the UPnP device.
It can be retrieved from the thing ID when using auto discovery.
Additionally, a `upnpserver` device has the following optional configuration parameters:
* `filter`: when true, only list content that is playable on the renderer, default is `false`.
* `sortcriteria`: Sort criteria for the titles in the selection list and when sending for playing to a renderer.
The criteria are defined in UPnP sort criteria format, examples: `+dc:title`, `-dc:creator`, `+upnp:album`.
Support for sort criteria will depend on the media server.
The default is to sort ascending on title, `+dc:title`.
The full syntax for manual configuration is:
```
Thing upnpcontrol:upnpserver:<serverId> [udn="<udn of media server>"]
Thing upnpcontrol:upnprenderer:<rendererId> [udn="<udn of media renderer>", filter=<true/false>, sortcriteria="<sort criteria string>"]
```
## Channels
The `upnpserver` has the following channels:
* `upnprenderer`: The renderer to send the media content to for playback.
The channel allows selecting from all discovered media renderers.
This list is dynamically adjusted as media renderers are being added/removed.
* `currentid`: Current ID of media container or entry ready for playback.
This channel can be used to skip to a specific container or entry in the content directory.
This is especially useful in rules.
* `browse`: Browse and serve media content.
The browsing will start at the top of the content directory tree and allows you to go down and up (represented by ..) in the tree.
The list of containers (directories) and media entries for selection in the content hierarchy is updated dynamically when selecting a container or entry.
All media in the selection list, playable on the currently selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
* `search`: Search for media content on the server.
Search criteria are defined in UPnP search criteria format.
Examples: `dc:title contains "song"`, `dc:creator contains "SpringSteen"`, `unp:class = "object.item.audioItem"`, `upnp:album contains "Born in"`.
The search starts at the value of the `currentid` channel and searches down from there.
When no `currentid` is selected, the search starts at the top.
All media in the search result list, playable on the current selected `upnprenderer` channel, are automatically queued to the renderer as next media for playback.
The `upnprenderer` has the following channels:
| Channel Type ID | Item Type | Access Mode | Description |
|-----------------|-----------|-------------|----------------------------------------------------|
| `volume` | Dimmer | RW | playback volume |
| `control` | Player | RW | play, pause, next, previous control |
| `stop` | Switch | RW | stop media playback |
| `title` | String | R | media title |
| `album` | String | R | media album |
| `albumart` | Image | R | image for media album |
| `creator` | String | R | media creator |
| `artist` | String | R | media artist |
| `publisher` | String | R | media publisher |
| `genre` | String | R | media genre |
| `tracknumber` | Number | R | track number of current track in album |
| `trackduration` | Number:Time | R | track duration of current track in album |
| `trackposition` | Number:Time | R | current position in track during playback or pause |
## Audio Support
All configured media renderers are registered as an audio sink.
`playSound`and `playStream`commands can be used in rules to play back audio fragments or audio streams to a renderer.
## Limitations
The current version of BasicUI does not support dynamic refreshing of the selection list in the `upnpserver` channels `renderer` and `browse`.
A refresh of the browser will be required to show the adjusted selection list.
The `upnpserver search` channel requires input of a string to trigger a search.
This cannot be done with BasicUI, but can be achieved with rules.
## Full Example
.things:
```
Thing upnpcontrol:upnpserver:mymediaserver [udn="538cf6e8-d188-4aed-8545-73a1b905466e"]
Thing upnpcontrol:upnprenderer:mymediarenderer [udn="0ec457ae-6c50-4e6e-9012-dee7bb25be2d", filter=true, sortcriteria="+dc:title"]
```
.items:
```
Group MediaServer <player>
Group MediaRenderer <player>
Dimmer Volume "Volume [%.1f %%]" <soundvolume> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:volume"}
Switch Mute "Mute" <soundvolume_mute> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:mute"}
Player Controls "Controller" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:control"}
Switch Stop "Stop" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:stop"}
String Title "Now playing [%s]" <text> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:title"}
String Album "Album" <text> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:album"}
Image AlbumArt "Album Art" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:albumart"}
String Creator "Creator" <text> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:creator"}
String Artist "Artist" <text> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:artist"}
String Publisher "Publisher" <text> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:publisher"}
String Genre "Genre" <text> (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:genre"}
Number TrackNumber "Track Number" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:tracknumber"}
Number:Time TrackDuration "Track Duration [%d %unit%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:trackduration"}
Number:Time TrackPosition "Track Position [%d %unit%]" (MediaRenderer) {channel="upnpcontrol:upnprenderer:mymediarenderer:trackposition"}
String Renderer "Renderer [%s]" <text> (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:title"}
String CurrentId "Current Entry [%s]" <text> (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:currentid"}
String Browse "Browse" (MediaServer) {channel="upnpcontrol:upnpserver:mymediaserver:browse"}
```
.sitemap:
```
Slider item=Volume
Switch item=Mute
Default item=Controls
Switch item=Stop mappings=[ON="STOP"]
Text item=Title
Text item=Album
Default item=AlbumArt
Text item=Creator
Text item=Artist
Text item=Publisher
Text item=Genre
Text item=TrackNumber
Text item=TrackDuration
Text item=TrackPosition
Text item=Renderer
Text item=CurrentId
Text item=Browse
```
Audio sink usage examples in rules:
```
playSound(“doorbell.mp3”)
playStream("upnpcontrol:upnprenderer:mymediarenderer", "http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3”)
```

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.upnpcontrol</artifactId>
<name>openHAB Add-ons :: Bundles :: UPnP Control Binding</name>
</project>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.upnpcontrol-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-upnpcontrol" description="UPnP Control Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-upnp</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.upnpcontrol/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,126 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import java.io.IOException;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioHTTPServer;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.audio.FixedLengthAudioStream;
import org.openhab.core.audio.URLAudioStream;
import org.openhab.core.audio.UnsupportedAudioFormatException;
import org.openhab.core.audio.UnsupportedAudioStreamException;
import org.openhab.core.library.types.PercentType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public class UpnpAudioSink implements AudioSink {
private final Logger logger = LoggerFactory.getLogger(UpnpAudioSink.class);
private static final Set<Class<? extends AudioStream>> SUPPORTED_STREAMS = Stream
.of(AudioStream.class, FixedLengthAudioStream.class).collect(Collectors.toSet());
private UpnpRendererHandler handler;
private AudioHTTPServer audioHTTPServer;
private String callbackUrl;
public UpnpAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
this.handler = handler;
this.audioHTTPServer = audioHTTPServer;
this.callbackUrl = callbackUrl;
}
@Override
public String getId() {
return handler.getThing().getUID().toString();
}
@Override
public @Nullable String getLabel(@Nullable Locale locale) {
return handler.getThing().getLabel();
}
@Override
public void process(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
if (audioStream == null) {
stopMedia();
return;
}
String url = null;
if (audioStream instanceof URLAudioStream) {
URLAudioStream urlAudioStream = (URLAudioStream) audioStream;
url = urlAudioStream.getURL();
} else if (!callbackUrl.isEmpty()) {
String relativeUrl = audioStream instanceof FixedLengthAudioStream
? audioHTTPServer.serve((FixedLengthAudioStream) audioStream, 20)
: audioHTTPServer.serve(audioStream);
url = String.valueOf(this.callbackUrl) + relativeUrl;
} else {
logger.warn("We do not have any callback url, so {} cannot play the audio stream!", handler.getUDN());
return;
}
playMedia(url);
}
@Override
public Set<AudioFormat> getSupportedFormats() {
return handler.getSupportedAudioFormats();
}
@Override
public Set<Class<? extends AudioStream>> getSupportedStreams() {
return SUPPORTED_STREAMS;
}
@Override
public PercentType getVolume() throws IOException {
return handler.getCurrentVolume();
}
@Override
public void setVolume(@Nullable PercentType volume) throws IOException {
if (volume != null) {
handler.setVolume(handler.getCurrentChannel(), volume);
}
}
private void stopMedia() {
handler.stop();
}
private void playMedia(String url) {
stopMedia();
String newUrl = url;
if (!url.startsWith("x-") && !url.startsWith("http")) {
newUrl = "x-file-cifs:" + url;
}
handler.setCurrentURI(newUrl, "");
handler.play();
}
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
/**
* Interface class to be implemented in {@link UpnpControlHandlerFactory}, allows a {UpnpRendererHandler} to register
* itself as an audio sink when it supports audio. If it supports audio is only known after the communication with the
* renderer is established.
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public interface UpnpAudioSinkReg {
/**
* Implemented method should create a new {@link UpnpAudioSink} and register the handler parameter as an audio sink.
*
* @param handler
*/
void registerAudioSink(UpnpRendererHandler handler);
}

View File

@@ -0,0 +1,70 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link UpnpControlBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public class UpnpControlBindingConstants {
public static final String BINDING_ID = "upnpcontrol";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_RENDERER = new ThingTypeUID(BINDING_ID, "upnprenderer");
public static final ThingTypeUID THING_TYPE_SERVER = new ThingTypeUID(BINDING_ID, "upnpserver");
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Stream.of(THING_TYPE_RENDERER, THING_TYPE_SERVER)
.collect(Collectors.toSet());
// List of thing parameter names
public static final String HOST_PARAMETER = "ipAddress";
public static final String TCP_PORT_PARAMETER = "port";
public static final String UDN_PARAMETER = "udn";
public static final String REFRESH_INTERVAL = "refreshInterval";
// List of all Channel ids
public static final String VOLUME = "volume";
public static final String MUTE = "mute";
public static final String CONTROL = "control";
public static final String STOP = "stop";
public static final String TITLE = "title";
public static final String ALBUM = "album";
public static final String ALBUM_ART = "albumart";
public static final String CREATOR = "creator";
public static final String ARTIST = "artist";
public static final String PUBLISHER = "publisher";
public static final String GENRE = "genre";
public static final String TRACK_NUMBER = "tracknumber";
public static final String TRACK_DURATION = "trackduration";
public static final String TRACK_POSITION = "trackposition";
public static final String UPNPRENDERER = "upnprenderer";
public static final String CURRENTID = "currentid";
public static final String BROWSE = "browse";
public static final String SEARCH = "search";
public static final String SERVE = "serve";
// Thing config properties
public static final String CONFIG_FILTER = "filter";
public static final String SORT_CRITERIA = "sortcriteria";
}

View File

@@ -0,0 +1,176 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.util.Hashtable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
import org.openhab.binding.upnpcontrol.internal.handler.UpnpServerHandler;
import org.openhab.core.audio.AudioHTTPServer;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.net.HttpServiceUtil;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link UpnpControlHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Mark Herwege - Initial contribution
*/
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.upnpcontrol")
@NonNullByDefault
public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implements UpnpAudioSinkReg {
private final Logger logger = LoggerFactory.getLogger(getClass());
private ConcurrentMap<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers = new ConcurrentHashMap<>();
private ConcurrentMap<String, UpnpServerHandler> upnpServers = new ConcurrentHashMap<>();
private final UpnpIOService upnpIOService;
private final AudioHTTPServer audioHTTPServer;
private final NetworkAddressService networkAddressService;
private final UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
private final UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
private String callbackUrl = "";
@Activate
public UpnpControlHandlerFactory(final @Reference UpnpIOService upnpIOService,
final @Reference AudioHTTPServer audioHTTPServer,
final @Reference NetworkAddressService networkAddressService,
final @Reference UpnpDynamicStateDescriptionProvider dynamicStateDescriptionProvider,
final @Reference UpnpDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider) {
this.upnpIOService = upnpIOService;
this.audioHTTPServer = audioHTTPServer;
this.networkAddressService = networkAddressService;
this.upnpStateDescriptionProvider = dynamicStateDescriptionProvider;
this.upnpCommandDescriptionProvider = dynamicCommandDescriptionProvider;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(THING_TYPE_RENDERER)) {
return addRenderer(thing);
} else if (thingTypeUID.equals(THING_TYPE_SERVER)) {
return addServer(thing);
}
return null;
}
@Override
public void unregisterHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
String key = thing.getUID().toString();
if (thingTypeUID.equals(THING_TYPE_RENDERER)) {
removeRenderer(key);
} else if (thingTypeUID.equals(THING_TYPE_SERVER)) {
removeServer(key);
}
super.unregisterHandler(thing);
}
private UpnpServerHandler addServer(Thing thing) {
UpnpServerHandler handler = new UpnpServerHandler(thing, upnpIOService, upnpRenderers,
upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
String key = thing.getUID().toString();
upnpServers.put(key, handler);
logger.debug("Media server handler created for {}", thing.getLabel());
return handler;
}
private UpnpRendererHandler addRenderer(Thing thing) {
callbackUrl = createCallbackUrl();
UpnpRendererHandler handler = new UpnpRendererHandler(thing, upnpIOService, this);
String key = thing.getUID().toString();
upnpRenderers.put(key, handler);
upnpServers.forEach((thingId, value) -> value.addRendererOption(key));
logger.debug("Media renderer handler created for {}", thing.getLabel());
return handler;
}
private void removeServer(String key) {
logger.debug("Removing media server handler for {}", upnpServers.get(key).getThing().getLabel());
upnpServers.remove(key);
}
private void removeRenderer(String key) {
logger.debug("Removing media renderer handler for {}", upnpRenderers.get(key).getThing().getLabel());
if (audioSinkRegistrations.containsKey(key)) {
logger.debug("Removing audio sink registration for {}", upnpRenderers.get(key).getThing().getLabel());
ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(key);
reg.unregister();
audioSinkRegistrations.remove(key);
}
upnpServers.forEach((thingId, value) -> value.removeRendererOption(key));
upnpRenderers.remove(key);
}
@Override
public void registerAudioSink(UpnpRendererHandler handler) {
if (!(callbackUrl.isEmpty())) {
UpnpAudioSink audioSink = new UpnpAudioSink(handler, audioHTTPServer, callbackUrl);
@SuppressWarnings("unchecked")
ServiceRegistration<AudioSink> reg = (ServiceRegistration<AudioSink>) bundleContext
.registerService(AudioSink.class.getName(), audioSink, new Hashtable<String, Object>());
Thing thing = handler.getThing();
audioSinkRegistrations.put(thing.getUID().toString(), reg);
logger.debug("Audio sink added for media renderer {}", thing.getLabel());
}
}
private String createCallbackUrl() {
if (!callbackUrl.isEmpty()) {
return callbackUrl;
}
NetworkAddressService nwaService = networkAddressService;
String ipAddress = nwaService.getPrimaryIpv4HostAddress();
if (ipAddress == null) {
logger.warn("No network interface could be found.");
return "";
}
int port = HttpServiceUtil.getHttpServicePort(bundleContext);
if (port == -1) {
logger.warn("Cannot find port of the http service.");
return "";
}
return "http://" + ipAddress + ":" + port;
}
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.type.DynamicCommandDescriptionProvider;
import org.openhab.core.types.CommandDescription;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* @author Mark Herwege - Initial contribution
*/
@Component(service = { DynamicCommandDescriptionProvider.class, UpnpDynamicCommandDescriptionProvider.class })
@NonNullByDefault
public class UpnpDynamicCommandDescriptionProvider implements DynamicCommandDescriptionProvider {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final Map<ChannelUID, @Nullable CommandDescription> descriptions = new ConcurrentHashMap<>();
public void setDescription(ChannelUID channelUID, @Nullable CommandDescription description) {
logger.debug("Adding command description for channel {}", channelUID);
descriptions.put(channelUID, description);
}
public void removeAllDescriptions() {
logger.debug("Removing all command descriptions");
descriptions.clear();
}
@Override
public @Nullable CommandDescription getCommandDescription(Channel channel,
@Nullable CommandDescription originalCommandDescription, @Nullable Locale locale) {
CommandDescription description = descriptions.get(channel.getUID());
return description;
}
@Deactivate
public void deactivate() {
descriptions.clear();
}
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.type.DynamicStateDescriptionProvider;
import org.openhab.core.types.StateDescription;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* @author Mark Herwege - Initial contribution
*/
@Component(service = { DynamicStateDescriptionProvider.class, UpnpDynamicStateDescriptionProvider.class })
@NonNullByDefault
public class UpnpDynamicStateDescriptionProvider implements DynamicStateDescriptionProvider {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final Map<ChannelUID, @Nullable StateDescription> descriptions = new ConcurrentHashMap<>();
public void setDescription(ChannelUID channelUID, @Nullable StateDescription description) {
logger.debug("Adding state description for channel {}", channelUID);
descriptions.put(channelUID, description);
}
public void removeAllDescriptions() {
logger.debug("Removing all state descriptions");
descriptions.clear();
}
@Override
public @Nullable StateDescription getStateDescription(Channel channel,
@Nullable StateDescription originalStateDescription, @Nullable Locale locale) {
StateDescription description = descriptions.get(channel.getUID());
return description;
}
@Deactivate
public void deactivate() {
descriptions.clear();
}
}

View File

@@ -0,0 +1,202 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.lang.StringEscapeUtils;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
*/
@NonNullByDefault
public class UpnpEntry {
private static final String DIRECTORY_ROOT = "0";
private static final Pattern CONTAINER_PATTERN = Pattern.compile("object.container");
private String id;
private String refId;
private String parentId;
private String upnpClass;
private String title = "";
private List<UpnpEntryRes> resList = new ArrayList<>();
private String album = "";
private String albumArtUri = "";
private String creator = "";
private String artist = "";
private String publisher = "";
private String genre = "";
private @Nullable Integer originalTrackNumber;
private boolean isContainer;
public UpnpEntry(String id, String refId, String parentId, String upnpClass) {
this.id = id;
this.refId = refId;
this.parentId = parentId;
this.upnpClass = upnpClass;
Matcher matcher = CONTAINER_PATTERN.matcher(upnpClass);
isContainer = matcher.find();
}
public UpnpEntry withTitle(String title) {
this.title = title;
return this;
}
public UpnpEntry withAlbum(String album) {
this.album = album;
return this;
}
public UpnpEntry withAlbumArtUri(String albumArtUri) {
this.albumArtUri = albumArtUri;
return this;
}
public UpnpEntry withCreator(String creator) {
this.creator = creator;
return this;
}
public UpnpEntry withArtist(String artist) {
this.artist = artist;
return this;
}
public UpnpEntry withPublisher(String publisher) {
this.publisher = publisher;
return this;
}
public UpnpEntry withGenre(String genre) {
this.genre = genre;
return this;
}
public UpnpEntry withResList(List<UpnpEntryRes> resList) {
this.resList = resList;
return this;
}
public UpnpEntry withTrackNumber(@Nullable Integer originalTrackNumber) {
this.originalTrackNumber = originalTrackNumber;
return this;
}
/**
* @return the title of the entry.
*/
@Override
public String toString() {
return title;
}
/**
* @return the unique identifier of this entry.
*/
public String getId() {
return id;
}
/**
* @return the title of the entry.
*/
public String getTitle() {
return title;
}
/**
* @return the identifier of the entry this reference intry refers to.
*/
public String getRefId() {
return refId;
}
/**
* @return the unique identifier of the parent of this entry.
*/
public String getParentId() {
return parentId.isEmpty() ? DIRECTORY_ROOT : parentId;
}
/**
* @return a URI for this entry. Thumbnail resources are not considered.
*/
public String getRes() {
return resList.stream().filter(res -> !res.isThumbnailRes()).map(UpnpEntryRes::getRes).findAny().orElse("");
}
public List<String> getProtocolList() {
return resList.stream().map(UpnpEntryRes::getProtocolInfo).collect(Collectors.toList());
}
/**
* @return the UPnP classname for this entry.
*/
public String getUpnpClass() {
return upnpClass;
}
public boolean isContainer() {
return isContainer;
}
/**
* @return the name of the album.
*/
public String getAlbum() {
return album;
}
/**
* @return the URI for the album art.
*/
public String getAlbumArtUri() {
return StringEscapeUtils.unescapeXml(albumArtUri);
}
/**
* @return the name of the artist who created the entry.
*/
public String getCreator() {
return creator;
}
public String getArtist() {
return artist;
}
public String getPublisher() {
return publisher;
}
public String getGenre() {
return genre;
}
public @Nullable Integer getOriginalTrackNumber() {
return originalTrackNumber;
}
}

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
class UpnpEntryRes {
private String protocolInfo;
private @Nullable Long size;
private String duration;
private String importUri;
private String res = "";
UpnpEntryRes(String protocolInfo, @Nullable Long size, @Nullable String duration, @Nullable String importUri) {
this.protocolInfo = protocolInfo;
this.size = size;
this.duration = (duration == null) ? "" : duration;
this.importUri = (importUri == null) ? "" : importUri;
}
/**
* @return the res
*/
public String getRes() {
return res;
}
/**
* @param res the res to set
*/
public void setRes(String res) {
this.res = res;
}
public String getProtocolInfo() {
return protocolInfo;
}
/**
* @return the size
*/
public @Nullable Long getSize() {
return size;
}
/**
* @return the duration
*/
public String getDuration() {
return duration;
}
/**
* @return the importUri
*/
public String getImportUri() {
return importUri;
}
/**
* @return true if this resource defines a thumbnail as specified in the DLNA specs
*/
public boolean isThumbnailRes() {
return getProtocolInfo().toLowerCase().contains("dlna.org_pn=jpeg_tn");
}
}

View File

@@ -0,0 +1,119 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public final class UpnpProtocolMatcher {
private static final Logger LOGGER = LoggerFactory.getLogger(UpnpProtocolMatcher.class);
private UpnpProtocolMatcher() {
}
/**
* Test if an UPnP protocol matches the object class. This method is used to filter resources for the primary
* resource.
*
* @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
* e.g. http-get:*:audio/mpeg:*
* @param objectClass e.g. object.item.audioItem.musicTrack
* @return true if protocol matches objectClass
*/
public static boolean testProtocol(String protocol, String objectClass) {
String[] protocolDetails = protocol.split(":");
if (protocolDetails.length < 3) {
LOGGER.debug("Protocol string {} not valid", protocol);
return false;
}
String protocolType = protocolDetails[2].toLowerCase();
int index = protocolType.indexOf("/");
if (index <= 0) {
LOGGER.debug("Protocol string {} not valid", protocol);
return false;
}
protocolType = protocolType.substring(0, index);
String[] objectClassDetails = objectClass.split("\\.");
if (objectClassDetails.length < 3) {
LOGGER.debug("Object class {} not valid", objectClass);
return false;
}
String objectType = objectClassDetails[2].toLowerCase();
LOGGER.debug("Matching protocol type '{}' with object type '{}'", protocolType, objectType);
return objectType.startsWith(protocolType);
}
/**
* Test if a UPnP protocol is in a set of protocols.
* Ignore vendor specific additionalInfo part in UPnP protocol string.
* Do all comparisons in lower case.
*
* @param protocol format: <protocol>:<network>:<contentFormat>:<additionalInfo>
* @param protocolSet
* @return true if protocol in protocolSet
*/
public static boolean testProtocol(String protocol, List<String> protocolSet) {
int index = protocol.lastIndexOf(":");
if (index <= 0) {
LOGGER.debug("Protocol {} not valid", protocol);
return false;
}
String p = protocol.toLowerCase().substring(0, index);
List<String> pSet = new ArrayList<>();
protocolSet.forEach(f -> {
int i = f.lastIndexOf(":");
if (i <= 0) {
LOGGER.debug("Protocol {} from set not valid", f);
} else {
pSet.add(f.toLowerCase().substring(0, i));
}
});
LOGGER.trace("Testing {} in {}", p, pSet);
return pSet.contains(p);
}
/**
* Test if any of the UPnP protocols in protocolList can be found in a set of protocols.
*
* @param protocolList
* @param protocolSet
* @return true if one of the protocols in protocolSet
*/
public static boolean testProtocolList(List<String> protocolList, List<String> protocolSet) {
return protocolList.stream().anyMatch(p -> testProtocol(p, protocolSet));
}
/**
* Return all UPnP protocols from protocolList that are part of a set of protocols.
*
* @param protocolList
* @param protocolSet
* @return sublist of protocolList
*/
public static List<String> getProtocols(List<String> protocolList, List<String> protocolSet) {
return protocolList.stream().filter(p -> testProtocol(p, protocolSet)).collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,364 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
import java.io.IOException;
import java.io.StringReader;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import org.apache.commons.lang.StringEscapeUtils;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
/**
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
*/
@NonNullByDefault
public class UpnpXMLParser {
private static final Logger LOGGER = LoggerFactory.getLogger(UpnpXMLParser.class);
private static final MessageFormat METADATA_FORMAT = new MessageFormat(
"<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" "
+ "xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" "
+ "xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\">"
+ "<item id=\"{0}\" parentID=\"{1}\" restricted=\"true\">" + "<dc:title>{2}</dc:title>"
+ "<upnp:class>{3}</upnp:class>" + "<upnp:album>{4}</upnp:album>"
+ "<upnp:albumArtURI>{5}</upnp:albumArtURI>" + "<dc:creator>{6}</dc:creator>"
+ "<upnp:artist>{7}</upnp:artist>" + "<dc:publisher>{8}</dc:publisher>"
+ "<upnp:genre>{9}</upnp:genre>" + "<upnp:originalTrackNumber>{10}</upnp:originalTrackNumber>"
+ "</item></DIDL-Lite>");
private enum Element {
TITLE,
CLASS,
ALBUM,
ALBUM_ART_URI,
CREATOR,
ARTIST,
PUBLISHER,
GENRE,
TRACK_NUMBER,
RES
}
public static Map<String, String> getAVTransportFromXML(String xml) {
if (xml.isEmpty()) {
LOGGER.debug("Could not parse AV Transport from empty xml");
return Collections.emptyMap();
}
AVTransportEventHandler handler = new AVTransportEventHandler();
try {
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();
saxParser.parse(new InputSource(new StringReader(xml)), handler);
} catch (IOException e) {
// This should never happen - we're not performing I/O!
LOGGER.error("Could not parse AV Transport from string '{}'", xml, e);
} catch (SAXException | ParserConfigurationException s) {
LOGGER.debug("Could not parse AV Transport from string '{}'", xml, s);
}
return handler.getChanges();
}
/**
* @param xml
* @return a list of Entries from the given xml string.
* @throws IOException
* @throws SAXException
*/
public static List<UpnpEntry> getEntriesFromXML(String xml) {
if (xml.isEmpty()) {
LOGGER.debug("Could not parse Entries from empty xml");
return Collections.emptyList();
}
EntryHandler handler = new EntryHandler();
try {
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser saxParser = factory.newSAXParser();
saxParser.parse(new InputSource(new StringReader(xml)), handler);
} catch (IOException e) {
// This should never happen - we're not performing I/O!
LOGGER.error("Could not parse Entries from string '{}'", xml, e);
} catch (SAXException | ParserConfigurationException s) {
LOGGER.debug("Could not parse Entries from string '{}'", xml, s);
}
return handler.getEntries();
}
private static class AVTransportEventHandler extends DefaultHandler {
private final Map<String, String> changes = new HashMap<String, String>();
AVTransportEventHandler() {
// shouldn't be used outside of this package.
}
@Override
public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
@Nullable Attributes atts) throws SAXException {
/*
* The events are all of the form <qName val="value"/> so we can get all
* the info we need from here.
*/
if ((qName != null) && (atts != null) && (atts.getValue("val") != null)) {
changes.put(qName, atts.getValue("val"));
}
}
public Map<String, String> getChanges() {
return changes;
}
}
private static class EntryHandler extends DefaultHandler {
// Maintain a set of elements it is not useful to complain about.
// This list will be initialized on the first failure case.
private static List<String> ignore = new ArrayList<String>();
private String id = "";
private String refId = "";
private String parentId = "";
private StringBuilder upnpClass = new StringBuilder();
private List<UpnpEntryRes> resList = new ArrayList<>();
private StringBuilder res = new StringBuilder();
private StringBuilder title = new StringBuilder();
private StringBuilder album = new StringBuilder();
private StringBuilder albumArtUri = new StringBuilder();
private StringBuilder creator = new StringBuilder();
private StringBuilder artist = new StringBuilder();
private List<String> artistList = new ArrayList<>();
private StringBuilder publisher = new StringBuilder();
private StringBuilder genre = new StringBuilder();
private StringBuilder trackNumber = new StringBuilder();
private @Nullable Element element = null;
private List<UpnpEntry> entries = new ArrayList<>();
EntryHandler() {
// shouldn't be used outside of this package.
}
@Override
public void startElement(@Nullable String uri, @Nullable String localName, @Nullable String qName,
@Nullable Attributes attributes) throws SAXException {
if (qName == null) {
element = null;
return;
}
switch (qName) {
case "container":
case "item":
if (attributes != null) {
if (attributes.getValue("id") != null) {
id = attributes.getValue("id");
}
if (attributes.getValue("refID") != null) {
refId = attributes.getValue("refID");
}
if (attributes.getValue("parentID") != null) {
parentId = attributes.getValue("parentID");
}
}
break;
case "res":
if (attributes != null) {
String protocolInfo = attributes.getValue("protocolInfo");
Long size;
try {
size = Long.parseLong(attributes.getValue("size"));
} catch (NumberFormatException e) {
size = null;
}
String duration = attributes.getValue("duration");
String importUri = attributes.getValue("importUri");
resList.add(0, new UpnpEntryRes(protocolInfo, size, duration, importUri));
element = Element.RES;
}
break;
case "dc:title":
element = Element.TITLE;
break;
case "upnp:class":
element = Element.CLASS;
break;
case "dc:creator":
element = Element.CREATOR;
break;
case "upnp:artist":
element = Element.ARTIST;
break;
case "dc:publisher":
element = Element.PUBLISHER;
break;
case "upnp:genre":
element = Element.GENRE;
break;
case "upnp:album":
element = Element.ALBUM;
break;
case "upnp:albumArtURI":
element = Element.ALBUM_ART_URI;
break;
case "upnp:originalTrackNumber":
element = Element.TRACK_NUMBER;
break;
default:
if (ignore.isEmpty()) {
ignore.add("");
ignore.add("DIDL-Lite");
ignore.add("type");
ignore.add("ordinal");
ignore.add("description");
ignore.add("writeStatus");
ignore.add("storageUsed");
ignore.add("supported");
ignore.add("pushSource");
ignore.add("icon");
ignore.add("playlist");
ignore.add("date");
ignore.add("rating");
ignore.add("userrating");
ignore.add("episodeSeason");
ignore.add("childCountContainer");
ignore.add("modificationTime");
ignore.add("containerContent");
}
if (!ignore.contains(localName)) {
LOGGER.debug("Did not recognise element named {}", localName);
}
element = null;
}
}
@Override
public void characters(char @Nullable [] ch, int start, int length) throws SAXException {
Element el = element;
if (el == null) {
return;
}
switch (el) {
case TITLE:
title.append(ch, start, length);
break;
case CLASS:
upnpClass.append(ch, start, length);
break;
case RES:
res.append(ch, start, length);
break;
case ALBUM:
album.append(ch, start, length);
break;
case ALBUM_ART_URI:
albumArtUri.append(ch, start, length);
break;
case CREATOR:
creator.append(ch, start, length);
break;
case ARTIST:
artist.append(ch, start, length);
break;
case PUBLISHER:
publisher.append(ch, start, length);
break;
case GENRE:
genre.append(ch, start, length);
break;
case TRACK_NUMBER:
trackNumber.append(ch, start, length);
break;
}
}
@Override
public void endElement(@Nullable String uri, @Nullable String localName, @Nullable String qName)
throws SAXException {
if ("container".equals(qName) || "item".equals(qName)) {
element = null;
Integer trackNumberVal;
try {
trackNumberVal = Integer.parseInt(trackNumber.toString());
} catch (NumberFormatException e) {
trackNumberVal = null;
}
entries.add(new UpnpEntry(id, refId, parentId, upnpClass.toString()).withTitle(title.toString())
.withAlbum(album.toString()).withAlbumArtUri(albumArtUri.toString())
.withCreator(creator.toString())
.withArtist(artistList.size() > 0 ? artistList.get(0) : artist.toString())
.withPublisher(publisher.toString()).withGenre(genre.toString()).withTrackNumber(trackNumberVal)
.withResList(resList));
title = new StringBuilder();
upnpClass = new StringBuilder();
resList = new ArrayList<>();
album = new StringBuilder();
albumArtUri = new StringBuilder();
creator = new StringBuilder();
artistList = new ArrayList<>();
publisher = new StringBuilder();
genre = new StringBuilder();
trackNumber = new StringBuilder();
} else if ("res".equals(qName)) {
resList.get(0).setRes(res.toString());
res = new StringBuilder();
} else if ("upnp:artist".equals(qName)) {
artistList.add(artist.toString());
artist = new StringBuilder();
}
}
public List<UpnpEntry> getEntries() {
return entries;
}
}
public static String compileMetadataString(UpnpEntry entry) {
String id = entry.getId();
String parentId = entry.getParentId();
String title = StringEscapeUtils.escapeXml(entry.getTitle());
String upnpClass = entry.getUpnpClass();
String album = StringEscapeUtils.escapeXml(entry.getAlbum());
String albumArtUri = entry.getAlbumArtUri();
String creator = StringEscapeUtils.escapeXml(entry.getCreator());
String artist = StringEscapeUtils.escapeXml(entry.getArtist());
String publisher = StringEscapeUtils.escapeXml(entry.getPublisher());
String genre = StringEscapeUtils.escapeXml(entry.getGenre());
Integer trackNumber = entry.getOriginalTrackNumber();
String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
creator, artist, publisher, genre, trackNumber });
return metadata;
}
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
*/
@NonNullByDefault
public class UpnpControlConfiguration {
public @Nullable String udn;
}

View File

@@ -0,0 +1,25 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public class UpnpControlServerConfiguration extends UpnpControlConfiguration {
public boolean filter = false;
public String sortcriteria = "+dc:title";
}

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal.discovery;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.jupnp.model.meta.RemoteDevice;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.upnp.UpnpDiscoveryParticipant;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* @author Mark Herwege - Initial contribution
*/
@Component(service = { UpnpDiscoveryParticipant.class })
@NonNullByDefault
public class UpnpControlDiscoveryParticipant implements UpnpDiscoveryParticipant {
private final Logger logger = LoggerFactory.getLogger(getClass());
@Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
return SUPPORTED_THING_TYPES_UIDS;
}
@Override
public @Nullable DiscoveryResult createResult(RemoteDevice device) {
DiscoveryResult result = null;
ThingUID thingUid = getThingUID(device);
if (thingUid != null) {
String label = device.getDetails().getFriendlyName().isEmpty() ? device.getDisplayString()
: device.getDetails().getFriendlyName();
Map<String, Object> properties = new HashMap<>();
properties.put("ipAddress", device.getIdentity().getDescriptorURL().getHost());
properties.put("udn", device.getIdentity().getUdn().getIdentifierString());
result = DiscoveryResultBuilder.create(thingUid).withLabel(label).withProperties(properties)
.withRepresentationProperty("udn").build();
}
return result;
}
@Override
public @Nullable ThingUID getThingUID(RemoteDevice device) {
ThingUID result = null;
String deviceType = device.getType().getType();
String manufacturer = device.getDetails().getManufacturerDetails().getManufacturer();
String model = device.getDetails().getModelDetails().getModelName();
String serialNumber = device.getDetails().getSerialNumber();
logger.debug("Device type {}, manufacturer {}, model {}, SN# {}", deviceType, manufacturer, model,
serialNumber);
if (deviceType.equalsIgnoreCase("MediaRenderer")) {
this.logger.debug("Media renderer found: {}, {}", manufacturer, model);
ThingTypeUID thingTypeUID = THING_TYPE_RENDERER;
result = new ThingUID(thingTypeUID, device.getIdentity().getUdn().getIdentifierString());
} else if (deviceType.equalsIgnoreCase("MediaServer")) {
this.logger.debug("Media server found: {}, {}", manufacturer, model);
ThingTypeUID thingTypeUID = THING_TYPE_SERVER;
result = new ThingUID(thingTypeUID, device.getIdentity().getUdn().getIdentifierString());
}
return result;
}
}

View File

@@ -0,0 +1,212 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal.handler;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}.
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
*/
@NonNullByDefault
public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant {
private final Logger logger = LoggerFactory.getLogger(UpnpHandler.class);
protected UpnpIOService service;
protected volatile String transportState = "";
protected volatile int connectionId;
protected volatile int avTransportId;
protected volatile int rcsId;
protected @NonNullByDefault({}) UpnpControlConfiguration config;
public UpnpHandler(Thing thing, UpnpIOService upnpIOService) {
super(thing);
upnpIOService.registerParticipant(this);
this.service = upnpIOService;
}
@Override
public void initialize() {
config = getConfigAs(UpnpControlConfiguration.class);
service.registerParticipant(this);
}
@Override
public void dispose() {
service.unregisterParticipant(this);
}
/**
* Invoke PrepareForConnection on the UPnP Connection Manager.
* Result is received in {@link onValueReceived}.
*
* @param remoteProtocolInfo
* @param peerConnectionManager
* @param peerConnectionId
* @param direction
*/
protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
String direction) {
HashMap<String, String> inputs = new HashMap<String, String>();
inputs.put("RemoteProtocolInfo", remoteProtocolInfo);
inputs.put("PeerConnectionManager", peerConnectionManager);
inputs.put("PeerConnectionID", Integer.toString(peerConnectionId));
inputs.put("Direction", direction);
invokeAction("ConnectionManager", "PrepareForConnection", inputs);
}
/**
* Invoke ConnectionComplete on UPnP Connection Manager.
*
* @param connectionId
*/
protected void connectionComplete(int connectionId) {
HashMap<String, String> inputs = new HashMap<String, String>();
inputs.put("ConnectionID", String.valueOf(connectionId));
invokeAction("ConnectionManager", "ConnectionComplete", inputs);
}
/**
* Invoke GetTransportState on UPnP AV Transport.
* Result is received in {@link onValueReceived}.
*/
protected void getTransportState() {
HashMap<String, String> inputs = new HashMap<String, String>();
inputs.put("InstanceID", Integer.toString(avTransportId));
invokeAction("AVTransport", "GetTransportInfo", inputs);
}
/**
* Invoke GetProtocolInfo on UPnP Connection Manager.
* Result is received in {@link onValueReceived}.
*/
protected void getProtocolInfo() {
Map<String, String> inputs = new HashMap<>();
invokeAction("ConnectionManager", "GetProtocolInfo", inputs);
}
@Override
public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
logger.debug("Upnp device {} received subscription reply {} from service {}", thing.getLabel(), succeeded,
service);
}
@Override
public void onStatusChanged(boolean status) {
if (status) {
updateStatus(ThingStatus.ONLINE);
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication lost with " + thing.getLabel());
}
}
@Override
public @Nullable String getUDN() {
return config.udn;
}
/**
* This method wraps {@link org.openhab.core.io.transport.upnp.UpnpIOService.invokeAction}. It schedules and
* submits the call and calls {@link onValueReceived} upon completion. All state updates or other actions depending
* on the results should be triggered from {@link onValueReceived} because the class fields with results will be
* filled asynchronously.
*
* @param serviceId
* @param actionId
* @param inputs
*/
protected void invokeAction(String serviceId, String actionId, Map<String, String> inputs) {
scheduler.submit(() -> {
Map<String, String> result = service.invokeAction(this, serviceId, actionId, inputs);
if (logger.isDebugEnabled() && !"GetPositionInfo".equals(actionId)) {
// don't log position info refresh every second
logger.debug("Upnp device {} invoke upnp action {} on service {} with inputs {}", thing.getLabel(),
actionId, serviceId, inputs);
logger.debug("Upnp device {} invoke upnp action {} on service {} reply {}", thing.getLabel(), actionId,
serviceId, result);
}
for (String variable : result.keySet()) {
onValueReceived(variable, result.get(variable), serviceId);
}
});
}
@Override
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
if (variable == null) {
return;
}
switch (variable) {
case "CurrentTransportState":
if (!((value == null) || (value.isEmpty()))) {
transportState = value;
}
break;
case "ConnectionID":
connectionId = Integer.parseInt(value);
break;
case "AVTransportID":
avTransportId = Integer.parseInt(value);
break;
case "RcsID":
rcsId = Integer.parseInt(value);
break;
default:
break;
}
}
/**
* Subscribe this handler as a participant to a GENA subscription.
*
* @param serviceId
* @param duration
*/
protected void addSubscription(String serviceId, int duration) {
logger.debug("Upnp device {} add upnp subscription on {}", thing.getLabel(), serviceId);
service.addSubscription(this, serviceId, duration);
}
/**
* Remove this handler from the GENA subscriptions.
*
* @param serviceId
*/
protected void removeSubscription(String serviceId) {
if (service.isRegistered(this)) {
service.removeSubscription(this, serviceId);
}
}
}

View File

@@ -0,0 +1,928 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal.handler;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.upnpcontrol.internal.UpnpAudioSink;
import org.openhab.binding.upnpcontrol.internal.UpnpAudioSinkReg;
import org.openhab.binding.upnpcontrol.internal.UpnpEntry;
import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.io.net.http.HttpUtil;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.NextPreviousType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.RewindFastforwardType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.SmartHomeUnits;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends
* {@link UpnpHandler} with UPnP renderer specific logic.
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
*/
@NonNullByDefault
public class UpnpRendererHandler extends UpnpHandler {
private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class);
private static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
// UPnP protocol pattern
private static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
private volatile boolean audioSupport;
protected volatile Set<AudioFormat> supportedAudioFormats = new HashSet<>();
private volatile boolean audioSinkRegistered;
private volatile UpnpAudioSinkReg audioSinkReg;
private volatile boolean upnpSubscribed;
private static final String UPNP_CHANNEL = "Master";
private volatile OnOffType soundMute = OnOffType.OFF;
private volatile PercentType soundVolume = new PercentType();
private volatile List<String> sink = new ArrayList<>();
private volatile ArrayList<UpnpEntry> currentQueue = new ArrayList<>();
private volatile UpnpIterator<UpnpEntry> queueIterator = new UpnpIterator<>(currentQueue.listIterator());
private volatile @Nullable UpnpEntry currentEntry = null;
private volatile @Nullable UpnpEntry nextEntry = null;
private volatile boolean playerStopped;
private volatile boolean playing;
private volatile @Nullable CompletableFuture<Boolean> isSettingURI;
private volatile int trackDuration = 0;
private volatile int trackPosition = 0;
private volatile @Nullable ScheduledFuture<?> trackPositionRefresh;
private volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
private final Runnable subscriptionRefresh = () -> {
removeSubscription("AVTransport");
addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
};
/**
* The {@link ListIterator} class does not keep a cursor position and therefore will not give the previous element
* when next was called before, or give the next element when previous was called before. This iterator will always
* go to previous/next.
*/
private static class UpnpIterator<T> {
private final ListIterator<T> listIterator;
private boolean nextWasCalled = false;
private boolean previousWasCalled = false;
public UpnpIterator(ListIterator<T> listIterator) {
this.listIterator = listIterator;
}
public T next() {
if (previousWasCalled) {
previousWasCalled = false;
listIterator.next();
}
nextWasCalled = true;
return listIterator.next();
}
public T previous() {
if (nextWasCalled) {
nextWasCalled = false;
listIterator.previous();
}
previousWasCalled = true;
return listIterator.previous();
}
public boolean hasNext() {
if (previousWasCalled) {
return true;
} else {
return listIterator.hasNext();
}
}
public boolean hasPrevious() {
if (previousIndex() < 0) {
return false;
} else if (nextWasCalled) {
return true;
} else {
return listIterator.hasPrevious();
}
}
public int nextIndex() {
if (previousWasCalled) {
return listIterator.nextIndex() + 1;
} else {
return listIterator.nextIndex();
}
}
public int previousIndex() {
if (nextWasCalled) {
return listIterator.previousIndex() - 1;
} else {
return listIterator.previousIndex();
}
}
}
public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg) {
super(thing, upnpIOService);
this.audioSinkReg = audioSinkReg;
}
@Override
public void initialize() {
super.initialize();
logger.debug("Initializing handler for media renderer device {}", thing.getLabel());
if (config.udn != null) {
if (service.isRegistered(this)) {
initRenderer();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication cannot be established with " + thing.getLabel());
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No UDN configured for " + thing.getLabel());
}
}
@Override
public void dispose() {
cancelSubscriptionRefreshJob();
removeSubscription("AVTransport");
cancelTrackPositionRefresh();
super.dispose();
}
private void cancelSubscriptionRefreshJob() {
ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
if (refreshJob != null) {
refreshJob.cancel(true);
}
subscriptionRefreshJob = null;
upnpSubscribed = false;
}
private void initRenderer() {
if (!upnpSubscribed) {
addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
upnpSubscribed = true;
subscriptionRefreshJob = scheduler.scheduleWithFixedDelay(subscriptionRefresh,
SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
}
getProtocolInfo();
getTransportState();
updateStatus(ThingStatus.ONLINE);
}
/**
* Invoke Stop on UPnP AV Transport.
*/
public void stop() {
Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
invokeAction("AVTransport", "Stop", inputs);
}
/**
* Invoke Play on UPnP AV Transport.
*/
public void play() {
CompletableFuture<Boolean> setting = isSettingURI;
try {
if ((setting == null) || (setting.get(2500, TimeUnit.MILLISECONDS))) {
// wait for maximum 2.5s until the media URI is set before playing
Map<String, String> inputs = new HashMap<>();
inputs.put("InstanceID", Integer.toString(avTransportId));
inputs.put("Speed", "1");
invokeAction("AVTransport", "Play", inputs);
} else {
logger.debug("Cannot play, cancelled setting URI in the renderer");
}
} catch (InterruptedException | ExecutionException | TimeoutException e) {
logger.debug("Cannot play, media URI not yet set in the renderer");
}
}
/**
* Invoke Pause on UPnP AV Transport.
*/
public void pause() {
Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
invokeAction("AVTransport", "Pause", inputs);
}
/**
* Invoke Next on UPnP AV Transport.
*/
protected void next() {
Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
invokeAction("AVTransport", "Next", inputs);
}
/**
* Invoke Previous on UPnP AV Transport.
*/
protected void previous() {
Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
invokeAction("AVTransport", "Previous", inputs);
}
/**
* Invoke SetAVTransportURI on UPnP AV Transport.
*
* @param URI
* @param URIMetaData
*/
public void setCurrentURI(String URI, String URIMetaData) {
CompletableFuture<Boolean> setting = isSettingURI;
if (setting != null) {
setting.complete(false);
}
isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished setting
// URI
Map<String, String> inputs = new HashMap<>();
try {
inputs.put("InstanceID", Integer.toString(avTransportId));
inputs.put("CurrentURI", URI);
inputs.put("CurrentURIMetaData", URIMetaData);
invokeAction("AVTransport", "SetAVTransportURI", inputs);
} catch (NumberFormatException ex) {
logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
}
}
/**
* Invoke SetNextAVTransportURI on UPnP AV Transport.
*
* @param nextURI
* @param nextURIMetaData
*/
public void setNextURI(String nextURI, String nextURIMetaData) {
Map<String, String> inputs = new HashMap<>();
try {
inputs.put("InstanceID", Integer.toString(avTransportId));
inputs.put("NextURI", nextURI);
inputs.put("NextURIMetaData", nextURIMetaData);
invokeAction("AVTransport", "SetNextAVTransportURI", inputs);
} catch (NumberFormatException ex) {
logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
}
}
/**
* Retrieves the current audio channel ('Master' by default).
*
* @return current audio channel
*/
public String getCurrentChannel() {
return UPNP_CHANNEL;
}
/**
* Retrieves the current volume known to the control point, gets updated by GENA events or after UPnP Rendering
* Control GetVolume call. This method is used to retrieve volume by {@link UpnpAudioSink.getVolume}.
*
* @return current volume
*/
public PercentType getCurrentVolume() {
return soundVolume;
}
/**
* Invoke GetVolume on UPnP Rendering Control.
* Result is received in {@link onValueReceived}.
*
* @param channel
*/
protected void getVolume(String channel) {
Map<String, String> inputs = new HashMap<>();
inputs.put("InstanceID", Integer.toString(rcsId));
inputs.put("Channel", channel);
invokeAction("RenderingControl", "GetVolume", inputs);
}
/**
* Invoke SetVolume on UPnP Rendering Control.
*
* @param channel
* @param volume
*/
public void setVolume(String channel, PercentType volume) {
Map<String, String> inputs = new HashMap<>();
inputs.put("InstanceID", Integer.toString(rcsId));
inputs.put("Channel", channel);
inputs.put("DesiredVolume", String.valueOf(volume.intValue()));
invokeAction("RenderingControl", "SetVolume", inputs);
}
/**
* Invoke getMute on UPnP Rendering Control.
* Result is received in {@link onValueReceived}.
*
* @param channel
*/
protected void getMute(String channel) {
Map<String, String> inputs = new HashMap<>();
inputs.put("InstanceID", Integer.toString(rcsId));
inputs.put("Channel", channel);
invokeAction("RenderingControl", "GetMute", inputs);
}
/**
* Invoke SetMute on UPnP Rendering Control.
*
* @param channel
* @param mute
*/
protected void setMute(String channel, OnOffType mute) {
Map<String, String> inputs = new HashMap<>();
inputs.put("InstanceID", Integer.toString(rcsId));
inputs.put("Channel", channel);
inputs.put("DesiredMute", mute == OnOffType.ON ? "1" : "0");
invokeAction("RenderingControl", "SetMute", inputs);
}
/**
* Invoke getPositionInfo on UPnP Rendering Control.
* Result is received in {@link onValueReceived}.
*/
protected void getPositionInfo() {
Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(rcsId));
invokeAction("AVTransport", "GetPositionInfo", inputs);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
String transportState;
if (command instanceof RefreshType) {
switch (channelUID.getId()) {
case VOLUME:
getVolume(getCurrentChannel());
break;
case MUTE:
getMute(getCurrentChannel());
break;
case CONTROL:
transportState = this.transportState;
State newState = UnDefType.UNDEF;
if ("PLAYING".equals(transportState)) {
newState = PlayPauseType.PLAY;
} else if ("STOPPED".equals(transportState)) {
newState = PlayPauseType.PAUSE;
} else if ("PAUSED_PLAYBACK".equals(transportState)) {
newState = PlayPauseType.PAUSE;
}
updateState(channelUID, newState);
break;
}
return;
} else {
switch (channelUID.getId()) {
case VOLUME:
setVolume(getCurrentChannel(), (PercentType) command);
break;
case MUTE:
setMute(getCurrentChannel(), (OnOffType) command);
break;
case STOP:
if (command == OnOffType.ON) {
updateState(CONTROL, PlayPauseType.PAUSE);
playerStopped = true;
stop();
updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND));
}
break;
case CONTROL:
playerStopped = false;
if (command instanceof PlayPauseType) {
if (command == PlayPauseType.PLAY) {
play();
} else if (command == PlayPauseType.PAUSE) {
pause();
}
} else if (command instanceof NextPreviousType) {
if (command == NextPreviousType.NEXT) {
playerStopped = true;
serveNext();
} else if (command == NextPreviousType.PREVIOUS) {
playerStopped = true;
servePrevious();
}
} else if (command instanceof RewindFastforwardType) {
}
break;
}
return;
}
}
@Override
public void onStatusChanged(boolean status) {
logger.debug("Renderer status changed to {}", status);
if (status) {
initRenderer();
} else {
cancelSubscriptionRefreshJob();
updateState(CONTROL, PlayPauseType.PAUSE);
cancelTrackPositionRefresh();
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication lost with " + thing.getLabel());
}
super.onStatusChanged(status);
}
@Override
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
if (logger.isTraceEnabled()) {
logger.trace("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(),
variable, value, service);
} else {
if (logger.isDebugEnabled() && !("AbsTime".equals(variable) || "RelCount".equals(variable)
|| "RelTime".equals(variable) || "AbsCount".equals(variable) || "Track".equals(variable)
|| "TrackDuration".equals(variable))) {
// don't log all variables received when updating the track position every second
logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(),
variable, value, service);
}
}
if (variable == null) {
return;
}
switch (variable) {
case "CurrentMute":
if (!((value == null) || (value.isEmpty()))) {
soundMute = OnOffType.from(Boolean.parseBoolean(value));
updateState(MUTE, soundMute);
}
break;
case "CurrentVolume":
if (!((value == null) || (value.isEmpty()))) {
soundVolume = PercentType.valueOf(value);
updateState(VOLUME, soundVolume);
}
break;
case "Sink":
if (!((value == null) || (value.isEmpty()))) {
updateProtocolInfo(value);
}
break;
case "LastChange":
// pre-process some variables, eg XML processing
if (!((value == null) || value.isEmpty())) {
if ("AVTransport".equals(service)) {
Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
// Update the transport state after the update of the media information
// to not break the notification mechanism
if (!"TransportState".equals(entrySet.getKey())) {
onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
}
if ("AVTransportURI".equals(entrySet.getKey())) {
onValueReceived("CurrentTrackURI", entrySet.getValue(), service);
} else if ("AVTransportURIMetaData".equals(entrySet.getKey())) {
onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service);
}
}
if (parsedValues.containsKey("TransportState")) {
onValueReceived("TransportState", parsedValues.get("TransportState"), service);
}
}
}
break;
case "TransportState":
transportState = (value == null) ? "" : value;
if ("STOPPED".equals(value)) {
updateState(CONTROL, PlayPauseType.PAUSE);
cancelTrackPositionRefresh();
// playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
// end of an entry. We should then move to the next entry if the queue is not at the end already.
if (playing && !playerStopped) {
// Only go to next for first STOP command, then wait until we received PLAYING before moving
// to next (avoids issues with renderers sending multiple stop states)
playing = false;
serveNext();
} else {
currentEntry = nextEntry; // Try to get the metadata for the next entry if controlled by an
// external control point
playing = false;
}
} else if ("PLAYING".equals(value)) {
playerStopped = false;
playing = true;
updateState(CONTROL, PlayPauseType.PLAY);
scheduleTrackPositionRefresh();
} else if ("PAUSED_PLAYBACK".contentEquals(value)) {
updateState(CONTROL, PlayPauseType.PAUSE);
}
break;
case "CurrentTrackURI":
UpnpEntry current = currentEntry;
if (queueIterator.hasNext() && (current != null) && !current.getRes().equals(value)
&& currentQueue.get(queueIterator.nextIndex()).getRes().equals(value)) {
// Renderer advanced to next entry independent of openHAB UPnP control point.
// Advance in the queue to keep proper position status.
// Make the next entry available to renderers that support it.
updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
logger.trace("Renderer moved from '{}' to next entry '{}' in queue", currentEntry,
currentQueue.get(queueIterator.nextIndex()));
currentEntry = queueIterator.next();
if (queueIterator.hasNext()) {
UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
}
}
if (isSettingURI != null) {
isSettingURI.complete(true); // We have received current URI, so can allow play to start
}
break;
case "CurrentTrackMetaData":
if (!((value == null) || (value.isEmpty()))) {
List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
if (!list.isEmpty()) {
updateMetaDataState(list.get(0));
}
}
break;
case "NextAVTransportURIMetaData":
if (!((value == null) || (value.isEmpty() || "NOT_IMPLEMENTED".equals(value)))) {
List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
if (!list.isEmpty()) {
nextEntry = list.get(0);
}
}
break;
case "CurrentTrackDuration":
case "TrackDuration":
// track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
// interested in the fractional seconds, so drop everything after . and calculate in seconds.
if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
trackDuration = 0;
updateState(TRACK_DURATION, UnDefType.UNDEF);
} else {
trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
.reduce(0, (n, m) -> n * 60 + m);
updateState(TRACK_DURATION, new QuantityType<>(trackDuration, SmartHomeUnits.SECOND));
}
break;
case "RelTime":
if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
trackPosition = 0;
updateState(TRACK_POSITION, UnDefType.UNDEF);
} else {
trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
.reduce(0, (n, m) -> n * 60 + m);
updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
}
break;
default:
super.onValueReceived(variable, value, service);
break;
}
}
private void updateProtocolInfo(String value) {
sink.clear();
supportedAudioFormats.clear();
audioSupport = false;
sink.addAll(Arrays.asList(value.split(",")));
for (String protocol : sink) {
Matcher matcher = PROTOCOL_PATTERN.matcher(protocol);
if (matcher.find()) {
String format = matcher.group(1);
switch (format) {
case "audio/mpeg3":
case "audio/mp3":
case "audio/mpeg":
supportedAudioFormats.add(AudioFormat.MP3);
break;
case "audio/wav":
case "audio/wave":
supportedAudioFormats.add(AudioFormat.WAV);
break;
}
audioSupport = audioSupport || Pattern.matches("audio.*", format);
}
}
if (audioSupport) {
logger.debug("Device {} supports audio", thing.getLabel());
registerAudioSink();
}
}
private void registerAudioSink() {
if (audioSinkRegistered) {
logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
return;
} else if (!service.isRegistered(this)) {
logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
return;
}
logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
audioSinkReg.registerAudioSink(this);
audioSinkRegistered = true;
}
private void clearCurrentEntry() {
updateState(TITLE, UnDefType.UNDEF);
updateState(ALBUM, UnDefType.UNDEF);
updateState(ALBUM_ART, UnDefType.UNDEF);
updateState(CREATOR, UnDefType.UNDEF);
updateState(ARTIST, UnDefType.UNDEF);
updateState(PUBLISHER, UnDefType.UNDEF);
updateState(GENRE, UnDefType.UNDEF);
updateState(TRACK_NUMBER, UnDefType.UNDEF);
trackDuration = 0;
updateState(TRACK_DURATION, UnDefType.UNDEF);
trackPosition = 0;
updateState(TRACK_POSITION, UnDefType.UNDEF);
currentEntry = null;
}
/**
* Register a new queue with media entries to the renderer. Set the next position at the first entry in the list.
* If the renderer is currently playing, set the first entry in the list as the next media. If not playing, set it
* as current media.
*
* @param queue
*/
public void registerQueue(ArrayList<UpnpEntry> queue) {
logger.debug("Registering queue on renderer {}", thing.getLabel());
currentQueue = queue;
queueIterator = new UpnpIterator<>(currentQueue.listIterator());
if (playing) {
if (queueIterator.hasNext()) {
// make the next entry available to renderers that support it
logger.trace("Still playing, set new queue as next entry");
UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
}
} else {
if (queueIterator.hasNext()) {
UpnpEntry entry = queueIterator.next();
updateMetaDataState(entry);
setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
currentEntry = entry;
} else {
clearCurrentEntry();
}
}
}
/**
* Move to next position in queue and start playing.
*/
private void serveNext() {
if (queueIterator.hasNext()) {
currentEntry = queueIterator.next();
logger.debug("Serve next media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
serve();
} else {
logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel());
cancelTrackPositionRefresh();
stop();
queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
if (currentQueue.isEmpty()) {
clearCurrentEntry();
} else {
updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
UpnpEntry entry = queueIterator.next();
setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
currentEntry = entry;
}
}
}
/**
* Move to previous position in queue and start playing.
*/
private void servePrevious() {
if (queueIterator.hasPrevious()) {
currentEntry = queueIterator.previous();
logger.debug("Serve previous media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
serve();
} else {
logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel());
cancelTrackPositionRefresh();
stop();
queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
if (currentQueue.isEmpty()) {
clearCurrentEntry();
} else {
updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
UpnpEntry entry = queueIterator.next();
setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
currentEntry = entry;
}
}
}
/**
* Play media.
*
* @param media
*/
private void serve() {
UpnpEntry entry = currentEntry;
if (entry != null) {
logger.trace("Ready to play '{}' from queue", currentEntry);
updateMetaDataState(entry);
String res = entry.getRes();
if (res.isEmpty()) {
logger.debug("Cannot serve media '{}', no URI", currentEntry);
return;
}
setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry));
play();
// make the next entry available to renderers that support it
if (queueIterator.hasNext()) {
UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
}
}
}
/**
* Update the current track position every second if the channel is linked.
*/
private void scheduleTrackPositionRefresh() {
cancelTrackPositionRefresh();
if (!isLinked(TRACK_POSITION)) {
return;
}
if (trackPositionRefresh == null) {
trackPositionRefresh = scheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1, TimeUnit.SECONDS);
}
}
private void cancelTrackPositionRefresh() {
ScheduledFuture<?> refresh = trackPositionRefresh;
if (refresh != null) {
refresh.cancel(true);
}
trackPositionRefresh = null;
trackPosition = 0;
updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
}
/**
* Update metadata channels for media with data received from the Media Server or AV Transport.
*
* @param media
*/
private void updateMetaDataState(UpnpEntry media) {
// The AVTransport passes the URI resource in the ID.
// We don't want to update metadata if the metadata from the AVTransport is empty for the current entry.
boolean isCurrent;
UpnpEntry entry = currentEntry;
if (entry == null) {
entry = new UpnpEntry(media.getId(), media.getId(), "", "object.item");
currentEntry = entry;
isCurrent = false;
} else {
isCurrent = media.getId().equals(entry.getRes());
}
logger.trace("Media ID: {}", media.getId());
logger.trace("Current queue res: {}", entry.getRes());
logger.trace("Updating current entry: {}", isCurrent);
if (!(isCurrent && media.getTitle().isEmpty())) {
updateState(TITLE, StringType.valueOf(media.getTitle()));
}
if (!(isCurrent && (media.getAlbum().isEmpty() || media.getAlbum().matches("Unknown.*")))) {
updateState(ALBUM, StringType.valueOf(media.getAlbum()));
}
if (!(isCurrent
&& (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")))) {
if (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")) {
updateState(ALBUM_ART, UnDefType.UNDEF);
} else {
State albumArt = HttpUtil.downloadImage(media.getAlbumArtUri());
if (albumArt == null) {
logger.debug("Failed to download the content of album art from URL {}", media.getAlbumArtUri());
if (!isCurrent) {
updateState(ALBUM_ART, UnDefType.UNDEF);
}
} else {
updateState(ALBUM_ART, albumArt);
}
}
}
if (!(isCurrent && (media.getCreator().isEmpty() || media.getCreator().matches("Unknown.*")))) {
updateState(CREATOR, StringType.valueOf(media.getCreator()));
}
if (!(isCurrent && (media.getArtist().isEmpty() || media.getArtist().matches("Unknown.*")))) {
updateState(ARTIST, StringType.valueOf(media.getArtist()));
}
if (!(isCurrent && (media.getPublisher().isEmpty() || media.getPublisher().matches("Unknown.*")))) {
updateState(PUBLISHER, StringType.valueOf(media.getPublisher()));
}
if (!(isCurrent && (media.getGenre().isEmpty() || media.getGenre().matches("Unknown.*")))) {
updateState(GENRE, StringType.valueOf(media.getGenre()));
}
if (!(isCurrent && (media.getOriginalTrackNumber() == null))) {
Integer trackNumber = media.getOriginalTrackNumber();
State trackNumberState = (trackNumber != null) ? new DecimalType(trackNumber) : UnDefType.UNDEF;
updateState(TRACK_NUMBER, trackNumberState);
}
}
/**
* @return Audio formats supported by the renderer.
*/
public Set<AudioFormat> getSupportedAudioFormats() {
return supportedAudioFormats;
}
/**
* @return UPnP sink definitions supported by the renderer.
*/
protected List<String> getSink() {
return sink;
}
}

View File

@@ -0,0 +1,484 @@
/**
* Copyright (c) 2010-2020 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal.handler;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory;
import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
import org.openhab.binding.upnpcontrol.internal.UpnpEntry;
import org.openhab.binding.upnpcontrol.internal.UpnpProtocolMatcher;
import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser;
import org.openhab.binding.upnpcontrol.internal.config.UpnpControlServerConfiguration;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.Command;
import org.openhab.core.types.CommandDescription;
import org.openhab.core.types.CommandDescriptionBuilder;
import org.openhab.core.types.CommandOption;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.StateDescription;
import org.openhab.core.types.StateDescriptionFragmentBuilder;
import org.openhab.core.types.StateOption;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server.
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
*/
@NonNullByDefault
public class UpnpServerHandler extends UpnpHandler {
private static final String DIRECTORY_ROOT = "0";
private static final String UP = "..";
private final Logger logger = LoggerFactory.getLogger(UpnpServerHandler.class);
private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
private volatile @Nullable UpnpRendererHandler currentRendererHandler;
private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
private @NonNullByDefault({}) ChannelUID rendererChannelUID;
private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
private volatile UpnpEntry currentEntry = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
"object.container");
private volatile List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>()); // current entry list in
// selection
private volatile Map<String, UpnpEntry> parentMap = new HashMap<>(); // store parents in hierarchy separately to be
// able to move up in directory structure
private UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
private UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
public UpnpServerHandler(Thing thing, UpnpIOService upnpIOService,
ConcurrentMap<String, UpnpRendererHandler> upnpRenderers,
UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
super(thing, upnpIOService);
this.upnpRenderers = upnpRenderers;
this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
// put root as highest level in parent map
parentMap.put(currentEntry.getId(), currentEntry);
}
@Override
public void initialize() {
super.initialize();
config = getConfigAs(UpnpControlServerConfiguration.class);
logger.debug("Initializing handler for media server device {}", thing.getLabel());
Channel rendererChannel = thing.getChannel(UPNPRENDERER);
if (rendererChannel != null) {
rendererChannelUID = rendererChannel.getUID();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Channel " + UPNPRENDERER + " not defined");
return;
}
Channel selectionChannel = thing.getChannel(BROWSE);
if (selectionChannel != null) {
currentSelectionChannelUID = selectionChannel.getUID();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Channel " + BROWSE + " not defined");
return;
}
if (config.udn != null) {
if (service.isRegistered(this)) {
initServer();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication cannot be established with " + thing.getLabel());
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No UDN configured for " + thing.getLabel());
}
}
private void initServer() {
rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
synchronized (rendererStateOptionList) {
upnpRenderers.forEach((key, value) -> {
StateOption stateOption = new StateOption(key, value.getThing().getLabel());
rendererStateOptionList.add(stateOption);
});
}
updateStateDescription(rendererChannelUID, rendererStateOptionList);
getProtocolInfo();
browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
updateStatus(ThingStatus.ONLINE);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.debug("Handle command {} for channel {} on server {}", command, channelUID, thing.getLabel());
switch (channelUID.getId()) {
case UPNPRENDERER:
if (command instanceof StringType) {
currentRendererHandler = (upnpRenderers.get(((StringType) command).toString()));
if (config.filter) {
// only refresh title list if filtering by renderer capabilities
browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
}
} else if (command instanceof RefreshType) {
UpnpRendererHandler renderer = currentRendererHandler;
if (renderer != null) {
updateState(channelUID, StringType.valueOf(renderer.getThing().getLabel()));
}
}
break;
case CURRENTID:
String currentId = "";
if (command instanceof StringType) {
currentId = String.valueOf(command);
} else if (command instanceof RefreshType) {
currentId = currentEntry.getId();
updateState(channelUID, StringType.valueOf(currentId));
}
logger.debug("Setting currentId to {}", currentId);
if (!currentId.isEmpty()) {
browse(currentId, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
}
case BROWSE:
if (command instanceof StringType) {
String browseTarget = command.toString();
if (browseTarget != null) {
if (!UP.equals(browseTarget)) {
final String target = browseTarget;
synchronized (entries) {
Optional<UpnpEntry> current = entries.stream()
.filter(entry -> target.equals(entry.getId())).findFirst();
if (current.isPresent()) {
currentEntry = current.get();
} else {
logger.info("Trying to browse invalid target {}", browseTarget);
browseTarget = UP; // move up on invalid target
}
}
}
if (UP.equals(browseTarget)) {
// Move up in tree
browseTarget = currentEntry.getParentId();
if (browseTarget.isEmpty()) {
// No parent found, so make it the root directory
browseTarget = DIRECTORY_ROOT;
}
currentEntry = parentMap.get(browseTarget);
}
updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
logger.debug("Browse target {}", browseTarget);
browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
}
}
break;
case SEARCH:
if (command instanceof StringType) {
String criteria = command.toString();
if (criteria != null) {
String searchContainer = "";
if (currentEntry.isContainer()) {
searchContainer = currentEntry.getId();
} else {
searchContainer = currentEntry.getParentId();
}
if (searchContainer.isEmpty()) {
// No parent found, so make it the root directory
searchContainer = DIRECTORY_ROOT;
}
updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
logger.debug("Search container {} for {}", searchContainer, criteria);
search(searchContainer, criteria, "*", "0", "0", config.sortcriteria);
}
}
break;
}
}
/**
* Add a renderer to the renderer channel state option list.
* This method is called from the {@link UpnpControlHandlerFactory} class when creating a renderer handler.
*
* @param key
*/
public void addRendererOption(String key) {
synchronized (rendererStateOptionList) {
rendererStateOptionList.add(new StateOption(key, upnpRenderers.get(key).getThing().getLabel()));
}
updateStateDescription(rendererChannelUID, rendererStateOptionList);
logger.debug("Renderer option {} added to {}", key, thing.getLabel());
}
/**
* Remove a renderer from the renderer channel state option list.
* This method is called from the {@link UpnpControlHandlerFactory} class when removing a renderer handler.
*
* @param key
*/
public void removeRendererOption(String key) {
UpnpRendererHandler handler = currentRendererHandler;
if ((handler != null) && (handler.getThing().getUID().toString().equals(key))) {
currentRendererHandler = null;
updateState(rendererChannelUID, UnDefType.UNDEF);
}
synchronized (rendererStateOptionList) {
rendererStateOptionList.removeIf(stateOption -> (stateOption.getValue().equals(key)));
}
updateStateDescription(rendererChannelUID, rendererStateOptionList);
logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
}
private void updateTitleSelection(List<UpnpEntry> titleList) {
logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
// Optionally, filter only items that can be played on the renderer
logger.debug("Filtering content on server {}: {}", thing.getLabel(), config.filter);
List<UpnpEntry> resultList = config.filter ? filterEntries(titleList, true) : titleList;
List<CommandOption> commandOptionList = new ArrayList<>();
// Add a directory up selector if not in the directory root
if ((!resultList.isEmpty() && !(DIRECTORY_ROOT.equals(resultList.get(0).getParentId())))
|| (resultList.isEmpty() && !DIRECTORY_ROOT.equals(currentEntry.getId()))) {
CommandOption commandOption = new CommandOption(UP, UP);
commandOptionList.add(commandOption);
logger.debug("UP added to selection list on server {}", thing.getLabel());
}
synchronized (entries) {
entries.clear(); // always only keep the current selection in the entry map to keep memory usage down
resultList.forEach((value) -> {
CommandOption commandOption = new CommandOption(value.getId(), value.getTitle());
commandOptionList.add(commandOption);
logger.trace("{} added to selection list on server {}", value.getId(), thing.getLabel());
// Keep the entries in a map so we can find the parent and container for the current selection to go
// back up
if (value.isContainer()) {
parentMap.put(value.getId(), value);
}
entries.add(value);
});
}
// Set the currentId to the parent of the first entry in the list
if (!resultList.isEmpty()) {
updateState(CURRENTID, StringType.valueOf(resultList.get(0).getId()));
}
logger.debug("{} entries added to selection list on server {}", commandOptionList.size(), thing.getLabel());
updateCommandDescription(currentSelectionChannelUID, commandOptionList);
serveMedia();
}
/**
* Filter a list of media and only keep the media that are playable on the currently selected renderer.
*
* @param resultList
* @param includeContainers
* @return
*/
private List<UpnpEntry> filterEntries(List<UpnpEntry> resultList, boolean includeContainers) {
logger.debug("Raw result list {}", resultList);
List<UpnpEntry> list = new ArrayList<>();
UpnpRendererHandler handler = currentRendererHandler;
if (handler != null) {
List<String> sink = handler.getSink();
list = resultList.stream()
.filter(entry -> (includeContainers && entry.isContainer())
|| UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink))
.collect(Collectors.toList());
}
logger.debug("Filtered result list {}", list);
return list;
}
private void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
.withOptions(stateOptionList).build().toStateDescription();
upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
}
private void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
.build();
upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
}
/**
* Method that does a UPnP browse on a content directory. Results will be retrieved in the {@link onValueReceived}
* method.
*
* @param objectID content directory object
* @param browseFlag BrowseMetaData or BrowseDirectChildren
* @param filter properties to be returned
* @param startingIndex starting index of objects to return
* @param requestedCount number of objects to return, 0 for all
* @param sortCriteria sort criteria, example: +dc:title
*/
public void browse(String objectID, String browseFlag, String filter, String startingIndex, String requestedCount,
String sortCriteria) {
Map<String, String> inputs = new HashMap<>();
inputs.put("ObjectID", objectID);
inputs.put("BrowseFlag", browseFlag);
inputs.put("Filter", filter);
inputs.put("StartingIndex", startingIndex);
inputs.put("RequestedCount", requestedCount);
inputs.put("SortCriteria", sortCriteria);
invokeAction("ContentDirectory", "Browse", inputs);
}
/**
* Method that does a UPnP search on a content directory. Results will be retrieved in the {@link onValueReceived}
* method.
*
* @param containerID content directory container
* @param searchCriteria search criteria, examples:
* dc:title contains "song"
* dc:creator contains "Springsteen"
* upnp:class = "object.item.audioItem"
* upnp:album contains "Born in"
* @param filter properties to be returned
* @param startingIndex starting index of objects to return
* @param requestedCount number of objects to return, 0 for all
* @param sortCriteria sort criteria, example: +dc:title
*/
public void search(String containerID, String searchCriteria, String filter, String startingIndex,
String requestedCount, String sortCriteria) {
Map<String, String> inputs = new HashMap<>();
inputs.put("ContainerID", containerID);
inputs.put("SearchCriteria", searchCriteria);
inputs.put("Filter", filter);
inputs.put("StartingIndex", startingIndex);
inputs.put("RequestedCount", requestedCount);
inputs.put("SortCriteria", sortCriteria);
invokeAction("ContentDirectory", "Search", inputs);
}
@Override
public void onStatusChanged(boolean status) {
logger.debug("Server status changed to {}", status);
if (status) {
initServer();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication lost with " + thing.getLabel());
}
super.onStatusChanged(status);
}
@Override
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(), variable,
value, service);
if (variable == null) {
return;
}
switch (variable) {
case "Result":
if (!((value == null) || (value.isEmpty()))) {
updateTitleSelection(removeDuplicates(UpnpXMLParser.getEntriesFromXML(value)));
} else {
updateTitleSelection(new ArrayList<UpnpEntry>());
}
break;
case "Source":
case "NumberReturned":
case "TotalMatches":
case "UpdateID":
break;
default:
super.onValueReceived(variable, value, service);
break;
}
}
/**
* Remove double entries by checking the refId if it exists as Id in the list and only keeping the original entry if
* available. If the original entry is not in the list, only keep one referring entry.
*
* @param list
* @return filtered list
*/
private List<UpnpEntry> removeDuplicates(List<UpnpEntry> list) {
List<UpnpEntry> newList = new ArrayList<>();
Set<String> refIdSet = new HashSet<>();
final Set<String> idSet = list.stream().map(UpnpEntry::getId).collect(Collectors.toSet());
list.forEach(entry -> {
String refId = entry.getRefId();
if (refId.isEmpty() || (!idSet.contains(refId)) && !refIdSet.contains(refId)) {
newList.add(entry);
}
if (!refId.isEmpty()) {
refIdSet.add(refId);
}
});
return newList;
}
private void serveMedia() {
UpnpRendererHandler handler = currentRendererHandler;
if (handler != null) {
ArrayList<UpnpEntry> mediaQueue = new ArrayList<>();
mediaQueue.addAll(filterEntries(entries, false));
if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
mediaQueue.add(currentEntry);
}
if (mediaQueue.isEmpty()) {
logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
handler.getThing().getLabel());
} else {
handler.registerQueue(mediaQueue);
logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
handler.getThing().getLabel());
}
} else {
logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());
}
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="upnpcontrol" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>UPnP Control Binding</name>
<description>This binding acts as a UPnP Control Point that can query media server content directories and serve
content to media renderers.</description>
<author>Mark Herwege</author>
</binding:binding>

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="upnpcontrol"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Thing Types -->
<thing-type id="upnprenderer">
<label>UPnPRenderer</label>
<description>UPnP AV Renderer</description>
<channels>
<channel id="volume" typeId="system.volume"/>
<channel id="mute" typeId="system.mute"/>
<channel id="control" typeId="system.media-control"/>
<channel id="stop" typeId="stop"/>
<channel id="title" typeId="system.media-title"/>
<channel id="album" typeId="album"/>
<channel id="albumart" typeId="albumart"/>
<channel id="creator" typeId="creator"/>
<channel id="artist" typeId="system.media-artist"/>
<channel id="publisher" typeId="publisher"/>
<channel id="genre" typeId="genre"/>
<channel id="tracknumber" typeId="tracknumber"/>
<channel id="trackduration" typeId="trackduration"/>
<channel id="trackposition" typeId="trackposition"/>
</channels>
<config-description>
<parameter name="udn" type="text" required="true">
<label>Unique Device Name</label>
<description>The UDN identifies the UPnP Renderer</description>
</parameter>
</config-description>
</thing-type>
<thing-type id="upnpserver">
<label>UPnPServer</label>
<description>UPnP AV Server</description>
<channels>
<channel id="upnprenderer" typeId="upnprenderer"/>
<channel id="currentid" typeId="currentid"/>
<channel id="browse" typeId="browse"/>
<channel id="search" typeId="search"/>
</channels>
<config-description>
<parameter name="udn" type="text" required="true">
<label>Unique Device Name</label>
<description>The UDN identifies the UPnP Media Server</description>
</parameter>
<parameter name="filter" type="boolean" required="false">
<label>Filter Content</label>
<description>Only list content which is playable on the selected renderer</description>
<default>false</default>
<advanced>false</advanced>
</parameter>
<parameter name="sortcriteria" type="text" required="false">
<label>Sort Criteria</label>
<description>Sort criteria for the titles in the selection list and when sending for playing to a renderer. The
criteria are defined in UPnP sort criteria format. Examples: +dc:title, -dc:creator, +upnp:album. Supported sort
criteria will depend on the media server</description>
<default>+dc:title</default>
</parameter>
</config-description>
</thing-type>
<!-- Channel Types -->
<channel-type id="stop">
<item-type>Switch</item-type>
<label>Stop</label>
<description>Stop the player</description>
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
<channel-type id="album">
<item-type>String</item-type>
<label>Album</label>
<description>Now playing album</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="albumart">
<item-type>Image</item-type>
<label>Album Art</label>
<description>Now playing album art</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="creator">
<item-type>String</item-type>
<label>Creator</label>
<description>Now playing creator</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="publisher">
<item-type>String</item-type>
<label>Publisher</label>
<description>Now playing publisher</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="genre">
<item-type>String</item-type>
<label>Genre</label>
<description>Now playing genre</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="tracknumber">
<item-type>Number</item-type>
<label>Track Number</label>
<description>Now playing track number</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="trackduration">
<item-type>Number:Time</item-type>
<label>Track Duration</label>
<description>Now playing track duration</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="trackposition">
<item-type>Number:Time</item-type>
<label>Track Position</label>
<description>Now playing track position</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="upnprenderer">
<item-type>String</item-type>
<label>Renderer</label>
<description>Select AV renderer</description>
</channel-type>
<channel-type id="currentid">
<item-type>String</item-type>
<label>Current Media Id</label>
<description>Current id of media entry or container</description>
</channel-type>
<channel-type id="browse">
<item-type>String</item-type>
<label>Browse Selection</label>
<description>Browse selection for playing</description>
</channel-type>
<channel-type id="search">
<item-type>String</item-type>
<label>Search Criteria</label>
<description>Search criteria for searching the directory. Search criteria are defined in UPnP search criteria format.
Examples: dc:title contains "song", dc:creator contains "SpringSteen", unp:class = "object.item.audioItem",
upnp:album contains "Born in"</description>
</channel-type>
</thing:thing-descriptions>