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