]> git.basschouten.com Git - openhab-addons.git/blob
3bbe561a72a57eb21613a0743b3afa57d2bf5069
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.upnpcontrol.internal.handler;
14
15 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
16
17 import java.util.ArrayList;
18 import java.util.Arrays;
19 import java.util.Collections;
20 import java.util.HashMap;
21 import java.util.HashSet;
22 import java.util.List;
23 import java.util.ListIterator;
24 import java.util.Map;
25 import java.util.Set;
26 import java.util.concurrent.CompletableFuture;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
31 import java.util.regex.Matcher;
32 import java.util.regex.Pattern;
33
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.upnpcontrol.internal.UpnpAudioSink;
37 import org.openhab.binding.upnpcontrol.internal.UpnpAudioSinkReg;
38 import org.openhab.binding.upnpcontrol.internal.UpnpEntry;
39 import org.openhab.binding.upnpcontrol.internal.UpnpXMLParser;
40 import org.openhab.core.audio.AudioFormat;
41 import org.openhab.core.io.net.http.HttpUtil;
42 import org.openhab.core.io.transport.upnp.UpnpIOService;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.NextPreviousType;
45 import org.openhab.core.library.types.OnOffType;
46 import org.openhab.core.library.types.PercentType;
47 import org.openhab.core.library.types.PlayPauseType;
48 import org.openhab.core.library.types.QuantityType;
49 import org.openhab.core.library.types.RewindFastforwardType;
50 import org.openhab.core.library.types.StringType;
51 import org.openhab.core.library.unit.SmartHomeUnits;
52 import org.openhab.core.thing.ChannelUID;
53 import org.openhab.core.thing.Thing;
54 import org.openhab.core.thing.ThingStatus;
55 import org.openhab.core.thing.ThingStatusDetail;
56 import org.openhab.core.types.Command;
57 import org.openhab.core.types.RefreshType;
58 import org.openhab.core.types.State;
59 import org.openhab.core.types.UnDefType;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
62
63 /**
64  * The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends
65  * {@link UpnpHandler} with UPnP renderer specific logic.
66  *
67  * @author Mark Herwege - Initial contribution
68  * @author Karel Goderis - Based on UPnP logic in Sonos binding
69  */
70 @NonNullByDefault
71 public class UpnpRendererHandler extends UpnpHandler {
72
73     private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class);
74
75     private static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
76
77     // UPnP protocol pattern
78     private static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
79
80     private volatile boolean audioSupport;
81     protected volatile Set<AudioFormat> supportedAudioFormats = new HashSet<>();
82     private volatile boolean audioSinkRegistered;
83
84     private volatile UpnpAudioSinkReg audioSinkReg;
85
86     private volatile boolean upnpSubscribed;
87
88     private static final String UPNP_CHANNEL = "Master";
89
90     private volatile OnOffType soundMute = OnOffType.OFF;
91     private volatile PercentType soundVolume = new PercentType();
92     private volatile List<String> sink = new ArrayList<>();
93
94     private volatile ArrayList<UpnpEntry> currentQueue = new ArrayList<>();
95     private volatile UpnpIterator<UpnpEntry> queueIterator = new UpnpIterator<>(currentQueue.listIterator());
96     private volatile @Nullable UpnpEntry currentEntry = null;
97     private volatile @Nullable UpnpEntry nextEntry = null;
98     private volatile boolean playerStopped;
99     private volatile boolean playing;
100     private volatile @Nullable CompletableFuture<Boolean> isSettingURI;
101     private volatile int trackDuration = 0;
102     private volatile int trackPosition = 0;
103     private volatile @Nullable ScheduledFuture<?> trackPositionRefresh;
104
105     private volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
106     private final Runnable subscriptionRefresh = () -> {
107         removeSubscription("AVTransport");
108         addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
109     };
110
111     /**
112      * The {@link ListIterator} class does not keep a cursor position and therefore will not give the previous element
113      * when next was called before, or give the next element when previous was called before. This iterator will always
114      * go to previous/next.
115      */
116     private static class UpnpIterator<T> {
117         private final ListIterator<T> listIterator;
118
119         private boolean nextWasCalled = false;
120         private boolean previousWasCalled = false;
121
122         public UpnpIterator(ListIterator<T> listIterator) {
123             this.listIterator = listIterator;
124         }
125
126         public T next() {
127             if (previousWasCalled) {
128                 previousWasCalled = false;
129                 listIterator.next();
130             }
131             nextWasCalled = true;
132             return listIterator.next();
133         }
134
135         public T previous() {
136             if (nextWasCalled) {
137                 nextWasCalled = false;
138                 listIterator.previous();
139             }
140             previousWasCalled = true;
141             return listIterator.previous();
142         }
143
144         public boolean hasNext() {
145             if (previousWasCalled) {
146                 return true;
147             } else {
148                 return listIterator.hasNext();
149             }
150         }
151
152         public boolean hasPrevious() {
153             if (previousIndex() < 0) {
154                 return false;
155             } else if (nextWasCalled) {
156                 return true;
157             } else {
158                 return listIterator.hasPrevious();
159             }
160         }
161
162         public int nextIndex() {
163             if (previousWasCalled) {
164                 return listIterator.nextIndex() + 1;
165             } else {
166                 return listIterator.nextIndex();
167             }
168         }
169
170         public int previousIndex() {
171             if (nextWasCalled) {
172                 return listIterator.previousIndex() - 1;
173             } else {
174                 return listIterator.previousIndex();
175             }
176         }
177     }
178
179     public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg) {
180         super(thing, upnpIOService);
181
182         this.audioSinkReg = audioSinkReg;
183     }
184
185     @Override
186     public void initialize() {
187         super.initialize();
188
189         logger.debug("Initializing handler for media renderer device {}", thing.getLabel());
190
191         if (config.udn != null) {
192             if (service.isRegistered(this)) {
193                 initRenderer();
194             } else {
195                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
196                         "Communication cannot be established with " + thing.getLabel());
197             }
198         } else {
199             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
200                     "No UDN configured for " + thing.getLabel());
201         }
202     }
203
204     @Override
205     public void dispose() {
206         cancelSubscriptionRefreshJob();
207         removeSubscription("AVTransport");
208
209         cancelTrackPositionRefresh();
210
211         super.dispose();
212     }
213
214     private void cancelSubscriptionRefreshJob() {
215         ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
216
217         if (refreshJob != null) {
218             refreshJob.cancel(true);
219         }
220         subscriptionRefreshJob = null;
221
222         upnpSubscribed = false;
223     }
224
225     private void initRenderer() {
226         if (!upnpSubscribed) {
227             addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
228             upnpSubscribed = true;
229
230             subscriptionRefreshJob = scheduler.scheduleWithFixedDelay(subscriptionRefresh,
231                     SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
232         }
233         getProtocolInfo();
234         getTransportState();
235
236         updateStatus(ThingStatus.ONLINE);
237     }
238
239     /**
240      * Invoke Stop on UPnP AV Transport.
241      */
242     public void stop() {
243         Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
244
245         invokeAction("AVTransport", "Stop", inputs);
246     }
247
248     /**
249      * Invoke Play on UPnP AV Transport.
250      */
251     public void play() {
252         CompletableFuture<Boolean> setting = isSettingURI;
253         try {
254             if ((setting == null) || (setting.get(2500, TimeUnit.MILLISECONDS))) {
255                 // wait for maximum 2.5s until the media URI is set before playing
256                 Map<String, String> inputs = new HashMap<>();
257                 inputs.put("InstanceID", Integer.toString(avTransportId));
258                 inputs.put("Speed", "1");
259
260                 invokeAction("AVTransport", "Play", inputs);
261             } else {
262                 logger.debug("Cannot play, cancelled setting URI in the renderer");
263             }
264         } catch (InterruptedException | ExecutionException | TimeoutException e) {
265             logger.debug("Cannot play, media URI not yet set in the renderer");
266         }
267     }
268
269     /**
270      * Invoke Pause on UPnP AV Transport.
271      */
272     public void pause() {
273         Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
274
275         invokeAction("AVTransport", "Pause", inputs);
276     }
277
278     /**
279      * Invoke Next on UPnP AV Transport.
280      */
281     protected void next() {
282         Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
283
284         invokeAction("AVTransport", "Next", inputs);
285     }
286
287     /**
288      * Invoke Previous on UPnP AV Transport.
289      */
290     protected void previous() {
291         Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
292
293         invokeAction("AVTransport", "Previous", inputs);
294     }
295
296     /**
297      * Invoke SetAVTransportURI on UPnP AV Transport.
298      *
299      * @param URI
300      * @param URIMetaData
301      */
302     public void setCurrentURI(String URI, String URIMetaData) {
303         CompletableFuture<Boolean> setting = isSettingURI;
304         if (setting != null) {
305             setting.complete(false);
306         }
307         isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished setting
308                                                          // URI
309         Map<String, String> inputs = new HashMap<>();
310         try {
311             inputs.put("InstanceID", Integer.toString(avTransportId));
312             inputs.put("CurrentURI", URI);
313             inputs.put("CurrentURIMetaData", URIMetaData);
314
315             invokeAction("AVTransport", "SetAVTransportURI", inputs);
316         } catch (NumberFormatException ex) {
317             logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
318         }
319     }
320
321     /**
322      * Invoke SetNextAVTransportURI on UPnP AV Transport.
323      *
324      * @param nextURI
325      * @param nextURIMetaData
326      */
327     public void setNextURI(String nextURI, String nextURIMetaData) {
328         Map<String, String> inputs = new HashMap<>();
329         try {
330             inputs.put("InstanceID", Integer.toString(avTransportId));
331             inputs.put("NextURI", nextURI);
332             inputs.put("NextURIMetaData", nextURIMetaData);
333
334             invokeAction("AVTransport", "SetNextAVTransportURI", inputs);
335         } catch (NumberFormatException ex) {
336             logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
337         }
338     }
339
340     /**
341      * Retrieves the current audio channel ('Master' by default).
342      *
343      * @return current audio channel
344      */
345     public String getCurrentChannel() {
346         return UPNP_CHANNEL;
347     }
348
349     /**
350      * Retrieves the current volume known to the control point, gets updated by GENA events or after UPnP Rendering
351      * Control GetVolume call. This method is used to retrieve volume by {@link UpnpAudioSink.getVolume}.
352      *
353      * @return current volume
354      */
355     public PercentType getCurrentVolume() {
356         return soundVolume;
357     }
358
359     /**
360      * Invoke GetVolume on UPnP Rendering Control.
361      * Result is received in {@link onValueReceived}.
362      *
363      * @param channel
364      */
365     protected void getVolume(String channel) {
366         Map<String, String> inputs = new HashMap<>();
367         inputs.put("InstanceID", Integer.toString(rcsId));
368         inputs.put("Channel", channel);
369
370         invokeAction("RenderingControl", "GetVolume", inputs);
371     }
372
373     /**
374      * Invoke SetVolume on UPnP Rendering Control.
375      *
376      * @param channel
377      * @param volume
378      */
379     public void setVolume(String channel, PercentType volume) {
380         Map<String, String> inputs = new HashMap<>();
381         inputs.put("InstanceID", Integer.toString(rcsId));
382         inputs.put("Channel", channel);
383         inputs.put("DesiredVolume", String.valueOf(volume.intValue()));
384
385         invokeAction("RenderingControl", "SetVolume", inputs);
386     }
387
388     /**
389      * Invoke getMute on UPnP Rendering Control.
390      * Result is received in {@link onValueReceived}.
391      *
392      * @param channel
393      */
394     protected void getMute(String channel) {
395         Map<String, String> inputs = new HashMap<>();
396         inputs.put("InstanceID", Integer.toString(rcsId));
397         inputs.put("Channel", channel);
398
399         invokeAction("RenderingControl", "GetMute", inputs);
400     }
401
402     /**
403      * Invoke SetMute on UPnP Rendering Control.
404      *
405      * @param channel
406      * @param mute
407      */
408     protected void setMute(String channel, OnOffType mute) {
409         Map<String, String> inputs = new HashMap<>();
410         inputs.put("InstanceID", Integer.toString(rcsId));
411         inputs.put("Channel", channel);
412         inputs.put("DesiredMute", mute == OnOffType.ON ? "1" : "0");
413
414         invokeAction("RenderingControl", "SetMute", inputs);
415     }
416
417     /**
418      * Invoke getPositionInfo on UPnP Rendering Control.
419      * Result is received in {@link onValueReceived}.
420      */
421     protected void getPositionInfo() {
422         Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(rcsId));
423
424         invokeAction("AVTransport", "GetPositionInfo", inputs);
425     }
426
427     @Override
428     public void handleCommand(ChannelUID channelUID, Command command) {
429         logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
430
431         String transportState;
432         if (command instanceof RefreshType) {
433             switch (channelUID.getId()) {
434                 case VOLUME:
435                     getVolume(getCurrentChannel());
436                     break;
437                 case MUTE:
438                     getMute(getCurrentChannel());
439                     break;
440                 case CONTROL:
441                     transportState = this.transportState;
442                     State newState = UnDefType.UNDEF;
443                     if ("PLAYING".equals(transportState)) {
444                         newState = PlayPauseType.PLAY;
445                     } else if ("STOPPED".equals(transportState)) {
446                         newState = PlayPauseType.PAUSE;
447                     } else if ("PAUSED_PLAYBACK".equals(transportState)) {
448                         newState = PlayPauseType.PAUSE;
449                     }
450                     updateState(channelUID, newState);
451                     break;
452             }
453             return;
454         } else {
455             switch (channelUID.getId()) {
456                 case VOLUME:
457                     setVolume(getCurrentChannel(), (PercentType) command);
458                     break;
459                 case MUTE:
460                     setMute(getCurrentChannel(), (OnOffType) command);
461                     break;
462                 case STOP:
463                     if (command == OnOffType.ON) {
464                         updateState(CONTROL, PlayPauseType.PAUSE);
465                         playerStopped = true;
466                         stop();
467                         updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND));
468                     }
469                     break;
470                 case CONTROL:
471                     playerStopped = false;
472                     if (command instanceof PlayPauseType) {
473                         if (command == PlayPauseType.PLAY) {
474                             play();
475                         } else if (command == PlayPauseType.PAUSE) {
476                             pause();
477                         }
478                     } else if (command instanceof NextPreviousType) {
479                         if (command == NextPreviousType.NEXT) {
480                             playerStopped = true;
481                             serveNext();
482                         } else if (command == NextPreviousType.PREVIOUS) {
483                             playerStopped = true;
484                             servePrevious();
485                         }
486                     } else if (command instanceof RewindFastforwardType) {
487                     }
488                     break;
489             }
490
491             return;
492         }
493     }
494
495     @Override
496     public void onStatusChanged(boolean status) {
497         logger.debug("Renderer status changed to {}", status);
498         if (status) {
499             initRenderer();
500         } else {
501             cancelSubscriptionRefreshJob();
502
503             updateState(CONTROL, PlayPauseType.PAUSE);
504             cancelTrackPositionRefresh();
505
506             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
507                     "Communication lost with " + thing.getLabel());
508         }
509         super.onStatusChanged(status);
510     }
511
512     @Override
513     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
514         if (logger.isTraceEnabled()) {
515             logger.trace("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(),
516                     variable, value, service);
517         } else {
518             if (logger.isDebugEnabled() && !("AbsTime".equals(variable) || "RelCount".equals(variable)
519                     || "RelTime".equals(variable) || "AbsCount".equals(variable) || "Track".equals(variable)
520                     || "TrackDuration".equals(variable))) {
521                 // don't log all variables received when updating the track position every second
522                 logger.debug("Upnp device {} received variable {} with value {} from service {}", thing.getLabel(),
523                         variable, value, service);
524             }
525         }
526         if (variable == null) {
527             return;
528         }
529
530         switch (variable) {
531             case "CurrentMute":
532                 if (!((value == null) || (value.isEmpty()))) {
533                     soundMute = OnOffType.from(Boolean.parseBoolean(value));
534                     updateState(MUTE, soundMute);
535                 }
536                 break;
537             case "CurrentVolume":
538                 if (!((value == null) || (value.isEmpty()))) {
539                     soundVolume = PercentType.valueOf(value);
540                     updateState(VOLUME, soundVolume);
541                 }
542                 break;
543             case "Sink":
544                 if (!((value == null) || (value.isEmpty()))) {
545                     updateProtocolInfo(value);
546                 }
547                 break;
548             case "LastChange":
549                 // pre-process some variables, eg XML processing
550                 if (!((value == null) || value.isEmpty())) {
551                     if ("AVTransport".equals(service)) {
552                         Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
553                         for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
554                             // Update the transport state after the update of the media information
555                             // to not break the notification mechanism
556                             if (!"TransportState".equals(entrySet.getKey())) {
557                                 onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
558                             }
559                             if ("AVTransportURI".equals(entrySet.getKey())) {
560                                 onValueReceived("CurrentTrackURI", entrySet.getValue(), service);
561                             } else if ("AVTransportURIMetaData".equals(entrySet.getKey())) {
562                                 onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service);
563                             }
564                         }
565                         if (parsedValues.containsKey("TransportState")) {
566                             onValueReceived("TransportState", parsedValues.get("TransportState"), service);
567                         }
568                     }
569                 }
570                 break;
571             case "TransportState":
572                 transportState = (value == null) ? "" : value;
573                 if ("STOPPED".equals(value)) {
574                     updateState(CONTROL, PlayPauseType.PAUSE);
575                     cancelTrackPositionRefresh();
576                     // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
577                     // end of an entry. We should then move to the next entry if the queue is not at the end already.
578                     if (playing && !playerStopped) {
579                         // Only go to next for first STOP command, then wait until we received PLAYING before moving
580                         // to next (avoids issues with renderers sending multiple stop states)
581                         playing = false;
582                         serveNext();
583                     } else {
584                         currentEntry = nextEntry; // Try to get the metadata for the next entry if controlled by an
585                                                   // external control point
586                         playing = false;
587                     }
588                 } else if ("PLAYING".equals(value)) {
589                     playerStopped = false;
590                     playing = true;
591                     updateState(CONTROL, PlayPauseType.PLAY);
592                     scheduleTrackPositionRefresh();
593                 } else if ("PAUSED_PLAYBACK".equals(value)) {
594                     updateState(CONTROL, PlayPauseType.PAUSE);
595                 }
596                 break;
597             case "CurrentTrackURI":
598                 UpnpEntry current = currentEntry;
599                 if (queueIterator.hasNext() && (current != null) && !current.getRes().equals(value)
600                         && currentQueue.get(queueIterator.nextIndex()).getRes().equals(value)) {
601                     // Renderer advanced to next entry independent of openHAB UPnP control point.
602                     // Advance in the queue to keep proper position status.
603                     // Make the next entry available to renderers that support it.
604                     updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
605                     logger.trace("Renderer moved from '{}' to next entry '{}' in queue", currentEntry,
606                             currentQueue.get(queueIterator.nextIndex()));
607                     currentEntry = queueIterator.next();
608                     if (queueIterator.hasNext()) {
609                         UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
610                         setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
611                     }
612                 }
613                 if (isSettingURI != null) {
614                     isSettingURI.complete(true); // We have received current URI, so can allow play to start
615                 }
616                 break;
617             case "CurrentTrackMetaData":
618                 if (!((value == null) || (value.isEmpty()))) {
619                     List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
620                     if (!list.isEmpty()) {
621                         updateMetaDataState(list.get(0));
622                     }
623                 }
624                 break;
625             case "NextAVTransportURIMetaData":
626                 if (!((value == null) || (value.isEmpty() || "NOT_IMPLEMENTED".equals(value)))) {
627                     List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
628                     if (!list.isEmpty()) {
629                         nextEntry = list.get(0);
630                     }
631                 }
632                 break;
633             case "CurrentTrackDuration":
634             case "TrackDuration":
635                 // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
636                 // interested in the fractional seconds, so drop everything after . and calculate in seconds.
637                 if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
638                     trackDuration = 0;
639                     updateState(TRACK_DURATION, UnDefType.UNDEF);
640                 } else {
641                     trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
642                             .reduce(0, (n, m) -> n * 60 + m);
643                     updateState(TRACK_DURATION, new QuantityType<>(trackDuration, SmartHomeUnits.SECOND));
644                 }
645                 break;
646             case "RelTime":
647                 if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
648                     trackPosition = 0;
649                     updateState(TRACK_POSITION, UnDefType.UNDEF);
650                 } else {
651                     trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
652                             .reduce(0, (n, m) -> n * 60 + m);
653                     updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
654                 }
655                 break;
656             default:
657                 super.onValueReceived(variable, value, service);
658                 break;
659         }
660     }
661
662     private void updateProtocolInfo(String value) {
663         sink.clear();
664         supportedAudioFormats.clear();
665         audioSupport = false;
666
667         sink.addAll(Arrays.asList(value.split(",")));
668
669         for (String protocol : sink) {
670             Matcher matcher = PROTOCOL_PATTERN.matcher(protocol);
671             if (matcher.find()) {
672                 String format = matcher.group(1);
673                 switch (format) {
674                     case "audio/mpeg3":
675                     case "audio/mp3":
676                     case "audio/mpeg":
677                         supportedAudioFormats.add(AudioFormat.MP3);
678                         break;
679                     case "audio/wav":
680                     case "audio/wave":
681                         supportedAudioFormats.add(AudioFormat.WAV);
682                         break;
683                 }
684                 audioSupport = audioSupport || Pattern.matches("audio.*", format);
685             }
686         }
687
688         if (audioSupport) {
689             logger.debug("Device {} supports audio", thing.getLabel());
690             registerAudioSink();
691         }
692     }
693
694     private void registerAudioSink() {
695         if (audioSinkRegistered) {
696             logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
697             return;
698         } else if (!service.isRegistered(this)) {
699             logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
700             return;
701         }
702         logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
703         audioSinkReg.registerAudioSink(this);
704         audioSinkRegistered = true;
705     }
706
707     private void clearCurrentEntry() {
708         updateState(TITLE, UnDefType.UNDEF);
709         updateState(ALBUM, UnDefType.UNDEF);
710         updateState(ALBUM_ART, UnDefType.UNDEF);
711         updateState(CREATOR, UnDefType.UNDEF);
712         updateState(ARTIST, UnDefType.UNDEF);
713         updateState(PUBLISHER, UnDefType.UNDEF);
714         updateState(GENRE, UnDefType.UNDEF);
715         updateState(TRACK_NUMBER, UnDefType.UNDEF);
716         trackDuration = 0;
717         updateState(TRACK_DURATION, UnDefType.UNDEF);
718         trackPosition = 0;
719         updateState(TRACK_POSITION, UnDefType.UNDEF);
720
721         currentEntry = null;
722     }
723
724     /**
725      * Register a new queue with media entries to the renderer. Set the next position at the first entry in the list.
726      * If the renderer is currently playing, set the first entry in the list as the next media. If not playing, set it
727      * as current media.
728      *
729      * @param queue
730      */
731     public void registerQueue(ArrayList<UpnpEntry> queue) {
732         logger.debug("Registering queue on renderer {}", thing.getLabel());
733         currentQueue = queue;
734         queueIterator = new UpnpIterator<>(currentQueue.listIterator());
735         if (playing) {
736             if (queueIterator.hasNext()) {
737                 // make the next entry available to renderers that support it
738                 logger.trace("Still playing, set new queue as next entry");
739                 UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
740                 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
741             }
742         } else {
743             if (queueIterator.hasNext()) {
744                 UpnpEntry entry = queueIterator.next();
745                 updateMetaDataState(entry);
746                 setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
747                 currentEntry = entry;
748             } else {
749                 clearCurrentEntry();
750             }
751         }
752     }
753
754     /**
755      * Move to next position in queue and start playing.
756      */
757     private void serveNext() {
758         if (queueIterator.hasNext()) {
759             currentEntry = queueIterator.next();
760             logger.debug("Serve next media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
761             serve();
762         } else {
763             logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel());
764             cancelTrackPositionRefresh();
765             stop();
766             queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
767             if (currentQueue.isEmpty()) {
768                 clearCurrentEntry();
769             } else {
770                 updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
771                 UpnpEntry entry = queueIterator.next();
772                 setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
773                 currentEntry = entry;
774             }
775         }
776     }
777
778     /**
779      * Move to previous position in queue and start playing.
780      */
781     private void servePrevious() {
782         if (queueIterator.hasPrevious()) {
783             currentEntry = queueIterator.previous();
784             logger.debug("Serve previous media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
785             serve();
786         } else {
787             logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel());
788             cancelTrackPositionRefresh();
789             stop();
790             queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
791             if (currentQueue.isEmpty()) {
792                 clearCurrentEntry();
793             } else {
794                 updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
795                 UpnpEntry entry = queueIterator.next();
796                 setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
797                 currentEntry = entry;
798             }
799         }
800     }
801
802     /**
803      * Play media.
804      *
805      * @param media
806      */
807     private void serve() {
808         UpnpEntry entry = currentEntry;
809         if (entry != null) {
810             logger.trace("Ready to play '{}' from queue", currentEntry);
811             updateMetaDataState(entry);
812             String res = entry.getRes();
813             if (res.isEmpty()) {
814                 logger.debug("Cannot serve media '{}', no URI", currentEntry);
815                 return;
816             }
817             setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry));
818             play();
819
820             // make the next entry available to renderers that support it
821             if (queueIterator.hasNext()) {
822                 UpnpEntry next = currentQueue.get(queueIterator.nextIndex());
823                 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
824             }
825         }
826     }
827
828     /**
829      * Update the current track position every second if the channel is linked.
830      */
831     private void scheduleTrackPositionRefresh() {
832         cancelTrackPositionRefresh();
833         if (!isLinked(TRACK_POSITION)) {
834             return;
835         }
836         if (trackPositionRefresh == null) {
837             trackPositionRefresh = scheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1, TimeUnit.SECONDS);
838         }
839     }
840
841     private void cancelTrackPositionRefresh() {
842         ScheduledFuture<?> refresh = trackPositionRefresh;
843
844         if (refresh != null) {
845             refresh.cancel(true);
846         }
847         trackPositionRefresh = null;
848
849         trackPosition = 0;
850         updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
851     }
852
853     /**
854      * Update metadata channels for media with data received from the Media Server or AV Transport.
855      *
856      * @param media
857      */
858     private void updateMetaDataState(UpnpEntry media) {
859         // The AVTransport passes the URI resource in the ID.
860         // We don't want to update metadata if the metadata from the AVTransport is empty for the current entry.
861         boolean isCurrent;
862         UpnpEntry entry = currentEntry;
863         if (entry == null) {
864             entry = new UpnpEntry(media.getId(), media.getId(), "", "object.item");
865             currentEntry = entry;
866             isCurrent = false;
867         } else {
868             isCurrent = media.getId().equals(entry.getRes());
869         }
870         logger.trace("Media ID: {}", media.getId());
871         logger.trace("Current queue res: {}", entry.getRes());
872         logger.trace("Updating current entry: {}", isCurrent);
873
874         if (!(isCurrent && media.getTitle().isEmpty())) {
875             updateState(TITLE, StringType.valueOf(media.getTitle()));
876         }
877         if (!(isCurrent && (media.getAlbum().isEmpty() || media.getAlbum().matches("Unknown.*")))) {
878             updateState(ALBUM, StringType.valueOf(media.getAlbum()));
879         }
880         if (!(isCurrent
881                 && (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")))) {
882             if (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")) {
883                 updateState(ALBUM_ART, UnDefType.UNDEF);
884             } else {
885                 State albumArt = HttpUtil.downloadImage(media.getAlbumArtUri());
886                 if (albumArt == null) {
887                     logger.debug("Failed to download the content of album art from URL {}", media.getAlbumArtUri());
888                     if (!isCurrent) {
889                         updateState(ALBUM_ART, UnDefType.UNDEF);
890                     }
891                 } else {
892                     updateState(ALBUM_ART, albumArt);
893                 }
894             }
895         }
896         if (!(isCurrent && (media.getCreator().isEmpty() || media.getCreator().matches("Unknown.*")))) {
897             updateState(CREATOR, StringType.valueOf(media.getCreator()));
898         }
899         if (!(isCurrent && (media.getArtist().isEmpty() || media.getArtist().matches("Unknown.*")))) {
900             updateState(ARTIST, StringType.valueOf(media.getArtist()));
901         }
902         if (!(isCurrent && (media.getPublisher().isEmpty() || media.getPublisher().matches("Unknown.*")))) {
903             updateState(PUBLISHER, StringType.valueOf(media.getPublisher()));
904         }
905         if (!(isCurrent && (media.getGenre().isEmpty() || media.getGenre().matches("Unknown.*")))) {
906             updateState(GENRE, StringType.valueOf(media.getGenre()));
907         }
908         if (!(isCurrent && (media.getOriginalTrackNumber() == null))) {
909             Integer trackNumber = media.getOriginalTrackNumber();
910             State trackNumberState = (trackNumber != null) ? new DecimalType(trackNumber) : UnDefType.UNDEF;
911             updateState(TRACK_NUMBER, trackNumberState);
912         }
913     }
914
915     /**
916      * @return Audio formats supported by the renderer.
917      */
918     public Set<AudioFormat> getSupportedAudioFormats() {
919         return supportedAudioFormats;
920     }
921
922     /**
923      * @return UPnP sink definitions supported by the renderer.
924      */
925     protected List<String> getSink() {
926         return sink;
927     }
928 }