[upnpcontrol] Rework and extension of binding. (#9081)

Signed-off-by: Mark Herwege <mark.herwege@telenet.be>
This commit is contained in:
Mark Herwege
2020-11-28 13:38:44 +01:00
committed by GitHub
parent 6b2d217e08
commit 821a84067a
29 changed files with 6176 additions and 934 deletions

View File

@@ -0,0 +1,172 @@
/**
* 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.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* This enum contains default openHAB channel configurations for optional channels as defined in the UPnP standard.
* Vendor specific channels are not part of this.
*
* @author Mark Herwege - Initial contribution
*
*/
@NonNullByDefault
public enum UpnpChannelName {
// Volume channels
LF_VOLUME("LFvolume", "Left Front Volume", "Left front volume, will be left volume with stereo sound",
ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
RF_VOLUME("RFvolume", "Right Front Volume", "Right front volume, will be left volume with stereo sound",
ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
CF_VOLUME("CFvolume", "Center Front Volume", "Center front volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
LFE_VOLUME("LFEvolume", "Frequency Enhancement Volume", "Low frequency enhancement volume (subwoofer)",
ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
LS_VOLUME("LSvolume", "Left Surround Volume", "Left surround volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
RS_VOLUME("RSvolume", "Right Surround Volume", "Right surround volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
LFC_VOLUME("LFCvolume", "Left of Center Volume", "Left of center (in front) volume", ITEM_TYPE_VOLUME,
CHANNEL_TYPE_VOLUME),
RFC_VOLUME("RFCvolume", "Right of Center Volume", "Right of center (in front) volume", ITEM_TYPE_VOLUME,
CHANNEL_TYPE_VOLUME),
SD_VOLUME("SDvolume", "Surround Volume", "Surround (rear) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
SL_VOLUME("SLvolume", "Side Left Volume", "Side left (left wall) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
SR_VOLUME("SRvolume", "Side Right Volume", "Side right (right wall) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
T_VOLUME("Tvolume", "Top Volume", "Top (overhead) volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
B_VOLUME("Bvolume", "Bottom Volume", "Bottom volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
BC_VOLUME("BCvolume", "Back Center Volume", "Back center volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
BL_VOLUME("BLvolume", "Back Left Volume", "Back Left Volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
BR_VOLUME("BRvolume", "Back Right Volume", "Back right volume", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME),
// Mute channels
LF_MUTE("LFmute", "Left Front Mute", "Left front mute, will be left mute with stereo sound", ITEM_TYPE_MUTE,
CHANNEL_TYPE_MUTE),
RF_MUTE("RFmute", "Right Front Mute", "Right front mute, will be left mute with stereo sound", ITEM_TYPE_MUTE,
CHANNEL_TYPE_MUTE),
CF_MUTE("CFmute", "Center Front Mute", "Center front mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
LFE_MUTE("LFEmute", "Frequency Enhancement Mute", "Low frequency enhancement mute (subwoofer)", ITEM_TYPE_MUTE,
CHANNEL_TYPE_MUTE),
LS_MUTE("LSmute", "Left Surround Mute", "Left surround mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
RS_MUTE("RSmute", "Right Surround Mute", "Right surround mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
LFC_MUTE("LFCmute", "Left of Center Mute", "Left of center (in front) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
RFC_MUTE("RFCmute", "Right of Center Mute", "Right of center (in front) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
SD_MUTE("SDmute", "Surround Mute", "Surround (rear) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
SL_MUTE("SLmute", "Side Left Mute", "Side left (left wall) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
SR_MUTE("SRmute", "Side Right Mute", "Side right (right wall) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
T_MUTE("Tmute", "Top Mute", "Top (overhead) mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
B_MUTE("Bmute", "Bottom Mute", "Bottom mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
BC_MUTE("BCmute", "Back Center Mute", "Back center mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
BL_MUTE("BLmute", "Back Left Mute", "Back Left Mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
BR_MUTE("BRmute", "Back Right Mute", "Back right mute", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE),
// Loudness channels
LOUDNESS("loudness", "Loudness", "Master loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
LF_LOUDNESS("LFloudness", "Left Front Loudness", "Left front loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
RF_LOUDNESS("RFloudness", "Right Front Loudness", "Right front loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
CF_LOUDNESS("CFloudness", "Center Front Loudness", "Center front loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
LFE_LOUDNESS("LFEloudness", "Frequency Enhancement Loudness", "Low frequency enhancement loudness (subwoofer)",
ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
LS_LOUDNESS("LSloudness", "Left Surround Loudness", "Left surround loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
RS_LOUDNESS("RSloudness", "Right Surround Loudness", "Right surround loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
LFC_LOUDNESS("LFCloudness", "Left of Center Loudness", "Left of center (in front) loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
RFC_LOUDNESS("RFCloudness", "Right of Center Loudness", "Right of center (in front) loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
SD_LOUDNESS("SDloudness", "Surround Loudness", "Surround (rear) loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
SL_LOUDNESS("SLloudness", "Side Left Loudness", "Side left (left wall) loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
SR_LOUDNESS("SRloudness", "Side Right Loudness", "Side right (right wall) loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
T_LOUDNESS("Tloudness", "Top Loudness", "Top (overhead) loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
B_LOUDNESS("Bloudness", "Bottom Loudness", "Bottom loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
BC_LOUDNESS("BCloudness", "Back Center Loudness", "Back center loudness", ITEM_TYPE_LOUDNESS,
CHANNEL_TYPE_LOUDNESS),
BL_LOUDNESS("BLloudness", "Back Left Loudness", "Back Left Loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS),
BR_LOUDNESS("BRloudness", "Back Right Loudness", "Back right loudness", ITEM_TYPE_LOUDNESS, CHANNEL_TYPE_LOUDNESS);
private static final Map<String, UpnpChannelName> UPNP_CHANNEL_NAME_MAP = Stream.of(UpnpChannelName.values())
.collect(Collectors.toMap(UpnpChannelName::getChannelId, Function.identity()));
private final String channelId;
private final String label;
private final String description;
private final String itemType;
private final String channelType;
UpnpChannelName(final String channelId, final String label, final String description, final String itemType,
final String channelType) {
this.channelId = channelId;
this.label = label;
this.description = description;
this.itemType = itemType;
this.channelType = channelType;
}
/**
* @return The name of the Channel
*/
public String getChannelId() {
return channelId;
}
/**
* @return The label for the Channel
*/
public String getLabel() {
return label;
}
/**
* @return The description for the Channel
*/
public String getDescription() {
return description;
}
/**
* @return The item type for the Channel
*/
public String getItemType() {
return itemType;
}
/**
* @return The channel type for the Channel
*/
public String getChannelType() {
return channelType;
}
/**
* Returns the UPnP Channel enum for the given channel id or null if there is no enum available for the given
* channel.
*
* @param channelId Channel to find
* @return The UPnP Channel enum or null if there is none.
*/
public static @Nullable UpnpChannelName channelIdToUpnpChannelName(final String channelId) {
return UPNP_CHANNEL_NAME_MAP.get(channelId);
}
}

View File

@@ -12,12 +12,16 @@
*/
package org.openhab.binding.upnpcontrol.internal;
import java.io.File;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.OpenHAB;
import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.type.ChannelTypeUID;
/**
* The {@link UpnpControlBindingConstants} class defines common constants, which are
@@ -36,17 +40,35 @@ public class UpnpControlBindingConstants {
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";
// Binding config parameters
public static final String PATH = "path";
// Thing config parameters
public static final String UDN_PARAMETER = "udn";
public static final String REFRESH_INTERVAL = "refreshInterval";
public static final String REFRESH_INTERVAL = "refresh";
public static final String RESPONSE_TIMEOUT = "responsetimeout";
// Server thing only config parameters
public static final String CONFIG_FILTER = "filter";
public static final String SORT_CRITERIA = "sortcriteria";
public static final String BROWSE_DOWN = "browsedown";
public static final String SEARCH_FROM_ROOT = "searchfromroot";
// Renderer thing only config parameters
public static final String NOTIFICATION_VOLUME_ADJUSTMENT = "notificationvolumeadjustment";
public static final String MAX_NOTIFICATION_DURATION = "maxnotificationduration";
public static final String SEEK_STEP = "seekstep";
// 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 REPEAT = "repeat";
public static final String SHUFFLE = "shuffle";
public static final String ONLY_PLAY_ONE = "onlyplayone";
public static final String URI = "uri";
public static final String FAVORITE_SELECT = "favoriteselect";
public static final String FAVORITE = "favorite";
public static final String FAVORITE_ACTION = "favoriteaction";
public static final String TITLE = "title";
public static final String ALBUM = "album";
public static final String ALBUM_ART = "albumart";
@@ -57,14 +79,44 @@ public class UpnpControlBindingConstants {
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 REL_TRACK_POSITION = "reltrackposition";
public static final String UPNPRENDERER = "upnprenderer";
public static final String CURRENTID = "currentid";
public static final String CURRENTTITLE = "currenttitle";
public static final String BROWSE = "browse";
public static final String SEARCH = "search";
public static final String SERVE = "serve";
public static final String PLAYLIST_SELECT = "playlistselect";
public static final String PLAYLIST = "playlist";
public static final String PLAYLIST_ACTION = "playlistaction";
// Thing config properties
public static final String CONFIG_FILTER = "filter";
public static final String SORT_CRITERIA = "sortcriteria";
// Type constants for dynamic renderer channels
public static final String CHANNEL_TYPE_VOLUME = DefaultSystemChannelTypeProvider.SYSTEM_VOLUME.toString();
public static final String CHANNEL_TYPE_MUTE = DefaultSystemChannelTypeProvider.SYSTEM_MUTE.toString();
public static final String CHANNEL_TYPE_LOUDNESS = (new ChannelTypeUID(BINDING_ID, "loudness")).toString();
public static final String ITEM_TYPE_VOLUME = "Dimmer";
public static final String ITEM_TYPE_MUTE = "Switch";
public static final String ITEM_TYPE_LOUDNESS = "Switch";
// Command options for playlist and favorite actions
public static final String RESTORE = "RESTORE";
public static final String SAVE = "SAVE";
public static final String APPEND = "APPEND";
public static final String DELETE = "DELETE";
// Channels that are duplicated on server to control current renderer
public static final Set<String> SERVER_CONTROL_CHANNELS = Set.of(VOLUME, MUTE, CONTROL, STOP);
// Master volume and mute identifier
public static final String UPNP_MASTER = "Master";
// Filepath and extension defaults and constants for playlists and favorites
public static final String DEFAULT_PATH = OpenHAB.getUserDataFolder() + File.separator + BINDING_ID
+ File.separator;
public static final String PLAYLIST_FILE_EXTENSION = ".lst";
public static final String FAVORITE_FILE_EXTENSION = ".fav";
// Notification audio sink name extension
public static final String NOTIFICATION_AUDIOSINK_EXTENSION = "-notify";
}

View File

@@ -15,15 +15,27 @@ package org.openhab.binding.upnpcontrol.internal;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.util.Hashtable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.jupnp.UpnpService;
import org.jupnp.model.meta.LocalDevice;
import org.jupnp.model.meta.RemoteDevice;
import org.jupnp.registry.Registry;
import org.jupnp.registry.RegistryListener;
import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink;
import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpNotificationAudioSink;
import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
import org.openhab.binding.upnpcontrol.internal.handler.UpnpHandler;
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.config.core.Configuration;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.net.HttpServiceUtil;
import org.openhab.core.net.NetworkAddressService;
@@ -35,6 +47,8 @@ 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.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -47,15 +61,19 @@ import org.slf4j.LoggerFactory;
*/
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.upnpcontrol")
@NonNullByDefault
public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implements UpnpAudioSinkReg {
public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implements UpnpAudioSinkReg, RegistryListener {
final UpnpControlBindingConfiguration configuration = new UpnpControlBindingConfiguration();
private final Logger logger = LoggerFactory.getLogger(getClass());
private final Logger logger = LoggerFactory.getLogger(UpnpControlHandlerFactory.class);
private ConcurrentMap<String, ServiceRegistration<AudioSink>> audioSinkRegistrations = new ConcurrentHashMap<>();
private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers = new ConcurrentHashMap<>();
private ConcurrentMap<String, UpnpServerHandler> upnpServers = new ConcurrentHashMap<>();
private ConcurrentMap<String, UpnpHandler> handlers = new ConcurrentHashMap<>();
private ConcurrentMap<String, RemoteDevice> devices = new ConcurrentHashMap<>();
private final UpnpIOService upnpIOService;
private final UpnpService upnpService;
private final AudioHTTPServer audioHTTPServer;
private final NetworkAddressService networkAddressService;
private final UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
@@ -64,16 +82,36 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
private String callbackUrl = "";
@Activate
public UpnpControlHandlerFactory(final @Reference UpnpIOService upnpIOService,
public UpnpControlHandlerFactory(final @Reference UpnpIOService upnpIOService, @Reference UpnpService upnpService,
final @Reference AudioHTTPServer audioHTTPServer,
final @Reference NetworkAddressService networkAddressService,
final @Reference UpnpDynamicStateDescriptionProvider dynamicStateDescriptionProvider,
final @Reference UpnpDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider) {
final @Reference UpnpDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider,
Map<String, Object> config) {
this.upnpIOService = upnpIOService;
this.upnpService = upnpService;
this.audioHTTPServer = audioHTTPServer;
this.networkAddressService = networkAddressService;
this.upnpStateDescriptionProvider = dynamicStateDescriptionProvider;
this.upnpCommandDescriptionProvider = dynamicCommandDescriptionProvider;
upnpService.getRegistry().addListener(this);
modified(config);
}
@Modified
protected void modified(Map<String, Object> config) {
// We update instead of replace the configuration object, so that if the user updates the
// configuration, the values are automatically available in all handlers. Because they all
// share the same instance.
configuration.update(new Configuration(config).as(UpnpControlBindingConfiguration.class));
logger.debug("Updated binding configuration to {}", configuration);
}
@Deactivate
protected void deActivate() {
upnpService.getRegistry().removeListener(this);
}
@Override
@@ -108,38 +146,78 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
private UpnpServerHandler addServer(Thing thing) {
UpnpServerHandler handler = new UpnpServerHandler(thing, upnpIOService, upnpRenderers,
upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
upnpStateDescriptionProvider, upnpCommandDescriptionProvider, configuration);
String key = thing.getUID().toString();
upnpServers.put(key, handler);
logger.debug("Media server handler created for {}", thing.getLabel());
logger.debug("Media server handler created for {} with UID {}", thing.getLabel(), thing.getUID());
String udn = handler.getUDN();
if (udn != null) {
handlers.put(udn, handler);
remoteDeviceUpdated(null, devices.get(udn));
}
return handler;
}
private UpnpRendererHandler addRenderer(Thing thing) {
callbackUrl = createCallbackUrl();
UpnpRendererHandler handler = new UpnpRendererHandler(thing, upnpIOService, this);
UpnpRendererHandler handler = new UpnpRendererHandler(thing, upnpIOService, this, upnpStateDescriptionProvider,
upnpCommandDescriptionProvider, configuration);
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());
logger.debug("Media renderer handler created for {} with UID {}", thing.getLabel(), thing.getUID());
String udn = handler.getUDN();
if (udn != null) {
handlers.put(udn, handler);
remoteDeviceUpdated(null, devices.get(udn));
}
return handler;
}
private void removeServer(String key) {
logger.debug("Removing media server handler for {}", upnpServers.get(key).getThing().getLabel());
UpnpHandler handler = upnpServers.get(key);
if (handler == null) {
return;
}
logger.debug("Removing media server handler for {} with UID {}", handler.getThing().getLabel(),
handler.getThing().getUID());
handlers.remove(handler.getUDN());
upnpServers.remove(key);
}
private void removeRenderer(String key) {
logger.debug("Removing media renderer handler for {}", upnpRenderers.get(key).getThing().getLabel());
UpnpHandler handler = upnpServers.get(key);
if (handler == null) {
return;
}
logger.debug("Removing media renderer handler for {} with UID {}", handler.getThing().getLabel(),
handler.getThing().getUID());
if (audioSinkRegistrations.containsKey(key)) {
logger.debug("Removing audio sink registration for {}", upnpRenderers.get(key).getThing().getLabel());
logger.debug("Removing audio sink registration for {}", handler.getThing().getLabel());
ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(key);
reg.unregister();
if (reg != null) {
reg.unregister();
}
audioSinkRegistrations.remove(key);
}
String notificationKey = key + NOTIFICATION_AUDIOSINK_EXTENSION;
if (audioSinkRegistrations.containsKey(notificationKey)) {
logger.debug("Removing notification audio sink registration for {}", handler.getThing().getLabel());
ServiceRegistration<AudioSink> reg = audioSinkRegistrations.get(notificationKey);
if (reg != null) {
reg.unregister();
}
audioSinkRegistrations.remove(notificationKey);
}
upnpServers.forEach((thingId, value) -> value.removeRendererOption(key));
handlers.remove(handler.getUDN());
upnpRenderers.remove(key);
}
@@ -153,6 +231,14 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
Thing thing = handler.getThing();
audioSinkRegistrations.put(thing.getUID().toString(), reg);
logger.debug("Audio sink added for media renderer {}", thing.getLabel());
UpnpNotificationAudioSink notificationAudioSink = new UpnpNotificationAudioSink(handler, audioHTTPServer,
callbackUrl);
@SuppressWarnings("unchecked")
ServiceRegistration<AudioSink> notificationReg = (ServiceRegistration<AudioSink>) bundleContext
.registerService(AudioSink.class.getName(), notificationAudioSink, new Hashtable<String, Object>());
audioSinkRegistrations.put(thing.getUID().toString() + NOTIFICATION_AUDIOSINK_EXTENSION, notificationReg);
logger.debug("Notification audio sink added for media renderer {}", thing.getLabel());
}
}
@@ -173,4 +259,67 @@ public class UpnpControlHandlerFactory extends BaseThingHandlerFactory implement
}
return "http://" + ipAddress + ":" + port;
}
@Override
public void remoteDeviceDiscoveryStarted(@Nullable Registry registry, @Nullable RemoteDevice device) {
}
@Override
public void remoteDeviceDiscoveryFailed(@Nullable Registry registry, @Nullable RemoteDevice device,
@Nullable Exception ex) {
}
@Override
public void remoteDeviceAdded(@Nullable Registry registry, @Nullable RemoteDevice device) {
if (device == null) {
return;
}
String udn = device.getIdentity().getUdn().getIdentifierString();
if ("MediaServer".equals(device.getType().getType()) || "MediaRenderer".equals(device.getType().getType())) {
devices.put(udn, device);
}
if (handlers.containsKey(udn)) {
remoteDeviceUpdated(registry, device);
}
}
@Override
public void remoteDeviceUpdated(@Nullable Registry registry, @Nullable RemoteDevice device) {
if (device == null) {
return;
}
String udn = device.getIdentity().getUdn().getIdentifierString();
UpnpHandler handler = handlers.get(udn);
if (handler != null) {
handler.updateDeviceConfig(device);
}
}
@Override
public void remoteDeviceRemoved(@Nullable Registry registry, @Nullable RemoteDevice device) {
if (device == null) {
return;
}
devices.remove(device.getIdentity().getUdn().getIdentifierString());
}
@Override
public void localDeviceAdded(@Nullable Registry registry, @Nullable LocalDevice device) {
}
@Override
public void localDeviceRemoved(@Nullable Registry registry, @Nullable LocalDevice device) {
}
@Override
public void beforeShutdown(@Nullable Registry registry) {
devices = new ConcurrentHashMap<>();
}
@Override
public void afterShutdown() {
}
}

View File

@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.audiosink;
import java.io.IOException;
import java.util.Locale;
@@ -44,9 +44,9 @@ public class UpnpAudioSink implements AudioSink {
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;
protected UpnpRendererHandler handler;
protected AudioHTTPServer audioHTTPServer;
protected String callbackUrl;
public UpnpAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
this.handler = handler;
@@ -106,16 +106,15 @@ public class UpnpAudioSink implements AudioSink {
@Override
public void setVolume(@Nullable PercentType volume) throws IOException {
if (volume != null) {
handler.setVolume(handler.getCurrentChannel(), volume);
handler.setVolume(volume);
}
}
private void stopMedia() {
protected void stopMedia() {
handler.stop();
}
private void playMedia(String url) {
stopMedia();
protected void playMedia(String url) {
String newUrl = url;
if (!url.startsWith("x-") && !url.startsWith("http")) {
newUrl = "x-file-cifs:" + url;

View File

@@ -10,9 +10,10 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.audiosink;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory;
import org.openhab.binding.upnpcontrol.internal.handler.UpnpRendererHandler;
/**

View File

@@ -0,0 +1,67 @@
/**
* 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.audiosink;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.NOTIFICATION_AUDIOSINK_EXTENSION;
import java.io.IOException;
import java.util.Locale;
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.AudioHTTPServer;
import org.openhab.core.library.types.PercentType;
/**
*
* This class works as a standard audio sink for openHAB, but with specific behavior for the audio players. It is only
* meant to be used for playing notifications. When sending audio through this sink, the previously playing media will
* be interrupted and will automatically resume after playing the notification. If no volume is specified, the
* notification volume will be controlled by the media player notification volume configuration.
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public class UpnpNotificationAudioSink extends UpnpAudioSink {
public UpnpNotificationAudioSink(UpnpRendererHandler handler, AudioHTTPServer audioHTTPServer, String callbackUrl) {
super(handler, audioHTTPServer, callbackUrl);
}
@Override
public String getId() {
return handler.getThing().getUID().toString() + NOTIFICATION_AUDIOSINK_EXTENSION;
}
@Override
public @Nullable String getLabel(@Nullable Locale locale) {
return handler.getThing().getLabel() + NOTIFICATION_AUDIOSINK_EXTENSION;
}
@Override
public void setVolume(@Nullable PercentType volume) throws IOException {
if (volume != null) {
handler.setNotificationVolume(volume);
}
}
@Override
protected void playMedia(String url) {
String newUrl = url;
if (!url.startsWith("x-") && !url.startsWith("http")) {
newUrl = "x-file-cifs:" + url;
}
handler.playNotification(newUrl);
}
}

View File

@@ -0,0 +1,60 @@
/**
* 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 static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.DEFAULT_PATH;
import java.io.File;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Class containing the binding configuration parameters. Some helper methods take care of updating the relevant classes
* with parameter changes.
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public class UpnpControlBindingConfiguration {
private final Logger logger = LoggerFactory.getLogger(UpnpControlBindingConfiguration.class);
public String path = DEFAULT_PATH;
public void update(UpnpControlBindingConfiguration newConfig) {
String newPath = newConfig.path;
if (newPath.isEmpty()) {
path = DEFAULT_PATH;
} else {
File file = new File(newPath);
if (!file.isDirectory()) {
file = file.getParentFile();
}
if (file.exists()) {
if (!(newPath.endsWith(File.separator) || newPath.endsWith("/"))) {
newPath = newPath + File.separator;
}
path = newPath;
} else {
path = DEFAULT_PATH;
}
}
logger.debug("Storage path updated to {}", path);
UpnpControlUtil.bindingConfigurationChanged(path);
}
}

View File

@@ -23,4 +23,6 @@ import org.eclipse.jdt.annotation.Nullable;
@NonNullByDefault
public class UpnpControlConfiguration {
public @Nullable String udn;
public int refresh = 60;
public int responseTimeout = 2500;
}

View File

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

View File

@@ -21,5 +21,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
@NonNullByDefault
public class UpnpControlServerConfiguration extends UpnpControlConfiguration {
public boolean filter = false;
public String sortcriteria = "+dc:title";
public String sortCriteria = "+dc:title";
public boolean browseDown = true;
public boolean searchFromRoot = false;
}

View File

@@ -14,6 +14,7 @@ package org.openhab.binding.upnpcontrol.internal.discovery;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@@ -21,6 +22,7 @@ import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.jupnp.model.meta.RemoteDevice;
import org.jupnp.model.meta.RemoteService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.upnp.UpnpDiscoveryParticipant;
@@ -53,8 +55,17 @@ public class UpnpControlDiscoveryParticipant implements UpnpDiscoveryParticipant
String label = device.getDetails().getFriendlyName().isEmpty() ? device.getDisplayString()
: device.getDetails().getFriendlyName();
Map<String, Object> properties = new HashMap<>();
properties.put("ipAddress", device.getIdentity().getDescriptorURL().getHost());
URL descriptorURL = device.getIdentity().getDescriptorURL();
properties.put("ipAddress", descriptorURL.getHost());
properties.put("udn", device.getIdentity().getUdn().getIdentifierString());
properties.put("deviceDescrURL", descriptorURL.toString());
URL baseURL = device.getDetails().getBaseURL();
if (baseURL != null) {
properties.put("baseURL", device.getDetails().getBaseURL().toString());
}
for (RemoteService service : device.getServices()) {
properties.put(service.getServiceType().getType() + "DescrURI", service.getDescriptorURI().toString());
}
result = DiscoveryResultBuilder.create(thingUid).withLabel(label).withProperties(properties)
.withRepresentationProperty("udn").build();
}
@@ -68,9 +79,10 @@ public class UpnpControlDiscoveryParticipant implements UpnpDiscoveryParticipant
String manufacturer = device.getDetails().getManufacturerDetails().getManufacturer();
String model = device.getDetails().getModelDetails().getModelName();
String serialNumber = device.getDetails().getSerialNumber();
String udn = device.getIdentity().getUdn().getIdentifierString();
logger.debug("Device type {}, manufacturer {}, model {}, SN# {}", deviceType, manufacturer, model,
serialNumber);
logger.debug("Device type {}, manufacturer {}, model {}, SN# {}, UDN {}", deviceType, manufacturer, model,
serialNumber, udn);
if (deviceType.equalsIgnoreCase("MediaRenderer")) {
this.logger.debug("Media renderer found: {}, {}", manufacturer, model);

View File

@@ -12,55 +12,281 @@
*/
package org.openhab.binding.upnpcontrol.internal.handler;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.jupnp.model.meta.RemoteDevice;
import org.jupnp.registry.RegistryListener;
import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
import org.openhab.binding.upnpcontrol.internal.config.UpnpControlConfiguration;
import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
import org.openhab.core.io.transport.upnp.UpnpIOService;
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.thing.binding.BaseThingHandler;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.types.CommandDescription;
import org.openhab.core.types.CommandDescriptionBuilder;
import org.openhab.core.types.CommandOption;
import org.openhab.core.types.StateDescription;
import org.openhab.core.types.StateDescriptionFragmentBuilder;
import org.openhab.core.types.StateOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}.
* The {@link UpnpHandler} is the base class for {@link UpnpRendererHandler} and {@link UpnpServerHandler}. The base
* class implements UPnPConnectionManager service actions.
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
*/
@NonNullByDefault
public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant {
public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOParticipant, UpnpPlaylistsListener {
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;
// UPnP constants
static final String CONNECTION_MANAGER = "ConnectionManager";
static final String CONNECTION_ID = "ConnectionID";
static final String AV_TRANSPORT_ID = "AVTransportID";
static final String RCS_ID = "RcsID";
static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
public UpnpHandler(Thing thing, UpnpIOService upnpIOService) {
protected UpnpIOService upnpIOService;
protected volatile @Nullable RemoteDevice device;
// The handlers can potentially create an important number of tasks, therefore put them in a separate thread pool
protected ScheduledExecutorService upnpScheduler = ThreadPoolManager.getScheduledPool("binding-upnpcontrol");
private boolean updateChannels;
private final List<Channel> updatedChannels = new ArrayList<>();
private final List<ChannelUID> updatedChannelUIDs = new ArrayList<>();
protected volatile int connectionId = 0; // UPnP Connection Id
protected volatile int avTransportId = 0; // UPnP AVTtransport Id
protected volatile int rcsId = 0; // UPnP Rendering Control Id
protected UpnpControlBindingConfiguration bindingConfig;
protected UpnpControlConfiguration config;
protected final Object invokeActionLock = new Object();
protected @Nullable ScheduledFuture<?> pollingJob;
protected final Object jobLock = new Object();
protected volatile @Nullable CompletableFuture<Boolean> isConnectionIdSet;
protected volatile @Nullable CompletableFuture<Boolean> isAvTransportIdSet;
protected volatile @Nullable CompletableFuture<Boolean> isRcsIdSet;
protected static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
protected List<String> serviceSubscriptions = new ArrayList<>();
protected volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
protected final Runnable subscriptionRefresh = () -> {
for (String subscription : serviceSubscriptions) {
removeSubscription(subscription);
addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
}
};
protected volatile boolean upnpSubscribed;
protected UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
protected UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
public UpnpHandler(Thing thing, UpnpIOService upnpIOService, UpnpControlBindingConfiguration configuration,
UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
super(thing);
upnpIOService.registerParticipant(this);
this.service = upnpIOService;
this.upnpIOService = upnpIOService;
this.bindingConfig = configuration;
this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
// Get this in constructor, so the UDN is immediately available from the config. The concrete classes should
// update the config from the initialize method.
config = getConfigAs(UpnpControlConfiguration.class);
}
@Override
public void initialize() {
config = getConfigAs(UpnpControlConfiguration.class);
service.registerParticipant(this);
upnpIOService.registerParticipant(this);
UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
UpnpControlUtil.playlistsSubscribe(this);
}
@Override
public void dispose() {
service.unregisterParticipant(this);
cancelPollingJob();
removeSubscriptions();
UpnpControlUtil.playlistsUnsubscribe(this);
CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
if (connectionIdFuture != null) {
connectionIdFuture.complete(false);
isConnectionIdSet = null;
}
CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
if (avTransportIdFuture != null) {
avTransportIdFuture.complete(false);
isAvTransportIdSet = null;
}
CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
if (rcsIdFuture != null) {
rcsIdFuture.complete(false);
isRcsIdSet = null;
}
updateChannels = false;
updatedChannels.clear();
updatedChannelUIDs.clear();
upnpIOService.removeStatusListener(this);
upnpIOService.unregisterParticipant(this);
}
private void cancelPollingJob() {
ScheduledFuture<?> job = pollingJob;
if (job != null) {
job.cancel(true);
}
pollingJob = null;
}
/**
* To be called from implementing classes when initializing the device, to start initialization refresh
*/
protected void initDevice() {
String udn = getUDN();
if ((udn != null) && !udn.isEmpty()) {
if (config.refresh == 0) {
upnpScheduler.submit(this::initJob);
} else {
pollingJob = upnpScheduler.scheduleWithFixedDelay(this::initJob, 0, config.refresh, TimeUnit.SECONDS);
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No UDN configured for " + thing.getLabel());
}
}
/**
* Job to be executed in an asynchronous process when initializing a device. This checks if the connection id's are
* correctly set up for the connection. It can also be called from a polling job to get the thing back online when
* connection is lost.
*/
protected abstract void initJob();
@Override
protected void updateStatus(ThingStatus status) {
ThingStatus currentStatus = thing.getStatus();
super.updateStatus(status);
// When status changes to ThingStatus.ONLINE, make sure to refresh all linked channels
if (!status.equals(currentStatus) && status.equals(ThingStatus.ONLINE)) {
thing.getChannels().forEach(channel -> {
if (isLinked(channel.getUID())) {
channelLinked(channel.getUID());
}
});
}
}
/**
* Method called when a the remote device represented by the thing for this handler is added to the jupnp
* {@link RegistryListener} or is updated. Configuration info can be retrieved from the {@link RemoteDevice}.
*
* @param device
*/
public void updateDeviceConfig(RemoteDevice device) {
this.device = device;
};
protected void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
.withOptions(stateOptionList).build().toStateDescription();
upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
}
protected void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
.build();
upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
}
protected void createChannel(@Nullable UpnpChannelName upnpChannelName) {
if ((upnpChannelName != null)) {
createChannel(upnpChannelName.getChannelId(), upnpChannelName.getLabel(), upnpChannelName.getDescription(),
upnpChannelName.getItemType(), upnpChannelName.getChannelType());
}
}
protected void createChannel(String channelId, String label, String description, String itemType,
String channelType) {
ChannelUID channelUID = new ChannelUID(thing.getUID(), channelId);
if (thing.getChannel(channelUID) != null) {
// channel already exists
logger.trace("UPnP device {}, channel {} already exists", thing.getLabel(), channelId);
return;
}
ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
Channel channel = ChannelBuilder.create(channelUID).withLabel(label).withDescription(description)
.withAcceptedItemType(itemType).withType(channelTypeUID).build();
logger.debug("UPnP device {}, created channel {}", thing.getLabel(), channelId);
updatedChannels.add(channel);
updatedChannelUIDs.add(channelUID);
updateChannels = true;
}
protected void updateChannels() {
if (updateChannels) {
List<Channel> channels = thing.getChannels().stream().filter(c -> !updatedChannelUIDs.contains(c.getUID()))
.collect(Collectors.toList());
channels.addAll(updatedChannels);
final ThingBuilder thingBuilder = editThing();
thingBuilder.withChannels(channels);
updateThing(thingBuilder.build());
}
updatedChannels.clear();
updatedChannelUIDs.clear();
updateChannels = false;
}
/**
@@ -74,36 +300,84 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
*/
protected void prepareForConnection(String remoteProtocolInfo, String peerConnectionManager, int peerConnectionId,
String direction) {
CompletableFuture<Boolean> settingConnection = isConnectionIdSet;
CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
CompletableFuture<Boolean> settingRcs = isRcsIdSet;
if (settingConnection != null) {
settingConnection.complete(false);
}
if (settingAVTransport != null) {
settingAVTransport.complete(false);
}
if (settingRcs != null) {
settingRcs.complete(false);
}
// Set new futures, so we don't try to use service when connection id's are not known yet
isConnectionIdSet = new CompletableFuture<Boolean>();
isAvTransportIdSet = new CompletableFuture<Boolean>();
isRcsIdSet = new CompletableFuture<Boolean>();
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);
invokeAction(CONNECTION_MANAGER, "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));
protected void connectionComplete() {
Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
invokeAction("ConnectionManager", "ConnectionComplete", inputs);
invokeAction(CONNECTION_MANAGER, "ConnectionComplete", inputs);
}
/**
* Invoke GetTransportState on UPnP AV Transport.
* Invoke GetCurrentConnectionIDs on the UPnP Connection Manager.
* Result is received in {@link onValueReceived}.
*/
protected void getTransportState() {
HashMap<String, String> inputs = new HashMap<String, String>();
inputs.put("InstanceID", Integer.toString(avTransportId));
protected void getCurrentConnectionIDs() {
Map<String, String> inputs = Collections.emptyMap();
invokeAction("AVTransport", "GetTransportInfo", inputs);
invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionIDs", inputs);
}
/**
* Invoke GetCurrentConnectionInfo on the UPnP Connection Manager.
* Result is received in {@link onValueReceived}.
*/
protected void getCurrentConnectionInfo() {
CompletableFuture<Boolean> settingAVTransport = isAvTransportIdSet;
CompletableFuture<Boolean> settingRcs = isRcsIdSet;
if (settingAVTransport != null) {
settingAVTransport.complete(false);
}
if (settingRcs != null) {
settingRcs.complete(false);
}
// Set new futures, so we don't try to use service when connection id's are not known yet
isAvTransportIdSet = new CompletableFuture<Boolean>();
isRcsIdSet = new CompletableFuture<Boolean>();
// ConnectionID will default to 0 if not set through prepareForConnection method
Map<String, String> inputs = Collections.singletonMap(CONNECTION_ID, Integer.toString(connectionId));
invokeAction(CONNECTION_MANAGER, "GetCurrentConnectionInfo", inputs);
}
/**
* Invoke GetFeatureList on the UPnP Connection Manager.
* Result is received in {@link onValueReceived}.
*/
protected void getFeatureList() {
Map<String, String> inputs = Collections.emptyMap();
invokeAction(CONNECTION_MANAGER, "GetFeatureList", inputs);
}
/**
@@ -111,30 +385,31 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
* Result is received in {@link onValueReceived}.
*/
protected void getProtocolInfo() {
Map<String, String> inputs = new HashMap<>();
Map<String, String> inputs = Collections.emptyMap();
invokeAction("ConnectionManager", "GetProtocolInfo", inputs);
invokeAction(CONNECTION_MANAGER, "GetProtocolInfo", inputs);
}
@Override
public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
logger.debug("Upnp device {} received subscription reply {} from service {}", thing.getLabel(), 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 {
if (!succeeded) {
upnpSubscribed = false;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication lost with " + thing.getLabel());
"Could not subscribe to service " + service + "for" + thing.getLabel());
}
}
@Override
public @Nullable String getUDN() {
return config.udn;
public void onStatusChanged(boolean status) {
logger.debug("UPnP device {} received status update {}", thing.getLabel(), status);
if (status) {
initJob();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Communication lost with " + thing.getLabel());
}
}
/**
@@ -148,14 +423,28 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
* @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);
upnpScheduler.submit(() -> {
Map<String, @Nullable String> result;
synchronized (invokeActionLock) {
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);
}
result = upnpIOService.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 {} reply {}", thing.getLabel(),
actionId, serviceId, result);
}
if (!result.isEmpty()) {
// We can be sure a non-empty result means the device is online.
// An empty result could be expected for certain actions, but could also be hiding an exception.
updateStatus(ThingStatus.ONLINE);
}
result = preProcessInvokeActionResult(inputs, serviceId, actionId, result);
}
for (String variable : result.keySet()) {
onValueReceived(variable, result.get(variable), serviceId);
@@ -163,31 +452,133 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
});
}
/**
* Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
* method will return an adjusted result list. The default implementation will copy over the received result without
* additional processing. Derived classes can add additional logic.
*
* @param inputs
* @param service
* @param result
* @return
*/
protected Map<String, @Nullable String> preProcessInvokeActionResult(Map<String, String> inputs,
@Nullable String service, @Nullable String action, Map<String, @Nullable String> result) {
Map<String, @Nullable String> newResult = new HashMap<>();
for (String variable : result.keySet()) {
String newVariable = preProcessValueReceived(inputs, variable, result.get(variable), service, action);
if (newVariable != null) {
newResult.put(newVariable, result.get(variable));
}
}
return newResult;
}
/**
* Some received values need info on inputs of action. Therefore we allow to pre-process in a separate step. The
* default implementation will return the original value. Derived classes can implement additional logic.
*
* @param inputs
* @param variable
* @param value
* @param service
* @return
*/
protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
@Nullable String value, @Nullable String service, @Nullable String action) {
return variable;
}
@Override
public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
if (variable == null || value == null) {
return;
}
switch (variable) {
case "CurrentTransportState":
case CONNECTION_ID:
onValueReceivedConnectionId(value);
break;
case AV_TRANSPORT_ID:
onValueReceivedAVTransportId(value);
break;
case RCS_ID:
onValueReceivedRcsId(value);
break;
case "Source":
case "Sink":
if (!value.isEmpty()) {
transportState = value;
updateProtocolInfo(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;
}
}
private void onValueReceivedConnectionId(@Nullable String value) {
try {
connectionId = (value == null) ? 0 : Integer.parseInt(value);
} catch (NumberFormatException e) {
connectionId = 0;
}
CompletableFuture<Boolean> connectionIdFuture = isConnectionIdSet;
if (connectionIdFuture != null) {
connectionIdFuture.complete(true);
}
}
private void onValueReceivedAVTransportId(@Nullable String value) {
try {
avTransportId = (value == null) ? 0 : Integer.parseInt(value);
} catch (NumberFormatException e) {
avTransportId = 0;
}
CompletableFuture<Boolean> avTransportIdFuture = isAvTransportIdSet;
if (avTransportIdFuture != null) {
avTransportIdFuture.complete(true);
}
}
private void onValueReceivedRcsId(@Nullable String value) {
try {
rcsId = (value == null) ? 0 : Integer.parseInt(value);
} catch (NumberFormatException e) {
rcsId = 0;
}
CompletableFuture<Boolean> rcsIdFuture = isRcsIdSet;
if (rcsIdFuture != null) {
rcsIdFuture.complete(true);
}
}
@Override
public @Nullable String getUDN() {
return config.udn;
}
protected boolean checkForConnectionIds() {
return checkForConnectionId(isConnectionIdSet) & checkForConnectionId(isAvTransportIdSet)
& checkForConnectionId(isRcsIdSet);
}
private boolean checkForConnectionId(@Nullable CompletableFuture<Boolean> future) {
try {
if (future != null) {
return future.get(config.responseTimeout, TimeUnit.MILLISECONDS);
}
} catch (InterruptedException | ExecutionException | TimeoutException e) {
return false;
}
return true;
}
/**
* Update internal representation of supported protocols, needs to be implemented in derived classes.
*
* @param value
*/
protected abstract void updateProtocolInfo(String value);
/**
* Subscribe this handler as a participant to a GENA subscription.
*
@@ -195,8 +586,10 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
* @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);
if (upnpIOService.isRegistered(this)) {
logger.debug("UPnP device {} add upnp subscription on {}", thing.getLabel(), serviceId);
upnpIOService.addSubscription(this, serviceId, duration);
}
}
/**
@@ -205,8 +598,55 @@ public abstract class UpnpHandler extends BaseThingHandler implements UpnpIOPart
* @param serviceId
*/
protected void removeSubscription(String serviceId) {
if (service.isRegistered(this)) {
service.removeSubscription(this, serviceId);
if (upnpIOService.isRegistered(this)) {
upnpIOService.removeSubscription(this, serviceId);
}
}
protected void addSubscriptions() {
upnpSubscribed = true;
for (String subscription : serviceSubscriptions) {
addSubscription(subscription, SUBSCRIPTION_DURATION_SECONDS);
}
subscriptionRefreshJob = upnpScheduler.scheduleWithFixedDelay(subscriptionRefresh,
SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
// This action should exist on all media devices and return a result, so a good candidate for testing the
// connection.
upnpIOService.addStatusListener(this, CONNECTION_MANAGER, "GetCurrentConnectionIDs", config.refresh);
}
protected void removeSubscriptions() {
cancelSubscriptionRefreshJob();
for (String subscription : serviceSubscriptions) {
removeSubscription(subscription);
}
upnpIOService.removeStatusListener(this);
upnpSubscribed = false;
}
private void cancelSubscriptionRefreshJob() {
ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
if (refreshJob != null) {
refreshJob.cancel(true);
}
subscriptionRefreshJob = null;
}
@Override
public abstract void playlistsListChanged();
/**
* Get access to all device info through the UPnP {@link RemoteDevice}.
*
* @return UPnP RemoteDevice
*/
protected @Nullable RemoteDevice getDevice() {
return device;
}
}

View File

@@ -22,7 +22,12 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -30,10 +35,13 @@ 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.UpnpControlBindingConfiguration;
import org.openhab.binding.upnpcontrol.internal.config.UpnpControlServerConfiguration;
import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
import org.openhab.binding.upnpcontrol.internal.util.UpnpProtocolMatcher;
import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
import org.openhab.core.io.transport.upnp.UpnpIOService;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
@@ -42,19 +50,17 @@ 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.State;
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.
* The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server. It implements UPnP
* ContentDirectory service actions.
*
* @author Mark Herwege - Initial contribution
* @author Karel Goderis - Based on UPnP logic in Sonos binding
@@ -62,41 +68,49 @@ import org.slf4j.LoggerFactory;
@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;
// UPnP constants
static final String CONTENT_DIRECTORY = "ContentDirectory";
static final String DIRECTORY_ROOT = "0";
static final String UP = "..";
ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
private volatile @Nullable UpnpRendererHandler currentRendererHandler;
private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
private volatile List<CommandOption> playlistCommandOptionList = List.of();
private @NonNullByDefault({}) ChannelUID rendererChannelUID;
private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
private volatile UpnpEntry currentEntry = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
private volatile @Nullable CompletableFuture<Boolean> isBrowsing;
private volatile boolean browseUp = false; // used to avoid automatically going down a level if only one container
// entry found when going up in the hierarchy
private static final UpnpEntry ROOT_ENTRY = 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
volatile UpnpEntry currentEntry = ROOT_ENTRY;
// current entry list in selection
List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>());
// store parents in hierarchy separately to be able to move up in directory structure
private ConcurrentMap<String, UpnpEntry> parentMap = new ConcurrentHashMap<>();
private UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
private UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
private volatile String playlistName = "";
protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
public UpnpServerHandler(Thing thing, UpnpIOService upnpIOService,
ConcurrentMap<String, UpnpRendererHandler> upnpRenderers,
UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
super(thing, upnpIOService);
UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
UpnpControlBindingConfiguration configuration) {
super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
this.upnpRenderers = upnpRenderers;
this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
// put root as highest level in parent map
parentMap.put(currentEntry.getId(), currentEntry);
parentMap.put(ROOT_ENTRY.getId(), ROOT_ENTRY);
}
@Override
@@ -122,34 +136,151 @@ public class UpnpServerHandler extends UpnpHandler {
"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());
}
Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
if (playlistSelectChannel != null) {
playlistSelectChannelUID = playlistSelectChannel.getUID();
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No UDN configured for " + thing.getLabel());
"Channel " + PLAYLIST_SELECT + " not defined");
return;
}
initDevice();
}
@Override
public void dispose() {
logger.debug("Disposing handler for media server device {}", thing.getLabel());
CompletableFuture<Boolean> browsingFuture = isBrowsing;
if (browsingFuture != null) {
browsingFuture.complete(false);
isBrowsing = null;
}
super.dispose();
}
@Override
protected void initJob() {
synchronized (jobLock) {
if (!upnpIOService.isRegistered(this)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"UPnP device with UDN " + getUDN() + " not yet registered");
return;
}
if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
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);
playlistsListChanged();
updateStatus(ThingStatus.ONLINE);
}
if (!upnpSubscribed) {
addSubscriptions();
}
}
}
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);
});
/**
* 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
*/
protected void browse(String objectID, String browseFlag, String filter, String startingIndex,
String requestedCount, String sortCriteria) {
CompletableFuture<Boolean> browsing = isBrowsing;
boolean browsed = true;
try {
if (browsing != null) {
// wait for maximum 2.5s until browsing is finished
browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
}
} catch (InterruptedException | ExecutionException | TimeoutException e) {
logger.debug("Exception, previous server query on {} interrupted or timed out, trying new browse anyway",
thing.getLabel());
}
updateStateDescription(rendererChannelUID, rendererStateOptionList);
getProtocolInfo();
if (browsed) {
isBrowsing = new CompletableFuture<Boolean>();
browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.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);
updateStatus(ThingStatus.ONLINE);
invokeAction(CONTENT_DIRECTORY, "Browse", inputs);
} else {
logger.debug("Cannot browse, cancelled querying server {}", thing.getLabel());
}
}
/**
* 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
*/
protected void search(String containerID, String searchCriteria, String filter, String startingIndex,
String requestedCount, String sortCriteria) {
CompletableFuture<Boolean> browsing = isBrowsing;
boolean browsed = true;
try {
if (browsing != null) {
// wait for maximum 2.5s until browsing is finished
browsed = browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
}
} catch (InterruptedException | ExecutionException | TimeoutException e) {
logger.debug("Exception, previous server query on {} interrupted or timed out, trying new search anyway",
thing.getLabel());
}
if (browsed) {
isBrowsing = new CompletableFuture<Boolean>();
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(CONTENT_DIRECTORY, "Search", inputs);
} else {
logger.debug("Cannot search, cancelled querying server {}", thing.getLabel());
}
}
protected void updateServerState(ChannelUID channelUID, State state) {
updateState(channelUID, state);
}
@Override
@@ -158,89 +289,256 @@ public class UpnpServerHandler extends UpnpHandler {
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()));
}
}
handleCommandUpnpRenderer(channelUID, command);
break;
case CURRENTTITLE:
handleCommandCurrentTitle(channelUID, command);
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;
}
UpnpEntry entry = parentMap.get(browseTarget);
if (entry == null) {
logger.info("Browse target not found. Exiting.");
return;
}
currentEntry = entry;
}
updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
logger.debug("Browse target {}", browseTarget);
browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
}
}
handleCommandBrowse(channelUID, command);
break;
case SEARCH:
if (command instanceof StringType) {
String criteria = command.toString();
if (criteria != null) {
String searchContainer = "";
if (currentEntry.isContainer()) {
searchContainer = currentEntry.getId();
handleCommandSearch(command);
break;
case PLAYLIST_SELECT:
handleCommandPlaylistSelect(channelUID, command);
break;
case PLAYLIST:
handleCommandPlaylist(channelUID, command);
break;
case PLAYLIST_ACTION:
handleCommandPlaylistAction(command);
break;
case VOLUME:
case MUTE:
case CONTROL:
case STOP:
// Pass these on to the media renderer thing if one is selected
handleCommandInRenderer(channelUID, command);
break;
}
}
private void handleCommandUpnpRenderer(ChannelUID channelUID, Command command) {
UpnpRendererHandler renderer = null;
UpnpRendererHandler previousRenderer = currentRendererHandler;
if (command instanceof StringType) {
renderer = (upnpRenderers.get(((StringType) command).toString()));
currentRendererHandler = renderer;
if (config.filter) {
// only refresh title list if filtering by renderer capabilities
browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
} else {
serveMedia();
}
}
if ((renderer != null) && !renderer.equals(previousRenderer)) {
if (previousRenderer != null) {
previousRenderer.unsetServerHandler();
}
renderer.setServerHandler(this);
Channel channel;
if ((channel = thing.getChannel(VOLUME)) != null) {
handleCommand(channel.getUID(), RefreshType.REFRESH);
}
if ((channel = thing.getChannel(MUTE)) != null) {
handleCommand(channel.getUID(), RefreshType.REFRESH);
}
if ((channel = thing.getChannel(CONTROL)) != null) {
handleCommand(channel.getUID(), RefreshType.REFRESH);
}
}
if ((renderer = currentRendererHandler) != null) {
updateState(channelUID, StringType.valueOf(renderer.getThing().getUID().toString()));
} else {
updateState(channelUID, UnDefType.UNDEF);
}
}
private void handleCommandCurrentTitle(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
updateState(channelUID, StringType.valueOf(currentEntry.getTitle()));
}
}
private void handleCommandBrowse(ChannelUID channelUID, Command command) {
String browseTarget = "";
if (command instanceof StringType) {
browseTarget = command.toString();
if (!browseTarget.isEmpty()) {
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;
}
browseUp = true;
}
UpnpEntry entry = parentMap.get(browseTarget);
if (entry != null) {
currentEntry = entry;
} else {
final String target = browseTarget;
synchronized (entries) {
Optional<UpnpEntry> current = entries.stream().filter(e -> target.equals(e.getId()))
.findFirst();
if (current.isPresent()) {
currentEntry = current.get();
} else {
searchContainer = currentEntry.getParentId();
// The real entry is not in the parentMap or options list yet, so construct a default one
currentEntry = new UpnpEntry(browseTarget, browseTarget, DIRECTORY_ROOT,
"object.container");
}
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;
logger.debug("Browse target {}", browseTarget);
logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
updateState(channelUID, StringType.valueOf(browseTarget));
updateState(CURRENTTITLE, StringType.valueOf(currentEntry.getTitle()));
browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
}
} else if (command instanceof RefreshType) {
browseTarget = currentEntry.getId();
updateState(channelUID, StringType.valueOf(browseTarget));
}
}
private void handleCommandSearch(Command command) {
if (command instanceof StringType) {
String criteria = command.toString();
if (!criteria.isEmpty()) {
String searchContainer = "";
if (currentEntry.isContainer()) {
searchContainer = currentEntry.getId();
} else {
searchContainer = currentEntry.getParentId();
}
if (config.searchFromRoot || searchContainer.isEmpty()) {
// Config option search from root or no parent found, so make it the root directory
searchContainer = DIRECTORY_ROOT;
}
UpnpEntry entry = parentMap.get(searchContainer);
if (entry != null) {
currentEntry = entry;
} else {
// The real entry is not in the parentMap yet, so construct a default one
currentEntry = new UpnpEntry(searchContainer, searchContainer, DIRECTORY_ROOT, "object.container");
}
logger.debug("Navigating to node {} on server {}", searchContainer, thing.getLabel());
updateState(BROWSE, StringType.valueOf(currentEntry.getId()));
logger.debug("Search container {} for {}", searchContainer, criteria);
search(searchContainer, criteria, "*", "0", "0", config.sortCriteria);
}
}
}
private void handleCommandPlaylistSelect(ChannelUID channelUID, Command command) {
if (command instanceof StringType) {
playlistName = command.toString();
updateState(PLAYLIST, StringType.valueOf(playlistName));
}
}
private void handleCommandPlaylist(ChannelUID channelUID, Command command) {
if (command instanceof StringType) {
playlistName = command.toString();
}
updateState(channelUID, StringType.valueOf(playlistName));
}
private void handleCommandPlaylistAction(Command command) {
if (command instanceof StringType) {
switch (command.toString()) {
case RESTORE:
handleCommandPlaylistRestore();
break;
case SAVE:
handleCommandPlaylistSave(false);
break;
case APPEND:
handleCommandPlaylistSave(true);
break;
case DELETE:
handleCommandPlaylistDelete();
break;
}
}
}
private void handleCommandPlaylistRestore() {
if (!playlistName.isEmpty()) {
// Don't immediately restore a playlist if a browse or search is still underway, or it could get overwritten
CompletableFuture<Boolean> browsing = isBrowsing;
try {
if (browsing != null) {
// wait for maximum 2.5s until browsing is finished
browsing.get(config.responseTimeout, TimeUnit.MILLISECONDS);
}
} catch (InterruptedException | ExecutionException | TimeoutException e) {
logger.debug(
"Exception, previous server on {} query interrupted or timed out, restoring playlist anyway",
thing.getLabel());
}
UpnpEntryQueue queue = new UpnpEntryQueue();
queue.restoreQueue(playlistName, config.udn, bindingConfig.path);
updateTitleSelection(queue.getEntryList());
String parentId;
UpnpEntry current = queue.get(0);
if (current != null) {
parentId = current.getParentId();
UpnpEntry entry = parentMap.get(parentId);
if (entry != null) {
currentEntry = entry;
} else {
// The real entry is not in the parentMap yet, so construct a default one
currentEntry = new UpnpEntry(parentId, parentId, DIRECTORY_ROOT, "object.container");
}
} else {
parentId = DIRECTORY_ROOT;
currentEntry = ROOT_ENTRY;
}
logger.debug("Restoring playlist to node {} on server {}", parentId, thing.getLabel());
}
}
private void handleCommandPlaylistSave(boolean append) {
if (!playlistName.isEmpty()) {
List<UpnpEntry> mediaQueue = new ArrayList<>();
mediaQueue.addAll(entries);
if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
mediaQueue.add(currentEntry);
}
UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, config.udn);
queue.persistQueue(playlistName, append, bindingConfig.path);
UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
}
}
private void handleCommandPlaylistDelete() {
if (!playlistName.isEmpty()) {
UpnpControlUtil.deletePlaylist(playlistName, bindingConfig.path);
UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
updateState(PLAYLIST, UnDefType.UNDEF);
}
}
private void handleCommandInRenderer(ChannelUID channelUID, Command command) {
String channelId = channelUID.getId();
UpnpRendererHandler handler = currentRendererHandler;
Channel channel;
if ((handler != null) && (channel = handler.getThing().getChannel(channelId)) != null) {
handler.handleCommand(channel.getUID(), command);
} else if (!STOP.equals(channelId)) {
updateState(channelId, UnDefType.UNDEF);
}
}
@@ -252,7 +550,10 @@ public class UpnpServerHandler extends UpnpHandler {
*/
public void addRendererOption(String key) {
synchronized (rendererStateOptionList) {
rendererStateOptionList.add(new StateOption(key, upnpRenderers.get(key).getThing().getLabel()));
UpnpRendererHandler handler = upnpRenderers.get(key);
if (handler != null) {
rendererStateOptionList.add(new StateOption(key, handler.getThing().getLabel()));
}
}
updateStateDescription(rendererChannelUID, rendererStateOptionList);
logger.debug("Renderer option {} added to {}", key, thing.getLabel());
@@ -277,27 +578,32 @@ public class UpnpServerHandler extends UpnpHandler {
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());
@Override
public void playlistsListChanged() {
playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
.collect(Collectors.toList());
updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
}
private void updateTitleSelection(List<UpnpEntry> titleList) {
// 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<>();
List<StateOption> stateOptionList = 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);
StateOption stateOption = new StateOption(UP, UP);
stateOptionList.add(stateOption);
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);
StateOption stateOption = new StateOption(value.getId(), value.getTitle());
stateOptionList.add(stateOption);
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
@@ -309,131 +615,47 @@ public class UpnpServerHandler extends UpnpHandler {
});
}
// 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);
logger.debug("{} entries added to selection list on server {}", stateOptionList.size(), thing.getLabel());
updateStateDescription(currentSelectionChannelUID, stateOptionList);
updateState(BROWSE, StringType.valueOf(currentEntry.getId()));
updateState(CURRENTTITLE, StringType.valueOf(currentEntry.getTitle()));
serveMedia();
}
/**
* Filter a list of media and only keep the media that are playable on the currently selected renderer.
* Filter a list of media and only keep the media that are playable on the currently selected renderer. Return all
* if no renderer is selected.
*
* @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<>();
logger.debug("Server {}, raw result list {}", thing.getLabel(), resultList);
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);
List<String> sink = (handler != null) ? handler.getSink() : null;
List<UpnpEntry> list = resultList.stream()
.filter(entry -> ((includeContainers && entry.isContainer()) || (sink == null) && !entry.isContainer())
|| ((sink != null) && UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink)))
.collect(Collectors.toList());
logger.debug("Server {}, filtered result list {}", thing.getLabel(), 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(String, String, String)} 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(String, String, String)} 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,
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>());
}
onValueReceivedResult(value);
break;
case "Source":
case "NumberReturned":
case "TotalMatches":
case "UpdateID":
@@ -444,6 +666,39 @@ public class UpnpServerHandler extends UpnpHandler {
}
}
private void onValueReceivedResult(@Nullable String value) {
CompletableFuture<Boolean> browsing = isBrowsing;
if (!((value == null) || (value.isEmpty()))) {
List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
if (config.browseDown && (list.size() == 1) && list.get(0).isContainer() && !browseUp) {
// We only received one container entry, so we immediately browse to the next level if config.browsedown
// = true
if (browsing != null) {
browsing.complete(true); // Clear previous browse flag before starting new browse
}
currentEntry = list.get(0);
String browseTarget = currentEntry.getId();
parentMap.put(browseTarget, currentEntry);
logger.debug("Server {}, browsing down one level to the unique container result {}", thing.getLabel(),
browseTarget);
browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortCriteria);
} else {
updateTitleSelection(removeDuplicates(list));
}
} else {
updateTitleSelection(new ArrayList<UpnpEntry>());
}
browseUp = false;
if (browsing != null) {
browsing.complete(true); // We have received browse or search results, so can launch new browse or
// search
}
}
@Override
protected void updateProtocolInfo(String value) {
}
/**
* 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.
@@ -454,13 +709,10 @@ public class UpnpServerHandler extends UpnpHandler {
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)) {
if (refId.isEmpty() || !refIdSet.contains(refId)) {
newList.add(entry);
}
if (!refId.isEmpty()) {
refIdSet.add(refId);
}
});
@@ -470,7 +722,7 @@ public class UpnpServerHandler extends UpnpHandler {
private void serveMedia() {
UpnpRendererHandler handler = currentRendererHandler;
if (handler != null) {
ArrayList<UpnpEntry> mediaQueue = new ArrayList<>();
List<UpnpEntry> mediaQueue = new ArrayList<>();
mediaQueue.addAll(filterEntries(entries, false));
if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
mediaQueue.add(currentEntry);
@@ -479,9 +731,14 @@ public class UpnpServerHandler extends UpnpHandler {
logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
handler.getThing().getLabel());
} else {
handler.registerQueue(mediaQueue);
UpnpEntryQueue queue = new UpnpEntryQueue(mediaQueue, getUDN());
handler.registerQueue(queue);
logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
handler.getThing().getLabel());
// always keep a copy of current list that is being served
queue.persistQueue(bindingConfig.path);
UpnpControlUtil.updatePlaylistsList(bindingConfig.path);
}
} else {
logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());

View File

@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.queue;
import java.util.ArrayList;
import java.util.List;
@@ -50,6 +50,10 @@ public class UpnpEntry {
private boolean isContainer;
public UpnpEntry() {
this("", "", "", "");
}
public UpnpEntry(String id, String refId, String parentId, String upnpClass) {
this.id = id;
this.refId = refId;

View File

@@ -0,0 +1,402 @@
/**
* 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.queue;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.PLAYLIST_FILE_EXTENSION;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonParseException;
/**
* The class {@link UpnpEntryQueue} represents a queue of UPnP media entries to be played on a renderer. It keeps track
* of a current index in the queue. It has convenience methods to play previous/next entries, whereby the queue can be
* organized to play from first to last (with no repetition), to restart at the start when the end is reached (in a
* continuous loop), or to random shuffle the entries. Repeat and shuffle are off by default, but can be set using the
* {@link setRepeat} and {@link setShuffle} methods.
*
* @author Mark Herwege - Initial contribution
*
*/
@NonNullByDefault
public class UpnpEntryQueue {
private final Logger logger = LoggerFactory.getLogger(UpnpEntryQueue.class);
private volatile boolean repeat = false;
private volatile boolean shuffle = false;
private volatile int currentIndex = -1;
private class Playlist {
@SuppressWarnings("unused")
String name; // Used in serialization
volatile Map<String, List<UpnpEntry>> masterList;
Playlist(String name, Map<String, List<UpnpEntry>> masterList) {
this.name = name;
this.masterList = masterList;
}
}
private volatile Playlist playlist;
private volatile List<UpnpEntry> currentQueue;
private volatile List<UpnpEntry> shuffledQueue = Collections.emptyList();
private final Gson gson = new Gson();
public UpnpEntryQueue() {
this(Collections.emptyList());
}
/**
* @param queue
*/
public UpnpEntryQueue(List<UpnpEntry> queue) {
this(queue, "");
}
/**
* @param queue
* @param udn Defines the UPnP media server source of the queue, could be used to re-query the server if URL
* resources are out of date.
*/
public UpnpEntryQueue(List<UpnpEntry> queue, @Nullable String udn) {
String serverUdn = (udn != null) ? udn : "";
Map<String, List<UpnpEntry>> masterList = Collections.synchronizedMap(new HashMap<>());
List<UpnpEntry> localQueue = new ArrayList<>(queue);
masterList.put(serverUdn, localQueue);
playlist = new Playlist("", masterList);
currentQueue = localQueue.stream().filter(e -> !e.isContainer()).collect(Collectors.toList());
}
/**
* Switch on/off repeat mode.
*
* @param repeat
*/
public void setRepeat(boolean repeat) {
this.repeat = repeat;
}
/**
* Switch on/off shuffle mode.
*
* @param shuffle
*/
public synchronized void setShuffle(boolean shuffle) {
if (shuffle) {
shuffle();
} else {
int index = currentIndex;
if (index != -1) {
currentIndex = currentQueue.indexOf(shuffledQueue.get(index));
}
this.shuffle = false;
}
}
private synchronized void shuffle() {
UpnpEntry current = null;
int index = currentIndex;
if (index != -1) {
current = this.shuffle ? shuffledQueue.get(index) : currentQueue.get(index);
}
// Shuffle the queue again
shuffledQueue = new ArrayList<UpnpEntry>(currentQueue);
Collections.shuffle(shuffledQueue);
if (current != null) {
// Put the current entry at the beginning of the shuffled queue
shuffledQueue.remove(current);
shuffledQueue.add(0, current);
currentIndex = 0;
}
this.shuffle = true;
}
/**
* @return will return the next element in the queue, or null when the end of the queue has been reached. With
* repeat set, will restart at the beginning of the queue when the end has been reached. The method will
* return null if the queue is empty.
*/
public synchronized @Nullable UpnpEntry next() {
currentIndex++;
if (currentIndex >= size()) {
if (shuffle && repeat) {
currentIndex = -1;
shuffle();
}
currentIndex = repeat ? 0 : -1;
}
return currentIndex >= 0 ? get(currentIndex) : null;
}
/**
* @return will return the previous element in the queue, or null when the start of the queue has been reached. With
* repeat set, will restart at the end of the queue when the start has been reached. The method will return
* null if the queue is empty.
*/
public synchronized @Nullable UpnpEntry previous() {
currentIndex--;
if (currentIndex < 0) {
if (shuffle && repeat) {
currentIndex = -1;
shuffle();
}
currentIndex = repeat ? (size() - 1) : -1;
}
return currentIndex >= 0 ? get(currentIndex) : null;
}
/**
* @return the index of the current element in the queue.
*/
public int index() {
return currentIndex;
}
/**
* @return the index of the next element in the queue that will be served if {@link next} is called, or -1 if
* nothing to serve for next.
*/
public synchronized int nextIndex() {
int index = currentIndex + 1;
if (index >= size()) {
index = repeat ? 0 : -1;
}
return index;
}
/**
* @return the index of the previous element in the queue that will be served if {@link previous} is called, or -1
* if nothing to serve for next.
*/
public synchronized int previousIndex() {
int index = currentIndex - 1;
if (index < 0) {
index = repeat ? (size() - 1) : -1;
}
return index;
}
/**
* @return true if there is an element to server when calling {@link next}.
*/
public synchronized boolean hasNext() {
int size = currentQueue.size();
if (repeat && (size > 0)) {
return true;
}
return (currentIndex < (size - 1));
}
/**
* @return true if there is an element to server when calling {@link previous}.
*/
public synchronized boolean hasPrevious() {
int size = currentQueue.size();
if (repeat && (size > 0)) {
return true;
}
return (currentIndex > 0);
}
/**
* @param index
* @return the UpnpEntry at the index position in the queue, or null when none can be retrieved.
*/
public @Nullable synchronized UpnpEntry get(int index) {
if ((index >= 0) && (index < size())) {
if (shuffle) {
return shuffledQueue.get(index);
} else {
return currentQueue.get(index);
}
} else {
return null;
}
}
/**
* Reset the queue position to before the start of the queue (-1).
*/
public synchronized void resetIndex() {
currentIndex = -1;
if (shuffle) {
shuffle();
}
}
/**
* @return number of element in the queue.
*/
public synchronized int size() {
return currentQueue.size();
}
/**
* @return true if the queue is empty.
*/
public synchronized boolean isEmpty() {
return currentQueue.isEmpty();
}
/**
* Persist queue as a playlist with name "current"
*
* @param path of playlist directory
*/
public void persistQueue(String path) {
persistQueue("current", false, path);
}
/**
* Persist the queue as a playlist.
*
* @param name of the playlist
* @param append to the playlist if it already exists
* @param path of playlist directory
*/
public synchronized void persistQueue(String name, boolean append, String path) {
String fileName = path + name + PLAYLIST_FILE_EXTENSION;
File file = new File(fileName);
String json;
try {
// ensure full path exists
file.getParentFile().mkdirs();
if (append && file.exists()) {
try {
logger.debug("Reading contents of {} for appending", file.getAbsolutePath());
final byte[] contents = Files.readAllBytes(file.toPath());
json = new String(contents, StandardCharsets.UTF_8);
Playlist appendList = gson.fromJson(json, Playlist.class);
if (appendList == null) {
// empty playlist file, so just overwrite
playlist.name = name;
json = gson.toJson(playlist);
} else {
// Merging masterList with persistList, overwriting persistList UpnpEntry objects with same id
playlist.masterList.forEach((u, list) -> appendList.masterList.merge(u, list,
(oldlist,
newlist) -> new ArrayList<>(Stream.of(oldlist, newlist).flatMap(List::stream)
.collect(Collectors.toMap(UpnpEntry::getId, entry -> entry,
(UpnpEntry oldentry, UpnpEntry newentry) -> newentry))
.values())));
json = gson.toJson(new Playlist(name, appendList.masterList));
}
} catch (JsonParseException | UnsupportedOperationException e) {
logger.debug("Could not append, JsonParseException reading {}: {}", file.toPath(), e.getMessage(),
e);
return;
} catch (IOException e) {
logger.debug("Could not append, IOException reading playlist {} from {}", name, file.toPath());
return;
}
} else {
playlist.name = name;
json = gson.toJson(playlist);
}
final byte[] contents = json.getBytes(StandardCharsets.UTF_8);
Files.write(file.toPath(), contents);
} catch (IOException e) {
logger.debug("IOException writing playlist {} to {}", name, file.toPath());
}
}
/**
* Replace the current queue with the playlist name and reset the queue index.
*
* @param name
* @param path directory containing playlist to restore
*/
public void restoreQueue(String name, @Nullable String path) {
restoreQueue(name, null, path);
}
/**
* Replace the current queue with the playlist name and reset the queue index. Filter the content of the playlist on
* the server udn.
*
* @param name
* @param udn of the server the playlist entries were created on, all entries when null
* @param path of playlist directory
*/
public synchronized void restoreQueue(String name, @Nullable String udn, @Nullable String path) {
if (path == null) {
return;
}
String fileName = path + name + PLAYLIST_FILE_EXTENSION;
File file = new File(fileName);
if (file.exists()) {
try {
logger.debug("Reading contents of {}", file.getAbsolutePath());
final byte[] contents = Files.readAllBytes(file.toPath());
final String json = new String(contents, StandardCharsets.UTF_8);
Playlist list = gson.fromJson(json, Playlist.class);
if (list == null) {
logger.debug("Empty playlist file {}", file.getAbsolutePath());
return;
}
playlist = list;
Stream<Entry<String, List<UpnpEntry>>> stream = playlist.masterList.entrySet().stream();
if (udn != null) {
stream = stream.filter(u -> u.getKey().equals(udn));
}
currentQueue = stream.map(p -> p.getValue()).flatMap(List::stream).filter(e -> !e.isContainer())
.collect(Collectors.toList());
resetIndex();
} catch (JsonParseException | UnsupportedOperationException e) {
logger.debug("JsonParseException reading {}: {}", file.toPath(), e.getMessage(), e);
} catch (IOException e) {
logger.debug("IOException reading playlist {} from {}", name, file.toPath());
}
}
}
/**
* @return list of all UpnpEntries in the queue.
*/
public List<UpnpEntry> getEntryList() {
return currentQueue;
}
}

View File

@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.queue;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.Nullable;
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
class UpnpEntryRes {
public class UpnpEntryRes {
private String protocolInfo;
private @Nullable Long size;
@@ -28,11 +28,12 @@ class UpnpEntryRes {
private String importUri;
private String res = "";
UpnpEntryRes(String protocolInfo, @Nullable Long size, @Nullable String duration, @Nullable String importUri) {
this.protocolInfo = protocolInfo;
public UpnpEntryRes(String protocolInfo, @Nullable Long size, @Nullable String duration,
@Nullable String importUri) {
this.protocolInfo = protocolInfo.trim();
this.size = size;
this.duration = (duration == null) ? "" : duration;
this.importUri = (importUri == null) ? "" : importUri;
this.duration = (duration == null) ? "" : duration.trim();
this.importUri = (importUri == null) ? "" : importUri.trim();
}
/**
@@ -46,7 +47,7 @@ class UpnpEntryRes {
* @param res the res to set
*/
public void setRes(String res) {
this.res = res;
this.res = res.trim();
}
public String getProtocolInfo() {

View File

@@ -0,0 +1,150 @@
/**
* 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.queue;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.FAVORITE_FILE_EXTENSION;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonParseException;
/**
* Class used to model favorites, with and without full meta data. If metadata exists, it will be in UpnpEntry.
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public class UpnpFavorite {
private final Logger logger = LoggerFactory.getLogger(UpnpFavorite.class);
/**
* Inner class used for streaming a favorite to disk as a json object.
*
*/
private class Favorite {
String name;
String uri;
@Nullable
UpnpEntry entry;
Favorite(String name, String uri, @Nullable UpnpEntry entry) {
this.name = name;
this.uri = uri;
this.entry = entry;
}
}
private volatile Favorite favorite;
private final Gson gson = new Gson();
/**
* Create a new favorite from provide URI and details. If {@link UpnpEntry} entry is null, no metadata will be
* available with the favorite.
*
* @param name
* @param uri
* @param entry
*/
public UpnpFavorite(String name, String uri, @Nullable UpnpEntry entry) {
favorite = new Favorite(name, uri, entry);
}
/**
* Create a new favorite from a file copy stored on disk. If the favorite cannot be read from disk, an empty
* favorite is created.
*
* @param name
* @param path
*/
public UpnpFavorite(String name, @Nullable String path) {
String fileName = path + name + FAVORITE_FILE_EXTENSION;
File file = new File(fileName);
Favorite fav = null;
if ((path != null) && file.exists()) {
try {
logger.debug("Reading contents of {}", file.getAbsolutePath());
final byte[] contents = Files.readAllBytes(file.toPath());
final String json = new String(contents, StandardCharsets.UTF_8);
fav = gson.fromJson(json, Favorite.class);
} catch (JsonParseException | UnsupportedOperationException e) {
logger.debug("JsonParseException reading {}: {}", file.toPath(), e.getMessage(), e);
} catch (IOException e) {
logger.debug("IOException reading favorite {} from {}", name, file.toPath());
}
}
favorite = (fav != null) ? fav : new Favorite(name, "", null);
}
/**
* @return name of favorite
*/
public String getName() {
return favorite.name;
}
/**
* @return URI of favorite
*/
public String getUri() {
return favorite.uri;
}
/**
* @return {@link UpnpEntry} known details of favorite
*/
@Nullable
public UpnpEntry getUpnpEntry() {
return favorite.entry;
}
/**
* Save the favorite to disk.
*
* @param name
* @param path
*/
public void saveFavorite(String name, @Nullable String path) {
if (path == null) {
return;
}
String fileName = path + name + FAVORITE_FILE_EXTENSION;
File file = new File(fileName);
try {
// ensure full path exists
file.getParentFile().mkdirs();
String json = gson.toJson(favorite);
final byte[] contents = json.getBytes(StandardCharsets.UTF_8);
Files.write(file.toPath(), contents);
} catch (IOException e) {
logger.debug("IOException writing favorite {} to {}", name, file.toPath());
}
}
}

View File

@@ -0,0 +1,27 @@
/**
* 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.queue;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Interface for updating playlists list in multiple handlers.
*
* @author Mark Herwege - Initial contribution
*
*/
@NonNullByDefault
public interface UpnpPlaylistsListener {
public void playlistsListChanged();
}

View File

@@ -0,0 +1,66 @@
/**
* 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.services;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.jupnp.model.meta.RemoteDevice;
import org.jupnp.model.meta.RemoteService;
import org.jupnp.model.types.ServiceId;
/**
* Class representing the configuration of the renderer. Instantiation will get configuration parameters from UPnP
* {@link RemoteDevice}.
*
* @author Mark Herwege - Initial contribution
*/
@NonNullByDefault
public class UpnpRenderingControlConfiguration {
protected static final String UPNP_RENDERING_CONTROL_SCHEMA = "urn:schemas-upnp-org:service:RenderingControl";
public Set<String> audioChannels = Collections.emptySet();
public boolean volume;
public boolean mute;
public boolean loudness;
public long maxvolume = 100;
public UpnpRenderingControlConfiguration() {
}
public UpnpRenderingControlConfiguration(@Nullable RemoteDevice device) {
if (device == null) {
return;
}
RemoteService rcService = device.findService(ServiceId.valueOf(UPNP_RENDERING_CONTROL_SCHEMA));
if (rcService != null) {
volume = (rcService.getStateVariable("Volume") != null);
if (volume) {
maxvolume = rcService.getStateVariable("Volume").getTypeDetails().getAllowedValueRange().getMaximum();
}
mute = (rcService.getStateVariable("Mute") != null);
loudness = (rcService.getStateVariable("Loudness") != null);
if (rcService.getStateVariable("A_ARG_TYPE_Channel") != null) {
audioChannels = new HashSet<String>(Arrays
.asList(rcService.getStateVariable("A_ARG_TYPE_Channel").getTypeDetails().getAllowedValues()));
}
}
}
}

View File

@@ -0,0 +1,129 @@
/**
* 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.util;
import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.upnpcontrol.internal.queue.UpnpPlaylistsListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Class with some static utility methods for the upnpcontrol binding.
*
* @author Mark Herwege - Initial contribution
*
*/
@NonNullByDefault
public final class UpnpControlUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(UpnpControlUtil.class);
private static volatile List<String> playlistList = new ArrayList<>();
private static final Set<UpnpPlaylistsListener> PLAYLIST_SUBSCRIPTIONS = new CopyOnWriteArraySet<>();
public static void updatePlaylistsList(@Nullable String path) {
playlistList = list(path, PLAYLIST_FILE_EXTENSION);
PLAYLIST_SUBSCRIPTIONS.forEach(UpnpPlaylistsListener::playlistsListChanged);
}
public static void playlistsSubscribe(UpnpPlaylistsListener listener) {
PLAYLIST_SUBSCRIPTIONS.add(listener);
}
public static void playlistsUnsubscribe(UpnpPlaylistsListener listener) {
PLAYLIST_SUBSCRIPTIONS.remove(listener);
}
public static void bindingConfigurationChanged(@Nullable String path) {
updatePlaylistsList(path);
}
/**
* Get names of saved playlists.
*
* @return playlists
*/
public static List<String> playlists() {
return playlistList;
}
/**
* Delete a saved playlist.
*
* @param name of playlist to delete
* @param path of playlist directory
*/
public static void deletePlaylist(String name, @Nullable String path) {
delete(name, path, PLAYLIST_FILE_EXTENSION);
}
/**
* Get names of saved favorites.
*
* @param path of favorite directory
* @return favorites
*/
public static List<String> favorites(@Nullable String path) {
return list(path, FAVORITE_FILE_EXTENSION);
}
/**
* Delete a saved favorite.
*
* @param name of favorite to delete
* @param path of favorite directory
*/
public static void deleteFavorite(String name, @Nullable String path) {
delete(name, path, FAVORITE_FILE_EXTENSION);
}
private static List<String> list(@Nullable String path, String extension) {
if (path == null) {
LOGGER.debug("No path set for {} files", extension);
return Collections.emptyList();
}
File directory = new File(path);
File[] files = directory.listFiles((dir, name) -> name.toLowerCase().endsWith(extension));
if (files == null) {
LOGGER.debug("No {} files in {}", extension, path);
return Collections.emptyList();
}
List<String> result = (Arrays.asList(files)).stream().map(p -> p.getName().replace(extension, ""))
.collect(Collectors.toList());
return result;
}
private static void delete(String name, @Nullable String path, String extension) {
if (path == null) {
return;
}
File file = new File(path + name + extension);
file.delete();
}
}

View File

@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.util;
import java.util.ArrayList;
import java.util.List;

View File

@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.upnpcontrol.internal;
package org.openhab.binding.upnpcontrol.internal.util;
import java.io.IOException;
import java.io.StringReader;
@@ -28,6 +28,8 @@ 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.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryRes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.Attributes;
@@ -45,16 +47,15 @@ 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 static final String METADATA_PATTERN = "<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,
@@ -69,6 +70,62 @@ public class UpnpXMLParser {
RES
}
public static Map<String, @Nullable String> getRenderingControlFromXML(String xml) {
if (xml.isEmpty()) {
LOGGER.debug("Could not parse Rendering Control from empty xml");
return Collections.emptyMap();
}
RenderingControlEventHandler handler = new RenderingControlEventHandler();
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 Rendering Control from string '{}'", xml);
} catch (SAXException | ParserConfigurationException s) {
LOGGER.error("Could not parse Rendering Control from string '{}'", xml);
}
return handler.getChanges();
}
private static class RenderingControlEventHandler extends DefaultHandler {
private final Map<String, @Nullable String> changes = new HashMap<>();
RenderingControlEventHandler() {
// 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) {
return;
}
switch (qName) {
case "Volume":
case "Mute":
case "Loudness":
String channel = attributes == null ? null : attributes.getValue("channel");
String val = attributes == null ? null : attributes.getValue("val");
if (channel != null && val != null) {
changes.put(channel + qName, val);
}
break;
default:
if ((attributes != null) && (attributes.getValue("val") != null)) {
changes.put(qName, attributes.getValue("val"));
}
break;
}
}
public Map<String, @Nullable String> getChanges() {
return changes;
}
}
public static Map<String, String> getAVTransportFromXML(String xml) {
if (xml.isEmpty()) {
LOGGER.debug("Could not parse AV Transport from empty xml");
@@ -88,12 +145,31 @@ public class UpnpXMLParser {
return handler.getChanges();
}
/**
* @param xml
* @return a list of Entries from the given xml string.
* @throws IOException
* @throws SAXException
*/
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 attributes) 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) && (attributes != null) && (attributes.getValue("val") != null)) {
changes.put(qName, attributes.getValue("val"));
}
}
public Map<String, String> getChanges() {
return changes;
}
}
public static List<UpnpEntry> getEntriesFromXML(String xml) {
if (xml.isEmpty()) {
LOGGER.debug("Could not parse Entries from empty xml");
@@ -113,31 +189,6 @@ public class UpnpXMLParser {
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.
@@ -356,7 +407,8 @@ public class UpnpXMLParser {
String genre = StringEscapeUtils.escapeXml(entry.getGenre());
Integer trackNumber = entry.getOriginalTrackNumber();
String metadata = METADATA_FORMAT.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
final MessageFormat messageFormat = new MessageFormat(METADATA_PATTERN);
String metadata = messageFormat.format(new Object[] { id, parentId, title, upnpClass, album, albumArtUri,
creator, artist, publisher, genre, trackNumber });
return metadata;

View File

@@ -6,5 +6,11 @@
<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>
<config-description>
<parameter name="path" type="text">
<label>Storage Path</label>
<description>Folder path for playlists and favourites. If not set, it will default to $OPENHAB_USERDATA/upnpcontrol.
The folder will be created on first use when it does not exist.</description>
</parameter>
</config-description>
</binding:binding>

View File

@@ -11,8 +11,21 @@
<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="repeat" typeId="repeat"/>
<channel id="shuffle" typeId="shuffle"/>
<channel id="onlyplayone" typeId="onlyplayone"/>
<channel id="uri" typeId="uri"/>
<channel id="favoriteselect" typeId="favoriteselect"/>
<channel id="favorite" typeId="favorite"/>
<channel id="favoriteaction" typeId="favoriteaction"/>
<channel id="playlistselect" typeId="playlistselect"/>
<channel id="title" typeId="system.media-title"/>
<channel id="album" typeId="album"/>
<channel id="albumart" typeId="albumart"/>
@@ -23,52 +36,163 @@
<channel id="tracknumber" typeId="tracknumber"/>
<channel id="trackduration" typeId="trackduration"/>
<channel id="trackposition" typeId="trackposition"/>
<channel id="reltrackposition" typeId="reltrackposition"/>
</channels>
<representation-property>udn</representation-property>
<config-description>
<parameter name="udn" type="text" required="true">
<label>Unique Device Name</label>
<description>The UDN identifies the UPnP Renderer</description>
</parameter>
<parameter name="refresh" type="integer" unit="s">
<label>Refresh Interval</label>
<description>Specifies the refresh interval in seconds</description>
<default>60</default>
</parameter>
<parameter name="notificationVolumeAdjustment" type="integer" min="-100" max="100" step="1" unit="%">
<label>Notification Sound Volume Adjustment</label>
<description>Specifies the percentage adjustment to the current sound volume when playing notifications</description>
<default>10</default>
</parameter>
<parameter name="maxNotificationDuration" type="integer" unit="s">
<label>Maximum Notification Duration</label>
<description>Specifies the maximum duration for notifications, longer notification sounds will be interrupted. O
represents no maximum duration</description>
<default>15</default>
</parameter>
<parameter name="seekStep" type="integer" min="1">
<label>Fast Forward/Rewind Step</label>
<description>Step in seconds for fast forward rewind</description>
<default>5</default>
</parameter>
<parameter name="responseTimeout" type="integer" unit="ms">
<label>UPnP Response Timeout</label>
<description>Specifies the timeout in milliseconds when waiting for responses on UPnP actions</description>
<default>2500</default>
<advanced>true</advanced>
</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="currenttitle" typeId="system.media-title"/>
<channel id="browse" typeId="browse"/>
<channel id="search" typeId="search"/>
<channel id="playlistselect" typeId="playlistselect"/>
<channel id="playlist" typeId="playlist"/>
<channel id="playlistaction" typeId="playlistaction"/>
<channel id="volume" typeId="system.volume"/>
<channel id="mute" typeId="system.mute"/>
<channel id="control" typeId="system.media-control"/>
<channel id="stop" typeId="stop"/>
</channels>
<representation-property>udn</representation-property>
<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">
<parameter name="refresh" type="integer" unit="s">
<label>Refresh Interval</label>
<description>Specifies the refresh interval in seconds</description>
<default>60</default>
</parameter>
<parameter name="filter" type="boolean">
<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">
<parameter name="sortCriteria" type="text">
<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>
<parameter name="browseDown" type="boolean">
<label>Auto Browse Down</label>
<description>When browse or search results in exactly one container entry, iteratively browse down until the
result
contains multiple container entries or at least one media entry</description>
<default>true</default>
</parameter>
<parameter name="searchFromRoot" type="boolean">
<label>Search From Root</label>
<description>Always search from the root directory</description>
<default>false</default>
</parameter>
<parameter name="responseTimeout" type="integer" unit="ms">
<label>UPnP Response Timeout</label>
<description>Specifies the timeout in milliseconds when waiting for responses on UPnP actions</description>
<default>2500</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<!-- Channel Types -->
<channel-type id="loudness">
<item-type>Switch</item-type>
<label>Loudness</label>
<description>Loudness</description>
<category>SoundVolume</category>
</channel-type>
<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="repeat">
<item-type>Switch</item-type>
<label>Repeat</label>
<description>Repeat the selection</description>
</channel-type>
<channel-type id="shuffle">
<item-type>Switch</item-type>
<label>Shuffle</label>
<description>Random shuffle the selection</description>
</channel-type>
<channel-type id="onlyplayone">
<item-type>Switch</item-type>
<label>Only Play One</label>
<description>Stop playback after playing one media entry from queue</description>
</channel-type>
<channel-type id="uri">
<item-type>String</item-type>
<label>URI</label>
<description>Now playing URI</description>
</channel-type>
<channel-type id="favoriteselect">
<item-type>String</item-type>
<label>Select Favorite</label>
<description>Select favorite to play</description>
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
<channel-type id="favorite">
<item-type>String</item-type>
<label>Favorite</label>
<description>Favorite name</description>
</channel-type>
<channel-type id="favoriteaction">
<item-type>String</item-type>
<label>Favorite Action</label>
<description>Favorite action</description>
<command>
<options>
<option value="SAVE">Save</option>
<option value="DELETE">Delete</option>
</options>
</command>
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
<channel-type id="album">
<item-type>String</item-type>
<label>Album</label>
@@ -115,7 +239,13 @@
<item-type>Number:Time</item-type>
<label>Track Position</label>
<description>Now playing track position</description>
<state readOnly="true" pattern="%d %unit%"/>
<state pattern="%d %unit%"/>
</channel-type>
<channel-type id="reltrackposition">
<item-type>Dimmer</item-type>
<label>Relative Track Position</label>
<description>Track position as percentage of track duration</description>
<category>MediaControl</category>
</channel-type>
<channel-type id="upnprenderer">
@@ -123,15 +253,10 @@
<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>
<label>Current Media Id</label>
<description>Current id of media entry or container, option list to browse hierarchy</description>
</channel-type>
<channel-type id="search">
<item-type>String</item-type>
@@ -140,4 +265,29 @@
Examples: dc:title contains "song", dc:creator contains "SpringSteen", unp:class = "object.item.audioItem",
upnp:album contains "Born in"</description>
</channel-type>
<channel-type id="playlistselect">
<item-type>String</item-type>
<label>Select Playlist</label>
<description>Playlist for selection</description>
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
<channel-type id="playlist">
<item-type>String</item-type>
<label>Playlist</label>
<description>Playlist name</description>
</channel-type>
<channel-type id="playlistaction">
<item-type>String</item-type>
<label>Playlist Action</label>
<description>Playlist action</description>
<command>
<options>
<option value="RESTORE">Restore</option>
<option value="SAVE">Save</option>
<option value="APPEND">Append</option>
<option value="DELETE">Delete</option>
</options>
</command>
<autoUpdatePolicy>veto</autoUpdatePolicy>
</channel-type>
</thing:thing-descriptions>