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