[upnpcontrol] Rework and extension of binding. (#9081)
Signed-off-by: Mark Herwege <mark.herwege@telenet.be>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user