]> git.basschouten.com Git - openhab-addons.git/blob
db1eacb48b59b81aadfb736432a2b1018f5ca7e0
[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                             currentEntry = parentMap.get(browseTarget);
211                         }
212                         updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
213                         logger.debug("Browse target {}", browseTarget);
214                         browse(browseTarget, "BrowseDirectChildren", "*", "0", "0", config.sortcriteria);
215                     }
216                 }
217                 break;
218             case SEARCH:
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();
225                         } else {
226                             searchContainer = currentEntry.getParentId();
227                         }
228                         if (searchContainer.isEmpty()) {
229                             // No parent found, so make it the root directory
230                             searchContainer = DIRECTORY_ROOT;
231                         }
232                         updateState(CURRENTID, StringType.valueOf(currentEntry.getId()));
233                         logger.debug("Search container {} for {}", searchContainer, criteria);
234                         search(searchContainer, criteria, "*", "0", "0", config.sortcriteria);
235                     }
236                 }
237                 break;
238         }
239     }
240
241     /**
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.
244      *
245      * @param key
246      */
247     public void addRendererOption(String key) {
248         synchronized (rendererStateOptionList) {
249             rendererStateOptionList.add(new StateOption(key, upnpRenderers.get(key).getThing().getLabel()));
250         }
251         updateStateDescription(rendererChannelUID, rendererStateOptionList);
252         logger.debug("Renderer option {} added to {}", key, thing.getLabel());
253     }
254
255     /**
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.
258      *
259      * @param key
260      */
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);
266         }
267         synchronized (rendererStateOptionList) {
268             rendererStateOptionList.removeIf(stateOption -> (stateOption.getValue().equals(key)));
269         }
270         updateStateDescription(rendererChannelUID, rendererStateOptionList);
271         logger.debug("Renderer option {} removed from {}", key, thing.getLabel());
272     }
273
274     private void updateTitleSelection(List<UpnpEntry> titleList) {
275         logger.debug("Navigating to node {} on server {}", currentEntry.getId(), thing.getLabel());
276
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;
280
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());
288         }
289
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());
296
297                 // Keep the entries in a map so we can find the parent and container for the current selection to go
298                 // back up
299                 if (value.isContainer()) {
300                     parentMap.put(value.getId(), value);
301                 }
302                 entries.add(value);
303             });
304         }
305
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()));
309         }
310
311         logger.debug("{} entries added to selection list on server {}", commandOptionList.size(), thing.getLabel());
312         updateCommandDescription(currentSelectionChannelUID, commandOptionList);
313
314         serveMedia();
315     }
316
317     /**
318      * Filter a list of media and only keep the media that are playable on the currently selected renderer.
319      *
320      * @param resultList
321      * @param includeContainers
322      * @return
323      */
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());
334         }
335         logger.debug("Filtered result list {}", list);
336         return list;
337     }
338
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);
343     }
344
345     private void updateCommandDescription(ChannelUID channelUID, List<CommandOption> commandOptionList) {
346         CommandDescription commandDescription = CommandDescriptionBuilder.create().withCommandOptions(commandOptionList)
347                 .build();
348         upnpCommandDescriptionProvider.setDescription(channelUID, commandDescription);
349     }
350
351     /**
352      * Method that does a UPnP browse on a content directory. Results will be retrieved in the {@link onValueReceived}
353      * method.
354      *
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
361      */
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);
371
372         invokeAction("ContentDirectory", "Browse", inputs);
373     }
374
375     /**
376      * Method that does a UPnP search on a content directory. Results will be retrieved in the {@link onValueReceived}
377      * method.
378      *
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
389      */
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);
399
400         invokeAction("ContentDirectory", "Search", inputs);
401     }
402
403     @Override
404     public void onStatusChanged(boolean status) {
405         logger.debug("Server status changed to {}", status);
406         if (status) {
407             initServer();
408         } else {
409             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
410                     "Communication lost with " + thing.getLabel());
411         }
412         super.onStatusChanged(status);
413     }
414
415     @Override
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,
418                 value, service);
419         if (variable == null) {
420             return;
421         }
422         switch (variable) {
423             case "Result":
424                 if (!((value == null) || (value.isEmpty()))) {
425                     updateTitleSelection(removeDuplicates(UpnpXMLParser.getEntriesFromXML(value)));
426                 } else {
427                     updateTitleSelection(new ArrayList<UpnpEntry>());
428                 }
429                 break;
430             case "Source":
431             case "NumberReturned":
432             case "TotalMatches":
433             case "UpdateID":
434                 break;
435             default:
436                 super.onValueReceived(variable, value, service);
437                 break;
438         }
439     }
440
441     /**
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.
444      *
445      * @param list
446      * @return filtered list
447      */
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)) {
455                 newList.add(entry);
456             }
457             if (!refId.isEmpty()) {
458                 refIdSet.add(refId);
459             }
460         });
461         return newList;
462     }
463
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);
471             }
472             if (mediaQueue.isEmpty()) {
473                 logger.debug("Nothing to serve from server {} to renderer {}", thing.getLabel(),
474                         handler.getThing().getLabel());
475             } else {
476                 handler.registerQueue(mediaQueue);
477                 logger.debug("Serving media queue {} from server {} to renderer {}", mediaQueue, thing.getLabel(),
478                         handler.getThing().getLabel());
479             }
480         } else {
481             logger.warn("Cannot serve media from server {}, no renderer selected", thing.getLabel());
482         }
483     }
484 }