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