2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.upnpcontrol.internal.handler;
15 import static org.openhab.binding.upnpcontrol.internal.UpnpControlBindingConstants.*;
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;
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;
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;
64 * The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends
65 * {@link UpnpHandler} with UPnP renderer specific logic.
67 * @author Mark Herwege - Initial contribution
68 * @author Karel Goderis - Based on UPnP logic in Sonos binding
71 public class UpnpRendererHandler extends UpnpHandler {
73 private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class);
75 private static final int SUBSCRIPTION_DURATION_SECONDS = 3600;
77 // UPnP protocol pattern
78 private static final Pattern PROTOCOL_PATTERN = Pattern.compile("(?:.*):(?:.*):(.*):(?:.*)");
80 private volatile boolean audioSupport;
81 protected volatile Set<AudioFormat> supportedAudioFormats = new HashSet<>();
82 private volatile boolean audioSinkRegistered;
84 private volatile UpnpAudioSinkReg audioSinkReg;
86 private volatile boolean upnpSubscribed;
88 private static final String UPNP_CHANNEL = "Master";
90 private volatile OnOffType soundMute = OnOffType.OFF;
91 private volatile PercentType soundVolume = new PercentType();
92 private volatile List<String> sink = new ArrayList<>();
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;
105 private volatile @Nullable ScheduledFuture<?> subscriptionRefreshJob;
106 private final Runnable subscriptionRefresh = () -> {
107 removeSubscription("AVTransport");
108 addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
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.
116 private static class UpnpIterator<T> {
117 private final ListIterator<T> listIterator;
119 private boolean nextWasCalled = false;
120 private boolean previousWasCalled = false;
122 public UpnpIterator(ListIterator<T> listIterator) {
123 this.listIterator = listIterator;
127 if (previousWasCalled) {
128 previousWasCalled = false;
131 nextWasCalled = true;
132 return listIterator.next();
135 public T previous() {
137 nextWasCalled = false;
138 listIterator.previous();
140 previousWasCalled = true;
141 return listIterator.previous();
144 public boolean hasNext() {
145 if (previousWasCalled) {
148 return listIterator.hasNext();
152 public boolean hasPrevious() {
153 if (previousIndex() < 0) {
155 } else if (nextWasCalled) {
158 return listIterator.hasPrevious();
162 public int nextIndex() {
163 if (previousWasCalled) {
164 return listIterator.nextIndex() + 1;
166 return listIterator.nextIndex();
170 public int previousIndex() {
172 return listIterator.previousIndex() - 1;
174 return listIterator.previousIndex();
179 public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg) {
180 super(thing, upnpIOService);
182 this.audioSinkReg = audioSinkReg;
186 public void initialize() {
189 logger.debug("Initializing handler for media renderer device {}", thing.getLabel());
191 if (config.udn != null) {
192 if (service.isRegistered(this)) {
195 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
196 "Communication cannot be established with " + thing.getLabel());
199 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
200 "No UDN configured for " + thing.getLabel());
205 public void dispose() {
206 cancelSubscriptionRefreshJob();
207 removeSubscription("AVTransport");
209 cancelTrackPositionRefresh();
214 private void cancelSubscriptionRefreshJob() {
215 ScheduledFuture<?> refreshJob = subscriptionRefreshJob;
217 if (refreshJob != null) {
218 refreshJob.cancel(true);
220 subscriptionRefreshJob = null;
222 upnpSubscribed = false;
225 private void initRenderer() {
226 if (!upnpSubscribed) {
227 addSubscription("AVTransport", SUBSCRIPTION_DURATION_SECONDS);
228 upnpSubscribed = true;
230 subscriptionRefreshJob = scheduler.scheduleWithFixedDelay(subscriptionRefresh,
231 SUBSCRIPTION_DURATION_SECONDS / 2, SUBSCRIPTION_DURATION_SECONDS / 2, TimeUnit.SECONDS);
236 updateStatus(ThingStatus.ONLINE);
240 * Invoke Stop on UPnP AV Transport.
243 Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
245 invokeAction("AVTransport", "Stop", inputs);
249 * Invoke Play on UPnP AV Transport.
252 CompletableFuture<Boolean> setting = isSettingURI;
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");
260 invokeAction("AVTransport", "Play", inputs);
262 logger.debug("Cannot play, cancelled setting URI in the renderer");
264 } catch (InterruptedException | ExecutionException | TimeoutException e) {
265 logger.debug("Cannot play, media URI not yet set in the renderer");
270 * Invoke Pause on UPnP AV Transport.
272 public void pause() {
273 Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
275 invokeAction("AVTransport", "Pause", inputs);
279 * Invoke Next on UPnP AV Transport.
281 protected void next() {
282 Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
284 invokeAction("AVTransport", "Next", inputs);
288 * Invoke Previous on UPnP AV Transport.
290 protected void previous() {
291 Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(avTransportId));
293 invokeAction("AVTransport", "Previous", inputs);
297 * Invoke SetAVTransportURI on UPnP AV Transport.
302 public void setCurrentURI(String URI, String URIMetaData) {
303 CompletableFuture<Boolean> setting = isSettingURI;
304 if (setting != null) {
305 setting.complete(false);
307 isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished setting
309 Map<String, String> inputs = new HashMap<>();
311 inputs.put("InstanceID", Integer.toString(avTransportId));
312 inputs.put("CurrentURI", URI);
313 inputs.put("CurrentURIMetaData", URIMetaData);
315 invokeAction("AVTransport", "SetAVTransportURI", inputs);
316 } catch (NumberFormatException ex) {
317 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
322 * Invoke SetNextAVTransportURI on UPnP AV Transport.
325 * @param nextURIMetaData
327 public void setNextURI(String nextURI, String nextURIMetaData) {
328 Map<String, String> inputs = new HashMap<>();
330 inputs.put("InstanceID", Integer.toString(avTransportId));
331 inputs.put("NextURI", nextURI);
332 inputs.put("NextURIMetaData", nextURIMetaData);
334 invokeAction("AVTransport", "SetNextAVTransportURI", inputs);
335 } catch (NumberFormatException ex) {
336 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
341 * Retrieves the current audio channel ('Master' by default).
343 * @return current audio channel
345 public String getCurrentChannel() {
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}.
353 * @return current volume
355 public PercentType getCurrentVolume() {
360 * Invoke GetVolume on UPnP Rendering Control.
361 * Result is received in {@link onValueReceived}.
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);
370 invokeAction("RenderingControl", "GetVolume", inputs);
374 * Invoke SetVolume on UPnP Rendering Control.
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()));
385 invokeAction("RenderingControl", "SetVolume", inputs);
389 * Invoke getMute on UPnP Rendering Control.
390 * Result is received in {@link onValueReceived}.
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);
399 invokeAction("RenderingControl", "GetMute", inputs);
403 * Invoke SetMute on UPnP Rendering Control.
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");
414 invokeAction("RenderingControl", "SetMute", inputs);
418 * Invoke getPositionInfo on UPnP Rendering Control.
419 * Result is received in {@link onValueReceived}.
421 protected void getPositionInfo() {
422 Map<String, String> inputs = Collections.singletonMap("InstanceID", Integer.toString(rcsId));
424 invokeAction("AVTransport", "GetPositionInfo", inputs);
428 public void handleCommand(ChannelUID channelUID, Command command) {
429 logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
431 String transportState;
432 if (command instanceof RefreshType) {
433 switch (channelUID.getId()) {
435 getVolume(getCurrentChannel());
438 getMute(getCurrentChannel());
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;
450 updateState(channelUID, newState);
455 switch (channelUID.getId()) {
457 setVolume(getCurrentChannel(), (PercentType) command);
460 setMute(getCurrentChannel(), (OnOffType) command);
463 if (command == OnOffType.ON) {
464 updateState(CONTROL, PlayPauseType.PAUSE);
465 playerStopped = true;
467 updateState(TRACK_POSITION, new QuantityType<>(0, SmartHomeUnits.SECOND));
471 playerStopped = false;
472 if (command instanceof PlayPauseType) {
473 if (command == PlayPauseType.PLAY) {
475 } else if (command == PlayPauseType.PAUSE) {
478 } else if (command instanceof NextPreviousType) {
479 if (command == NextPreviousType.NEXT) {
480 playerStopped = true;
482 } else if (command == NextPreviousType.PREVIOUS) {
483 playerStopped = true;
486 } else if (command instanceof RewindFastforwardType) {
496 public void onStatusChanged(boolean status) {
497 logger.debug("Renderer status changed to {}", status);
501 cancelSubscriptionRefreshJob();
503 updateState(CONTROL, PlayPauseType.PAUSE);
504 cancelTrackPositionRefresh();
506 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
507 "Communication lost with " + thing.getLabel());
509 super.onStatusChanged(status);
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);
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);
526 if (variable == null) {
532 if (!((value == null) || (value.isEmpty()))) {
533 soundMute = OnOffType.from(Boolean.parseBoolean(value));
534 updateState(MUTE, soundMute);
537 case "CurrentVolume":
538 if (!((value == null) || (value.isEmpty()))) {
539 soundVolume = PercentType.valueOf(value);
540 updateState(VOLUME, soundVolume);
544 if (!((value == null) || (value.isEmpty()))) {
545 updateProtocolInfo(value);
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);
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);
565 if (parsedValues.containsKey("TransportState")) {
566 onValueReceived("TransportState", parsedValues.get("TransportState"), service);
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)
584 currentEntry = nextEntry; // Try to get the metadata for the next entry if controlled by an
585 // external control point
588 } else if ("PLAYING".equals(value)) {
589 playerStopped = false;
591 updateState(CONTROL, PlayPauseType.PLAY);
592 scheduleTrackPositionRefresh();
593 } else if ("PAUSED_PLAYBACK".contentEquals(value)) {
594 updateState(CONTROL, PlayPauseType.PAUSE);
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));
613 if (isSettingURI != null) {
614 isSettingURI.complete(true); // We have received current URI, so can allow play to start
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));
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);
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))) {
639 updateState(TRACK_DURATION, UnDefType.UNDEF);
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));
647 if ((value == null) || ("NOT_IMPLEMENTED".equals(value))) {
649 updateState(TRACK_POSITION, UnDefType.UNDEF);
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));
657 super.onValueReceived(variable, value, service);
662 private void updateProtocolInfo(String value) {
664 supportedAudioFormats.clear();
665 audioSupport = false;
667 sink.addAll(Arrays.asList(value.split(",")));
669 for (String protocol : sink) {
670 Matcher matcher = PROTOCOL_PATTERN.matcher(protocol);
671 if (matcher.find()) {
672 String format = matcher.group(1);
677 supportedAudioFormats.add(AudioFormat.MP3);
681 supportedAudioFormats.add(AudioFormat.WAV);
684 audioSupport = audioSupport || Pattern.matches("audio.*", format);
689 logger.debug("Device {} supports audio", thing.getLabel());
694 private void registerAudioSink() {
695 if (audioSinkRegistered) {
696 logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
698 } else if (!service.isRegistered(this)) {
699 logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
702 logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
703 audioSinkReg.registerAudioSink(this);
704 audioSinkRegistered = true;
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);
717 updateState(TRACK_DURATION, UnDefType.UNDEF);
719 updateState(TRACK_POSITION, UnDefType.UNDEF);
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
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());
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));
743 if (queueIterator.hasNext()) {
744 UpnpEntry entry = queueIterator.next();
745 updateMetaDataState(entry);
746 setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
747 currentEntry = entry;
755 * Move to next position in queue and start playing.
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());
763 logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel());
764 cancelTrackPositionRefresh();
766 queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
767 if (currentQueue.isEmpty()) {
770 updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
771 UpnpEntry entry = queueIterator.next();
772 setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
773 currentEntry = entry;
779 * Move to previous position in queue and start playing.
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());
787 logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel());
788 cancelTrackPositionRefresh();
790 queueIterator = new UpnpIterator<>(currentQueue.listIterator()); // reset to beginning of queue
791 if (currentQueue.isEmpty()) {
794 updateMetaDataState(currentQueue.get(queueIterator.nextIndex()));
795 UpnpEntry entry = queueIterator.next();
796 setCurrentURI(entry.getRes(), UpnpXMLParser.compileMetadataString(entry));
797 currentEntry = entry;
807 private void serve() {
808 UpnpEntry entry = currentEntry;
810 logger.trace("Ready to play '{}' from queue", currentEntry);
811 updateMetaDataState(entry);
812 String res = entry.getRes();
814 logger.debug("Cannot serve media '{}', no URI", currentEntry);
817 setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry));
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));
829 * Update the current track position every second if the channel is linked.
831 private void scheduleTrackPositionRefresh() {
832 cancelTrackPositionRefresh();
833 if (!isLinked(TRACK_POSITION)) {
836 if (trackPositionRefresh == null) {
837 trackPositionRefresh = scheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1, TimeUnit.SECONDS);
841 private void cancelTrackPositionRefresh() {
842 ScheduledFuture<?> refresh = trackPositionRefresh;
844 if (refresh != null) {
845 refresh.cancel(true);
847 trackPositionRefresh = null;
850 updateState(TRACK_POSITION, new QuantityType<>(trackPosition, SmartHomeUnits.SECOND));
854 * Update metadata channels for media with data received from the Media Server or AV Transport.
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.
862 UpnpEntry entry = currentEntry;
864 entry = new UpnpEntry(media.getId(), media.getId(), "", "object.item");
865 currentEntry = entry;
868 isCurrent = media.getId().equals(entry.getRes());
870 logger.trace("Media ID: {}", media.getId());
871 logger.trace("Current queue res: {}", entry.getRes());
872 logger.trace("Updating current entry: {}", isCurrent);
874 if (!(isCurrent && media.getTitle().isEmpty())) {
875 updateState(TITLE, StringType.valueOf(media.getTitle()));
877 if (!(isCurrent && (media.getAlbum().isEmpty() || media.getAlbum().matches("Unknown.*")))) {
878 updateState(ALBUM, StringType.valueOf(media.getAlbum()));
881 && (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")))) {
882 if (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")) {
883 updateState(ALBUM_ART, UnDefType.UNDEF);
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());
889 updateState(ALBUM_ART, UnDefType.UNDEF);
892 updateState(ALBUM_ART, albumArt);
896 if (!(isCurrent && (media.getCreator().isEmpty() || media.getCreator().matches("Unknown.*")))) {
897 updateState(CREATOR, StringType.valueOf(media.getCreator()));
899 if (!(isCurrent && (media.getArtist().isEmpty() || media.getArtist().matches("Unknown.*")))) {
900 updateState(ARTIST, StringType.valueOf(media.getArtist()));
902 if (!(isCurrent && (media.getPublisher().isEmpty() || media.getPublisher().matches("Unknown.*")))) {
903 updateState(PUBLISHER, StringType.valueOf(media.getPublisher()));
905 if (!(isCurrent && (media.getGenre().isEmpty() || media.getGenre().matches("Unknown.*")))) {
906 updateState(GENRE, StringType.valueOf(media.getGenre()));
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);
916 * @return Audio formats supported by the renderer.
918 public Set<AudioFormat> getSupportedAudioFormats() {
919 return supportedAudioFormats;
923 * @return UPnP sink definitions supported by the renderer.
925 protected List<String> getSink() {