]> git.basschouten.com Git - openhab-addons.git/blob
1b5715a4bfa943eadc94d435a69df2dc070fef07
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.upnpcontrol.internal.handler;
14
15 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
16
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;
22 import java.util.Map;
23 import java.util.Optional;
24 import java.util.Set;
25 import java.util.concurrent.ConcurrentMap;
26 import java.util.stream.Collectors;
27
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;
55
56 /**
57  * The {@link UpnpServerHandler} is responsible for handling commands sent to the UPnP Server.
58  *
59  * @author Mark Herwege - Initial contribution
60  * @author Karel Goderis - Based on UPnP logic in Sonos binding
61  */
62 @NonNullByDefault
63 public class UpnpServerHandler extends UpnpHandler {
64
65     private static final String DIRECTORY_ROOT = "0";
66     private static final String UP = "..";
67
68     private final Logger logger = LoggerFactory.getLogger(UpnpServerHandler.class);
69
70     private ConcurrentMap<String, UpnpRendererHandler> upnpRenderers;
71     private volatile @Nullable UpnpRendererHandler currentRendererHandler;
72     private volatile List<StateOption> rendererStateOptionList = Collections.synchronizedList(new ArrayList<>());
73
74     private @NonNullByDefault({}) ChannelUID rendererChannelUID;
75     private @NonNullByDefault({}) ChannelUID currentSelectionChannelUID;
76
77     private volatile UpnpEntry currentEntry = new UpnpEntry(DIRECTORY_ROOT, DIRECTORY_ROOT, DIRECTORY_ROOT,
78             "object.container");
79     private volatile List<UpnpEntry> entries = Collections.synchronizedList(new ArrayList<>()); // current entry list in
80                                                                                                 // selection
81     private volatile Map<String, UpnpEntry> parentMap = new HashMap<>(); // store parents in hierarchy separately to be
82                                                                          // able to move up in directory structure
83
84     private UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider;
85     private UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider;
86
87     protected @NonNullByDefault({}) UpnpControlServerConfiguration config;
88
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;
97
98         // put root as highest level in parent map
99         parentMap.put(currentEntry.getId(), currentEntry);
100     }
101
102     @Override
103     public void initialize() {
104         super.initialize();
105         config = getConfigAs(UpnpControlServerConfiguration.class);
106
107         logger.debug("Initializing handler for media server device {}", thing.getLabel());
108
109         Channel rendererChannel = thing.getChannel(UPNPRENDERER);
110         if (rendererChannel != null) {
111             rendererChannelUID = rendererChannel.getUID();
112         } else {
113             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
114                     "Channel " + UPNPRENDERER + " not defined");
115             return;
116         }
117         Channel selectionChannel = thing.getChannel(BROWSE);
118         if (selectionChannel != null) {
119             currentSelectionChannelUID = selectionChannel.getUID();
120         } else {
121             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
122                     "Channel " + BROWSE + " not defined");
123             return;
124         }
125         if (config.udn != null) {
126             if (service.isRegistered(this)) {
127                 initServer();
128             } else {
129                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
130                         "Communication cannot be established with " + thing.getLabel());
131             }
132         } else {
133             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
134                     "No UDN configured for " + thing.getLabel());
135         }
136     }
137
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);
144             });
145         }
146         updateStateDescription(rendererChannelUID, rendererStateOptionList);
147
148         getProtocolInfo();
149
150         browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
151
152         updateStatus(ThingStatus.ONLINE);
153     }
154
155     @Override
156     public void handleCommand(ChannelUID channelUID, Command command) {
157         logger.debug("Handle command {} for channel {} on server {}", command, channelUID, thing.getLabel());
158
159         switch (channelUID.getId()) {
160             case UPNPRENDERER:
161                 if (command instanceof StringType) {
162                     currentRendererHandler = (upnpRenderers.get(((StringType) command).toString()));
163                     if (config.filter) {
164                         // only refresh title list if filtering by renderer capabilities
165                         browse(currentEntry.getId(), "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
166                     }
167                 } else if (command instanceof RefreshType) {
168                     UpnpRendererHandler renderer = currentRendererHandler;
169                     if (renderer != null) {
170                         updateState(channelUID, StringType.valueOf(renderer.getThing().getLabel()));
171                     }
172                 }
173                 break;
174             case CURRENTID:
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));
181                 }
182                 logger.debug("Setting currentId to {}", currentId);
183                 if (!currentId.isEmpty()) {
184                     browse(currentId, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
185                 }
186             case BROWSE:
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();
197                                 } else {
198                                     logger.info("Trying to browse invalid target {}", browseTarget);
199                                     browseTarget = UP; // move up on invalid target
200                                 }
201                             }
202                         }
203                         if (UP.equals(browseTarget)) {
204                             // Move up in tree
205                             browseTarget = currentEntry.getParentId();
206                             if (browseTarget.isEmpty()) {
207                                 // No parent found, so make it the root directory
208                                 browseTarget = DIRECTORY_ROOT;
209                             }
210                             UpnpEntry entry = parentMap.get(browseTarget);
211                             if (entry == null) {
212                                 logger.info("Browse target not found. Exiting.");
213                                 return;
214                             }
215                             currentEntry = entry;
216
217                         }
218                         updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
219                         logger.debug("Browse target {}", browseTarget);
220                         browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
221                     }
222                 }
223                 break;
224             case SEARCH:
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();
231                         } else {
232                             searchContainer = currentEntry.getParentId();
233                         }
234                         if (searchContainer.isEmpty()) {
235                             // No parent found, so make it the root directory
236                             searchContainer = DIRECTORY_ROOT;
237                         }
238                         updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
239                         logger.debug("Search container {} for {}", searchContainer, criteria);
240                         search(searchContainer, criteria, "*", "0", "0", config.sortcriteria);
241                     }
242                 }
243                 break;
244         }
245     }
246
247     /**
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.
250      *
251      * @param key
252      */
253     public void addRendererOption(String key) {
254         synchronized (rendererStateOptionList) {
255             rendererStateOptionList.add(new StateOption(key, upnpRenderers.get(key).getThing().getLabel()));
256         }
257         updateStateDescription(rendererChannelUID, rendererStateOptionList);
258         logger.debug("Renderer option {} added to {}", key, thing.getLabel());
259     }
260
261     /**
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.
264      *
265      * @param key
266      */
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);
272         }
273         synchronized (rendererStateOptionList) {
274             rendererStateOptionList.removeIf(stateOption -> (stateOption.getValue().equals(key)));
275         }
276         updateStateDescription(rendererChannelUID, rendererStateOptionList);
277         logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
278     }
279
280     private void updateTitleSelection(List<UpnpEntry> titleList) {
281         logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
282
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;
286
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());
294         }
295
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());
302
303                 // Keep the entries in a map so we can find the parent and container for the current selection to go
304                 // back up
305                 if (value.isContainer()) {
306                     parentMap.put(value.getId(), value);
307                 }
308                 entries.add(value);
309             });
310         }
311
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()));
315         }
316
317         logger.debug("{} entries added to selection list on server {}", commandOptionList.size(), thing.getLabel());
318         updateCommandDescription(currentSelectionChannelUID, commandOptionList);
319
320         serveMedia();
321     }
322
323     /**
324      * Filter a list of media and only keep the media that are playable on the currently selected renderer.
325      *
326      * @param resultList
327      * @param includeContainers
328      * @return
329      */
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());
340         }
341         logger.debug("Filtered result list {}", list);
342         return list;
343     }
344
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);
349     }
350
351     private void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
352         CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
353                 .build();
354         upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
355     }
356
357     /**
358      * Method that does a UPnP browse on a content directory. Results will be retrieved in the
359      * {@link #onValueReceived(String, String, String)} method.
360      *
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
367      */
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);
377
378         invokeAction("ContentDirectory", "Browse", inputs);
379     }
380
381     /**
382      * Method that does a UPnP search on a content directory. Results will be retrieved in the
383      * {@link #onValueReceived(String, String, String)} method.
384      *
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
395      */
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);
405
406         invokeAction("ContentDirectory", "Search", inputs);
407     }
408
409     @Override
410     public void onStatusChanged(boolean status) {
411         logger.debug("Server status changed to {}", status);
412         if (status) {
413             initServer();
414         } else {
415             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
416                     "Communication lost with " + thing.getLabel());
417         }
418         super.onStatusChanged(status);
419     }
420
421     @Override
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,
424                 value, service);
425         if (variable == null) {
426             return;
427         }
428         switch (variable) {
429             case "Result":
430                 if (!((value == null) || (value.isEmpty()))) {
431                     updateTitleSelection(removeDuplicates(UpnpXMLParser.getEntriesFromXML(value)));
432                 } else {
433                     updateTitleSelection(new ArrayList<UpnpEntry>());
434                 }
435                 break;
436             case "Source":
437             case "NumberReturned":
438             case "TotalMatches":
439             case "UpdateID":
440                 break;
441             default:
442                 super.onValueReceived(variable, value, service);
443                 break;
444         }
445     }
446
447     /**
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.
450      *
451      * @param list
452      * @return filtered list
453      */
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)) {
461                 newList.add(entry);
462             }
463             if (!refId.isEmpty()) {
464                 refIdSet.add(refId);
465             }
466         });
467         return newList;
468     }
469
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);
477             }
478             if (mediaQueue.isEmpty()) {
479                 logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
480                         handler.getThing().getLabel());
481             } else {
482                 handler.registerQueue(mediaQueue);
483                 logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
484                         handler.getThing().getLabel());
485             }
486         } else {
487             logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());
488         }
489     }
490 }