]> git.basschouten.com Git - openhab-addons.git/blob
79b82d6f2738eb3b536d08b1774d2859454ffd35
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.io.UnsupportedEncodingException;
18 import java.net.URLDecoder;
19 import java.nio.charset.StandardCharsets;
20 import java.time.Instant;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Collections;
24 import java.util.HashMap;
25 import java.util.HashSet;
26 import java.util.List;
27 import java.util.Map;
28 import java.util.Set;
29 import java.util.concurrent.CompletableFuture;
30 import java.util.concurrent.ConcurrentHashMap;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.ScheduledFuture;
33 import java.util.concurrent.TimeUnit;
34 import java.util.concurrent.TimeoutException;
35 import java.util.regex.Matcher;
36 import java.util.regex.Pattern;
37 import java.util.stream.Collectors;
38
39 import org.eclipse.jdt.annotation.NonNullByDefault;
40 import org.eclipse.jdt.annotation.Nullable;
41 import org.jupnp.model.meta.RemoteDevice;
42 import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
43 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
44 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
45 import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink;
46 import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
47 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
48 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlRendererConfiguration;
49 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
50 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
51 import org.openhab.binding.upnpcontrol.internal.queue.UpnpFavorite;
52 import org.openhab.binding.upnpcontrol.internal.services.UpnpRenderingControlConfiguration;
53 import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
54 import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
55 import org.openhab.core.audio.AudioFormat;
56 import org.openhab.core.io.net.http.HttpUtil;
57 import org.openhab.core.io.transport.upnp.UpnpIOService;
58 import org.openhab.core.library.types.DecimalType;
59 import org.openhab.core.library.types.NextPreviousType;
60 import org.openhab.core.library.types.OnOffType;
61 import org.openhab.core.library.types.PercentType;
62 import org.openhab.core.library.types.PlayPauseType;
63 import org.openhab.core.library.types.QuantityType;
64 import org.openhab.core.library.types.RewindFastforwardType;
65 import org.openhab.core.library.types.StringType;
66 import org.openhab.core.library.unit.Units;
67 import org.openhab.core.thing.Channel;
68 import org.openhab.core.thing.ChannelUID;
69 import org.openhab.core.thing.Thing;
70 import org.openhab.core.thing.ThingStatus;
71 import org.openhab.core.thing.ThingStatusDetail;
72 import org.openhab.core.types.Command;
73 import org.openhab.core.types.CommandOption;
74 import org.openhab.core.types.RefreshType;
75 import org.openhab.core.types.State;
76 import org.openhab.core.types.UnDefType;
77 import org.slf4j.Logger;
78 import org.slf4j.LoggerFactory;
79
80 /**
81  * The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends
82  * {@link UpnpHandler} with UPnP renderer specific logic. It implements UPnP AVTransport and RenderingControl service
83  * actions.
84  *
85  * @author Mark Herwege - Initial contribution
86  * @author Karel Goderis - Based on UPnP logic in Sonos binding
87  */
88 @NonNullByDefault
89 public class UpnpRendererHandler extends UpnpHandler {
90
91     private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class);
92
93     // UPnP constants
94     static final String RENDERING_CONTROL = "RenderingControl";
95     static final String AV_TRANSPORT = "AVTransport";
96     static final String INSTANCE_ID = "InstanceID";
97
98     private volatile boolean audioSupport;
99     protected volatile Set<AudioFormat> supportedAudioFormats = new HashSet<>();
100     private volatile boolean audioSinkRegistered;
101
102     private volatile UpnpAudioSinkReg audioSinkReg;
103
104     private volatile Set<UpnpServerHandler> serverHandlers = ConcurrentHashMap.newKeySet();
105
106     protected @NonNullByDefault({}) UpnpControlRendererConfiguration config;
107     private UpnpRenderingControlConfiguration renderingControlConfiguration = new UpnpRenderingControlConfiguration();
108
109     private volatile List<CommandOption> favoriteCommandOptionList = List.of();
110     private volatile List<CommandOption> playlistCommandOptionList = List.of();
111
112     private @NonNullByDefault({}) ChannelUID favoriteSelectChannelUID;
113     private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
114
115     private volatile PercentType soundVolume = new PercentType();
116     private @Nullable volatile PercentType notificationVolume;
117     private volatile List<String> sink = new ArrayList<>();
118
119     private volatile String favoriteName = ""; // Currently selected favorite
120
121     private volatile boolean repeat;
122     private volatile boolean shuffle;
123     private volatile boolean onlyplayone; // Set to true if we only want to play one at a time
124
125     // Queue as received from server and current and next media entries for playback
126     private volatile UpnpEntryQueue currentQueue = new UpnpEntryQueue();
127     volatile @Nullable UpnpEntry currentEntry = null;
128     volatile @Nullable UpnpEntry nextEntry = null;
129
130     // Group of fields representing current state of player
131     private volatile String nowPlayingUri = ""; // Used to block waiting for setting URI when it is the same as current
132                                                 // as some players will not send URI update when it is the same as
133                                                 // previous
134     private volatile String transportState = ""; // Current transportState to be able to refresh the control
135     volatile boolean playerStopped; // Set if the player is stopped from OH command or code, allows to identify
136                                     // if STOP came from other source when receiving STOP state from GENA event
137     volatile boolean playing; // Set to false when a STOP is received, so we can filter two consecutive STOPs
138                               // and not play next entry second time
139     private volatile @Nullable ScheduledFuture<?> paused; // Set when a pause command is given, to compensate for
140                                                           // renderers that cannot pause playback
141     private volatile @Nullable CompletableFuture<Boolean> isSettingURI; // Set to wait for setting URI before starting
142                                                                         // to play or seeking
143     private volatile @Nullable CompletableFuture<Boolean> isStopping; // Set when stopping to be able to wait for stop
144                                                                       // confirmation for subsequent actions that need
145                                                                       // the player to be stopped
146     volatile boolean registeredQueue; // Set when registering a new queue. This allows to decide if we just
147                                       // need to play URI, or serve the first entry in a queue when a play
148                                       // command is given.
149     volatile boolean playingQueue; // Identifies if we are playing a queue received from a server. If so, a new
150                                    // queue received will be played after the currently playing entry
151     private volatile boolean oneplayed; // Set to true when the one entry is being played, allows to check if stop is
152                                         // needed when only playing one
153     volatile boolean playingNotification; // Set when playing a notification
154     private volatile @Nullable ScheduledFuture<?> playingNotificationFuture; // Set when playing a notification, allows
155                                                                              // timing out notification
156     private volatile String notificationUri = ""; // Used to check if the received URI is from the notification
157     private final Object notificationLock = new Object();
158
159     // Track position and duration fields
160     private volatile int trackDuration = 0;
161     private volatile int trackPosition = 0;
162     private volatile long expectedTrackend = 0;
163     private volatile @Nullable ScheduledFuture<?> trackPositionRefresh;
164     private volatile int posAtNotificationStart = 0;
165
166     public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg,
167             UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
168             UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
169             UpnpControlBindingConfiguration configuration) {
170         super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
171
172         serviceSubscriptions.add(AV_TRANSPORT);
173         serviceSubscriptions.add(RENDERING_CONTROL);
174
175         this.audioSinkReg = audioSinkReg;
176     }
177
178     @Override
179     public void initialize() {
180         super.initialize();
181         config = getConfigAs(UpnpControlRendererConfiguration.class);
182         if (config.seekStep < 1) {
183             config.seekStep = 1;
184         }
185         logger.debug("Initializing handler for media renderer device {}", thing.getLabel());
186
187         Channel favoriteSelectChannel = thing.getChannel(FAVORITE_SELECT);
188         if (favoriteSelectChannel != null) {
189             favoriteSelectChannelUID = favoriteSelectChannel.getUID();
190         } else {
191             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
192                     "Channel " + FAVORITE_SELECT + " not defined");
193             return;
194         }
195         Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
196         if (playlistSelectChannel != null) {
197             playlistSelectChannelUID = playlistSelectChannel.getUID();
198         } else {
199             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
200                     "Channel " + PLAYLIST_SELECT + " not defined");
201             return;
202         }
203
204         initDevice();
205     }
206
207     @Override
208     public void dispose() {
209         logger.debug("Disposing handler for media renderer device {}", thing.getLabel());
210
211         cancelTrackPositionRefresh();
212         resetPaused();
213         CompletableFuture<Boolean> settingURI = isSettingURI;
214         if (settingURI != null) {
215             settingURI.complete(false);
216         }
217
218         super.dispose();
219     }
220
221     @Override
222     protected void initJob() {
223         synchronized (jobLock) {
224             if (!upnpIOService.isRegistered(this)) {
225                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
226                         "UPnP device with UDN " + getUDN() + " not yet registered");
227                 return;
228             }
229
230             if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
231                 getProtocolInfo();
232
233                 getCurrentConnectionInfo();
234                 if (!checkForConnectionIds()) {
235                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
236                             "No connection Id's set for UPnP device with UDN " + getUDN());
237                     return;
238                 }
239
240                 getTransportState();
241
242                 updateFavoritesList();
243                 playlistsListChanged();
244
245                 RemoteDevice device = getDevice();
246                 if (device != null) { // The handler factory will update the device config later when it has not been
247                                       // set yet
248                     updateDeviceConfig(device);
249                 }
250
251                 updateStatus(ThingStatus.ONLINE);
252             }
253
254             if (!upnpSubscribed) {
255                 addSubscriptions();
256             }
257         }
258     }
259
260     @Override
261     public void updateDeviceConfig(RemoteDevice device) {
262         super.updateDeviceConfig(device);
263
264         UpnpRenderingControlConfiguration config = new UpnpRenderingControlConfiguration(device);
265         renderingControlConfiguration = config;
266         for (String audioChannel : config.audioChannels) {
267             createAudioChannels(audioChannel);
268         }
269
270         updateChannels();
271     }
272
273     private void createAudioChannels(String audioChannel) {
274         UpnpRenderingControlConfiguration config = renderingControlConfiguration;
275         if (config.volume && !UPNP_MASTER.equals(audioChannel)) {
276             String name = audioChannel + "volume";
277             if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
278                 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
279             } else {
280                 createChannel(name, name, "Vendor specific UPnP volume channel", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME);
281             }
282         }
283         if (config.mute && !UPNP_MASTER.equals(audioChannel)) {
284             String name = audioChannel + "mute";
285             if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
286                 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
287             } else {
288                 createChannel(name, name, "Vendor specific  UPnP mute channel", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE);
289             }
290         }
291         if (config.loudness) {
292             String name = (UPNP_MASTER.equals(audioChannel) ? "" : audioChannel) + "loudness";
293             if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
294                 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
295             } else {
296                 createChannel(name, name, "Vendor specific  UPnP loudness channel", ITEM_TYPE_LOUDNESS,
297                         CHANNEL_TYPE_LOUDNESS);
298             }
299         }
300     }
301
302     /**
303      * Invoke Stop on UPnP AV Transport.
304      */
305     public void stop() {
306         playerStopped = true;
307
308         if (playing) {
309             CompletableFuture<Boolean> stopping = isStopping;
310             if (stopping != null) {
311                 stopping.complete(false);
312             }
313             isStopping = new CompletableFuture<Boolean>(); // set this so we can check if stop confirmation has been
314                                                            // received
315         }
316
317         Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
318
319         invokeAction(AV_TRANSPORT, "Stop", inputs);
320     }
321
322     /**
323      * Invoke Play on UPnP AV Transport.
324      */
325     public void play() {
326         CompletableFuture<Boolean> settingURI = isSettingURI;
327         boolean uriSet = true;
328         try {
329             if (settingURI != null) {
330                 // wait for maximum 2.5s until the media URI is set before playing
331                 uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
332             }
333         } catch (InterruptedException | ExecutionException | TimeoutException e) {
334             logger.debug("Timeout exception, media URI not yet set in renderer {}, trying to play anyway",
335                     thing.getLabel());
336         }
337
338         if (uriSet) {
339             Map<String, String> inputs = new HashMap<>();
340             inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
341             inputs.put("Speed", "1");
342
343             invokeAction(AV_TRANSPORT, "Play", inputs);
344         } else {
345             logger.debug("Cannot play, cancelled setting URI in the renderer {}", thing.getLabel());
346         }
347     }
348
349     /**
350      * Invoke Pause on UPnP AV Transport.
351      */
352     protected void pause() {
353         Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
354
355         invokeAction(AV_TRANSPORT, "Pause", inputs);
356     }
357
358     /**
359      * Invoke Next on UPnP AV Transport.
360      */
361     protected void next() {
362         Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
363
364         invokeAction(AV_TRANSPORT, "Next", inputs);
365     }
366
367     /**
368      * Invoke Previous on UPnP AV Transport.
369      */
370     protected void previous() {
371         Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
372
373         invokeAction(AV_TRANSPORT, "Previous", inputs);
374     }
375
376     /**
377      * Invoke Seek on UPnP AV Transport.
378      *
379      * @param seekTarget relative position in current track, format HH:mm:ss
380      */
381     protected void seek(String seekTarget) {
382         CompletableFuture<Boolean> settingURI = isSettingURI;
383         boolean uriSet = true;
384         try {
385             if (settingURI != null) {
386                 // wait for maximum 2.5s until the media URI is set before seeking
387                 uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
388             }
389         } catch (InterruptedException | ExecutionException | TimeoutException e) {
390             logger.debug("Timeout exception, media URI not yet set in renderer {}, skipping seek", thing.getLabel());
391             return;
392         }
393
394         if (uriSet) {
395             Map<String, String> inputs = new HashMap<>();
396             inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
397             inputs.put("Unit", "REL_TIME");
398             inputs.put("Target", seekTarget);
399
400             invokeAction(AV_TRANSPORT, "Seek", inputs);
401         } else {
402             logger.debug("Cannot seek, cancelled setting URI in the renderer {}", thing.getLabel());
403         }
404     }
405
406     /**
407      * Invoke SetAVTransportURI on UPnP AV Transport.
408      *
409      * @param URI
410      * @param URIMetaData
411      */
412     public void setCurrentURI(String URI, String URIMetaData) {
413         String uri = "";
414         try {
415             uri = URLDecoder.decode(URI.trim(), StandardCharsets.UTF_8.name());
416             // Some renderers don't send a URI Last Changed event when the same URI is requested, so don't wait for it
417             // before starting to play
418             if (!uri.equals(nowPlayingUri) && !playingNotification) {
419                 CompletableFuture<Boolean> settingURI = isSettingURI;
420                 if (settingURI != null) {
421                     settingURI.complete(false);
422                 }
423                 isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished
424                                                                  // setting URI
425             } else {
426                 logger.debug("New URI {} is same as previous on renderer {}", nowPlayingUri, thing.getLabel());
427             }
428         } catch (UnsupportedEncodingException ignore) {
429             uri = URI;
430         }
431
432         Map<String, String> inputs = new HashMap<>();
433         inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
434         inputs.put("CurrentURI", uri);
435         inputs.put("CurrentURIMetaData", URIMetaData);
436
437         invokeAction(AV_TRANSPORT, "SetAVTransportURI", inputs);
438     }
439
440     /**
441      * Invoke SetNextAVTransportURI on UPnP AV Transport.
442      *
443      * @param nextURI
444      * @param nextURIMetaData
445      */
446     protected void setNextURI(String nextURI, String nextURIMetaData) {
447         Map<String, String> inputs = new HashMap<>();
448         inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
449         inputs.put("NextURI", nextURI);
450         inputs.put("NextURIMetaData", nextURIMetaData);
451
452         invokeAction(AV_TRANSPORT, "SetNextAVTransportURI", inputs);
453     }
454
455     /**
456      * Invoke GetTransportState on UPnP AV Transport.
457      * Result is received in {@link onValueReceived}.
458      */
459     protected void getTransportState() {
460         Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
461
462         invokeAction(AV_TRANSPORT, "GetTransportInfo", inputs);
463     }
464
465     /**
466      * Invoke getPositionInfo on UPnP AV Transport.
467      * Result is received in {@link onValueReceived}.
468      */
469     protected void getPositionInfo() {
470         Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
471
472         invokeAction(AV_TRANSPORT, "GetPositionInfo", inputs);
473     }
474
475     /**
476      * Invoke GetMediaInfo on UPnP AV Transport.
477      * Result is received in {@link onValueReceived}.
478      */
479     protected void getMediaInfo() {
480         Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
481
482         invokeAction(AV_TRANSPORT, "smarthome:audio stream http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3", inputs);
483     }
484
485     /**
486      * Retrieves the current volume known to the control point, gets updated by GENA events or after UPnP Rendering
487      * Control GetVolume call. This method is used to retrieve volume by {@link UpnpAudioSink.getVolume}.
488      *
489      * @return current volume
490      */
491     public PercentType getCurrentVolume() {
492         return soundVolume;
493     }
494
495     /**
496      * Invoke GetVolume on UPnP Rendering Control.
497      * Result is received in {@link onValueReceived}.
498      *
499      * @param channel
500      */
501     protected void getVolume(String channel) {
502         Map<String, String> inputs = new HashMap<>();
503         inputs.put(INSTANCE_ID, Integer.toString(rcsId));
504         inputs.put("Channel", channel);
505
506         invokeAction(RENDERING_CONTROL, "GetVolume", inputs);
507     }
508
509     /**
510      * Invoke SetVolume on UPnP Rendering Control.
511      *
512      * @param channel
513      * @param volume
514      */
515     protected void setVolume(String channel, PercentType volume) {
516         UpnpRenderingControlConfiguration config = renderingControlConfiguration;
517
518         long newVolume = volume.intValue() * config.maxvolume / 100;
519         Map<String, String> inputs = new HashMap<>();
520         inputs.put(INSTANCE_ID, Integer.toString(rcsId));
521         inputs.put("Channel", channel);
522         inputs.put("DesiredVolume", String.valueOf(newVolume));
523
524         invokeAction(RENDERING_CONTROL, "SetVolume", inputs);
525     }
526
527     /**
528      * Invoke SetVolume for Master channel on UPnP Rendering Control.
529      *
530      * @param volume
531      */
532     public void setVolume(PercentType volume) {
533         setVolume(UPNP_MASTER, volume);
534     }
535
536     /**
537      * Invoke getMute on UPnP Rendering Control.
538      * Result is received in {@link onValueReceived}.
539      *
540      * @param channel
541      */
542     protected void getMute(String channel) {
543         Map<String, String> inputs = new HashMap<>();
544         inputs.put(INSTANCE_ID, Integer.toString(rcsId));
545         inputs.put("Channel", channel);
546
547         invokeAction(RENDERING_CONTROL, "GetMute", inputs);
548     }
549
550     /**
551      * Invoke SetMute on UPnP Rendering Control.
552      *
553      * @param channel
554      * @param mute
555      */
556     protected void setMute(String channel, OnOffType mute) {
557         Map<String, String> inputs = new HashMap<>();
558         inputs.put(INSTANCE_ID, Integer.toString(rcsId));
559         inputs.put("Channel", channel);
560         inputs.put("DesiredMute", mute == OnOffType.ON ? "1" : "0");
561
562         invokeAction(RENDERING_CONTROL, "SetMute", inputs);
563     }
564
565     /**
566      * Invoke getMute on UPnP Rendering Control.
567      * Result is received in {@link onValueReceived}.
568      *
569      * @param channel
570      */
571     protected void getLoudness(String channel) {
572         Map<String, String> inputs = new HashMap<>();
573         inputs.put(INSTANCE_ID, Integer.toString(rcsId));
574         inputs.put("Channel", channel);
575
576         invokeAction(RENDERING_CONTROL, "GetLoudness", inputs);
577     }
578
579     /**
580      * Invoke SetMute on UPnP Rendering Control.
581      *
582      * @param channel
583      * @param mute
584      */
585     protected void setLoudness(String channel, OnOffType mute) {
586         Map<String, String> inputs = new HashMap<>();
587         inputs.put(INSTANCE_ID, Integer.toString(rcsId));
588         inputs.put("Channel", channel);
589         inputs.put("DesiredLoudness", mute == OnOffType.ON ? "1" : "0");
590
591         invokeAction(RENDERING_CONTROL, "SetLoudness", inputs);
592     }
593
594     /**
595      * Called from server handler for renderer to be able to send back status to server handler
596      *
597      * @param handler
598      */
599     protected void setServerHandler(UpnpServerHandler handler) {
600         logger.debug("Set server handler {} on renderer {}", handler.getThing().getLabel(), thing.getLabel());
601         serverHandlers.add(handler);
602     }
603
604     /**
605      * Should be called from server handler when server stops serving this renderer
606      */
607     protected void unsetServerHandler() {
608         logger.debug("Unset server handler on renderer {}", thing.getLabel());
609         for (UpnpServerHandler handler : serverHandlers) {
610             Thing serverThing = handler.getThing();
611             Channel serverChannel;
612             for (String channel : SERVER_CONTROL_CHANNELS) {
613                 if ((serverChannel = serverThing.getChannel(channel)) != null) {
614                     handler.updateServerState(serverChannel.getUID(), UnDefType.UNDEF);
615                 }
616             }
617
618             serverHandlers.remove(handler);
619         }
620     }
621
622     @Override
623     protected void updateState(ChannelUID channelUID, State state) {
624         // override to be able to propagate channel state updates to corresponding channels on the server
625         if (SERVER_CONTROL_CHANNELS.contains(channelUID.getId())) {
626             for (UpnpServerHandler handler : serverHandlers) {
627                 Thing serverThing = handler.getThing();
628                 Channel serverChannel = serverThing.getChannel(channelUID.getId());
629                 if (serverChannel != null) {
630                     logger.debug("Update server {} channel {} with state {} from renderer {}", serverThing.getLabel(),
631                             state, channelUID, thing.getLabel());
632                     handler.updateServerState(serverChannel.getUID(), state);
633                 }
634             }
635         }
636         super.updateState(channelUID, state);
637     }
638
639     @Override
640     public void handleCommand(ChannelUID channelUID, Command command) {
641         logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
642
643         String id = channelUID.getId();
644
645         if (id.endsWith("volume")) {
646             handleCommandVolume(command, id);
647         } else if (id.endsWith("mute")) {
648             handleCommandMute(command, id);
649         } else if (id.endsWith("loudness")) {
650             handleCommandLoudness(command, id);
651         } else {
652             switch (id) {
653                 case STOP:
654                     handleCommandStop(command);
655                     break;
656                 case CONTROL:
657                     handleCommandControl(channelUID, command);
658                     break;
659                 case REPEAT:
660                     handleCommandRepeat(channelUID, command);
661                     break;
662                 case SHUFFLE:
663                     handleCommandShuffle(channelUID, command);
664                 case ONLY_PLAY_ONE:
665                     handleCommandOnlyPlayOne(channelUID, command);
666                     break;
667                 case URI:
668                     handleCommandUri(channelUID, command);
669                     break;
670                 case FAVORITE_SELECT:
671                     handleCommandFavoriteSelect(command);
672                     break;
673                 case FAVORITE:
674                     handleCommandFavorite(channelUID, command);
675                     break;
676                 case FAVORITE_ACTION:
677                     handleCommandFavoriteAction(command);
678                     break;
679                 case PLAYLIST_SELECT:
680                     handleCommandPlaylistSelect(command);
681                     break;
682                 case TRACK_POSITION:
683                     handleCommandTrackPosition(channelUID, command);
684                     break;
685                 case REL_TRACK_POSITION:
686                     handleCommandRelTrackPosition(channelUID, command);
687                     break;
688                 default:
689                     break;
690             }
691         }
692     }
693
694     private void handleCommandVolume(Command command, String id) {
695         if (command instanceof RefreshType) {
696             getVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""));
697         } else if (command instanceof PercentType) {
698             setVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""), (PercentType) command);
699         }
700     }
701
702     private void handleCommandMute(Command command, String id) {
703         if (command instanceof RefreshType) {
704             getMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""));
705         } else if (command instanceof OnOffType) {
706             setMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""), (OnOffType) command);
707         }
708     }
709
710     private void handleCommandLoudness(Command command, String id) {
711         if (command instanceof RefreshType) {
712             getLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""));
713         } else if (command instanceof OnOffType) {
714             setLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""), (OnOffType) command);
715         }
716     }
717
718     private void handleCommandStop(Command command) {
719         if (OnOffType.ON.equals(command)) {
720             updateState(CONTROL, PlayPauseType.PAUSE);
721             stop();
722             updateState(TRACK_POSITION, new QuantityType<>(0, Units.SECOND));
723         }
724     }
725
726     private void handleCommandControl(ChannelUID channelUID, Command command) {
727         String state;
728         if (command instanceof RefreshType) {
729             state = transportState;
730             State newState = UnDefType.UNDEF;
731             if ("PLAYING".equals(state)) {
732                 newState = PlayPauseType.PLAY;
733             } else if ("STOPPED".equals(state)) {
734                 newState = PlayPauseType.PAUSE;
735             } else if ("PAUSED_PLAYBACK".equals(state)) {
736                 newState = PlayPauseType.PAUSE;
737             }
738             updateState(channelUID, newState);
739         } else if (command instanceof PlayPauseType) {
740             if (PlayPauseType.PLAY.equals(command)) {
741                 if (registeredQueue) {
742                     registeredQueue = false;
743                     playingQueue = true;
744                     oneplayed = false;
745                     serve();
746                 } else {
747                     play();
748                 }
749             } else if (PlayPauseType.PAUSE.equals(command)) {
750                 checkPaused();
751                 pause();
752             }
753         } else if (command instanceof NextPreviousType) {
754             if (NextPreviousType.NEXT.equals(command)) {
755                 serveNext();
756             } else if (NextPreviousType.PREVIOUS.equals(command)) {
757                 servePrevious();
758             }
759         } else if (command instanceof RewindFastforwardType) {
760             int pos = 0;
761             if (RewindFastforwardType.FASTFORWARD.equals(command)) {
762                 pos = Integer.min(trackDuration, trackPosition + config.seekStep);
763             } else if (command == RewindFastforwardType.REWIND) {
764                 pos = Integer.max(0, trackPosition - config.seekStep);
765             }
766             seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
767         }
768     }
769
770     private void handleCommandRepeat(ChannelUID channelUID, Command command) {
771         if (command instanceof RefreshType) {
772             updateState(channelUID, OnOffType.from(repeat));
773         } else {
774             repeat = (OnOffType.ON.equals(command));
775             currentQueue.setRepeat(repeat);
776             updateState(channelUID, OnOffType.from(repeat));
777             logger.debug("Repeat set to {} for {}", repeat, thing.getLabel());
778         }
779     }
780
781     private void handleCommandShuffle(ChannelUID channelUID, Command command) {
782         if (command instanceof RefreshType) {
783             updateState(channelUID, OnOffType.from(shuffle));
784         } else {
785             shuffle = (OnOffType.ON.equals(command));
786             currentQueue.setShuffle(shuffle);
787             if (!playing) {
788                 resetToStartQueue();
789             }
790             updateState(channelUID, OnOffType.from(shuffle));
791             logger.debug("Shuffle set to {} for {}", shuffle, thing.getLabel());
792         }
793     }
794
795     private void handleCommandOnlyPlayOne(ChannelUID channelUID, Command command) {
796         if (command instanceof RefreshType) {
797             updateState(channelUID, OnOffType.from(onlyplayone));
798         } else {
799             onlyplayone = (OnOffType.ON.equals(command));
800             oneplayed = (onlyplayone && playing) ? true : false;
801             if (oneplayed) {
802                 setNextURI("", "");
803             } else {
804                 UpnpEntry next = nextEntry;
805                 if (next != null) {
806                     setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
807                 }
808             }
809             updateState(channelUID, OnOffType.from(onlyplayone));
810             logger.debug("OnlyPlayOne set to {} for {}", onlyplayone, thing.getLabel());
811         }
812     }
813
814     private void handleCommandUri(ChannelUID channelUID, Command command) {
815         if (command instanceof RefreshType) {
816             updateState(channelUID, StringType.valueOf(nowPlayingUri));
817         } else if (command instanceof StringType) {
818             setCurrentURI(command.toString(), "");
819             play();
820         }
821     }
822
823     private void handleCommandFavoriteSelect(Command command) {
824         if (command instanceof StringType) {
825             favoriteName = command.toString();
826             updateState(FAVORITE, StringType.valueOf(favoriteName));
827             playFavorite();
828         }
829     }
830
831     private void handleCommandFavorite(ChannelUID channelUID, Command command) {
832         if (command instanceof StringType) {
833             favoriteName = command.toString();
834             if (favoriteCommandOptionList.contains(new CommandOption(favoriteName, favoriteName))) {
835                 playFavorite();
836             }
837         }
838         updateState(channelUID, StringType.valueOf(favoriteName));
839     }
840
841     private void handleCommandFavoriteAction(Command command) {
842         if (command instanceof StringType) {
843             switch (command.toString()) {
844                 case SAVE:
845                     handleCommandFavoriteSave();
846                     break;
847                 case DELETE:
848                     handleCommandFavoriteDelete();
849                     break;
850             }
851         }
852     }
853
854     private void handleCommandFavoriteSave() {
855         if (!favoriteName.isEmpty()) {
856             UpnpFavorite favorite = new UpnpFavorite(favoriteName, nowPlayingUri, currentEntry);
857             favorite.saveFavorite(favoriteName, bindingConfig.path);
858             updateFavoritesList();
859         }
860     }
861
862     private void handleCommandFavoriteDelete() {
863         if (!favoriteName.isEmpty()) {
864             UpnpControlUtil.deleteFavorite(favoriteName, bindingConfig.path);
865             updateFavoritesList();
866             updateState(FAVORITE, UnDefType.UNDEF);
867         }
868     }
869
870     private void handleCommandPlaylistSelect(Command command) {
871         if (command instanceof StringType) {
872             String playlistName = command.toString();
873             UpnpEntryQueue queue = new UpnpEntryQueue();
874             queue.restoreQueue(playlistName, null, bindingConfig.path);
875             registerQueue(queue);
876             resetToStartQueue();
877             playingQueue = true;
878             serve();
879         }
880     }
881
882     private void handleCommandTrackPosition(ChannelUID channelUID, Command command) {
883         if (command instanceof RefreshType) {
884             updateState(channelUID, new QuantityType<>(trackPosition, Units.SECOND));
885         } else if (command instanceof QuantityType<?>) {
886             QuantityType<?> position = ((QuantityType<?>) command).toUnit(Units.SECOND);
887             if (position != null) {
888                 int pos = Integer.min(trackDuration, position.intValue());
889                 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
890             }
891         }
892     }
893
894     private void handleCommandRelTrackPosition(ChannelUID channelUID, Command command) {
895         if (command instanceof RefreshType) {
896             int relPosition = (trackDuration != 0) ? (trackPosition * 100) / trackDuration : 0;
897             updateState(channelUID, new PercentType(relPosition));
898         } else if (command instanceof PercentType) {
899             int pos = ((PercentType) command).intValue() * trackDuration / 100;
900             seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
901         }
902     }
903
904     /**
905      * Set the volume for notifications.
906      *
907      * @param volume
908      */
909     public void setNotificationVolume(PercentType volume) {
910         notificationVolume = volume;
911     }
912
913     /**
914      * Play a notification. Previous state of the renderer will resume at the end of the notification, or after the
915      * maximum notification duration as defined in the renderer parameters.
916      *
917      * @param URI for notification sound
918      */
919     public void playNotification(String URI) {
920         synchronized (notificationLock) {
921             if (URI.isEmpty()) {
922                 logger.debug("UPnP device {} received empty notification URI", thing.getLabel());
923                 return;
924             }
925
926             notificationUri = URI;
927
928             logger.debug("UPnP device {} playing notification {}", thing.getLabel(), URI);
929
930             cancelTrackPositionRefresh();
931             getPositionInfo();
932
933             cancelPlayingNotificationFuture();
934
935             if (config.maxNotificationDuration > 0) {
936                 playingNotificationFuture = upnpScheduler.schedule(this::stop, config.maxNotificationDuration,
937                         TimeUnit.SECONDS);
938             }
939             playingNotification = true;
940
941             setCurrentURI(URI, "");
942             setNextURI("", "");
943             PercentType volume = notificationVolume;
944             setVolume(volume == null
945                     ? new PercentType(Math.min(100,
946                             Math.max(0, (100 + config.notificationVolumeAdjustment) * soundVolume.intValue() / 100)))
947                     : volume);
948
949             CompletableFuture<Boolean> stopping = isStopping;
950             try {
951                 if (stopping != null) {
952                     // wait for maximum 2.5s until the renderer stopped before playing
953                     stopping.get(config.responseTimeout, TimeUnit.MILLISECONDS);
954                 }
955             } catch (InterruptedException | ExecutionException | TimeoutException e) {
956                 logger.debug("Timeout exception, renderer {} didn't stop yet, trying to play anyway", thing.getLabel());
957             }
958             play();
959         }
960     }
961
962     private void cancelPlayingNotificationFuture() {
963         ScheduledFuture<?> future = playingNotificationFuture;
964         if (future != null) {
965             future.cancel(true);
966             playingNotificationFuture = null;
967         }
968     }
969
970     private void resumeAfterNotification() {
971         synchronized (notificationLock) {
972             logger.debug("UPnP device {} resume after playing notification", thing.getLabel());
973
974             setCurrentURI(nowPlayingUri, "");
975             setVolume(soundVolume);
976
977             cancelPlayingNotificationFuture();
978
979             playingNotification = false;
980             notificationVolume = null;
981             notificationUri = "";
982
983             if (playing) {
984                 int pos = posAtNotificationStart;
985                 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
986                 play();
987             }
988             posAtNotificationStart = 0;
989         }
990     }
991
992     private void playFavorite() {
993         UpnpFavorite favorite = new UpnpFavorite(favoriteName, bindingConfig.path);
994         String uri = favorite.getUri();
995         UpnpEntry entry = favorite.getUpnpEntry();
996         if (!uri.isEmpty()) {
997             String metadata = "";
998             if (entry != null) {
999                 metadata = UpnpXMLParser.compileMetadataString(entry);
1000             }
1001             setCurrentURI(uri, metadata);
1002             play();
1003         }
1004     }
1005
1006     void updateFavoritesList() {
1007         favoriteCommandOptionList = UpnpControlUtil.favorites(bindingConfig.path).stream()
1008                 .map(p -> (new CommandOption(p, p))).collect(Collectors.toList());
1009         updateCommandDescription(favoriteSelectChannelUID, favoriteCommandOptionList);
1010     }
1011
1012     @Override
1013     public void playlistsListChanged() {
1014         playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
1015                 .collect(Collectors.toList());
1016         updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
1017     }
1018
1019     @Override
1020     public void onStatusChanged(boolean status) {
1021         if (!status) {
1022             removeSubscriptions();
1023
1024             updateState(CONTROL, PlayPauseType.PAUSE);
1025             cancelTrackPositionRefresh();
1026         }
1027         super.onStatusChanged(status);
1028     }
1029
1030     @Override
1031     protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
1032             @Nullable String value, @Nullable String service, @Nullable String action) {
1033         if (variable == null) {
1034             return null;
1035         } else {
1036             switch (variable) {
1037                 case "CurrentVolume":
1038                     return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Volume";
1039                 case "CurrentMute":
1040                     return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Mute";
1041                 case "CurrentLoudness":
1042                     return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Loudness";
1043                 default:
1044                     return variable;
1045             }
1046         }
1047     }
1048
1049     @Override
1050     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
1051         if (logger.isTraceEnabled()) {
1052             logger.trace("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
1053                     variable, value, service);
1054         } else {
1055             if (logger.isDebugEnabled() && !("AbsTime".equals(variable) || "RelCount".equals(variable)
1056                     || "RelTime".equals(variable) || "AbsCount".equals(variable) || "Track".equals(variable)
1057                     || "TrackDuration".equals(variable))) {
1058                 // don't log all variables received when updating the track position every second
1059                 logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
1060                         variable, value, service);
1061             }
1062         }
1063         if (variable == null) {
1064             return;
1065         }
1066
1067         if (variable.endsWith("Volume")) {
1068             onValueReceivedVolume(variable, value);
1069         } else if (variable.endsWith("Mute")) {
1070             onValueReceivedMute(variable, value);
1071         } else if (variable.endsWith("Loudness")) {
1072             onValueReceivedLoudness(variable, value);
1073         } else {
1074             switch (variable) {
1075                 case "LastChange":
1076                     onValueReceivedLastChange(value, service);
1077                     break;
1078                 case "CurrentTransportState":
1079                 case "TransportState":
1080                     onValueReceivedTransportState(value);
1081                     break;
1082                 case "CurrentTrackURI":
1083                 case "CurrentURI":
1084                     onValueReceivedCurrentURI(value);
1085                     break;
1086                 case "CurrentTrackMetaData":
1087                 case "CurrentURIMetaData":
1088                     onValueReceivedCurrentMetaData(value);
1089                     break;
1090                 case "NextAVTransportURIMetaData":
1091                 case "NextURIMetaData":
1092                     onValueReceivedNextMetaData(value);
1093                     break;
1094                 case "CurrentTrackDuration":
1095                 case "TrackDuration":
1096                     onValueReceivedDuration(value);
1097                     break;
1098                 case "RelTime":
1099                     onValueReceivedRelTime(value);
1100                     break;
1101                 default:
1102                     super.onValueReceived(variable, value, service);
1103                     break;
1104             }
1105         }
1106     }
1107
1108     private void onValueReceivedVolume(String variable, @Nullable String value) {
1109         if (value != null && !value.isEmpty()) {
1110             UpnpRenderingControlConfiguration config = renderingControlConfiguration;
1111
1112             long volume = Long.valueOf(value);
1113             volume = volume * 100 / config.maxvolume;
1114
1115             String upnpChannel = variable.replace("Volume", "volume").replace("Master", "");
1116             updateState(upnpChannel, new PercentType((int) volume));
1117
1118             if (!playingNotification && "volume".equals(upnpChannel)) {
1119                 soundVolume = new PercentType((int) volume);
1120             }
1121         }
1122     }
1123
1124     private void onValueReceivedMute(String variable, @Nullable String value) {
1125         if (value != null && !value.isEmpty()) {
1126             String upnpChannel = variable.replace("Mute", "mute").replace("Master", "");
1127             updateState(upnpChannel,
1128                     ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
1129         }
1130     }
1131
1132     private void onValueReceivedLoudness(String variable, @Nullable String value) {
1133         if (value != null && !value.isEmpty()) {
1134             String upnpChannel = variable.replace("Loudness", "loudness").replace("Master", "");
1135             updateState(upnpChannel,
1136                     ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
1137         }
1138     }
1139
1140     private void onValueReceivedLastChange(@Nullable String value, @Nullable String service) {
1141         // This is returned from a GENA subscription. The jupnp library does not allow receiving new GENA subscription
1142         // messages as long as this thread has not finished. As we may trigger long running processes based on this
1143         // result, we run it in a separate thread.
1144         upnpScheduler.submit(() -> {
1145             // pre-process some variables, eg XML processing
1146             if (value != null && !value.isEmpty()) {
1147                 if (AV_TRANSPORT.equals(service)) {
1148                     Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
1149                     for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
1150                         switch (entrySet.getKey()) {
1151                             case "TransportState":
1152                                 // Update the transport state after the update of the media information
1153                                 // to not break the notification mechanism
1154                                 break;
1155                             case "AVTransportURI":
1156                                 onValueReceived("CurrentTrackURI", entrySet.getValue(), service);
1157                                 break;
1158                             case "AVTransportURIMetaData":
1159                                 onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service);
1160                                 break;
1161                             default:
1162                                 onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
1163                         }
1164                     }
1165                     if (parsedValues.containsKey("TransportState")) {
1166                         onValueReceived("TransportState", parsedValues.get("TransportState"), service);
1167                     }
1168                 } else if (RENDERING_CONTROL.equals(service)) {
1169                     Map<String, @Nullable String> parsedValues = UpnpXMLParser.getRenderingControlFromXML(value);
1170                     for (String parsedValue : parsedValues.keySet()) {
1171                         onValueReceived(parsedValue, parsedValues.get(parsedValue), RENDERING_CONTROL);
1172                     }
1173                 }
1174             }
1175         });
1176     }
1177
1178     private void onValueReceivedTransportState(@Nullable String value) {
1179         transportState = (value == null) ? "" : value;
1180
1181         if ("STOPPED".equals(value)) {
1182             CompletableFuture<Boolean> stopping = isStopping;
1183             if (stopping != null) {
1184                 stopping.complete(true); // We have received stop confirmation
1185                 isStopping = null;
1186             }
1187
1188             if (playingNotification) {
1189                 resumeAfterNotification();
1190                 return;
1191             }
1192
1193             cancelCheckPaused();
1194             updateState(CONTROL, PlayPauseType.PAUSE);
1195             cancelTrackPositionRefresh();
1196             // Only go to next for first STOP command, then wait until we received PLAYING before moving
1197             // to next (avoids issues with renderers sending multiple stop states)
1198             if (playing) {
1199                 playing = false;
1200
1201                 // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
1202                 // end of an entry, because STOP would come from the player and not from openHAB. We should then
1203                 // move to the next entry if the queue is not at the end already.
1204                 if (!playerStopped) {
1205                     if (Instant.now().toEpochMilli() >= expectedTrackend) {
1206                         // If we are receiving track duration info, we know when the track is expected to end. If we
1207                         // received STOP before track end, and it is not coming from openHAB, it must have been stopped
1208                         // from the renderer directly, and we do not want to play the next entry.
1209                         if (playingQueue) {
1210                             serveNext();
1211                         }
1212                     }
1213                 } else if (playingQueue) {
1214                     playingQueue = false;
1215                 }
1216             }
1217         } else if ("PLAYING".equals(value)) {
1218             if (playingNotification) {
1219                 return;
1220             }
1221
1222             playerStopped = false;
1223             playing = true;
1224             registeredQueue = false; // reset queue registration flag as we are playing something
1225             updateState(CONTROL, PlayPauseType.PLAY);
1226             scheduleTrackPositionRefresh();
1227         } else if ("PAUSED_PLAYBACK".equals(value)) {
1228             cancelCheckPaused();
1229             updateState(CONTROL, PlayPauseType.PAUSE);
1230         } else if ("NO_MEDIA_PRESENT".equals(value)) {
1231             updateState(CONTROL, UnDefType.UNDEF);
1232         }
1233     }
1234
1235     private void onValueReceivedCurrentURI(@Nullable String value) {
1236         CompletableFuture<Boolean> settingURI = isSettingURI;
1237         if (settingURI != null) {
1238             settingURI.complete(true); // We have received current URI, so can allow play to start
1239         }
1240
1241         UpnpEntry current = currentEntry;
1242         UpnpEntry next = nextEntry;
1243
1244         String uri = "";
1245         String currentUri = "";
1246         String nextUri = "";
1247         try {
1248             if (value != null) {
1249                 uri = URLDecoder.decode(value.trim(), StandardCharsets.UTF_8.name());
1250             }
1251             if (current != null) {
1252                 currentUri = URLDecoder.decode(current.getRes().trim(), StandardCharsets.UTF_8.name());
1253             }
1254             if (next != null) {
1255                 nextUri = URLDecoder.decode(next.getRes(), StandardCharsets.UTF_8.name());
1256             }
1257         } catch (UnsupportedEncodingException ignore) {
1258             // If not valid current URI, we assume there is none
1259         }
1260
1261         if (playingNotification && uri.equals(notificationUri)) {
1262             // No need to update anything more if this is for playing a notification
1263             return;
1264         }
1265
1266         nowPlayingUri = uri;
1267         updateState(URI, StringType.valueOf(uri));
1268
1269         logger.trace("Renderer {} received URI: {}", thing.getLabel(), uri);
1270         logger.trace("Renderer {} current URI: {}, equal to received URI {}", thing.getLabel(), currentUri,
1271                 uri.equals(currentUri));
1272         logger.trace("Renderer {} next URI: {}", thing.getLabel(), nextUri);
1273
1274         if (!uri.equals(currentUri)) {
1275             if ((next != null) && uri.equals(nextUri)) {
1276                 // Renderer advanced to next entry independent of openHAB UPnP control point.
1277                 // Advance in the queue to keep proper position status.
1278                 // Make the next entry available to renderers that support it.
1279                 logger.trace("Renderer {} moved from '{}' to next entry '{}' in queue", thing.getLabel(), current,
1280                         next);
1281                 currentEntry = currentQueue.next();
1282                 nextEntry = currentQueue.get(currentQueue.nextIndex());
1283                 logger.trace("Renderer {} auto move forward, current queue index: {}", thing.getLabel(),
1284                         currentQueue.index());
1285
1286                 updateMetaDataState(next);
1287
1288                 // look one further to get next entry for next URI
1289                 next = nextEntry;
1290                 if ((next != null) && !onlyplayone) {
1291                     setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1292                 }
1293             } else {
1294                 // A new entry is being served that does not match the next entry in the queue. This can be because a
1295                 // sound or stream is being played through an action, or another control point started a new entry.
1296                 // We should clear the metadata in this case and wait for new metadata to arrive.
1297                 clearMetaDataState();
1298             }
1299         }
1300     }
1301
1302     private void onValueReceivedCurrentMetaData(@Nullable String value) {
1303         if (playingNotification) {
1304             // Don't update metadata when playing notification
1305             return;
1306         }
1307
1308         if (value != null && !value.isEmpty()) {
1309             List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
1310             if (!list.isEmpty()) {
1311                 updateMetaDataState(list.get(0));
1312                 return;
1313             }
1314         }
1315         clearMetaDataState();
1316     }
1317
1318     private void onValueReceivedNextMetaData(@Nullable String value) {
1319         if (value != null && !value.isEmpty() && !"NOT_IMPLEMENTED".equals(value)) {
1320             List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
1321             if (!list.isEmpty()) {
1322                 nextEntry = list.get(0);
1323             }
1324         }
1325     }
1326
1327     private void onValueReceivedDuration(@Nullable String value) {
1328         // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
1329         // interested in the fractional seconds, so drop everything after . and calculate in seconds.
1330         if (value == null || "NOT_IMPLEMENTED".equals(value)) {
1331             trackDuration = 0;
1332             updateState(TRACK_DURATION, UnDefType.UNDEF);
1333             updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1334         } else {
1335             try {
1336                 trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
1337                         .reduce(0, (n, m) -> n * 60 + m);
1338                 updateState(TRACK_DURATION, new QuantityType<>(trackDuration, Units.SECOND));
1339             } catch (NumberFormatException e) {
1340                 logger.debug("Illegal format for track duration {}", value);
1341                 return;
1342             }
1343         }
1344         setExpectedTrackend();
1345     }
1346
1347     private void onValueReceivedRelTime(@Nullable String value) {
1348         if (value == null || "NOT_IMPLEMENTED".equals(value)) {
1349             trackPosition = 0;
1350             updateState(TRACK_POSITION, UnDefType.UNDEF);
1351             updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1352         } else {
1353             try {
1354                 trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
1355                         .reduce(0, (n, m) -> n * 60 + m);
1356                 updateState(TRACK_POSITION, new QuantityType<>(trackPosition, Units.SECOND));
1357                 int relPosition = (trackDuration != 0) ? trackPosition * 100 / trackDuration : 0;
1358                 updateState(REL_TRACK_POSITION, new PercentType(relPosition));
1359             } catch (NumberFormatException e) {
1360                 logger.trace("Illegal format for track position {}", value);
1361                 return;
1362             }
1363         }
1364
1365         if (playingNotification) {
1366             posAtNotificationStart = trackPosition;
1367         }
1368
1369         setExpectedTrackend();
1370     }
1371
1372     @Override
1373     protected void updateProtocolInfo(String value) {
1374         sink.clear();
1375         supportedAudioFormats.clear();
1376         audioSupport = false;
1377
1378         sink.addAll(Arrays.asList(value.split(",")));
1379
1380         for (String protocol : sink) {
1381             Matcher matcher = PROTOCOL_PATTERN.matcher(protocol);
1382             if (matcher.find()) {
1383                 String format = matcher.group(1);
1384                 switch (format) {
1385                     case "audio/mpeg3":
1386                     case "audio/mp3":
1387                     case "audio/mpeg":
1388                         supportedAudioFormats.add(AudioFormat.MP3);
1389                         break;
1390                     case "audio/wav":
1391                     case "audio/wave":
1392                         supportedAudioFormats.add(AudioFormat.WAV);
1393                         break;
1394                 }
1395                 audioSupport = audioSupport || Pattern.matches("audio.*", format);
1396             }
1397         }
1398
1399         if (audioSupport) {
1400             logger.debug("Renderer {} supports audio", thing.getLabel());
1401             registerAudioSink();
1402         }
1403     }
1404
1405     private void clearCurrentEntry() {
1406         clearMetaDataState();
1407
1408         trackDuration = 0;
1409         updateState(TRACK_DURATION, UnDefType.UNDEF);
1410         trackPosition = 0;
1411         updateState(TRACK_POSITION, UnDefType.UNDEF);
1412         updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1413
1414         currentEntry = null;
1415     }
1416
1417     /**
1418      * Register a new queue with media entries to the renderer. Set the next position at the first entry in the list.
1419      * If the renderer is currently playing, set the first entry in the list as the next media. If not playing, set it
1420      * as current media.
1421      *
1422      * @param queue
1423      */
1424     protected void registerQueue(UpnpEntryQueue queue) {
1425         if (currentQueue.equals(queue)) {
1426             // We get the same queue, so do nothing
1427             return;
1428         }
1429
1430         logger.debug("Registering queue on renderer {}", thing.getLabel());
1431
1432         registeredQueue = true;
1433         currentQueue = queue;
1434         currentQueue.setRepeat(repeat);
1435         currentQueue.setShuffle(shuffle);
1436         if (playingQueue) {
1437             nextEntry = currentQueue.get(currentQueue.nextIndex());
1438             UpnpEntry next = nextEntry;
1439             if ((next != null) && !onlyplayone) {
1440                 // make the next entry available to renderers that support it
1441                 logger.trace("Renderer {} still playing, set new queue as next entry", thing.getLabel());
1442                 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1443             }
1444         } else {
1445             resetToStartQueue();
1446         }
1447     }
1448
1449     /**
1450      * Move to next position in queue and start playing.
1451      */
1452     private void serveNext() {
1453         if (currentQueue.hasNext()) {
1454             currentEntry = currentQueue.next();
1455             nextEntry = currentQueue.get(currentQueue.nextIndex());
1456             logger.debug("Serve next media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
1457             logger.trace("Serve next, current queue index: {}", currentQueue.index());
1458
1459             serve();
1460         } else {
1461             logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel());
1462             resetToStartQueue();
1463         }
1464     }
1465
1466     /**
1467      * Move to previous position in queue and start playing.
1468      */
1469     private void servePrevious() {
1470         if (currentQueue.hasPrevious()) {
1471             currentEntry = currentQueue.previous();
1472             nextEntry = currentQueue.get(currentQueue.nextIndex());
1473             logger.debug("Serve previous media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
1474             logger.trace("Serve previous, current queue index: {}", currentQueue.index());
1475
1476             serve();
1477         } else {
1478             logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel());
1479             resetToStartQueue();
1480         }
1481     }
1482
1483     private void resetToStartQueue() {
1484         logger.trace("Reset to start queue on renderer {}", thing.getLabel());
1485
1486         playingQueue = false;
1487         registeredQueue = true;
1488
1489         stop();
1490
1491         currentQueue.resetIndex(); // reset to beginning of queue
1492         currentEntry = currentQueue.next();
1493         nextEntry = currentQueue.get(currentQueue.nextIndex());
1494         logger.trace("Reset queue, current queue index: {}", currentQueue.index());
1495         UpnpEntry current = currentEntry;
1496         if (current != null) {
1497             clearMetaDataState();
1498             updateMetaDataState(current);
1499             setCurrentURI(current.getRes(), UpnpXMLParser.compileMetadataString(current));
1500         } else {
1501             clearCurrentEntry();
1502         }
1503
1504         UpnpEntry next = nextEntry;
1505         if (onlyplayone) {
1506             setNextURI("", "");
1507         } else if (next != null) {
1508             setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1509         }
1510     }
1511
1512     /**
1513      * Serve media from a queue and play immediately when already playing.
1514      *
1515      * @param media
1516      */
1517     private void serve() {
1518         logger.trace("Serve media on renderer {}", thing.getLabel());
1519
1520         UpnpEntry entry = currentEntry;
1521         if (entry != null) {
1522             clearMetaDataState();
1523             String res = entry.getRes();
1524             if (res.isEmpty()) {
1525                 logger.debug("Renderer {} cannot serve media '{}', no URI", thing.getLabel(), currentEntry);
1526                 playingQueue = false;
1527                 return;
1528             }
1529             updateMetaDataState(entry);
1530             setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry));
1531
1532             if ((playingQueue || playing) && !(onlyplayone && oneplayed)) {
1533                 logger.trace("Ready to play '{}' from queue", currentEntry);
1534
1535                 trackDuration = 0;
1536                 trackPosition = 0;
1537                 expectedTrackend = 0;
1538                 play();
1539
1540                 oneplayed = true;
1541                 playingQueue = true;
1542             }
1543
1544             // make the next entry available to renderers that support it
1545             if (!onlyplayone) {
1546                 UpnpEntry next = nextEntry;
1547                 if (next != null) {
1548                     setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1549                 }
1550             }
1551         }
1552     }
1553
1554     /**
1555      * Called before handling a pause CONTROL command. If we do not received PAUSED_PLAYBACK or STOPPED back within
1556      * timeout, we will revert to playing state. This takes care of renderers that cannot pause playback.
1557      */
1558     private void checkPaused() {
1559         paused = upnpScheduler.schedule(this::resetPaused, config.responseTimeout, TimeUnit.MILLISECONDS);
1560     }
1561
1562     private void resetPaused() {
1563         updateState(CONTROL, PlayPauseType.PLAY);
1564     }
1565
1566     private void cancelCheckPaused() {
1567         ScheduledFuture<?> future = paused;
1568         if (future != null) {
1569             future.cancel(true);
1570             paused = null;
1571         }
1572     }
1573
1574     private void setExpectedTrackend() {
1575         expectedTrackend = Instant.now().toEpochMilli() + (trackDuration - trackPosition) * 1000
1576                 - config.responseTimeout;
1577     }
1578
1579     /**
1580      * Update the current track position every second if the channel is linked.
1581      */
1582     private void scheduleTrackPositionRefresh() {
1583         if (playingNotification) {
1584             return;
1585         }
1586
1587         cancelTrackPositionRefresh();
1588         if (!(isLinked(TRACK_POSITION) || isLinked(REL_TRACK_POSITION))) {
1589             // only get it once, so we can use the track end to correctly identify STOP pressed directly on renderer
1590             getPositionInfo();
1591         } else {
1592             if (trackPositionRefresh == null) {
1593                 trackPositionRefresh = upnpScheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1,
1594                         TimeUnit.SECONDS);
1595             }
1596         }
1597     }
1598
1599     private void cancelTrackPositionRefresh() {
1600         ScheduledFuture<?> refresh = trackPositionRefresh;
1601
1602         if (refresh != null) {
1603             refresh.cancel(true);
1604         }
1605         trackPositionRefresh = null;
1606
1607         trackPosition = 0;
1608         updateState(TRACK_POSITION, new QuantityType<>(trackPosition, Units.SECOND));
1609         int relPosition = (trackDuration != 0) ? trackPosition / trackDuration : 0;
1610         updateState(REL_TRACK_POSITION, new PercentType(relPosition));
1611     }
1612
1613     /**
1614      * Update metadata channels for media with data received from the Media Server or AV Transport.
1615      *
1616      * @param media
1617      */
1618     private void updateMetaDataState(UpnpEntry media) {
1619         // We don't want to update metadata if the metadata from the AVTransport is less complete than in the current
1620         // entry.
1621         boolean isCurrent = false;
1622         UpnpEntry entry = null;
1623         if (playingQueue) {
1624             entry = currentEntry;
1625         }
1626
1627         logger.trace("Renderer {}, received media ID: {}", thing.getLabel(), media.getId());
1628
1629         if ((entry != null) && entry.getId().equals(media.getId())) {
1630             logger.trace("Current ID: {}", entry.getId());
1631
1632             isCurrent = true;
1633         } else {
1634             // Sometimes we receive the media URL without the ID, then compare on URL
1635             String mediaRes = media.getRes().trim();
1636             String entryRes = (entry != null) ? entry.getRes().trim() : "";
1637
1638             try {
1639                 String mediaUrl = URLDecoder.decode(mediaRes, StandardCharsets.UTF_8.name());
1640                 String entryUrl = URLDecoder.decode(entryRes, StandardCharsets.UTF_8.name());
1641                 isCurrent = mediaUrl.equals(entryUrl);
1642             } catch (UnsupportedEncodingException e) {
1643                 logger.debug("Renderer {} unsupported encoding for new {} or current {} res URL, trying string compare",
1644                         thing.getLabel(), mediaRes, entryRes);
1645                 isCurrent = mediaRes.equals(entryRes);
1646             }
1647
1648             logger.trace("Current queue res: {}", entryRes);
1649             logger.trace("Updated media res: {}", mediaRes);
1650         }
1651
1652         logger.trace("Received meta data is for current entry: {}", isCurrent);
1653
1654         if (!(isCurrent && media.getTitle().isEmpty())) {
1655             updateState(TITLE, StringType.valueOf(media.getTitle()));
1656         }
1657         if (!(isCurrent && (media.getAlbum().isEmpty() || media.getAlbum().matches("Unknown.*")))) {
1658             updateState(ALBUM, StringType.valueOf(media.getAlbum()));
1659         }
1660         if (!(isCurrent
1661                 && (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")))) {
1662             if (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")) {
1663                 updateState(ALBUM_ART, UnDefType.UNDEF);
1664             } else {
1665                 State albumArt = HttpUtil.downloadImage(media.getAlbumArtUri());
1666                 if (albumArt == null) {
1667                     logger.debug("Failed to download the content of album art from URL {}", media.getAlbumArtUri());
1668                     if (!isCurrent) {
1669                         updateState(ALBUM_ART, UnDefType.UNDEF);
1670                     }
1671                 } else {
1672                     updateState(ALBUM_ART, albumArt);
1673                 }
1674             }
1675         }
1676         if (!(isCurrent && (media.getCreator().isEmpty() || media.getCreator().matches("Unknown.*")))) {
1677             updateState(CREATOR, StringType.valueOf(media.getCreator()));
1678         }
1679         if (!(isCurrent && (media.getArtist().isEmpty() || media.getArtist().matches("Unknown.*")))) {
1680             updateState(ARTIST, StringType.valueOf(media.getArtist()));
1681         }
1682         if (!(isCurrent && (media.getPublisher().isEmpty() || media.getPublisher().matches("Unknown.*")))) {
1683             updateState(PUBLISHER, StringType.valueOf(media.getPublisher()));
1684         }
1685         if (!(isCurrent && (media.getGenre().isEmpty() || media.getGenre().matches("Unknown.*")))) {
1686             updateState(GENRE, StringType.valueOf(media.getGenre()));
1687         }
1688         if (!(isCurrent && (media.getOriginalTrackNumber() == null))) {
1689             Integer trackNumber = media.getOriginalTrackNumber();
1690             State trackNumberState = (trackNumber != null) ? new DecimalType(trackNumber) : UnDefType.UNDEF;
1691             updateState(TRACK_NUMBER, trackNumberState);
1692         }
1693     }
1694
1695     private void clearMetaDataState() {
1696         updateState(TITLE, UnDefType.UNDEF);
1697         updateState(ALBUM, UnDefType.UNDEF);
1698         updateState(ALBUM_ART, UnDefType.UNDEF);
1699         updateState(CREATOR, UnDefType.UNDEF);
1700         updateState(ARTIST, UnDefType.UNDEF);
1701         updateState(PUBLISHER, UnDefType.UNDEF);
1702         updateState(GENRE, UnDefType.UNDEF);
1703         updateState(TRACK_NUMBER, UnDefType.UNDEF);
1704     }
1705
1706     /**
1707      * @return Audio formats supported by the renderer.
1708      */
1709     public Set<AudioFormat> getSupportedAudioFormats() {
1710         return supportedAudioFormats;
1711     }
1712
1713     private void registerAudioSink() {
1714         if (audioSinkRegistered) {
1715             logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
1716             return;
1717         } else if (!upnpIOService.isRegistered(this)) {
1718             logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
1719             return;
1720         }
1721         logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
1722         audioSinkReg.registerAudioSink(this);
1723         audioSinkRegistered = true;
1724     }
1725
1726     /**
1727      * @return UPnP sink definitions supported by the renderer.
1728      */
1729     protected List<String> getSink() {
1730         return sink;
1731     }
1732 }