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