2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.upnpcontrol.internal.handler;
15 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
17 import java.util.ArrayList;
18 import java.util.Collections;
19 import java.util.HashMap;
20 import java.util.HashSet;
21 import java.util.List;
23 import java.util.Optional;
25 import java.util.concurrent.ConcurrentMap;
26 import java.util.stream.Collectors;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.binding.upnpcontrol.internal.UpnpControlHandlerFactory;
31 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
32 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
33 import org.openhab.binding.upnpcontrol.internal.UpnpEntry;
34 import org.openhab.binding.upnpcontrol.internal.UpnpProtocolMatcher;
35 import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser;
36 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlServerConfiguration;
37 import org.openhab.core.io.transport.upnp.UpnpIOService;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.thing.Channel;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.types.Command;
45 import org.openhab.core.types.CommandDescription;
46 import org.openhab.core.types.CommandDescriptionBuilder;
47 import org.openhab.core.types.CommandOption;
48 import org.openhab.core.types.RefreshType;
49 import org.openhab.core.types.StateDescription;
50 import org.openhab.core.types.StateDescriptionFragmentBuilder;
51 import org.openhab.core.types.StateOption;
52 import org.openhab.core.types.UnDefType;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
57 * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server.
59 * @author Mark Herwege - Initial contribution
60 * @author Karel Goderis - Based on UPnP logic in Sonos binding
63 public class UpnpServerHandler extends UpnpHandler {
65 private static final String DIRECTORY_ROOT = "0";
66 private static final String UP = "..";
68 private final Logger logger = LoggerFactory.getLogger(UpnpServerHandler.class);
70 private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
71 private volatile @Nullable UpnpRendererHandler currentRendererHandler;
72 private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
74 private @NonNullByDefault({}) ChannelUID rendererChannelUID;
75 private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
77 private volatile UpnpEntry currentEntry = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
79 private volatile List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>()); // current entry list in
81 private volatile Map<String, UpnpEntry> parentMap = new HashMap<>(); // store parents in hierarchy separately to be
82 // able to move up in directory structure
84 private UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
85 private UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
87 protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
89 public UpnpServerHandler(Thing thing, UpnpIOService upnpIOService,
90 ConcurrentMap<String, UpnpRendererHandler> upnpRenderers,
91 UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
92 UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider) {
93 super(thing, upnpIOService);
94 this.upnpRenderers = upnpRenderers;
95 this.upnpStateDescriptionProvider = upnpStateDescriptionProvider;
96 this.upnpCommandDescriptionProvider = upnpCommandDescriptionProvider;
98 // put root as highest level in parent map
99 parentMap.put(currentEntry.getId(), currentEntry);
103 public void initialize() {
105 config = getConfigAs(UpnpControlServerConfiguration.class);
107 logger.debug("Initializing handler for media server device {}", thing.getLabel());
109 Channel rendererChannel = thing.getChannel(UPNPRENDERER);
110 if (rendererChannel != null) {
111 rendererChannelUID = rendererChannel.getUID();
113 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
114 "Channel " + UPNPRENDERER + " not defined");
117 Channel selectionChannel = thing.getChannel(BROWSE);
118 if (selectionChannel != null) {
119 currentSelectionChannelUID = selectionChannel.getUID();
121 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
122 "Channel " + BROWSE + " not defined");
125 if (config.udn != null) {
126 if (service.isRegistered(this)) {
129 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
130 "Communication cannot be established with " + thing.getLabel());
133 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
134 "No UDN configured for " + thing.getLabel());
138 private void initServer() {
139 rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
140 synchronized (rendererStateOptionList) {
141 upnpRenderers.forEach((key, value) -> {
142 StateOption stateOption = new StateOption(key, value.getThing().getLabel());
143 rendererStateOptionList.add(stateOption);
146 updateStateDescription(rendererChannelUID, rendererStateOptionList);
150 browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
152 updateStatus(ThingStatus.ONLINE);
156 public void handleCommand(ChannelUID channelUID, Command command) {
157 logger.debug("Handle command {} for channel {} on server {}", command, channelUID, thing.getLabel());
159 switch (channelUID.getId()) {
161 if (command instanceof StringType) {
162 currentRendererHandler = (upnpRenderers.get(((StringType) command).toString()));
164 // only refresh title list if filtering by renderer capabilities
165 browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
167 } else if (command instanceof RefreshType) {
168 UpnpRendererHandler renderer = currentRendererHandler;
169 if (renderer != null) {
170 updateState(channelUID, StringType.valueOf(renderer.getThing().getLabel()));
175 String currentId = "";
176 if (command instanceof StringType) {
177 currentId = String.valueOf(command);
178 } else if (command instanceof RefreshType) {
179 currentId = currentEntry.getId();
180 updateState(channelUID, StringType.valueOf(currentId));
182 logger.debug("Setting currentId to {}", currentId);
183 if (!currentId.isEmpty()) {
184 browse(currentId, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
187 if (command instanceof StringType) {
188 String browseTarget = command.toString();
189 if (browseTarget != null) {
190 if (!UP.equals(browseTarget)) {
191 final String target = browseTarget;
192 synchronized (entries) {
193 Optional<UpnpEntry> current = entries.stream()
194 .filter(entry -> target.equals(entry.getId())).findFirst();
195 if (current.isPresent()) {
196 currentEntry = current.get();
198 logger.info("Trying to browse invalid target {}", browseTarget);
199 browseTarget = UP; // move up on invalid target
203 if (UP.equals(browseTarget)) {
205 browseTarget = currentEntry.getParentId();
206 if (browseTarget.isEmpty()) {
207 // No parent found, so make it the root directory
208 browseTarget = DIRECTORY_ROOT;
210 currentEntry = parentMap.get(browseTarget);
212 updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
213 logger.debug("Browse target {}", browseTarget);
214 browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
219 if (command instanceof StringType) {
220 String criteria = command.toString();
221 if (criteria != null) {
222 String searchContainer = "";
223 if (currentEntry.isContainer()) {
224 searchContainer = currentEntry.getId();
226 searchContainer = currentEntry.getParentId();
228 if (searchContainer.isEmpty()) {
229 // No parent found, so make it the root directory
230 searchContainer = DIRECTORY_ROOT;
232 updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
233 logger.debug("Search container {} for {}", searchContainer, criteria);
234 search(searchContainer, criteria, "*", "0", "0", config.sortcriteria);
242 * Add a renderer to the renderer channel state option list.
243 * This method is called from the {@link UpnpControlHandlerFactory} class when creating a renderer handler.
247 public void addRendererOption(String key) {
248 synchronized (rendererStateOptionList) {
249 rendererStateOptionList.add(new StateOption(key, upnpRenderers.get(key).getThing().getLabel()));
251 updateStateDescription(rendererChannelUID, rendererStateOptionList);
252 logger.debug("Renderer option {} added to {}", key, thing.getLabel());
256 * Remove a renderer from the renderer channel state option list.
257 * This method is called from the {@link UpnpControlHandlerFactory} class when removing a renderer handler.
261 public void removeRendererOption(String key) {
262 UpnpRendererHandler handler = currentRendererHandler;
263 if ((handler != null) && (handler.getThing().getUID().toString().equals(key))) {
264 currentRendererHandler = null;
265 updateState(rendererChannelUID, UnDefType.UNDEF);
267 synchronized (rendererStateOptionList) {
268 rendererStateOptionList.removeIf(stateOption -> (stateOption.getValue().equals(key)));
270 updateStateDescription(rendererChannelUID, rendererStateOptionList);
271 logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
274 private void updateTitleSelection(List<UpnpEntry> titleList) {
275 logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
277 // Optionally, filter only items that can be played on the renderer
278 logger.debug("Filtering content on server {}: {}", thing.getLabel(), config.filter);
279 List<UpnpEntry> resultList = config.filter ? filterEntries(titleList, true) : titleList;
281 List<CommandOption> commandOptionList = new ArrayList<>();
282 // Add a directory up selector if not in the directory root
283 if ((!resultList.isEmpty() && !(DIRECTORY_ROOT.equals(resultList.get(0).getParentId())))
284 || (resultList.isEmpty() && !DIRECTORY_ROOT.equals(currentEntry.getId()))) {
285 CommandOption commandOption = new CommandOption(UP, UP);
286 commandOptionList.add(commandOption);
287 logger.debug("UP added to selection list on server {}", thing.getLabel());
290 synchronized (entries) {
291 entries.clear(); // always only keep the current selection in the entry map to keep memory usage down
292 resultList.forEach((value) -> {
293 CommandOption commandOption = new CommandOption(value.getId(), value.getTitle());
294 commandOptionList.add(commandOption);
295 logger.trace("{} added to selection list on server {}", value.getId(), thing.getLabel());
297 // Keep the entries in a map so we can find the parent and container for the current selection to go
299 if (value.isContainer()) {
300 parentMap.put(value.getId(), value);
306 // Set the currentId to the parent of the first entry in the list
307 if (!resultList.isEmpty()) {
308 updateState(CURRENTID, StringType.valueOf(resultList.get(0).getId()));
311 logger.debug("{} entries added to selection list on server {}", commandOptionList.size(), thing.getLabel());
312 updateCommandDescription(currentSelectionChannelUID, commandOptionList);
318 * Filter a list of media and only keep the media that are playable on the currently selected renderer.
321 * @param includeContainers
324 private List<UpnpEntry> filterEntries(List<UpnpEntry> resultList, boolean includeContainers) {
325 logger.debug("Raw result list {}", resultList);
326 List<UpnpEntry> list = new ArrayList<>();
327 UpnpRendererHandler handler = currentRendererHandler;
328 if (handler != null) {
329 List<String> sink = handler.getSink();
330 list = resultList.stream()
331 .filter(entry -> (includeContainers && entry.isContainer())
332 || UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink))
333 .collect(Collectors.toList());
335 logger.debug("Filtered result list {}", list);
339 private void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
340 StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
341 .withOptions(stateOptionList).build().toStateDescription();
342 upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
345 private void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
346 CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
348 upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
352 * Method that does a UPnP browse on a content directory. Results will be retrieved in the {@link onValueReceived}
355 * @param objectID content directory object
356 * @param browseFlag BrowseMetaData or BrowseDirectChildren
357 * @param filter properties to be returned
358 * @param startingIndex starting index of objects to return
359 * @param requestedCount number of objects to return, 0 for all
360 * @param sortCriteria sort criteria, example: +dc:title
362 public void browse(String objectID, String browseFlag, String filter, String startingIndex, String requestedCount,
363 String sortCriteria) {
364 Map<String, String> inputs = new HashMap<>();
365 inputs.put("ObjectID", objectID);
366 inputs.put("BrowseFlag", browseFlag);
367 inputs.put("Filter", filter);
368 inputs.put("StartingIndex", startingIndex);
369 inputs.put("RequestedCount", requestedCount);
370 inputs.put("SortCriteria", sortCriteria);
372 invokeAction("ContentDirectory", "Browse", inputs);
376 * Method that does a UPnP search on a content directory. Results will be retrieved in the {@link onValueReceived}
379 * @param containerID content directory container
380 * @param searchCriteria search criteria, examples:
381 * dc:title contains "song"
382 * dc:creator contains "Springsteen"
383 * upnp:class = "object.item.audioItem"
384 * upnp:album contains "Born in"
385 * @param filter properties to be returned
386 * @param startingIndex starting index of objects to return
387 * @param requestedCount number of objects to return, 0 for all
388 * @param sortCriteria sort criteria, example: +dc:title
390 public void search(String containerID, String searchCriteria, String filter, String startingIndex,
391 String requestedCount, String sortCriteria) {
392 Map<String, String> inputs = new HashMap<>();
393 inputs.put("ContainerID", containerID);
394 inputs.put("SearchCriteria", searchCriteria);
395 inputs.put("Filter", filter);
396 inputs.put("StartingIndex", startingIndex);
397 inputs.put("RequestedCount", requestedCount);
398 inputs.put("SortCriteria", sortCriteria);
400 invokeAction("ContentDirectory", "Search", inputs);
404 public void onStatusChanged(boolean status) {
405 logger.debug("Server status changed to {}", status);
409 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
410 "Communication lost with " + thing.getLabel());
412 super.onStatusChanged(status);
416 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
417 logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(), variable,
419 if (variable == null) {
424 if (!((value == null) || (value.isEmpty()))) {
425 updateTitleSelection(removeDuplicates(UpnpXMLParser.getEntriesFromXML(value)));
427 updateTitleSelection(new ArrayList<UpnpEntry>());
431 case "NumberReturned":
436 super.onValueReceived(variable, value, service);
442 * Remove double entries by checking the refId if it exists as Id in the list and only keeping the original entry if
443 * available. If the original entry is not in the list, only keep one referring entry.
446 * @return filtered list
448 private List<UpnpEntry> removeDuplicates(List<UpnpEntry> list) {
449 List<UpnpEntry> newList = new ArrayList<>();
450 Set<String> refIdSet = new HashSet<>();
451 final Set<String> idSet = list.stream().map(UpnpEntry::getId).collect(Collectors.toSet());
452 list.forEach(entry -> {
453 String refId = entry.getRefId();
454 if (refId.isEmpty() || (!idSet.contains(refId)) && !refIdSet.contains(refId)) {
457 if (!refId.isEmpty()) {
464 private void serveMedia() {
465 UpnpRendererHandler handler = currentRendererHandler;
466 if (handler != null) {
467 ArrayList<UpnpEntry> mediaQueue = new ArrayList<>();
468 mediaQueue.addAll(filterEntries(entries, false));
469 if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
470 mediaQueue.add(currentEntry);
472 if (mediaQueue.isEmpty()) {
473 logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
474 handler.getThing().getLabel());
476 handler.registerQueue(mediaQueue);
477 logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
478 handler.getThing().getLabel());
481 logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());