added migrated 2.x add-ons
Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user