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 UpnpEntry entry = parentMap.get(browseTarget);
212 logger.info("Browse target not found. Exiting.");
215 currentEntry = entry;
218 updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
219 logger.debug("Browse target {}", browseTarget);
220 browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
225 if (command instanceof StringType) {
226 String criteria = command.toString();
227 if (criteria != null) {
228 String searchContainer = "";
229 if (currentEntry.isContainer()) {
230 searchContainer = currentEntry.getId();
232 searchContainer = currentEntry.getParentId();
234 if (searchContainer.isEmpty()) {
235 // No parent found, so make it the root directory
236 searchContainer = DIRECTORY_ROOT;
238 updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
239 logger.debug("Search container {} for {}", searchContainer, criteria);
240 search(searchContainer, criteria, "*", "0", "0", config.sortcriteria);
248 * Add a renderer to the renderer channel state option list.
249 * This method is called from the {@link UpnpControlHandlerFactory} class when creating a renderer handler.
253 public void addRendererOption(String key) {
254 synchronized (rendererStateOptionList) {
255 rendererStateOptionList.add(new StateOption(key, upnpRenderers.get(key).getThing().getLabel()));
257 updateStateDescription(rendererChannelUID, rendererStateOptionList);
258 logger.debug("Renderer option {} added to {}", key, thing.getLabel());
262 * Remove a renderer from the renderer channel state option list.
263 * This method is called from the {@link UpnpControlHandlerFactory} class when removing a renderer handler.
267 public void removeRendererOption(String key) {
268 UpnpRendererHandler handler = currentRendererHandler;
269 if ((handler != null) && (handler.getThing().getUID().toString().equals(key))) {
270 currentRendererHandler = null;
271 updateState(rendererChannelUID, UnDefType.UNDEF);
273 synchronized (rendererStateOptionList) {
274 rendererStateOptionList.removeIf(stateOption -> (stateOption.getValue().equals(key)));
276 updateStateDescription(rendererChannelUID, rendererStateOptionList);
277 logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
280 private void updateTitleSelection(List<UpnpEntry> titleList) {
281 logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
283 // Optionally, filter only items that can be played on the renderer
284 logger.debug("Filtering content on server {}: {}", thing.getLabel(), config.filter);
285 List<UpnpEntry> resultList = config.filter ? filterEntries(titleList, true) : titleList;
287 List<CommandOption> commandOptionList = new ArrayList<>();
288 // Add a directory up selector if not in the directory root
289 if ((!resultList.isEmpty() && !(DIRECTORY_ROOT.equals(resultList.get(0).getParentId())))
290 || (resultList.isEmpty() && !DIRECTORY_ROOT.equals(currentEntry.getId()))) {
291 CommandOption commandOption = new CommandOption(UP, UP);
292 commandOptionList.add(commandOption);
293 logger.debug("UP added to selection list on server {}", thing.getLabel());
296 synchronized (entries) {
297 entries.clear(); // always only keep the current selection in the entry map to keep memory usage down
298 resultList.forEach((value) -> {
299 CommandOption commandOption = new CommandOption(value.getId(), value.getTitle());
300 commandOptionList.add(commandOption);
301 logger.trace("{} added to selection list on server {}", value.getId(), thing.getLabel());
303 // Keep the entries in a map so we can find the parent and container for the current selection to go
305 if (value.isContainer()) {
306 parentMap.put(value.getId(), value);
312 // Set the currentId to the parent of the first entry in the list
313 if (!resultList.isEmpty()) {
314 updateState(CURRENTID, StringType.valueOf(resultList.get(0).getId()));
317 logger.debug("{} entries added to selection list on server {}", commandOptionList.size(), thing.getLabel());
318 updateCommandDescription(currentSelectionChannelUID, commandOptionList);
324 * Filter a list of media and only keep the media that are playable on the currently selected renderer.
327 * @param includeContainers
330 private List<UpnpEntry> filterEntries(List<UpnpEntry> resultList, boolean includeContainers) {
331 logger.debug("Raw result list {}", resultList);
332 List<UpnpEntry> list = new ArrayList<>();
333 UpnpRendererHandler handler = currentRendererHandler;
334 if (handler != null) {
335 List<String> sink = handler.getSink();
336 list = resultList.stream()
337 .filter(entry -> (includeContainers && entry.isContainer())
338 || UpnpProtocolMatcher.testProtocolList(entry.getProtocolList(), sink))
339 .collect(Collectors.toList());
341 logger.debug("Filtered result list {}", list);
345 private void updateStateDescription(ChannelUID channelUID, List<StateOption> stateOptionList) {
346 StateDescription stateDescription = StateDescriptionFragmentBuilder.create().withReadOnly(false)
347 .withOptions(stateOptionList).build().toStateDescription();
348 upnpStateDescriptionProvider.setDescription(channelUID, stateDescription);
351 private void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
352 CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
354 upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
358 * Method that does a UPnP browse on a content directory. Results will be retrieved in the
359 * {@link #onValueReceived(String, String, String)} method.
361 * @param objectID content directory object
362 * @param browseFlag BrowseMetaData or BrowseDirectChildren
363 * @param filter properties to be returned
364 * @param startingIndex starting index of objects to return
365 * @param requestedCount number of objects to return, 0 for all
366 * @param sortCriteria sort criteria, example: +dc:title
368 public void browse(String objectID, String browseFlag, String filter, String startingIndex, String requestedCount,
369 String sortCriteria) {
370 Map<String, String> inputs = new HashMap<>();
371 inputs.put("ObjectID", objectID);
372 inputs.put("BrowseFlag", browseFlag);
373 inputs.put("Filter", filter);
374 inputs.put("StartingIndex", startingIndex);
375 inputs.put("RequestedCount", requestedCount);
376 inputs.put("SortCriteria", sortCriteria);
378 invokeAction("ContentDirectory", "Browse", inputs);
382 * Method that does a UPnP search on a content directory. Results will be retrieved in the
383 * {@link #onValueReceived(String, String, String)} method.
385 * @param containerID content directory container
386 * @param searchCriteria search criteria, examples:
387 * dc:title contains "song"
388 * dc:creator contains "Springsteen"
389 * upnp:class = "object.item.audioItem"
390 * upnp:album contains "Born in"
391 * @param filter properties to be returned
392 * @param startingIndex starting index of objects to return
393 * @param requestedCount number of objects to return, 0 for all
394 * @param sortCriteria sort criteria, example: +dc:title
396 public void search(String containerID, String searchCriteria, String filter, String startingIndex,
397 String requestedCount, String sortCriteria) {
398 Map<String, String> inputs = new HashMap<>();
399 inputs.put("ContainerID", containerID);
400 inputs.put("SearchCriteria", searchCriteria);
401 inputs.put("Filter", filter);
402 inputs.put("StartingIndex", startingIndex);
403 inputs.put("RequestedCount", requestedCount);
404 inputs.put("SortCriteria", sortCriteria);
406 invokeAction("ContentDirectory", "Search", inputs);
410 public void onStatusChanged(boolean status) {
411 logger.debug("Server status changed to {}", status);
415 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
416 "Communication lost with " + thing.getLabel());
418 super.onStatusChanged(status);
422 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
423 logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(), variable,
425 if (variable == null) {
430 if (!((value == null) || (value.isEmpty()))) {
431 updateTitleSelection(removeDuplicates(UpnpXMLParser.getEntriesFromXML(value)));
433 updateTitleSelection(new ArrayList<UpnpEntry>());
437 case "NumberReturned":
442 super.onValueReceived(variable, value, service);
448 * Remove double entries by checking the refId if it exists as Id in the list and only keeping the original entry if
449 * available. If the original entry is not in the list, only keep one referring entry.
452 * @return filtered list
454 private List<UpnpEntry> removeDuplicates(List<UpnpEntry> list) {
455 List<UpnpEntry> newList = new ArrayList<>();
456 Set<String> refIdSet = new HashSet<>();
457 final Set<String> idSet = list.stream().map(UpnpEntry::getId).collect(Collectors.toSet());
458 list.forEach(entry -> {
459 String refId = entry.getRefId();
460 if (refId.isEmpty() || (!idSet.contains(refId)) && !refIdSet.contains(refId)) {
463 if (!refId.isEmpty()) {
470 private void serveMedia() {
471 UpnpRendererHandler handler = currentRendererHandler;
472 if (handler != null) {
473 ArrayList<UpnpEntry> mediaQueue = new ArrayList<>();
474 mediaQueue.addAll(filterEntries(entries, false));
475 if (mediaQueue.isEmpty() && !currentEntry.isContainer()) {
476 mediaQueue.add(currentEntry);
478 if (mediaQueue.isEmpty()) {
479 logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
480 handler.getThing().getLabel());
482 handler.registerQueue(mediaQueue);
483 logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
484 handler.getThing().getLabel());
487 logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());